Codeforces 191C 从树上差分到路径边计数
先明确状态与不变量,再对照手算、证明、边界和实现。
章节速览7 节 · 展开选择
LCA 与祖先查询回答“两点在哪里会合”,子树差分把一次子树更新变成少量标记。这篇把二者接起来:给定很多条树上路径,先记下每条路径的两个端点,再从叶子向根收集标记,就能知道每条边被经过了几次。
关键问题是:为什么最近公共祖先要减二,子树里的标记和为什么恰好对应一条边?
官方题意与离线条件
191C · Fools and Roads 官方难度 1900,标签为 data structures、dfs and similar、trees。题名、标签、难度与输入范围核对于 2026-10-28,正文用自己的语言整理问题与解法。
输入一棵 n 个点的无向树,再给出 k 对端点 (a,b)。每对端点代表一次沿唯一简单路径的行程;要求输出每条边被这些行程经过的次数,保持边的输入顺序。不是统计不同端点对的种数,重复行程也要分别贡献一次。
约束为 2≤n≤100000、0≤k≤100000;端点编号在 1 到 n 之间,同一次行程允许 a=b,这时不经过任何边。树保证连通且无环,代码按这些合法输入条件实现。
若对每条路径逐边累加,链状树里一条路径就可能长达 n−1,总时间最坏为 O(nk)。本题所有行程给完才输出答案,不要求每加入一条路径就立即询问,因此可以把更新先记账,最后一次性汇总。
一条路径只留下三个位置的标记
任取 1 为根。令 w=LCA(a,b),为一次行程添加:
d[a] += 1,d[b] += 1,d[w] −= 2。
若某些位置重合,更新都累加在同一个格子。例如 a 是 b 的祖先时,w=a,a 的净变化是 −1;a=b 时,+1+1−2=0,整条零长度路径不会留下标记。
把 d 看作需要向父亲传递的“未抵消数量”。a 与 b 各送出一份,它们沿父链到 w 时汇合;w 的 −2 阻止它们继续流到 w 的父边。这个直觉帮助记忆,下面还需要用切边证明排除遗漏。
flowchart LR A[一条路径 a 到 b] --> B[求最近公共祖先 w] B --> C[两个端点各加一] B --> D[w 减二] C --> E[保存所有路径的点标记] D --> E E --> F[最后统一向父亲汇总]
切开一条边,证明子树和就是答案
考察非根节点 v,以及它的父边 (parent[v],v)。删去这条边后,树分为两部分,一部分恰好是 v 的子树 S。
树上的简单路径会经过这条边,当且仅当两个端点恰有一个在 S 内。一条简单路径不会来回跨过同一条边。
对刚才的一次 (a,b) 更新,S 内的差分和为:
1[a∈S] + 1[b∈S] − 2·1[w∈S]。
这里 1[条件] 在条件成立时为 1,否则为 0。因为 S 是完整的有根子树,w 在 S 内当且仅当 a、b 都在 S 内:若两点在 S 内,它们的 LCA 不会跑到 v 上方;若 w 在 S 内,作为 w 后代的两个端点也都在 S 内。
| a、b 在 S 内的数量 | w 是否在 S 内 | 子树差分和 | 是否经过父边 |
|---|---|---|---|
| 0 | 否 | 0+0−0=0 | 否 |
| 1 | 否 | 1+0−0=1 | 是 |
| 2 | 是 | 1+1−2=0 | 否 |
因此一次行程对 S 的差分和,恰好等于这次行程是否经过 v 的父边。对所有行程相加,线性叠加仍成立。定义
sum[v] = v 子树中所有初始差分标记 d 的总和,
便得到 answer[parentEdge[v]]=sum[v]。这同时证明了标记公式、累加方法以及答案与父边的对应关系。
根的子树包含整棵树,每次行程的标记总和都是零,所以最后 sum[1]=0;它是一个调试不变量,根本身没有需要输出的父边。
路径 4→5 完全位于子树 2 中,为什么边 1—2 的答案不增加?
两个端点各加 1,LCA=2 减 2。子树 2 内的总变化为 1+1−2=0;路径只走 4—2—5,不跨过子树边界。若只给 LCA 减 1,就会错误地向父边多传一份。
七个点,把标记和输出顺序分开看
使用图中的树,但把六条边按 (2,4)、(1,2)、(3,6)、(1,3)、(2,5)、(3,7) 的顺序输入。四次行程依次为 (4,5)、(4,6)、(2,7)、(3,3)。前三条分别演示同子树、跨子树、以内部节点为端点;最后一条检查零长度边界。2 并不是 7 的祖先;若另考察 2→4,LCA 才与端点 2 重合。
| 行程 | LCA | 对初始 d 的净变化 | 实际经过的边 |
|---|---|---|---|
| 4→5 | 2 | d₄+1,d₅+1,d₂−2 | 4—2,2—5 |
| 4→6 | 1 | d₄+1,d₆+1,d₁−2 | 4—2,2—1,1—3,3—6 |
| 2→7 | 1 | d₂+1,d₇+1,d₁−2 | 2—1,1—3,3—7 |
| 3→3 | 3 | d₃+1+1−2=0 | 无 |
| 节点 v | 初始 d[v] | 收集孩子后的 sum[v] | 写入的输入边编号 |
|---|---|---|---|
| 1 | −4 | 0 | 根,无父边 |
| 2 | −1 | 2 | 2 |
| 3 | 0 | 2 | 4 |
| 4 | 2 | 2 | 1 |
| 5 | 1 | 1 | 5 |
| 6 | 1 | 1 | 3 |
| 7 | 1 | 1 | 6 |
例如节点 2 收到孩子 4 的 2 和孩子 5 的 1,连同自己的 −1 得到 2;根收到两棵子树各 2,抵消自己的 −4,得到 0。最终按输入边编号输出:2 2 1 2 1 1。六个答案总和为 9,恰好等于四条路径长度 2+4+3+0。
倍增 LCA 与非递归汇总怎样配合
预处理时,记录每个点的深度、父亲、连接父亲的输入边编号,以及二次幂级祖先 up[j][v]。up[j][v] 表示 v 向上走 2ʲ 步的位置。为了方便提升,把根的祖先都设为根;这只是 LCA 实现的边界约定,不会给根额外输出一条边。
求 LCA 时,先把深点提到相同深度;如果两点相同就返回。否则从大到小尝试共同提升,只要两个候选祖先不同便同时跳跃。最终两个点停在 LCA 的不同孩子方向,返回它们的共同父亲。每次查询 O(log n)。
遍历树时使用一个会逐步追加孩子的 order 数组,保证每个父亲早于孩子出现。这里不依赖递归 DFS,也不要求子树区间连续。反向扫描这个顺序,就保证每个孩子先于父亲汇总;这是汇总真正需要的性质。
处理非根 v 时,所有孩子已把最终值加入 d[v],所以此刻 d[v] 就是 sum[v]。先把它写入父边答案,再执行 d[parent[v]]+=d[v]。不对根做这一步,避免根的自父亲约定引发多加一次。
flowchart TD
A[迭代遍历:父亲先于孩子] --> B[建立深度、倍增祖先与父边编号]
B --> C[每条路径求 LCA 并打标记]
C --> D[从 order 末尾向前取非根节点 v]
D --> E[此时 d 下标 v 已含整棵子树]
E --> F[写入父边答案,再加给父亲]
F --> G{非根节点处理完了吗}
G -->|否| D
G -->|是| H[按原输入边序输出]
完整 C++17 实现
下面先读取并保存全部行程,再调用计数函数,方便独立测试。父边使用从 0 开始的输入编号,输出位置无需重新排序。使用 long long 保存标记与计数;本题单条边答案至多 k,32 位已足够,但较宽类型也便于将每次 +1 推广为整数权重。
1 |
|
建树和最终汇总各 O(n),倍增预处理 O(n log n),k 次路径更新合计 O(k log n),总时间 **O((n+k) log n)**。上面的完整实现保存了 k 对端点,所以额外空间为 **O(n log n+k)**。若将行程边读边更新,才可去掉 O(k) 的保存空间;这里不把未采用的优化算进当前实现。
容易混淆的边界与下一步
| 情况 | 本文行为 | 常见错误 |
|---|---|---|
| k=0 | 每条边输出 0 | 假设至少有一次路径更新 |
| a=b | 三次标记在同一点抵消 | 把这个点误算成一条经过的边 |
| 一端是另一端祖先 | 祖先处净减 1 | 忘记端点与 LCA 会重合 |
| 多条相同路径 | 每次都计入,答案可为 k | 对端点对去重 |
| 十万点链 | 迭代遍历和汇总,不占递归调用栈 | 深递归导致栈溢出 |
| 边输入方向或顺序改变 | 用 parentEdge 映射回原编号 | 按点编号输出 n−1 个数 |
| 要统计经过的点 | 需要另一套差分边界 | 直接把本文子树和当点答案 |
点计数的常见标记是端点各 +1、LCA −1、LCA 的父亲 −1;当 LCA 为根时,最后一次减法放在不汇总到树内的虚拟 0 号点,或直接跳过。它和本文的 LCA −2 服务不同的输出对象。不要把点计数公式中根的虚拟父亲,和本代码为倍增方便设置的 parent[1]=1 混用。
如果根改成另一个点,边的经过次数会改变吗?怎样检查?
不会。LCA、初始标记和哪一端被称为孩子都会变化,但切开一条无向边后“两端点是否分居两侧”的事实不变。可以重标号把另一个点映射为根 1,并保留边的输入编号,核对输出仍一致。还可检查每个答案处于 0 到 k、全部答案之和等于所有路径长度之和;这些是必要检查,不能代替完整正确性证明。
本题的收益来自“全部更新之后再统一收集”。如果需要每次更新后立即查询,或路径更新具有赋值、最大值等无法直接线性相加的语义,就要重新选择结构。可继续读重链剖分与 Water Tree,对照它怎样把路径拆成多个区间;也可以回到树算法专题,把子树、祖先、路径这三种范围放在同一条学习路线中。
本文的核验把正文 C++17 实际编译,并用逐路径 BFS 还原、删边后的连通分量参考检查小树;大规模链和星另用区间覆盖与叶端点计数核对。验证程序保留在 tests,不属于在线解法,也不把这些测试当作 Codeforces 提交通过记录。