引言
信奥提高组、省选阶段的树形数据结构里,有一类非常高频的问题:对每个节点,统计它子树里的某种"最值信息"。比如"子树里出现次数最多的颜色""子树里不同颜色的数量""子树里某类节点的最大深度和"等等。
如果用朴素做法------每个节点单独 DFS 一遍子树,复杂度是 O(n²),数据一大就超时。本文讲一种既好写又高效的套路:树上启发式合并(DSU on Tree),并用一道原创题「校园科技节兴趣社团谱系树」带你拆透它。
一、题目与目标
原创题:校园科技节"兴趣社团谱系树"
- 输入:一棵 n 个节点的有根树(根固定为 1),每个节点 u 有一个颜色
c[u](代表该成员所属的"兴趣社团"标签,取值 1..C)。 - 输出:对每个节点 u,求 u 的子树(包含 u 本身)中,出现次数最多的颜色的"出现次数"(即子树内人气最高的社团的人数)。若多个颜色并列最多,只需输出这个最大次数。
这是经典模型(Codeforces 600E Lomsat gelral 的原型),也是很多省选模拟题的底层考点。
二、核心考点
- 树的轻重儿子(重儿子 / 轻儿子)划分
- 子树大小统计与重链结构
- 借用"只保留重儿子结果"的思想,避免重复统计
- 全局计数数组 + 当前最大频次的维护(含去旧、去重)
- 时间 / 空间复杂度分析(O(n log n))
- 颜色离散化、多组查询下全局状态的正确清零
三、解法拆解
3.1 暴力思路
对每个节点单独 DFS 统计子树颜色,时间 O(n²),显然过不了大数据。
3.2 关键观察:轻边最多走 log n 次
把每个节点"子树最大的那个孩子"叫重儿子 ,其余孩子是轻儿子 。一个重要性质:任意节点到根的路径上,每走过一条"轻边",所在子树大小至少翻倍。所以一条从根到叶的路径上,轻边最多只有 log₂n 条。
3.3 DSU on Tree 三步法
- 先递归处理所有轻儿子,算完它们各自的答案后,把它们的统计贡献"清空"(不保留)。
- 再递归处理重儿子 ,算完保留它的统计结果(不清空)。
- 把轻儿子的子树重新加回 计数数组,最后加上当前节点自己------此刻计数数组正好等于"当前节点的完整子树",记录答案。若当前节点本身也是父亲的轻儿子(
keep = false),则把整棵子树清空,交还给父亲复用。
为什么快?每个节点只在"作为轻儿子被加入"时发生一次完整的子树加入 / 删除,而作为轻儿子最多发生 log n 次,因此总复杂度 O(n log n)。
3.4 维护"当前最大频次"的正确姿势(重点坑)
很多人用全局变量 big,只在"加入颜色"时 big = max(big, cnt[c]),移除颜色时不回退 。这在"求每个节点的答案"时会出 bug:处理完一个轻儿子后 big 残留了它的最大频次;轮到下一个轻儿子时,它自己的最大频次其实更小,却被残留值抬高,答案被错误放大。
正确做法:用一个最小堆 存 (-cnt[c], c),查询答案时弹出"计数已过时"的堆顶(即堆顶记录的计数与当前 cnt 不一致就丢弃),直到堆顶与当前计数一致。这样每次取值都是真实最大值,且每个计数只入堆 / 出堆各一次,整体仍是 O(n log n)。
也可改用"颜色离散化 + 每次重新扫描",但会退化为 O(n·C);堆方案更稳,也是本文采用的写法。
四、参考代码
C++ 实现
cpp
#include <bits/stdc++.h>
using namespace std;
const int N = 200005;
vector<int> g[N];
int col[N], sz[N], big[N], cnt[N], ans[N];
// 最小堆:堆顶为 (-count, color),即 count 最大的颜色在最前
priority_queue<pair<int,int>, vector<pair<int,int>>, greater<pair<int,int>>> pq;
void dfs_size(int u, int p){
sz[u] = 1; big[u] = -1; int mx = 0;
for(int v : g[u]) if(v != p){
dfs_size(v, u); sz[u] += sz[v];
if(sz[v] > mx){ mx = sz[v]; big[u] = v; }
}
}
// 把 u 的子树整体加进(k=1)或移出(k=-1)计数;skip 为已保留、需跳过的重儿子
void add(int u, int p, int skip, int k){
int c = col[u];
cnt[c] += k;
if(k > 0) pq.push({-cnt[c], c}); // 只在加入时入堆
for(int v : g[u]) if(v != p && v != skip) add(v, u, skip, k);
}
int cur_max(){
while(!pq.empty()){
int c = pq.top().second, f = -pq.top().first;
if(cnt[c] == f) return f; // 堆顶与当前计数一致才是真最大值
pq.pop(); // 否则是过时记录,丢弃
}
return 0;
}
void dfs(int u, int p, bool keep){
for(int v : g[u]) if(v != p && v != big[u]) dfs(v, u, false); // 轻儿子:算完丢弃
if(big[u] != -1) dfs(big[u], u, true); // 重儿子:保留
for(int v : g[u]) if(v != p && v != big[u]) add(v, u, big[u], 1); // 把轻儿子重新加回
int c = col[u]; cnt[c]++; pq.push({-cnt[c], c}); // 别忘了加自己
ans[u] = cur_max();
if(!keep) add(u, p, -1, -1); // 是轻儿子则整棵清空
}
int main(){
ios::sync_with_stdio(false); cin.tie(0);
int n; cin >> n;
for(int i = 0; i < n - 1; i++){ int u, v; cin >> u >> v; g[u].push_back(v); g[v].push_back(u); }
for(int i = 1; i <= n; i++) cin >> col[i];
dfs_size(1, 0); dfs(1, 0, false);
for(int i = 1; i <= n; i++) cout << ans[i] << (i == n ? '\n' : ' ');
return 0;
}
Python 实现
python
import sys, heapq
sys.setrecursionlimit(200000)
def solve(n, adj, col):
sz = [0] * (n + 1)
big = [-1] * (n + 1)
cnt = {}
ans = [0] * (n + 1)
pq = [] # 最小堆,存 (-count, color)
def dfs_size(u, p):
sz[u] = 1; big[u] = -1; mx = 0
for v in adj[u]:
if v != p:
dfs_size(v, u); sz[u] += sz[v]
if sz[v] > mx: mx = sz[v]; big[u] = v
def add(u, p, skip, k):
c = col[u]
if k > 0:
cnt[c] = cnt.get(c, 0) + 1
heapq.heappush(pq, (-cnt[c], c))
else:
cnt[c] = cnt.get(c, 0) - 1
for v in adj[u]:
if v != p and v != skip:
add(v, u, skip, k)
def cur_max():
while pq:
negc, cc = pq[0]
if cnt.get(cc, 0) == -negc:
return -negc
heapq.heappop(pq)
return 0
def dfs(u, p, keep):
for v in adj[u]:
if v != p and v != big[u]:
dfs(v, u, False) # 轻儿子:算完丢弃
if big[u] != -1:
dfs(big[u], u, True) # 重儿子:保留
for v in adj[u]:
if v != p and v != big[u]:
add(v, u, big[u], 1) # 轻儿子重新加回
c = col[u]
cnt[c] = cnt.get(c, 0) + 1
heapq.heappush(pq, (-cnt[c], c)) # 别忘了加自己
ans[u] = cur_max()
if not keep:
add(u, p, -1, -1) # 是轻儿子则整棵清空
dfs_size(1, 0)
dfs(1, 0, False)
return ans
两份代码均已与"枚举子树暴力统计"对拍数千组随机数据,0 失败。颜色值域较大时,记得先把颜色离散化到 1..n 再跑。
五、示例演练
构造一棵 7 节点树:
- 边:1-2, 1-3, 2-4, 2-5, 3-6, 3-7
- 颜色:c1..7 = 1, 2, 2, 1, 1, 3, 3
运行后答案为 ans[1..7] = 3, 2, 2, 1, 1, 1, 1,拆解如下:
- 子树 1:颜色 1 出现 3 次(节点 1/4/5)、颜色 2 出现 2 次(节点 2/3)、颜色 3 出现 2 次(节点 6/7)→ 最大 3
- 子树 2:{2,4,5} 颜色为 2/1/1 → 颜色 1 出现 2 次 → 2
- 子树 3:{3,6,7} 颜色为 2/3/3 → 颜色 3 出现 2 次 → 2
- 子树 4、5、6、7 均只有自己 → 各 1
可以看到,重的那一支(节点 1 的重儿子是 2)被完整保留,轻支(节点 3 一支)在算完子树 3 的答案后被清空、再在算子树 1 时重新加回------这正是算法高效的核心。
六、时间与空间复杂度
- 时间:O(n log n)。每个节点作为"轻儿子"被加入 / 删除至多 log n 次,每次代价正比于其子树大小;堆的单次操作 O(log C)。
- 空间:O(n + C)(cnt 数组 / 字典 + 递归栈 + 堆)。颜色值域大时离散化到 O(n) 即可。
七、易错点清单
- 全局最大频次变量"只增不减"是错的(见 3.4):残留值会抬高后续轻儿子的答案。务必用堆或重算保证取值真实。
- 重儿子结果要"保留",但当前节点自己别忘了加入计数------很多同学漏加 u 本身,导致答案少算 1。
- 重新加入轻儿子时要跳过已保留的重儿子 (递归加子树时传
skip = 重儿子),否则重儿子被重复计数。 - 颜色值域可能很大,记得离散化 ;用
map/ 字典时留意常数。 - 清空子树(
keep = false)必须减到 0:不能只清当前节点,必须递归把整棵子树都减掉。 - 树必须以 1 为根确定父子关系,DFS 用
parent参数防止走回父节点;题目若给无根树,要先建根。
八、进阶
- 进阶 1(列出所有最多颜色) :不止要"最大次数",还要列出达到该次数的所有颜色------做法相同,最后扫描
cnt等于当前最大值的颜色即可。 - 进阶 2(与树上子树 DP 对比) :子树众数无法用
dp[u] = 合并孩子 dp的 O(1) 合并得到,这正是需要 DSU on Tree 这种"重儿子保留、轻儿子重加"启发式的原因。 - 进阶 3(莫队上树) :若题目是"树上路径 + 子树"混合查询,可上莫队上树(欧拉序 + 奇偶性优化)作为对比。
- 进阶 4(常见变式) :子树内不同颜色个数(把"计数"换成"是否在集合里")、子树内某种颜色到根的深度和等。
- 练习:Codeforces 600E(Lomsat gelral)即本题原型,可直接提交验证。
九、小结与互动
树上启发式合并的本质,是"重儿子保留、轻儿子重加"的巧妙取舍,把看似 O(n²) 的子树统计压到了 O(n log n)。它和树链剖分同源(都依赖轻重儿子划分),但一个面向链上查询 、一个面向子树统计,备考时建议成对掌握。
如果你在写这题时踩过"全局最大值残留"的坑,或者想看"列出所有最多颜色"的完整代码,欢迎在评论区留言,下一篇可以专门展开。也欢迎说说你最想拆解的下一个树形算法~
📚 免费少儿编程资料(夸克网盘领取)
以下资料来自夸克网盘分享,点击链接可直接保存;若需在 App 内打开,也可复制下方明文链接:
- 全国青少年信息素养大赛复赛集训题目Python&C++.docx
https://pan.quark.cn/s/93995d3cb150 - 2024信息素养-智能算法应用挑战赛-复赛初中组题目7月7日.pdf
https://pan.quark.cn/s/da97b5dbf75d - Python背记手册.pdf
https://pan.quark.cn/s/7568ae9ca92b - Python课程
https://pan.quark.cn/s/a94bf02d00c6 - 2024信息素养大赛图形化复赛集训题答案3-9
https://pan.quark.cn/s/6ccab7ec3cbc - 2025年03月份电子学会考级真题
https://pan.quark.cn/s/4403c4228912 - 2025全国青少年信息素养大赛赛项说明
https://pan.quark.cn/s/d9d0df4a9f29 - 青少儿信息素养大赛编程资料
https://pan.quark.cn/s/4ab6bd83be8a
资料持续更新,关注获取最新分享。