树上启发式合并
AI写的
子树内树上启发式合并
一、它是什么
DSU on tree,又叫 树上启发式合并 / Sack,是一种处理静态树子树离线询问的算法。名字里有 DSU,但它和普通并查集关系不大,本质是:
重儿子信息保留,轻儿子暴力合并,不需要时整体清空。
典型复杂度 \(O(n\log n)\),空间 \(O(n)\)。
二、适用场景
通常满足:
- 有一棵静态树;
- 询问某个子树内的统计信息;
- 没有修改,或者可以离线;
- 信息容易“加入一个点”,也容易“清空/撤销”;
- 询问可加,例如:
- 子树内不同颜色数;
- 子树内出现次数最多的颜色;
- 子树内某种计数、权值和;
- 子树内满足某条件的点数等。
典型题:CF600E Lomsat gelral。
三、核心思想
先做一次 DFS,求出:
sz[u]:子树大小;son[u]:重儿子,即子树大小最大的儿子。
然后第二次 DFS:
- 先递归处理所有轻儿子,并且不保留它们对全局统计的贡献;
- 递归处理重儿子,并保留重儿子的贡献;
- 把轻儿子子树暴力加入当前统计;
- 把当前节点
u加入统计; - 此时统计结果就是子树
u的答案; - 如果当前节点是轻儿子(
keep=false),则删除整棵子树u的贡献。
流程直观图:
处理轻儿子们,算完答案后清空
↓
处理重儿子,算完答案后保留
↓
把轻儿子们暴力加回来
↓
加入 u 自己
↓
得到 ans[u]
↓
如果 u 是轻儿子,清空 u 子树
四、keep 是什么
keep 是 dfs2(u, p, keep) 里的布尔标志,意思是:
处理完当前子树 \(u\)、记录好
ans[u]之后,要不要把子树 \(u\) 的信息保留在全局统计结构里?
keep == true:保留子树 \(u\) 的贡献,不清空;keep == false:记录完ans[u]后,把子树 \(u\) 的所有点从全局统计中删掉,恢复到调用dfs2(u)之前的状态。
注意:ans[u] 在清空之前就已经记录好了,所以清空不影响 \(u\) 的答案。
为什么需要 keep
全局统计结构是共享的。父节点处理不同儿子时,不能让前面的儿子信息污染后面的计算。
看父节点 \(u\) 的流程:
for (v : 轻儿子) {
dfs2(v, u, false); // 算完 v 的答案后,清空 v 子树
}
dfs2(son[u], u, true); // 重儿子算完后,保留重儿子信息
for (v : 轻儿子) {
addSubtree(v, u, 1); // 把轻儿子暴力加回来
}
addNode(u, 1); // 加入自己
ans[u] = sum; // 此时统计的是整棵 u 子树
- 轻儿子传
false:轻儿子答案算完后,父节点暂时不需要它的信息,因为后面还要处理重儿子和其他轻儿子。如果保留会污染后续统计。 - 重儿子传
true:重儿子子树很大,父节点最后计算 \(u\) 的答案时需要它的信息。如果也清空,后面还得把重儿子整棵子树重新加一遍,太浪费。
keep 表达的是“这次调用结束后要不要保留”,不是“这个点是不是重儿子”本身。通常轻儿子传 false,重儿子传 true,根节点传什么都行。
keep == false 时在 dfs2 最后执行:
if (!keep) {
addSubtree(u, p, -1); // 把整棵 u 子树从统计中删掉
mx = 0;
sum = 0;
}
五、模板:CF600E 子树颜色众数
题意:对每个子树,求出现次数最多的颜色的编号和。
const int N = 1e5 + 5;
vector<int> g[N];
int col[N];
int sz[N], son[N];
int cnt[N], ans[N];
int mx; // 当前最大出现次数
long long sum; // 当前出现次数为 mx 的颜色编号和
void dfs1(int u, int p) {
sz[u] = 1;
son[u] = 0;
for (int v : g[u]) {
if (v == p) continue;
dfs1(v, u);
sz[u] += sz[v];
if (sz[v] > sz[son[u]]) son[u] = v;
}
}
void addNode(int u, int val) {
int c = col[u];
if (val == 1) {
++cnt[c];
if (cnt[c] > mx) {
mx = cnt[c];
sum = c;
} else if (cnt[c] == mx) {
sum += c;
}
} else {
--cnt[c];
// 删除时不维护 mx/sum,最后整体清空时重置
}
}
void addSubtree(int u, int p, int val) {
addNode(u, val);
for (int v : g[u]) {
if (v == p) continue;
addSubtree(v, u, val);
}
}
void dfs2(int u, int p, bool keep) {
// 1. 先处理轻儿子,不保留贡献
for (int v : g[u]) {
if (v == p || v == son[u]) continue;
dfs2(v, u, false);
}
// 2. 处理重儿子,保留贡献
if (son[u]) {
dfs2(son[u], u, true);
}
// 3. 暴力加入轻儿子子树
for (int v : g[u]) {
if (v == p || v == son[u]) continue;
addSubtree(v, u, 1);
}
// 4. 加入自己
addNode(u, 1);
// 5. 此时统计的是 u 子树的答案
ans[u] = sum;
// 6. 如果当前是轻儿子,删除整棵子树贡献
if (!keep) {
addSubtree(u, p, -1);
mx = 0;
sum = 0;
}
}
调用:
dfs1(1, 0);
dfs2(1, 0, true);
为什么删除时不维护 mx/sum
删除时只把计数减掉:
--cnt[c];
不认真维护 mx 和 sum。因为删除只发生在“整棵子树不要了,准备清空”的时候,删除完马上:
mx = 0;
sum = 0;
之后不会拿这个中间状态的 mx/sum 去回答任何询问。只要 cnt 正确减回 0,最后重置全局变量即可。如果删除后还要继续查询,那就必须维护删除,但标准 DSU on tree 流程不需要。
六、为什么是对的
用不变量做归纳。定义调用:
dfs2(u, p, keep)
要求它满足:
调用前,全局数据结构中不含子树 \(u\) 的任何点。
调用后:
- 若
keep == true,数据结构恰好包含子树 \(u\) 的所有点;- 若
keep == false,数据结构恢复到调用前状态,即不含子树 \(u\) 的任何点。
并且ans[u]已经在调用结束时被正确记录。
对树做归纳,假设所有儿子的 dfs2 都满足这个性质。
处理轻儿子
for (v : 轻儿子) dfs2(v, u, false);
每个轻儿子调用前,数据结构不含 \(v\) 子树;调用 keep=false 后,又把 \(v\) 子树删干净了。
所以所有轻儿子处理完后,数据结构仍然不含 \(u\) 的任何后代。
但它们的 ans[v] 已经算好并保存了,清空不影响答案。
处理重儿子
dfs2(son[u], u, true);
重儿子调用前,数据结构不含重儿子子树;调用 keep=true 后,数据结构中保留了重儿子子树的全部信息。
这是为了省掉后面重新加入重儿子子树的代价。
暴力加入轻儿子和自己的点
for (v : 轻儿子) addSubtree(v, u, 1);
addNode(u, 1);
此时:
- 重儿子子树信息已经在里面;
- 轻儿子子树被暴力加回来;
- 自己也被加入。
所以数据结构恰好包含整棵子树 \(u\)。
因为统计量是可加的,比如计数、颜色出现次数、权值和等,逐点加入和一次性加入整棵子树等价。加入顺序不影响最终统计结果。
记录答案
ans[u] = sum;
这时数据结构完整包含子树 \(u\),所以 sum 就是子树 \(u\) 的答案。
答案是在统计完整之后记录的,因此不会漏。
如果当前是轻儿子,就清空
if (!keep) {
addSubtree(u, p, -1);
mx = 0;
sum = 0;
}
如果 \(u\) 是作为轻儿子被调用的,父节点不希望保留 \(u\) 的信息,所以要把整棵子树 \(u\) 删掉,恢复到调用前状态。
虽然 \(u\) 的 ans 已经记录,删掉不影响它。
如果 \(u\) 是重儿子,keep=true,就不删,把信息留给父节点用。
所以归纳成立:每个 dfs2(u) 结束时,ans[u] 都是正确的。
一句话总结正确性
重儿子信息保留,轻儿子信息暴力补;补完并加入自己后,数据结构恰好是整棵子树 \(u\),此时记录 ans[u];若 \(u\) 是轻儿子,再把整棵子树清空,保证父节点计算时不受干扰。
七、为什么复杂度是 \(O(n\log n)\)
关键性质:轻儿子子树大小不超过父节点子树大小的一半。
因为 son[u] 是最大的儿子。若 \(v\) 是 \(u\) 的轻儿子,则:
而
所以:
也就是说,每经过一条轻边,子树大小至少减半。
现在看一个点 \(x\) 会被暴力加入多少次:
- 它自己作为当前节点被
addNode加入一次; - 它作为某个祖先的轻儿子子树成员,被
addSubtree暴力加入。
每次它因为“轻儿子”被暴力加入,说明从它到那个祖先之间经过了一条轻边。
每经过一条轻边,子树大小至少翻倍,所以一个点最多经过 \(O(\log n)\) 条轻边。
因此每个点被暴力加入的次数是:
总加入次数:
删除操作和加入同阶,也是 \(O(n\log n)\)。
重儿子的信息被保留,不需要重复加入,这正是优化的来源。
八、易错点
- 重儿子按子树大小最大选,不是按深度。
dfs2(u, p, keep)中,轻儿子传false,重儿子传true。- 删除操作通常只改计数,不维护复杂统计量,最后直接重置全局变量。
- 如果删除操作很难维护,可以用“记录修改栈后撤销”的方式。
- DSU on tree 适合子树问题,不适合带修改的在线问题。
- 它本质是启发式合并,不是普通并查集。
ans[u]必须在清空之前记录,否则轻儿子的答案会丢。
九、和其他方法对比
- 普通暴力合并
map/set:复杂度可能 \(O(n\log^2 n)\); - DSU on tree:常数较小,代码固定,适合子树离线统计;
- 线段树合并:也能做子树合并,但代码更重;
- 莫队:适合路径/区间,不是专门子树。
十、总结
DSU on tree = 重儿子信息保留 + 轻儿子暴力合并 + 不保留时清空。
- 正确性:靠“调用前不含子树 \(u\),调用后按
keep保留或清空”的归纳不变量; - 复杂度:靠“轻边使子树至少减半”,每个点最多被暴力合并 \(O(\log n)\) 次,总复杂度 \(O(n\log n)\);
keep:表示“算完这棵子树后,要不要留着它的信息给父节点用”。
适用于静态树的子树离线统计问题,典型题 CF600E。
路径树上启发式合并详细讲解
下面从头讲,不跳任何一步。以 CF741D 为主线,因为它是最经典的路径 DSU 题。
一、先搞清楚:子树 DSU 和路径 DSU 到底差在哪
子树 DSU 处理的问题
对每个节点 \(u\),求子树 \(u\) 内部的某个统计量。
子树是一个固定的、往下封闭的区域。当你 dfs2(u) 时,只要保证全局结构里恰好是子树 \(u\) 的信息,就能回答 ans[u]。
路径 DSU 处理的问题
给定很多路径 \((x,y)\),或者需要统计所有路径中满足某条件的最优路径。
路径 \((x,y)\) 的形状不固定:可以向上拐、可以跨分支、可以一端是祖先。
关键困难:路径不是“一棵子树”,你没法用“进入子树时加、离开子树时删”的简单方式描述它。
二、路径问题的第一个关键观察:LCA 唯一
任何一条路径 \((x,y)\),它的 LCA 是唯一的,记作 \(l = \operatorname{LCA}(x,y)\)。
这意味着:所有路径可以按 LCA 分类,且每条路径只属于一个 LCA。
于是问题变成:
对每个节点 \(u\),处理所有 LCA 恰好等于 \(u\) 的路径,用这些路径更新答案。
每条路径只会在它的 LCA 处被处理一次,不会重复,不会遗漏。
这就是路径 DSU 的出发点。
三、第二个关键观察:LCA = u 的路径长什么样
设 \(l = \operatorname{LCA}(x,y) = u\)。那么 \(x\) 和 \(y\) 必须满足:
- \(x,y\) 都在子树 \(u\) 中;
- \(x,y\) 不能同时在 \(u\) 的同一个儿子的子树里(否则 LCA 会更深);
- 允许 \(x = u\) 或 \(y = u\)(此时路径一端是 LCA 自己)。
换句话说:
LCA = u 的路径,两个端点要么一个是 \(u\),要么分属 \(u\) 的两个不同儿子的子树。
这是一个非常强的结构。它告诉我们:处理 \(u\) 时,只需要关心“跨不同分支”的配对。
四、第三个关键观察:怎么在 \(u\) 处统计跨分支路径
假设我们有一个全局数据结构 \(D\),里面存着一些已经处理过的点的信息。
现在考虑 \(u\) 的某个儿子 \(v\)。如果 \(D\) 里的点都来自“\(v\) 子树之外”的区域,那么对于 \(v\) 子树中的任意点 \(x\):
- \(x\) 和 \(D\) 中任意点 \(y\) 的路径,一定经过 \(u\);
- 并且 \(u\) 就是 \(\operatorname{LCA}(x,y)\)(因为 \(x,y\) 分属不同分支)。
于是:用 \(x\) 去 \(D\) 中查询最优配对,得到的路径 LCA 恰好是 \(u\)。
这就是路径 DSU 的核心操作:
结构 D = 已处理分支的信息
对当前分支 v 中的每个点 x:
在 D 中查询与 x 配对的最优 y
用路径 (x,y) 更新答案
五、怎么保证“结构 D 里的点都来自 v 子树之外”
这是整个算法最关键的设计。我们用和子树 DSU 一样的“重儿子保留”框架,但顺序和操作要精心安排。
回顾子树 DSU 的顺序
1. 轻儿子 dfs2(false):算完清空
2. 重儿子 dfs2(true):算完保留
3. 轻儿子暴力加回来
4. 加入 u
5. 记录 ans[u]
6. 如果 u 是轻儿子,清空
路径 DSU 的顺序
1. 轻儿子 dfs2(false):算完清空
2. 重儿子 dfs2(true):算完保留
—— 此时结构 D 里只有重儿子子树的信息
3. 把 u 自己加入 D
—— 此时 D = {u} ∪ 重儿子子树
4. 对每个轻儿子 v:
a. 遍历 v 子树中每个点 x,用 x 去 D 中查询,更新答案
—— 此时 D 里是 {u} ∪ 重儿子 ∪ 之前处理过的轻儿子,全都在 v 子树之外
b. 遍历 v 子树中每个点,把它们加入 D
—— 供后面的轻儿子使用
5. 此时 D = 整棵子树 u
6. ans[u] 继承子树的答案
7. 如果 u 是轻儿子,清空
关键点:第 4a 步查询时,\(D\) 里的点全部来自 \(v\) 子树之外(\(u\) 自己、重儿子、之前处理过的轻儿子),所以 \(v\) 中任意点与 \(D\) 中任意点的路径 LCA 都是 \(u\)。
六、为什么必须“先查后加”,不能边加边查
假设你对轻儿子 \(v\) 这样做:
for (x in subtree(v)) {
在 D 中查询 x;
把 x 加入 D;
}
会发生什么?
当处理到 \(v\) 子树中的第二个点 \(x_2\) 时,\(D\) 里已经包含了 \(v\) 子树中的第一个点 \(x_1\)。
如果 \(x_1\) 和 \(x_2\) 恰好能配对,你会得到一条路径 \((x_1, x_2)\)。但这条路径的 LCA 是 \(v\) 下面的某个节点,不是 \(u\)。
这条路径应该在更深的递归中被统计,现在却在 \(u\) 处被错误统计了。
所以必须:
// 先全部查完
for (x in subtree(v)) 在 D 中查询 x;
// 再全部加入
for (x in subtree(v)) 把 x 加入 D;
这样 \(v\) 内部的点永远不会互相配对,每一对配对都保证跨分支,LCA 恰好是 \(u\)。
七、为什么答案要“继承”
考虑路径 \((x,y)\),它的 LCA 是 \(u\) 下面的某个节点 \(w\)。这条路径在 dfs2(w) 时已经被统计过了。
但 ans[u] 问的是“子树 \(u\) 内”的最优路径,它应该包含所有后代子树的答案。
所以:
ans[u] = max(ans[u], ans[son[u]], ans[每个轻儿子]);
在代码里,通常写成:
// 处理完 u 的所有跨分支路径后
if (son[u]) ans[u] = max(ans[u], ans[son[u]]);
for (v : 轻儿子) ans[u] = max(ans[u], ans[v]);
或者更简单:在 dfs2 开头/结尾直接继承。
八、完整代码:CF741D
题意回顾
每个节点有一条到父亲的边,边权是 'a'~'v' 的字符。对每个节点 \(u\),求子树 \(u\) 内最长的简单路径,使得路径上的字符重排后能形成回文串。
转化
字符重排后是回文串 \(\iff\) 至多一个字符出现奇数次。
把 'a'~'v' 压成 22 位二进制。定义 dis[u] 为根到 \(u\) 路径上所有边权的异或和(边权对应位为 1)。
则路径 \((x,y)\) 的字符奇偶状态是:
合法 \(\iff\) \(\operatorname{popcount}(dis[x] \oplus dis[y]) \le 1\)。
路径长度是:
当 LCA 固定为 \(u\) 时,要最大化 \(dep[x] + dep[y]\)。
数据结构设计
用一个数组:
int mxDep[1 << 22];
mxDep[state] 表示:当前结构 \(D\) 中,所有满足 dis[x] == state 的点 \(x\) 的最大深度。
为什么只存最大深度?因为 LCA 固定时,路径长度只和 \(dep[x] + dep[y]\) 有关,对于同一个 dis 状态,深度越大越好。
查询操作
给定点 \(x\),要在 \(D\) 中找一个点 \(y\),使得:
即 dis[y] 要么等于 dis[x](0 个不同位),要么等于 dis[x] ^ (1 << i)(恰好 1 个不同位,\(i \in [0,21]\))。
所以查询:
int query(int x) {
int dx = dis[x];
int ret = mxDep[dx]; // 0 个奇数字符
for (int i = 0; i < 22; ++i)
ret = max(ret, mxDep[dx ^ (1 << i)]); // 恰好 1 个奇数字符
return ret;
}
返回的是满足条件的 \(y\) 的最大深度。如果不存在,mxDep 初始化为 \(-\infty\)。
加入操作
void addNode(int x) {
mxDep[dis[x]] = max(mxDep[dis[x]], dep[x]);
}
删除操作
清空时,把子树中每个点的状态重置:
void delSubtree(int u, int p) {
mxDep[dis[u]] = -INF;
for (int v : g[u]) if (v != p) delSubtree(v, u);
}
注意:这里直接重置为 \(-\infty\),不是“减一”。因为同一个 dis 状态可能有多个点,我们只存最大深度,删除时直接清掉这个状态即可(只要保证这个状态在结构里没有别的点)。在 DSU 框架下,清空时是整棵子树一起清,不会有“同一状态还有别的点没删”的问题——因为清空后结构里这个子树的状态全没了。
严格来说,如果结构里还有别的子树的点具有相同
dis状态,直接置 \(-\infty\) 会误删。但在 DSU on tree 中,清空只发生在“当前子树是轻儿子,父节点不需要它”时,此时结构里只有当前子树的信息(父节点还没加入其他东西),所以安全。这是 DSU on tree 的不变量保证的。
查询整棵子树(只查不加)
void querySubtree(int u, int p, int root) {
int q = query(u);
if (q != -INF)
ans[root] = max(ans[root], dep[u] + q - 2 * dep[root]);
for (int v : g[u]) if (v != p) querySubtree(v, u, root);
}
这里 root 就是当前的 \(u\)(LCA),用来计算路径长度。
加入整棵子树
void addSubtree(int u, int p) {
addNode(u);
for (int v : g[u]) if (v != p) addSubtree(v, u);
}
核心 dfs2
void dfs2(int u, int p, bool keep) {
// 1. 轻儿子:算完清空
for (int v : g[u]) {
if (v == p || v == son[u]) continue;
dfs2(v, u, false);
}
// 2. 重儿子:算完保留
if (son[u]) dfs2(son[u], u, true);
// 3. 把 u 自己加入
addNode(u);
// 4. 对每个轻儿子:先查后加
for (int v : g[u]) {
if (v == p || v == son[u]) continue;
// 4a. 查询:v 子树中的点 与 结构中已有的点 配对
querySubtree(v, u, u);
// 4b. 加入:把 v 子树加入结构,供后面的轻儿子使用
addSubtree(v, u);
}
// 5. 继承子树的答案
if (son[u]) ans[u] = max(ans[u], ans[son[u]]);
for (int v : g[u]) {
if (v == p || v == son[u]) continue;
ans[u] = max(ans[u], ans[v]);
}
// 6. 如果 u 是轻儿子,清空
if (!keep) {
delSubtree(u, p);
}
}
完整代码
#include<iostream>
#include<cstring>
#include<algorithm>
#include<queue>
#include<stack>
using namespace std;
namespace IO{
struct INID{INID(){ios::sync_with_stdio(false),cin.tie(nullptr);}};
static INID INI_D;
inline int gc(){return cin.rdbuf()->sbumpc();}
inline void pc(char c){cout.rdbuf()->sputc(c);}
template<typename T>void rd(T &x){
x=0;int f=1,c=gc();
while('0'>c||c>'9'){if(c=='-')f*=-1;c=gc();}
while('0'<=c&&c<='9'){x=x*10+c-'0';c=gc();}
x*=f;
}template<typename T>void wt(T x){
if(x<0)pc('-');
static int d,tp,stk[40];tp=0;
do{d=x%10;stk[++tp]=(d<0?-d:d)+'0';x/=10;}while(x);
while(tp)pc(stk[tp--]);
}
}using namespace IO;
/*-tpdef------------------------------*/
typedef long long ll;
#define int ll
/*-const------------------------------*/
constexpr int N=5e5+5;
constexpr int M=N*2;
constexpr int MASK=(1<<22)+5;
/*-def--------------------------------*/
int n;
struct graph{
struct edge{
int nxt,to,val;
}e[M];
int head[N],cntE;
inline void add(int u,int v,int w){
e[++cntE]={head[u],v,w};
head[u]=cntE;
}
inline int to(int i){return e[i].to;}
inline int val(int i){return e[i].val;}
inline int nxt(int i){return e[i].nxt;}
inline int hd(int u){return head[u];}
}e;
int ans[N],dep[N];
int son[N],sz[N];
int xorVal[N];//从根节点到u的权值异或
int mxDep[MASK];//结构中目前MASK最大的dep
/*-ds---------------------------------*/
/*-alg--------------------------------*/
void dfs1(int u,int fa,int msk){
dep[u]=dep[fa]+1;
xorVal[u]=msk;
sz[u]=1;
son[u]=0;
for(int i=e.hd(u);i;i=e.nxt(i)){
int v=e.to(i);
int w=e.val(i);
if(v==fa)continue;
dfs1(v,u,msk^w);
sz[u]+=sz[v];
if(sz[v]>sz[son[u]]){
son[u]=v;
}
}
}
inline int query(int u){
int ret=mxDep[xorVal[u]];
for(int i=0;i<22;++i){
ret=max(ret,mxDep[xorVal[u]^(1<<i)]);
}return ret;
}
inline void addNode(int u){
mxDep[xorVal[u]]=max(mxDep[xorVal[u]],dep[u]);
}
void addTree(int u,int fa){
addNode(u);
for(int i=e.hd(u);i;i=e.nxt(i)){
int v=e.to(i);
if(v==fa)continue;
addTree(v,u);
}
}
void queryTree(int u,int fa,int rt){
int dis=query(u)-dep[rt]+dep[u]-dep[rt];//注意这里求的是边树,+1-1抵消
ans[rt]=max(ans[rt],dis);
for(int i=e.hd(u);i;i=e.nxt(i)){
int v=e.to(i);
if(v==fa)continue;
queryTree(v,u,rt);
}
}
void dltTree(int u,int fa){
mxDep[xorVal[u]]=-0x3f3f3f3f3f3f3f3f;
for(int i=e.hd(u);i;i=e.nxt(i)){
int v=e.to(i);
if(v==fa)continue;
dltTree(v,u);
}
}
void dfs2(int u,int fa,bool keep){
for(int i=e.hd(u);i;i=e.nxt(i)){
int v=e.to(i);
if(v==fa||v==son[u])continue;
dfs2(v,u,false);
}
if(son[u]){
dfs2(son[u],u,true);
}
addNode(u);
ans[u]=max(ans[u],query(u)-dep[u]);
for(int i=e.hd(u);i;i=e.nxt(i)){
int v=e.to(i);
if(v==fa||v==son[u])continue;
queryTree(v,u,u);
addTree(v,u);
}
if(son[u]){
ans[u]=max(ans[u],ans[son[u]]);
}
for(int i=e.hd(u);i;i=e.nxt(i)){
int v=e.to(i);
if(v==fa||v==son[u])continue;
ans[u]=max(ans[u],ans[v]);
}
if(!keep){
dltTree(u,fa);
}
}
/*-main-------------------------------*/
inline void global(){
// freopen(".in","r",stdin);
}
namespace tqy{
inline void init(){
rd(n);
for(int i=1;i<=n-1;++i){
int u,v=i+1,w;
rd(u);
w=gc();
while(!('a'<=w&&w<='z'))w=gc();
w-='a';
e.add(u,v,1<<w);
e.add(v,u,1<<w);
}
}
inline void solve(){
memset(mxDep,-0x3f,sizeof mxDep);
dfs1(1,0,0);
dfs2(1,0,1);
for(int i=1;i<=n;++i){
wt(ans[i]),pc(" \n"[i==n]);
}
}
}
signed main(){
global();
int T=1;
while(T--)tqy::init(),tqy::solve();
return 0;
}
/*
*/
九、逐行解释 dfs2 的每一步
inline void dfs2(int u,int p,bool keep){
// 1. 轻儿子:算完清空
for(int v:g[u]){
if(v==p||v==son[u])continue;
dfs2(v,u,false);
}
为什么先处理轻儿子? 因为轻儿子的答案要先算出来(答案要继承),而且它们算完必须清空,不能让它们的信息污染后面重儿子的计算。
为什么传 false? 轻儿子算完后,当前节点 \(u\) 暂时不需要它的信息,清空保证结构干净。
// 2. 重儿子:算完保留
if(son[u])dfs2(son[u],u,true);
为什么重儿子放最后处理? 因为重儿子处理完后,\(u\) 不再处理其他“递归型”的儿子了(轻儿子的递归已经在第 1 步做完了)。重儿子保留下来,正好给 \(u\) 使用,不会再污染任何兄弟的计算。
为什么传 true? 重儿子最大,保留它省得后面重新加,这是复杂度的来源。
// 3. 加入 u 自己
addNode(u);
此时结构 \(D = \{u\} \cup\) 重儿子子树。\(u\) 自己加入,使得以 \(u\) 为一端的路径也能被统计。
// 4. 轻儿子:先查后加
for(int v:g[u]){
if(v==p||v==son[u])continue;
querySubtree(v,u,u);
addSubtree(v,u);
}
querySubtree(v,u,u):遍历 \(v\) 子树中每个点 \(x\),用 \(x\) 去 \(D\) 中查询。此时 \(D\) 里是 \(\{u\} \cup\) 重儿子 \(\cup\) 之前处理过的轻儿子,全部在 \(v\) 子树之外。所以 \(x\) 与 \(D\) 中任意点的路径 LCA 都是 \(u\)。
为什么先查? 防止 \(v\) 子树内部的点互相配对,那样 LCA 不是 \(u\)。
addSubtree(v,u):查询完后把 \(v\) 子树加入 \(D\),供后面的轻儿子配对使用。
// 5. 继承子树的答案
if(son[u])ans[u]=max(ans[u], ans[son[u]]);
for(int v:g[u]){
if(v==p||v==son[u])continue;
ans[u]=max(ans[u], ans[v]);
}
为什么继承? ans[u] 问的是“子树 \(u\) 内”的最优路径。LCA 在 \(u\) 下面的路径已经在递归中统计了,存在 ans[son] 和 ans[轻儿子] 里。\(u\) 处只统计了 LCA 恰好为 \(u\) 的路径。两者取 max 才是子树 \(u\) 的完整答案。
// 6. 轻儿子清空
if(!keep){
delSubtree(u,p);
}
如果 \(u\) 是作为轻儿子被调用的,父节点不需要 \(u\) 的信息,清空整棵子树,恢复到调用前状态。
十、复杂度分析
每个点被 querySubtree 遍历多少次?
一个点 \(x\) 被 querySubtree 遍历,当且仅当它属于某个祖先 \(u\) 的轻儿子子树。每次经过一条轻边,子树大小至少减半,所以最多 \(O(\log n)\) 次。
每次 querySubtree 中,对每个点调用一次 query,query 枚举 23 个状态(0 个 + 22 个一位翻转),是 \(O(22)\)。
所以查询总复杂度:
每个点被 addSubtree 加入多少次?
同理,\(O(\log n)\) 次。每次 addNode 是 \(O(1)\)(一次数组取 max)。
加入总复杂度:
删除
delSubtree 和 addSubtree 同阶,\(O(n\log n)\)。
总复杂度
\(22\) 是字符集大小,常数。
十一、常见变体和易错点
变体 1:查询的不是“最大深度”,而是“计数”
如果题目问的是“有多少对路径满足条件”,那么 mxDep 换成 cnt,查询返回计数之和,加入时 cnt[state]++。框架完全一样。
变体 2:路径有权值,要求路径和等于某值
数据结构换成 map 或哈希表,存“前缀和的某种函数”。查询时反推需要的状态。
易错点 1:忘记继承子树答案
ans[u] 必须取 max(ans[u], ans[son[u]], ans[每个轻儿子]),否则 LCA 在下面的路径会丢失。
易错点 2:先查后加写反
如果写成“边查边加”,同一子树内部的点会配对,LCA 错误。
易错点 3:u 自己忘记加入
如果第 3 步不加 addNode(u),那么以 \(u\) 为一端的路径(比如 \(u\) 到某个后代)不会被统计。
易错点 4:delSubtree 直接置 \(-\infty\) 的前提
在标准 DSU 框架下,清空时结构里只有当前子树的信息,所以直接置 \(-\infty\) 安全。但如果你在别的场景复用这个函数,要确认结构里没有其他子树的同状态点。
易错点 5:LCA 计算路径长度
路径长度 = dep[x] + dep[y] - 2 * dep[lca]。查询时返回的是 dep[y],所以:
ans[root] = max(ans[root], dep[x] + q - 2 * dep[root]);
这里 root 就是 LCA。
十二、和子树 DSU 的对照总结
| 步骤 | 子树 DSU | 路径 DSU |
|---|---|---|
| 轻儿子 | dfs2(v,u,false) |
同 |
| 重儿子 | dfs2(son,u,true) |
同 |
| 加入自己 | addNode(u,1) |
addNode(u) |
| 轻儿子处理 | 直接 addSubtree(v,u,1) |
先 querySubtree(v,u,u) 再 addSubtree(v,u) |
| 记录答案 | ans[u]=sum |
在 querySubtree 中更新 ans[u],再继承子树答案 |
| 清空 | addSubtree(u,p,-1) |
delSubtree(u,p) |
唯一的本质区别:路径 DSU 把“加入轻儿子”拆成了“先查后加”,因为路径要求配对的两点跨分支,而子树统计不要求。
十三、一句话总结
路径 DSU = 按 LCA 分类路径 + 重儿子信息保留 + 轻儿子“先查后加” + 继承子树答案。
- LCA 唯一性保证每条路径只被统计一次;
- 重儿子保留保证复杂度;
- 先查后加保证配对跨分支,LCA 正确;
- 继承子树答案保证子树内的路径不丢。

浙公网安备 33010602011771号