用“恰好出现 t 次的颜色种数”把子树判定降到 O(1),再用 DSU on Tree 让父亲复用重儿子的统计,做到 O(n log n)。
OJ: luogu
题目 ID: P9233
难度:提高+/省选-
标签:树DFS序启发式合并树形结构计数
创建: 2026-10-02 15:07
更新: 2026-10-03 11:07
形式化题目
给定一棵以
对每个结点
若
求颜色平衡的子树的个数。
需要注意:参与比较的只有出现过的颜色。子树里没出现的颜色不算“出现 0 次”去和别的颜色比较,所以“子树里只有一种颜色”一定平衡。
暴力解法
思路
题意的逐字翻译:对每个结点
具体做法:
- 用
bucket[c]记录颜色在当前子树中出现几次。 - 从
出发递归遍历整棵子树,把每个点的颜色扔进桶,同时把“第一次出现的颜色”记到 seen[]里。 - 遍历
seen[]中的所有颜色,检查它们的bucket值是否全部相等。 - 用
seen[]把桶清干净,恢复给下一个结点使用。
这个写法完全不需要任何额外观察,是最容易确认正确的基线,专门用来对拍和理解题意。
代码
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-02 15:07
* update_at: 2026-10-03 11:07
*/
// brute.cpp:小数据暴力解,用来帮助理解题意并辅助对拍。
// 做法:对每个结点 u,重新完整遍历一次子树 u,统计每种颜色出现几次,
// 再检查所有出现过的颜色点数是否相同。复杂度约为 O(n^2),只适合小数据。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 200005;
int n;
int col[MAXN]; // col[i]:结点 i 的颜色
int fa[MAXN]; // fa[i]:结点 i 的父亲
vector<int> son_list[MAXN]; // 孩子列表
int bucket[MAXN]; // bucket[c]:本轮统计中颜色 c 的结点个数
int seen[MAXN]; // seen[1..seen_cnt]:本轮出现过的颜色,用来检查和清空桶
int seen_cnt;
ll ans;
// 把 u 及其所有后代的颜色扔进桶,返回这棵子树的结点个数。
int collect(int u) {
if (bucket[col[u]] == 0) {
seen_cnt++;
seen[seen_cnt] = col[u];
}
bucket[col[u]]++;
int total = 1;
for (int i = 0; i < (int)son_list[u].size(); i++) {
total += collect(son_list[u][i]);
}
return total;
}
// 对每个结点暴力统计它自己的子树。
void dfs_solve(int u) {
seen_cnt = 0;
collect(u);
// 所有出现过的颜色,结点个数必须完全一样。
bool ok = true;
int first = bucket[seen[1]];
for (int i = 2; i <= seen_cnt; i++) {
if (bucket[seen[i]] != first) {
ok = false;
break;
}
}
if (ok) {
ans++;
}
// 还原桶,供后面的结点使用。
for (int i = 1; i <= seen_cnt; i++) {
bucket[seen[i]] = 0;
}
for (int i = 0; i < (int)son_list[u].size(); i++) {
dfs_solve(son_list[u][i]);
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> col[i] >> fa[i];
}
for (int i = 2; i <= n; i++) {
son_list[fa[i]].push_back(i);
}
dfs_solve(1);
cout << ans << "\n";
return 0;
}复杂度
设树的结点数为
总复杂度为
而
于是暴力是 brute.cpp,GCC 16 -O2)能看清这个增长趋势:
| 10000 | 20000 | 40000 | |
|---|---|---|---|
| 用时 | 0.41 s | 1.61 s | 6.31 s |
每翻倍一次耗时约四倍,到
瓶颈
瓶颈非常明确:每个结点都从零重新统计自己的子树,同一个点的颜色会被它的每一个祖先重复数一遍,重复次数等于它的深度。
想要变快,就必须让父亲复用儿子的统计结果,把“每个点被数几次”从“深度”降下来。
正解
思路
第一步:把判定改成
暴力里最贵的部分之一是第 3 步“遍历出现过的颜色看是否全相等”。但其实不需要真的逐个比较。
设子树
反过来,如果
所以我们只需要维护一个额外的数组:
occ[t]:当前统计范围内,恰好出现次的颜色种数。
再配合 kind(出现过的颜色种数),判定就变成了两行:
if (siz[u] % kind == 0 && occ[siz[u] / kind] == kind) ans++;occ 和 kind 都可以在“把一个点加入/移出统计”时
- 把一个颜色
的点加入时,它的次数从 变成 : occ[x]--,occ[x+1]++;若,说明这是一个新颜色, kind++。 - 移出时对称:
occ[x]--;若则 occ[x-1]++,否则这个颜色消失了,kind--。
occ[0] 没有意义,代码里用 cnt[c] > 0 来判断“这个颜色是否出现过”,就不必维护 occ[0]。
第二步:让父亲复用儿子的统计
现在我们有了
如果每个儿子都清掉,那每个点还是会被反复计入,复杂度退化成
DSU on Tree(树上启发式合并) 的关键动作是:只留一个“值得留”的儿子。
- 每个结点选出子树最大的儿子,称为重儿子
heavy[u],其余儿子称为轻儿子。 - 处理顺序:先把所有轻儿子各自算完、算完立刻清空;再算重儿子,算完保留;最后把轻儿子子树和
自己补进来。此时统计范围恰好是子树 ,直接判定,然后按需清空。
这样父亲手上有重儿子现成的统计,只需要额外加入“轻儿子们的总大小 + 1”个点,而不是重新加入一整棵子树。
第三步:为什么这样是
需要回答一个问题:一个点到底会被加入多少次?
引理:若
是 的轻儿子,则 。
理由是:重儿子是
于是从任意结点往上走到根,每经过一条轻边,所在子树大小至少翻倍。子树大小不超过
而一个点
因此总加入次数为
第四步:用 dfs 序把“加入整棵子树”变成区间循环
我们需要一个操作:把整棵子树 dfn[] 和 euler[],此时子树
一段连续区间。于是“加入整棵子树”就是一个普通 for 循环,既快又不需要递归。
另外,siz[i] 累加到 siz[F_i],并顺手用 siz[heavy[f]] 的比较维护重儿子。
第五步:把递归也换成迭代
到这里算法已经完整了,但还有一个实现陷阱:DSU on Tree 一般是递归写的,而
解决办法是把递归改成“显式栈 + 阶段标记”。观察递归版 dfs_sack(u, keep) 在每个结点上做三件事:
- 处理所有轻儿子(各自算完清空);
- 处理重儿子(算完保留);
- 补上轻儿子和自己、判定答案、按需清空。
于是给每个结点一个阶段 stage[u]:弹出 stage[u] == 0,就压入所有轻儿子并把 stage[u] 置 1;若 stage[u] == 1,就压入重儿子并把 stage[u] 置 2;若 stage[u] == 2,就执行第 3 件事并真正出栈。
keep 参数也换成数组:一个结点是父亲的重儿子就保留,否则清空。因为变长部分(儿子列表的遍历)全部用 for 循环完成、不需要入栈,所以每个结点最多同时占用一个栈位。
例如对下面这棵以 1 为根的小树(2 是 1 的重儿子,6 是 2 的重儿子,7 是 3 的重儿子),实际的判定顺序是
1
/ \
2 3 siz[2] = 4, siz[3] = 2 -> 1 的重儿子是 2
/ | \ \
4 5 6 7 siz[4]=siz[5]=siz[6]=siz[7]=1
-> 2 的重儿子是 6,3 的重儿子是 7
处理顺序与统计状态({...} 表示当前统计里的点):
① 轻儿子 3:
先算它的重儿子 7 -> {7},判定 7,保留
再补入轻儿子(无)和自己 3 -> {7,3} = S_3,判定 3
3 不是 1 的重儿子,整体清空 -> {}
② 重儿子 2:
先算轻儿子 4、5(各算完就清)-> {} -> {}
再算重儿子 6 -> {6},判定 6,保留
补入轻儿子 4、5 和自己 2 -> {6,4,5,2} = S_2,判定 2
2 是 1 的重儿子,保留 -> {6,4,5,2}
③ 回到 1:
此时统计里已经是 S_2,再补入轻儿子 3 的整棵子树 {3,7} 和自己 1
-> {6,4,5,2,7,3,1} = S_1,判定 1读这张图时注意两点:判定
代码
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-02 15:07
* update_at: 2026-10-03 11:07
*/
// main.cpp:P9233 颜色平衡树。
// 判断每个结点的子树是否“每种颜色的点数都相同”,用 DSU on Tree(树上启发式合并)做到 O(n log n)。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 200005;
int n;
int col[MAXN]; // col[i]:结点 i 的颜色
int fa[MAXN]; // fa[i]:结点 i 的父亲,fa[1] = 0
vector<int> son_list[MAXN]; // son_list[i]:结点 i 的孩子列表
int siz[MAXN]; // siz[u]:子树大小
int heavy[MAXN]; // heavy[u]:u 的重儿子编号,0 表示没有重儿子
bool keep_flag[MAXN]; // keep_flag[u]:算完子树 u 后是否保留统计信息(u 是父亲的重儿子时保留)
int dfn[MAXN]; // dfn[u]:u 的 dfs 序
int euler[MAXN]; // euler[k]:dfs 序为 k 的结点编号
int timer; // dfs 序计数器
int stk[MAXN]; // 求 dfs 序时用的手写栈
int work_stk[MAXN]; // 模拟 dfs_sack 时用的手写栈
int stage[MAXN]; // stage[u]:结点 u 当前处在 dfs_sack 的哪一步
// 当前统计范围内(一段连续结点)的颜色信息
int cnt[MAXN]; // cnt[c]:颜色 c 出现的次数
int occ[MAXN]; // occ[k]:恰好出现 k 次的颜色种数
int kind; // 当前统计范围内出现过的颜色种数
ll ans; // 颜色平衡的子树个数
// 把结点 u 的颜色计数加上 delta(delta = +1 或 -1),同时维护 occ 和 kind。
void add_point(int u, int delta) {
int c = col[u];
if (cnt[c] > 0) {
occ[cnt[c]]--; // 颜色 c 的旧次数不再计数
} else {
kind++; // 颜色 c 第一次出现
}
cnt[c] += delta;
if (cnt[c] > 0) {
occ[cnt[c]]++; // 颜色 c 的新次数开始计数
} else {
kind--; // 颜色 c 的点全部被移走
}
}
// 把整棵子树 u 的结点全部加入 / 移出统计范围。
// 子树在 dfs 序上是一段连续的区间,所以这里是一次简单的区间循环。
void add_subtree(int u, int delta) {
int l = dfn[u];
int r = dfn[u] + siz[u] - 1;
for (int i = l; i <= r; i++) {
add_point(euler[i], delta);
}
}
// 求子树大小和重儿子。题目保证 fa[i] < i,倒着扫一遍就能把孩子的大小汇总给父亲。
void build_size() {
for (int i = 1; i <= n; i++) {
siz[i] = 1;
heavy[i] = 0;
keep_flag[i] = false;
}
for (int i = n; i >= 2; i--) {
int f = fa[i];
siz[f] += siz[i];
if (siz[i] > siz[heavy[f]]) { // siz[0] 始终为 0,所以 heavy[f] == 0 时也能直接比较
heavy[f] = i;
}
}
// 重儿子算完后要保留信息给父亲复用,也就是递归版里参数 keep = true 的那些结点。
keep_flag[1] = true;
for (int i = 2; i <= n; i++) {
keep_flag[i] = (heavy[fa[i]] == i);
}
}
// 手写栈求 dfs 序。把一个点弹出时立刻给它编 dfs 序,再把它的全部孩子压栈。
// 因为栈是后进先出,弹出 u 之后压入的孩子会排在 u 的兄弟之上,
// 所以子树里的点会在 u 之后被连续弹出,每个点的后代恰好构成一段连续区间。
void build_euler() {
int top = 0;
stk[++top] = 1;
timer = 0;
while (top > 0) {
int u = stk[top];
top--;
timer++;
dfn[u] = timer;
euler[timer] = u;
for (int i = 0; i < (int)son_list[u].size(); i++) {
stk[++top] = son_list[u][i];
}
}
}
// DSU on Tree 主体。
// 递归版每个结点要处理三件事:① 先算所有轻儿子并清空;② 再算重儿子并保留;
// ③ 补上轻儿子和自己,判断答案,最后决定是否清空。
// 这里用 work_stk 模拟这个过程:stage[u] = 0 / 1 / 2 分别表示“还没开始”“轻儿子刚算完”
// “重儿子刚算完”,以便同一个结点被访问三次时做不同的事。
// 用迭代而不用递归,是为了防止链状数据(深度可达 2e5)把递归栈压爆。
void run_sack() {
int top = 0;
work_stk[++top] = 1;
stage[1] = 0;
while (top > 0) {
int u = work_stk[top];
if (stage[u] == 0) {
// 第一次访问 u:把所有轻儿子压栈,让它们各自单独算并清空。
stage[u] = 1;
for (int i = (int)son_list[u].size() - 1; i >= 0; i--) {
int v = son_list[u][i];
if (v != heavy[u]) {
top++;
work_stk[top] = v;
stage[v] = 0;
}
}
continue;
}
if (stage[u] == 1) {
// 第二次访问 u:轻儿子都算完并清空了,再算重儿子,作为 u 的“底子”。
stage[u] = 2;
if (heavy[u] != 0) {
top++;
work_stk[top] = heavy[u];
stage[heavy[u]] = 0;
}
continue;
}
// 第三次访问 u:此时统计范围正好是子树重儿子,补上轻儿子子树和 u 自己,
// 得到的就恰好是整棵子树 u。
for (int i = 0; i < (int)son_list[u].size(); i++) {
int v = son_list[u][i];
if (v != heavy[u]) {
add_subtree(v, 1);
}
}
add_point(u, 1);
// 判断子树 u 是否颜色平衡。
// 设子树大小为 S、出现过的颜色数为 kind。
// 每种颜色点数都相同 <=> 每种颜色都出现 S / kind 次 <=> kind 整除 S 且 occ[S / kind] == kind。
if (siz[u] % kind == 0 && occ[siz[u] / kind] == kind) {
ans++;
}
// 轻儿子要把信息清掉;重儿子(含 u 的整棵子树)留给父亲复用。
if (!keep_flag[u]) {
add_subtree(u, -1);
}
top--;
}
}
void read_input() {
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> col[i] >> fa[i];
}
}
void solve() {
for (int i = 2; i <= n; i++) {
son_list[fa[i]].push_back(i);
}
build_size();
build_euler();
run_sack();
cout << ans << "\n";
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
read_input();
solve();
return 0;
}复杂度
- 求子树大小与重儿子:
。 - 求 dfs 序:
。 - DSU on Tree:每个点被加入
次,每次 add_point是,共 。 - 判定:每个点
,共 。
时间和空间分别是
其中
总结
这道题的难点不在数据结构,而在于两个需要想清楚的地方:
- 判定条件的等价改写。 把“所有出现过的颜色次数都相同”改写成“
且恰好有 种颜色出现 次”,从而把判定降到 。这一步是所有优化的基础——如果不做这一步,即使统计范围已经就绪,判定本身仍要遍历颜色。 - 选择性地保留信息。 统计状态只有一份,不可能继承所有儿子,所以要挑一个最有价值的儿子留下。选“子树最大的儿子”,换来“每个点最多被重加
次”的结论,这就是 DSU on Tree 的全部精髓。
实现层面还有一条经验值得记住:在
同类问题的识别特征:需要对每个子树回答一个可以用少量全局计数器