树上启发式合并
创建时间:2025-09-03
树上启发式合并(DSU on Tree),可以很快的在子树中维护信息并解决问题,很多时候可以与线段树合并相通,但不管是逻辑还是代码都比线段树合并简单,唯一的缺点就是不支持修改、在线。同时,某些与路径有关的问题树上启发式合并也能做,可以与点分治互通。可以说树上启发式合并是一种很好用的树上算法
以CF600E Lomsat gelral为例,题目给定了一棵 \(n\) 个节点的树,其中 \(1\) 号节点为根节点,每个节点有一个颜色,对于所有 \(u \in [1,n]\),求以 \(u\) 为根的子树中出现次数最多的颜色的编号之和。
很容易想到暴力做法,首先枚举子树 \(u\),求出以 \(u\) 为根的子树中每个颜色的出现次数,若某个颜色出现次数大于目前次数最大值,则答案设为该颜色编号,否则若该颜色出现次数等于次数最大值,将答案加上该颜色编号。核心代码:
void dfs(int u, int fa) {
cnt[c[u]]++;
if (cnt[c[u]] > maxi)
maxi = cnt[c[u]], sum += c[u];
else if (cnt[c[u]] == maxi)
sum += c[u];
for (int v : e[u])
if (v != fa)
dfs(v, u);
}
由于以每个节点为根的子树都要统计一边答案,而每次统计答案都要便利整个子树,所以这个算法在最坏情况下的复杂度为 \(O(n^2)\),并不够优秀。
这时,就需要用到树上启发式合并啦!
可以发现前面讲的暴力算法的一大问题:每次递归求解完以某个孩子为根的子树的问题后都要清空 \(cnt\) 数组,否则可能对之后的递归产生不正确的影响,而直接清空的复杂度为 \(O(n)\),效率很低。有前面的分析显然可知:虽然大部分孩子节点需要清空,但最后递归的孩子节点其实是可以不清空的。故想到找出一种方案使以 \(u\) 为根的子树中除去最后递归的孩子的子树后,剩余节点个数最少,换句话说就是清空次数最少。
那么最优策略就很明显了,记 \(siz_u\) 为以 \(u\) 为根的子树的大小,\(son_u\) 为 \(u\) 的孩子中 \(siz\) 最小的一个(如果有多个的话任取一个),每次先递归求解以 \(u\) 的非 \(son_u\) 的孩子,并在递归结束后清空,然后递归求解 \(son_u\),最后计算 \(u\) 的答案。写成代码如下(其中 \(heavy\) 为 update 中规避的节点,也就是出发点的 \(son\)):
void update(int u, int fa, int heavy) {
cnt[c[u]]++;
if (cnt[c[u]] > maxi)
maxi = cnt[c[u]], sum = c[u];
else if (cnt[c[u]] == maxi)
sum += c[u];
for (int v : e[u])
if (v != fa && v != heavy)
update(v, u, heavy);
}
void clear(int u, int fa) {
cnt[c[u]] = 0;
for (int v : e[u])
if (v != fa)
clear(v, u);
}
void solve(int u, int fa) {
for (int v : e[u])
if (v != fa && v != son[u])
solve(v, u);
if (son[u])
solve(son[u], u);
update(u, fa, son[u]);
ans[u] = sum;
if (u != son[fa]) {
maxi = sum = 0;
clear(u, fa);
}
}
那这玩意的复杂度呢?啊它竟然是 \(O(n \log n)\) 的!考虑怎么证明它。
回顾重链剖分中的定义,将 \(son_u\) 称为 \(u\) 的重儿子,其他孩子的称为轻儿子,并将一条以轻儿子链头、其他每个节点都是前一个节点的重儿子的链称为一条重链。可以发现,当且仅当 \(u\) 是 \(u\) 的父亲的轻儿子时以 \(u\) 为根的子树会被清空,故节点 \(v\) 被清空的次数就是 \(v\) 的不是重儿子的祖先个数。而 \(u\) 是 \(u\) 的父亲的轻儿子等价于 \(u\) 是某个重链的链头,也就是说 \(v\) 被清空的次数就是从 \(v\) 到根节点经过的重链的个数。那这我们就很熟悉了,因为当 \(u\) 是 \(u\) 的父节点的轻儿子时,以 \(u\) 的父节点的 \(siz\) 一定大于 \(2siz_u\),所以每当从一个链头跳到一条新链,链头的 \(siz\) 就会变为原来的两倍多。注意到根的 \(siz\) 为 \(n\),所以经过的重链的个数最多为 \(\log n\),总的复杂度为 \(O(n \log n)\)。
完整代码:
#include <iostream>
#include <vector>
using namespace std;
const int MAX_N = 1e5 + 50;
int n, c[MAX_N];
int siz[MAX_N], son[MAX_N], cnt[MAX_N];
long long ans[MAX_N];
vector<int> e[MAX_N];
void dfs(int u, int fa) {
siz[u] = 1;
for (int v : e[u]) {
if (v == fa)
continue;
dfs(v, u);
siz[u] += siz[v];
if (siz[v] > siz[son[u]])
son[u] = v;
}
}
int maxi;
long long sum;
void update(int u, int fa, int heavy) {
cnt[c[u]]++;
if (cnt[c[u]] > maxi)
maxi = cnt[c[u]], sum = c[u];
else if (cnt[c[u]] == maxi)
sum += c[u];
for (int v : e[u])
if (v != fa && v != heavy)
update(v, u, heavy);
}
void clear(int u, int fa) {
cnt[c[u]] = 0;
for (int v : e[u])
if (v != fa)
clear(v, u);
}
void solve(int u, int fa) {
for (int v : e[u])
if (v != fa && v != son[u])
solve(v, u);
if (son[u])
solve(son[u], u);
update(u, fa, son[u]);
ans[u] = sum;
if (u != son[fa]) {
maxi = sum = 0;
clear(u, fa);
}
}
int main() {
cin >> n;
for (int i = 1; i <= n; i++)
cin >> c[i];
for (int i = 1, x, y; i < n; i++) {
cin >> x >> y;
e[x].push_back(y);
e[y].push_back(x);
}
dfs(1, 0);
solve(1, 0);
for (int i = 1; i <= n; i++)
cout << ans[i] << ' ';
return 0;
}
树上启发式合并用起来还是比较板的,大部分情况下只需要考虑怎么改 update 的前半部分就行了,dfs、clear、solve 看情况稍微变一下就行了。其他例题:
CF375D Tree and Queries(普通 DSU on Tree);
CF246E Blood Cousins Return(与深度有关的 DSU on Tree);
CF1009F Dominant Indices(与深度有关的 DSU on Tree);
CF741D Arpa’s letter-marked tree and Mehrdad’s Dokhtar-kosh paths(与路径有关的 DSU on Tree);
CF914E Palindromes in a Tree(与路径有关的 DSU on Tree);
AT_abc359_g [ABC359G] Sum of Tree Distance(与路径有关的 DSU on Tree);
P4103 [HEOI2014] 大工程(与路径有关的 DSU on Tree)。

浙公网安备 33010602011771号