引入
考虑这样一类允许离线的树上问题:
对于一棵静态有根树上的所有点,维护有关节点子树的某些信息(如:统计子树内颜色种类数、出现次数最多的颜色、路径异或和、满足某种条件的点对数量等),且这些信息可以通过合并子树信息得到。
显然暴力可以每次遍历每个节点的子树中的所有节点统计答案,但是这样做的复杂度显然就至少是 O(n2)O(n^2)O(n2) (“至少”是因为可能合并答案不是 O(1)O(1)O(1) 的)。于是考虑不在遍历,每个节点的时候都清空数据,而是在统计节点 uuu 的答案时直接从某个儿子 vvv 的答案合并而来。
树上启发式合并
算法流程
沿用树剖定义:
- 重子节点 &\&& 轻子节点:一个节点的子节点中子树最大的那个称为重子节点,如果有多个子树大小一样的取其一(如果是叶子节点的话重子节点默认为 000)。轻子节点是非重子节点的所有其他节点
- 重边 &\&& 轻边:重边是从重子节点到父节点的边,轻边是除重边以外的所有边
- 重链:由重边首尾相接的链是重链,有时候为了便于理解把是轻子节点的叶子也视为重链。
首先一遍 dfs 求出重儿子,代码如下:
void dfs(int u,int fa){
siz[u] = 1;
for(auto v: g[u]){
if(v == fa) continue;
dfs(v,u),siz[u] += siz[v];
if(siz[v] > siz[son[u]]) son[u] = v;//记录重儿子
}
}
然后进入树上启发式合并(dsu on tree)
先遍历 uuu 的轻儿子,计算答案,但不保留它对当前答案(状态)的影响。
然后遍历它的重儿子,保留它对当前答案的影响。
最后再次遍历 uuu 的轻儿子的子树,加入这些子树的贡献,得到 uuu 的答案。
核心代码如下:
void work(int u,int fa){
ins(u);//加入节点 u
for(auto v : g[u]){
if(v == fa) continue;
work(v,u);
}
}
void dsu(int u,int fa){
if(!son[u]){
ins(u),/*do something*/;//u 是叶子结点,直接加入并统计答案
return;
}
for(auto v : g[u]){
if(v == fa || v == son[u]) continue;
dsu(v,u);
init();//清空当前答案
}
dsu(son[u],u);//保留重儿子的答案
for(auto v : g[u]){
if(v == fa || v == son[u]) continue;
work(v,u);//加入其他子树答案
}
ins(u),/*do something*/;//加入 u 并统计答案
}
时间复杂度
注意到,对任意一个结点 uuu,它在 dsu 中被暴力插入/删除的充要条件是:uuu 所在的这条到根的路径上,至少有一条轻边被展开。换句话说,只有在 uuu 的某个祖先 ppp 满足 ppp 的轻儿子分支包含了 uuu 时 uuu 才会在 dsu 到 ppp 时被扫一遍。
而轻边次数是有上界的。一个重链剖分经典结论是,对于任意结点 uuu,从 uuu 向上到根的路径上,轻边条数 ≤log2n\leq \log_2 n≤log2n。因为每走一条轻边,子树大小至少减半。
设一个结点的一次插入/删除是 O(k)O(k)O(k) 的,节点 uuu 在整个算法运行期间最多被插入/删除 2×(log2n+1)2 \times (\log_2n + 1)2×(log2n+1) 次(+1+1+1 是因为 uuu 自身也可能被当作轻儿子展开,乘 222 是因为先插入后删除算两次)。于是每个点的总代价是 O(klog2n)O(k \log_2 n)O(klog2n)。全树有 nnn 个点,总代价即为 O(nklog2n)O(nk \log_2 n)O(nklog2n)。
kkk 通常是 O(1)O(1)O(1) 的,于是就像我们说树剖时间复杂度为 O(nlog2n)O(n \log_2n)O(nlog2n) 一样,启发式合并复杂度也为 O(nlog2n)O(n \log_2n)O(nlog2n)。

被折叠的 条评论
为什么被折叠?



