虚树学习笔记

引入

当一棵树上只有一部分节点是有用的,通过保留这些节点和这些节点两两之间的 LCA,就能够浓缩这棵树的信息。

这棵树就叫作虚树。

虚树的高效构造算法

一种方法是使用单调栈进行构建。

基础

为了方便,不妨将树的根加入到要建立虚树的节点(下称关键点)中。

将关键点按照 dfn 序,即 dfs 首次遍历到该点的顺序,升序排序。则排序后,数组中的点与相邻的两个点的 LCA 即囊括了虚树的所有节点。这样虚树中只有约 2n 个节点。

但是直接将相邻点的 LCA 加入可能会导致重复。

构造

使用单调栈维护树上的某一条关键点构成的链,满足从栈顶到栈底关键点的深度递减(即,栈顶端的节点是栈底部节点的后代)

  1. 将关键点按照 dfn 序进行排序。

  2. 将根加入栈中。

  3. 遍历每一个关键点,假设当前要加入关键点 ii

  4. 求关键点 ii 与栈顶的 LCA,记作 ll

    • ll 与栈顶相同,说明 ii 在栈维护的链上,直接将 ii 插入即可。

    • 否则,ii 不在栈维护的链中。由于 ll 是栈顶与 ii 的 LCA,则 ll 某些祖先必然在栈中。当栈中深度次大的点的深度大于 ll 的深度时,将次大点与栈顶连边,将栈顶弹出,直到栈中深度次大的点深度小于等于 ll 的深度。

      • 若栈中深度次大的点的深度小于 ll 的深度,则说明 ll 应当位于此时的栈顶与次大点之间。在 ll 和栈顶之间连边,并将栈顶弹出,将 ll 加入栈中。

      • 否则,说明 ll 就是深度次大的点,直接将 ii 加入即可。

  5. 在插入完所有边之后,栈所维护的一条链中,各个节点之间还没有连边,需要将这些点连边。

复杂度

算法时间瓶颈在于排序与求 LCA。使用基数排序和 O(1)O(1) LCA 可以做到 O(k)O(k)kk 是关键点数量。

小细节

不能在加入栈的时候,直接连边。因为可能后面有一些点的 LCA 插入会破坏树的结构。

有些题目需要多次建立虚树,此时就需要每一次入栈的时候,将这个点的链式前向星或邻接表清空。

代码实现

OI-Wiki 地了一份代码,解释的挺清晰的。

c++
bool cmp(const int x, const int y) { return id[x] < id[y]; }

void build() {
  sort(h + 1, h + k + 1, cmp);
  sta[top = 1] = 1, g.sz = 0, g.head[1] = -1;
  // 1 号节点入栈,清空 1 号节点对应的邻接表,设置邻接表边数为 1
  for (int i = 1, l; i <= k; ++i)
    if (h[i] != 1) {
      // 如果 1 号节点是关键节点就不要重复添加
      l = lca(h[i], sta[top]);
      // 计算当前节点与栈顶节点的 LCA
      if (l != sta[top]) {
        // 如果 LCA 和栈顶元素不同,则说明当前节点不在当前栈所存的链上
        while (id[l] < id[sta[top - 1]])
          // 当次大节点的 Dfs 序大于 LCA 的 Dfs 序
          g.push(sta[top - 1], sta[top]), top--;
        // 把与当前节点所在的链不重合的链连接掉并且弹出
        if (id[l] > id[sta[top - 1]])
          // 如果 LCA 不等于次大节点(这里的大于其实和不等于没有区别)
          g.head[l] = -1, g.push(l, sta[top]), sta[top] = l;
        // 说明 LCA 是第一次入栈,清空其邻接表,连边后弹出栈顶元素,并将 LCA
        // 入栈
        else
          g.push(l, sta[top--]);
        // 说明 LCA 就是次大节点,直接弹出栈顶元素
      }
      g.head[h[i]] = -1, sta[++top] = h[i];
      // 当前节点必然是第一次入栈,清空邻接表并入栈
    }
  for (int i = 1; i < top; ++i)
    g.push(sta[i], sta[i + 1]);  // 剩余的最后一条链连接一下
  return;
}
AGC008F - Black Radius
NOMURA2020 F - Sorting Game