引入

前置知识:树链剖分

由于树链剖分的时间复杂度为 ,而我们熟知的 LCT 虽然时间复杂度为 ,但常数较大,可能比树链剖分还慢。那么有什么既是 的,常数又相对较小的方法呢?这个时候全局平衡二叉树就出现了。

全局平衡二叉树实际上是一颗二叉树森林,其中的每颗二叉树维护一条重链。但是这个森林里的二叉树又互有联系,其中每个二叉树的根连向这个重链链头的父亲,就像 LCT 中一样。但全局平衡二叉树是静态树,区别于 LCT,建成后树的形态不变。

全局平衡二叉树是一种可以处理树上链修改/查询的数据结构,可以做到:

  • 一条链整体修改。
  • 一条链整体查询。
  • 求最近公共祖先,子树修改,子树查询等,这些复杂度和重链剖分是一样的。

主要性质

  1. 全局平衡二叉树由很多棵二叉树通过轻边连起来组成,每一棵二叉树维护了原树的一条重链,其中序遍历的顺序就是这条重链深度单调递增的顺序。每个节点都仅出现在一棵二叉树中。
  2. 边分为重边和轻边,重边是包含在二叉树中的边,维护的时候就像正常维护二叉树一样,记录左右儿子和父节点。轻边从一颗二叉树的根节点指向它所对应的重链顶端节点的父节点。轻边维护的时候 ” 认父不认子 “,即只能从子节点访问到父节点,不能反过来。注意,全局平衡二叉树中的边和原树中的边没有对应关系。
  3. 算上重边和轻边,全局平衡二叉树的高度是 级别的。这条是保证全局平衡二叉树时间复杂度的性质。

下面是一个全局平衡二叉树建树的例子。第一张图是原树,以节点 1 为根节点。实线是重边。

第二张图是建出来的全局平衡二叉树,其中虚线是轻边,实线是重边,每一棵二叉树用红圈表示。

建树

首先是像普通重链剖分一样,一次 DFS 求出每个节点的重儿子。然后从根开始,找到根节点所在的重链,对于这些点的轻儿子递归建树,并连上轻边。然后我们需要给重链上的点建一棵二叉树。我们先把重链上的点存到数组里,求出每个点轻儿子的子树大小之和加一(即该点本身所贡献的 size)。然后我们按照这个求出这条重链的加权中点,把它作为二叉树的根,两边递归建树,并连上重边。

代码如下:

实现

std::vector<int> G[N];
int n, fa[N], son[N], sz[N];
 
void dfsS(int u) {
  sz[u] = 1;
  for (int v : G[u]) {
    dfsS(v);
    sz[u] += sz[v];
    if (sz[v] > sz[son[u]]) son[u] = v;
  }
}
 
int b[N], bs[N], l[N], r[N], f[N], ss[N];
 
// 给b中[bl,br)内的点建二叉树,返回二叉树的根
int cbuild(int bl, int br) {
  int x = bl, y = br;
  while (y - x > 1) {
    int mid = (x + y) >> 1;
    if (2 * (bs[mid] - bs[bl]) <= bs[br] - bs[bl])
      x = mid;
    else
      y = mid;
  }
  // 二分求出按bs加权的中点
  y = b[x];
  ss[y] = br - bl;  // ss:二叉树中重子树的大小
  if (bl < x) {
    l[y] = cbuild(bl, x);
    f[l[y]] = y;
  }
  if (x + 1 < br) {
    r[y] = cbuild(x + 1, br);
    f[r[y]] = y;
  }
  return y;
}
 
int build(int x) {
  int y = x;
  do
    for (int v : G[y])
      if (v != son[y])
        f[build(v)] =
            y;  // 递归建树并连轻边,注意要从二叉树的根连边,不是从儿子连边
  while (y = son[y]);
  y = 0;
  do {
    b[y++] = x;                              // 存放重链中的点
    bs[y] = bs[y - 1] + sz[x] - sz[son[x]];  // bs:轻儿子size和+1,求前缀和
  } while (x = son[x]);
  return cbuild(0, y);
}

由代码可以看出建树的时间复杂度是 。接下来我们可以证明树高是 的:考虑从任意一个点跳父节点到根。跳轻边就相当于在原树中跳到另一条重链,由重链剖分的性质可得跳轻边最多 条;因为建二叉树的时候根节点找的是算轻儿子的加权中点,那么跳一次重边算上轻儿子的 size 至少翻倍,所以跳重边最多也是 条。整体树高就是 的。

查询

以上就是关于全局平衡二叉树的部分。剩下关于链修改和链查询的操作方法相对简单,只需要从要操作的点出发,一直跳跃到根节点。要操作某个点所在的重链上比它深度小的所有点,本质上等同于在这条重链的二叉树中操作目标节点左侧的所有节点。这些操作可以分解成一系列子树操作,与普通二叉树的维护方法类似,其中涉及到维护子树和以及打子树标记。在这一过程中,使用的是标记永久化。也可以用 pushdown 来打标记,用 pushup 维护子树和,不过这种方式可能相对复杂,因为通常情况下,处理二叉树是自上而下进行操作,但在这里,需要首先确定跳跃路径,然后再从上到下进行 pushdown,可能导致常数较大。

代码如下:

实现

// a:子树加标记
// s:子树和(不算加标记的)
int a[N], s[N];
 
void add(int x) {
  bool t = true;
  int z = 0;
  while (x) {
    s[x] += z;
    if (t) {
      a[x]++;
      if (r[x]) a[r[x]]--;
      z += 1 + ss[l[x]];
      s[x] -= ss[r[x]];
    }
    t = (x != l[f[x]]);
    if (t && x != r[f[x]]) z = 0;  // 跳过轻边要清空
    x = f[x];
  }
}
 
int query(int x) {
  int ret = 0;
  bool t = true;
  int z = 0;
  while (x) {
    if (t) {
      ret += s[x] - s[r[x]];
      ret -= 1ll * ss[r[x]] * a[r[x]];
      z += 1 + ss[l[x]];
    }
    ret += 1ll * z * a[x];
    t = (x != l[f[x]]);
    if (t && x != r[f[x]]) z = 0;  // 跳过轻边要清空
    x = f[x];
  }
  return ret;
}

此外,对于子树操作,就是要考虑轻儿子的,需要再维护一个包括轻儿子的子树和、子树标记,可以去做 “P3384【模板】轻重链剖分”。

例题

参考

P4211 [LNOI2014] LCA | 全局平衡二叉树