树上启发式合并

创建时间: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)

posted @ 2026-06-02 16:24  xubaichuan  阅读(12)  评论(0)    收藏  举报