引入
当一棵树上只有一部分节点是有用的,通过保留这些节点和这些节点两两之间的 LCA,就能够浓缩这棵树的信息。
这棵树就叫作虚树。
虚树的高效构造算法
一种方法是使用单调栈进行构建。
基础
为了方便,不妨将树的根加入到要建立虚树的节点(下称关键点)中。
将关键点按照 dfn 序,即 dfs 首次遍历到该点的顺序,升序排序。则排序后,数组中的点与相邻的两个点的 LCA 即囊括了虚树的所有节点。这样虚树中只有约 2n 个节点。
但是直接将相邻点的 LCA 加入可能会导致重复。
构造
使用单调栈维护树上的某一条关键点构成的链,满足从栈顶到栈底关键点的深度递减(即,栈顶端的节点是栈底部节点的后代)
将关键点按照 dfn 序进行排序。
将根加入栈中。
遍历每一个关键点,假设当前要加入关键点 。
求关键点 与栈顶的 LCA,记作 。
若 与栈顶相同,说明 在栈维护的链上,直接将 插入即可。
否则, 不在栈维护的链中。由于 是栈顶与 的 LCA,则 某些祖先必然在栈中。当栈中深度次大的点的深度大于 的深度时,将次大点与栈顶连边,将栈顶弹出,直到栈中深度次大的点深度小于等于 的深度。
若栈中深度次大的点的深度小于 的深度,则说明 应当位于此时的栈顶与次大点之间。在 和栈顶之间连边,并将栈顶弹出,将 加入栈中。
否则,说明 就是深度次大的点,直接将 加入即可。
在插入完所有边之后,栈所维护的一条链中,各个节点之间还没有连边,需要将这些点连边。
复杂度
算法时间瓶颈在于排序与求 LCA。使用基数排序和 LCA 可以做到 , 是关键点数量。
小细节
不能在加入栈的时候,直接连边。因为可能后面有一些点的 LCA 插入会破坏树的结构。
有些题目需要多次建立虚树,此时就需要每一次入栈的时候,将这个点的链式前向星或邻接表清空。
代码实现
从 OI-Wiki 地了一份代码,解释的挺清晰的。
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;
}