AIGC标识 树上启发式合并

AI写的

子树内树上启发式合并

一、它是什么

DSU on tree,又叫 树上启发式合并 / Sack,是一种处理静态树子树离线询问的算法。名字里有 DSU,但它和普通并查集关系不大,本质是:

重儿子信息保留,轻儿子暴力合并,不需要时整体清空。

典型复杂度 \(O(n\log n)\),空间 \(O(n)\)。


二、适用场景

通常满足:

  • 有一棵静态树;
  • 询问某个子树内的统计信息;
  • 没有修改,或者可以离线;
  • 信息容易“加入一个点”,也容易“清空/撤销”;
  • 询问可加,例如:
    • 子树内不同颜色数;
    • 子树内出现次数最多的颜色;
    • 子树内某种计数、权值和;
    • 子树内满足某条件的点数等。

典型题:CF600E Lomsat gelral。


三、核心思想

先做一次 DFS,求出:

  • sz[u]:子树大小;
  • son[u]:重儿子,即子树大小最大的儿子。

然后第二次 DFS:

  1. 先递归处理所有轻儿子,并且不保留它们对全局统计的贡献;
  2. 递归处理重儿子,并保留重儿子的贡献;
  3. 把轻儿子子树暴力加入当前统计;
  4. 把当前节点 u 加入统计;
  5. 此时统计结果就是子树 u 的答案;
  6. 如果当前节点是轻儿子(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\) 的轻儿子,则:

\[sz[son[u]] \ge sz[v] \]

而

\[sz[u] \ge 1 + sz[son[u]] + sz[v] \ge 1 + 2sz[v] \]

所以:

\[sz[v] < \frac{sz[u]}{2} \]

也就是说,每经过一条轻边,子树大小至少减半。

现在看一个点 \(x\) 会被暴力加入多少次:

  • 它自己作为当前节点被 addNode 加入一次;
  • 它作为某个祖先的轻儿子子树成员,被 addSubtree 暴力加入。

每次它因为“轻儿子”被暴力加入,说明从它到那个祖先之间经过了一条轻边。
每经过一条轻边,子树大小至少翻倍,所以一个点最多经过 \(O(\log n)\) 条轻边。

因此每个点被暴力加入的次数是:

\[O(\log n) \]

总加入次数:

\[O(n\log n) \]

删除操作和加入同阶,也是 \(O(n\log n)\)。
重儿子的信息被保留,不需要重复加入,这正是优化的来源。


八、易错点

  1. 重儿子按子树大小最大选,不是按深度。
  2. dfs2(u, p, keep) 中,轻儿子传 false,重儿子传 true。
  3. 删除操作通常只改计数,不维护复杂统计量,最后直接重置全局变量。
  4. 如果删除操作很难维护,可以用“记录修改栈后撤销”的方式。
  5. DSU on tree 适合子树问题,不适合带修改的在线问题。
  6. 它本质是启发式合并,不是普通并查集。
  7. 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)\) 的字符奇偶状态是:

\[dis[x] \oplus dis[y] \]

合法 \(\iff\) \(\operatorname{popcount}(dis[x] \oplus dis[y]) \le 1\)。

路径长度是:

\[dep[x] + dep[y] - 2 \cdot dep[\operatorname{LCA}(x,y)] \]

当 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\),使得:

\[\operatorname{popcount}(dis[x] \oplus dis[y]) \le 1 \]

即 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)\)。

所以查询总复杂度:

\[O(22 \cdot n\log n) \]

每个点被 addSubtree 加入多少次?

同理,\(O(\log n)\) 次。每次 addNode 是 \(O(1)\)(一次数组取 max)。

加入总复杂度:

\[O(n\log n) \]

删除

delSubtree 和 addSubtree 同阶,\(O(n\log n)\)。

总复杂度

\[O(22 \cdot n\log n) = 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 正确;
  • 继承子树答案保证子树内的路径不丢。
posted @ 2026-10-30 10:44  tqytql  阅读(12)  评论(0)    收藏  举报