news 2026/10/1 15:14:33

树上差分与LCA:从“闇の連鎖”理解边差分模型

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
树上差分与LCA:从“闇の連鎖”理解边差分模型

看到“闇の連鎖”这个标题,我第一反应是哪个番的剧情,直到打开题面才发现:这是经典的树上差分模型题,考察的就是边差分 + dfs预处理这套组合拳。题目结构很简洁——n 个点,n-1 条主边构成一棵树,另外再给 m 条附加边。问的是同时砍掉一条主边和一条附加边,有多少种方案能让整棵树变成两个互不连通的部分。第一次做这题的人,十有八九会从“枚举第一刀、再枚举第二刀”往下钻,然后被数据范围劝退。这篇文章就把这道题从题意建模、边差分原理、dfs预处理到最终统计答案完整拆一遍,适合正在刷 LCA 和树上差分的选手参考,也适合想搞懂“为什么三个标记就能代替一整条路径暴力加一”的新手。

1. 先把“闇の連鎖”的题意翻译成“切两条边”

1.1 树边加附加边,砍一刀已经不够了

先说我第一次见到这题时的直觉:既然有 n-1 条主边,树本来就是连通的,随便删一条主边似乎就应该分成两块。但题目偏要你“再删一条附加边”,这个附加条件才是关键。主边被删掉之后,如果恰好有一条附加边跨过这条主边的两侧,那么两个小连通块还能通过这条路重新连起来,树并没有真正分裂。这也是题目名字里“連鎖”的意味:附加边就像是给树上了额外锁链,一条链不够,得把锁链也剪断。

所以题意要重新翻译一下:我们要找的其实是“一对边”,其中第一条必须是主边,第二条必须是附加边,删除它们之后,整张图的连通分量数从 1 变成 2。这里有个很容易被忽略的细节:附加边并不是可以随便乱删的普通边,它本质上是“非树边”,不会成为新的必须删的点,但同时它也不能单独断开树。理解了这一点,再看后面差分算法的目标就顺了:我们只关心主边被“多少条附加边跨过”,也就是每条主边的附加边覆盖次数。

1.2 朴素枚举为什么必然超时

假设 n 和 m 都在 1e5 的规模。直接枚举主边和附加边的配对,就有 O(nm) 种方案,大约是 1e10,哪怕每次判断连通性只花常数时间都完全不能接受;如果每次再暴力跑一遍 DFS 或者并查集判连通,复杂度还要再乘一个 n,那基本是天文数字。

因此正确思路不是枚举“两把刀”,而是只枚举第一把刀:先想清楚删掉一条主边之后树会裂成什么样,再来回答“第二把刀选哪些附加边能真正断开”。这样枚举范围从 O(nm) 降到了 O(n),剩下要解决的是“快速知道每条主边被多少条附加边覆盖”。这个数量就是我们说的覆盖次数 cnt[e],它是后面所有统计的地基。说白了,这是一道“先静态统计、再分类计数”的题,暴力动态判连通是南辕北辙。

1.3 方案数的真正含义:每条主边的“连桥责任”

设主边 e 被 cnt[e] 条附加边跨过。删掉 e 后,树的左右两侧之间如果有额外连接,一定是那些跨过 e 的附加边。于是分三种情况:

  • cnt[e] = 0:e 本身就是桥。删掉它树已经分裂,第二把刀附加边随便选哪条都行,方案贡献 m。
  • cnt[e] = 1:只有那一条跨过 e 的附加边连接左右。必须连它也删掉,树才真正变成两块,贡献 1。
  • cnt[e] >= 2:至少还有两条“残余桥梁”。只删一条附加边并不能断开,贡献 0。

把所有主边的贡献加起来就是答案。整个问题瞬时变成一个单纯的树上统计问题:给 m 条附加边,每条都在树上对应一条简单路径 u -> v,我需要对这条路径上的所有边覆盖次数 +1,最后查每条边被加了几次。暴力做会超时,于是来到今天的重头戏:边差分。

2. 边差分标记的原理:三个 O(1) 更新搞定一条路径

2.1 从“路径上暴力 +1”到“端点打标记”的直觉转换

先回顾一维差分的思路:对数组 a 的区间 [l, r] 每个位置 +1,不需要真的循环,只需要 d[l] += 1,d[r+1] -= 1,最后做前缀和还原。树上差分本质是同一思想搬到树结构上,只是“前缀和”变成了“从叶子到根的累加”。

在树上,任何一条简单路径 u -> v 都可以看成两部分:u 往上到 lca,v 往上到 lca。如果我们想给“u 到根路径上的所有边”+1,做法很简单,标记 d[u] += 1 就完事,因为回溯累加时这个 1 会一路上传,覆盖 u 到根的每一条边。同理,给“v 到根路径上的所有边”+1,就标记 d[v] += 1。但 u -> v 路径是两段往上爬的路径,不能包括 lca 上面的公共部分,我们需要在 lca 处把上溢的标记扣掉,这正是 -2 的来源。

2.2 为什么 lca 处是 -2 而不是 -1

这是边差分新手最容易卡住的地方,我也曾经在这里翻过车。先看点差分:如果给路径 u->v 上的“节点权值”+1,标记是 d[u]++, d[v]++, d[lca]--, d[fa[lca]]--。因为 lca 这个点本身要被 +1 一次,而且从 lca 到根的公共段会被两个端点同时累加,所以要用两个 -1 分别扣除。

但边差分不同。树上每条边都“下沉”到它深度更大的那个子节点,也就是说,我们在统计环节用 cnt[child] 来表示“父节点到该子节点的那条边”。对路径 u->v,它的边集包括 u 到 lca 路径上所有子节点对应的边、v 到 lca 路径上所有子节点对应的边,但绝对不包括 lca 自己对应的边(lca 到父节点的边不在这条路径上)。如果 lca 处只减 1,回溯时 lca 上面还会残留重复计数,只有减 2 才能让公共段恰好抵消为 0。

用手推一个最小例子:链 1 - 2 - 3,附加边是 3 - 1。lca(3,1)=1,标记为 d[3]++, d[1]++, d[1]-=2,即 d[3]=1, d[1]=-1。后序累加:cnt[3]=1,cnt[2]=cnt[3]+d[2]=1,cnt[1]=d[1]+cnt[2]=0。cnt[3] 表示边 2-3 被覆盖 1 次,cnt[2] 表示边 1-2 被覆盖 1 次,cnt[1] 没有对应父边,结果为 0,完全正确。如果当初写成 -1,结果会多出一些不该有的覆盖。多说一句,代码里的 diff[l] -= 2 一定要和以后会遇到的点差分区分开,这是我在给朋友讲题时最常提醒的一个位置。

2.3 后序回溯累加:把标记还原成每条边的覆盖次数

标记打完之后,还需要一次 DFS 把所有标记“兑现”成真实覆盖次数。顺序必须是后序:先递归完所有子树,再处理当前节点。伪代码逻辑:

cnt[u] = diff[u]; for v in children(u): dfs(v); cnt[u] += cnt[v];

为什么不能先序?因为一个节点的 cnt 依赖所有孩子的 cnt,而孩子的 cnt 又依赖他们的孩子,必须自底向上才能把端点上的 +1 一点一点推向根部。执行完之后,cnt[u] 就是“节点 u 和它父亲之间那条主边”的附加边覆盖次数。这里同步强调一个关键点:根节点没有父边,所以根节点的 cnt 不参与答案统计,后面代码里会专门处理。

3. dfs 预处理三件套:深度、倍增 LCA、差分数组布局

3.1 一次 dfs 建树能顺手拿到什么

整道题的第一步是 dfs 预处理,目的就一个:让后续任意两个节点的 LCA 查询都能在 O(log n) 内完成。第一次 dfs 从根(我习惯用 1 号点)出发,会顺手拿到三样东西:

  • depth[u]:节点深度,用于把 u、v 对齐到同一层。
  • up[0][u]:u 的父节点,是倍增表的第 0 层。
  • up[k][u] = up[k-1][up[k-1][u]]:u 往上跳 2^k 步到达的点。

因为树本身是无环的,遍历时只需要判断“v == fa”就能防止走回头路,不需要额外的 vis 数组。这部分代码很短,但有个顺序问题值得单独拿出来说。

3.2 倍增 LCA 查询的书写细节与边界

LCA 查询分两步。第一步,若 depth[a] < depth[b] 则交换,保证 a 更深;然后按照深度差 d 的二进制位,把 a 往上跳到和 b 同层。第二步,从 k = LOG-1 开始往下枚举,只要 up[k][a] != up[k][b],就让 a 和 b 同时往上跳。这个“从大到小”的顺序很关键:如果从小到大跳,很容易跳过 LCA,跳到 LCA 的祖先上去,后面就全乱了。循环结束后 a 和 b 的儿子刚好在 LCA 的下方,所以返回 up[0][a] 即可。

边界情况也要想清楚:如果一开始 a 就是 b 的祖先,那么第一步对齐后 a == b,直接返回 a;如果 u == v,LCA 就是它自己。这些情况在差分标记里都不会出问题,因为 diff[lca] -= 2 依旧成立,只是路径退化成单点,最终覆盖次数为 0,不影响答案。

3.3 预处理顺序决定正确性:先建 up 表再递归子树

我见过不少同学的代码把递归子树的逻辑放在倍增表填充之前,写成了这样:

depth[v] = depth[u] + 1; dfs_pre(v, u); up[k][v] = ...

这样看起来好像也没差,但一旦查询 LCA 的时候访问到尚未填充的 up[k][v],就会读出未初始化值,轻则 TLE,重则答案完全错乱。正确顺序是:进入 dfs_pre(u, fa) 后,立即设置 up[0][u] = fa,用循环把整行 up[k][u] 全部填好,然后再遍历孩子递归。深度也是先算好再传下去。原因是子节点在递归里可能立刻要用父节点的整张 up 表,而父节点的表是“先有父才有子”的。

另外提醒一句:深链数据(比如一条 2e5 的直线)在严格评测环境里有可能把递归栈逼爆。大多数题解直接递归也能过,但如果你遇到栈溢出,可以先把递归版写好验证逻辑,再把它改成显式栈的迭代版,或者用某些编译指令扩栈,这个看个人习惯了。

4. 统计答案的三种分支与完整代码实现

4.1 cnt=0、cnt=1、cnt>=2 分别对应几次有效切割

其实在 1.3 已经推导过结论,这里再从实现角度重述一遍:对每一条主边,也就是对根节点之外的所有节点 u,检查 cnt[u] 的值。

条件含义附加边选择方案贡献
cnt[u] == 0该主边是桥m 条附加边任选其一+m
cnt[u] == 1唯一一条附加边跨过它必须选那条附加边+1
cnt[u] >= 2至少两条附加边跨过它选任一条都不够断开+0

为什么 cnt=0 时附加边可以任意选?因为删掉主边后树已经分裂,再删的附加边无论连接的是左块内部还是右块内部,都不会再把两块接回去。这里不要带普通图的直觉——附加边再多,它们也不参与主干连接,只要主桥断开,整图必裂。cnt 统计的是“跨过主边缝隙”的附加边,不是“图上所有附加边”。

4.2 可复现的 C++ 代码

给一个能直接改改就交的版本。我用 vector 存图,1 号点为根,LOG 取 20(n 在 2e5 级别足够;如果 n 更大,可以动态取LOG = __lg(n) + 2):

#include <bits/stdc++.h> using namespace std; const int N = 200005; const int LOG = 20; int n, m; vector<int> g[N]; int depth[N], up[LOG][N]; int diff[N], cnt[N]; long long ans = 0; void dfs_pre(int u, int fa) { up[0][u] = fa; for (int k = 1; k < LOG; k++) { up[k][u] = up[k - 1][up[k - 1][u]]; } for (int v : g[u]) { if (v == fa) continue; depth[v] = depth[u] + 1; dfs_pre(v, u); } } int lca(int a, int b) { if (depth[a] < depth[b]) swap(a, b); int d = depth[a] - depth[b]; for (int k = LOG - 1; k >= 0; k--) { if (d & (1 << k)) a = up[k][a]; } if (a == b) return a; for (int k = LOG - 1; k >= 0; k--) { if (up[k][a] != up[k][b]) { a = up[k][a]; b = up[k][b]; } } return up[0][a]; } void dfs_calc(int u, int fa) { cnt[u] = diff[u]; for (int v : g[u]) { if (v == fa) continue; dfs_calc(v, u); cnt[u] += cnt[v]; } if (fa != 0) { if (cnt[u] == 0) ans += m; else if (cnt[u] == 1) ans += 1; } } int main() { ios::sync_with_stdio(false); cin.tie(0); cin >> n >> m; for (int i = 1; i < n; i++) { int u, v; cin >> u >> v; g[u].push_back(v); g[v].push_back(u); } dfs_pre(1, 0); for (int i = 0; i < m; i++) { int u, v; cin >> u >> v; int l = lca(u, v); diff[u]++; diff[v]++; diff[l] -= 2; } dfs_calc(1, 0); cout << ans << '\n'; return 0; }

需要注意,这段代码假设输入顺序是先 n-1 条主边、后 m 条附加边。实际提交时看题面给的是哪种,我自己写题解的时候经常栽在这里。如果题目要求输出具体方案,那还需要在打标记时存下附加边的端点;但本题只需要计数,不需要输出“选哪条附加边”,所以在线处理完全没问题。

4.3 两个极易踩的坑:根节点的幽灵边与栈递归深度

第一个坑是根节点统计。dfs_calc 里我用了if (fa != 0)来判断当前节点是否有对应的父边。如果你写成if (u != 1),一旦根不止一个或者你换根,就会漏计或误计。根节点的 cnt[1] 表示“1 号点到它的父亲之间那条不存在的边”被覆盖多少次,这个值没有意义,不能加入答案。

第二个坑是类型。n 和 m 到 1e5 量级时,最坏答案接近 (n-1) * m,大约是 1e10,int 直接溢出,答案必须用 long long。很多人的代码逻辑完全正确,却因为 ans 是 int 而 WA,这种问题在 OJ 上很容易被阴。另外别忘了 main 开头那两行 ios 加速,不然输入量大时容易在 IO 上多吃几倍时间。

5. 从这道题看边差分模型的迁移套路

5.1 点差分和边差分的记忆方法

学完这题最容易混淆的,是“点差分”和“边差分”的标记到底差在哪。我提供一个自己的记忆方式:路径 u->v,核心是 lca 处怎么扣。

  • 点差分:路径上的“点”都 +1,lca 这个点本身也要算进来,所以diff[u]++, diff[v]++, diff[lca]--, diff[fa[lca]]--。
  • 边差分:路径上的“边”都 +1,相当于给每条边的“子节点”+1,而 lca 对应的边不参与路径,所以diff[u]++, diff[v]++, diff[lca]-=2。

为什么边差分不用管 fa[lca]?因为边差分在统计时根本没有“父边”的概念,减 2 在 lca 处正好把 u、v 两路上传的公共部分全部抵消。点差分里 lca 节点本身有贡献,需要一个 -1;它的父边不参与路径,再用一个 -1 传递到父节点。两个模型虽然只差一个位置,背后的几何意义完全不同。建议每次做题前先写一句话问自己:统计的是节点权值还是边权值?是在树上路径还是树链区间?答案自然就出来了。

5.2 一类题的共同骨架:路径加 + 统计覆盖 + 分类计数

闇の連鎖看起来套路化,但其实代表了一大类“树上路径覆盖计数”题目。核心流程永远是:

  1. dfs 预处理出 depth 和倍增表(或树剖 dfn)。
  2. 对每条路径 (u, v),用差分打标记。
  3. 再一次 dfs 后序累加,得到每条边或每个点的覆盖次数。
  4. 按题目要求分类计数或求最值。

比如换一个问法:给定若干条路径,问每条边被多少条路径覆盖,那就是去掉第 4 步直接输出 cnt。再比如问“删掉哪条边能最大程度破坏连通性”,就变成统计覆盖次数后找最小值或最大值。这套骨架我刷了不少题都是同一个味道,区别主要在差分标记的位置和统计阶段的语义,所以把原理吃透比背代码重要得多。

5.3 做题顺序建议与自测用例

最后给一个自测用例,保证你代码逻辑没问题。树为 1-2, 2-3, 2-4;三条附加边是 3-1, 3-4,以及再来一条 3-4(重边)。手动推:附加边 3-1 覆盖 1-2 和 2-3;第一条 3-4 覆盖 2-3 和 2-4;第二条重边同样覆盖 2-3 和 2-4。于是边 1-2 的覆盖次数为 1,边 2-3 为 3,边 2-4 为 2。答案只有 cnt=1 的一条主边贡献 1,其余贡献 0,总方案 1。拿这组数据跑一遍,再对比手推的 diff 数组,能快速定位是 LCA 错了还是差分扣错了。

我个人的做题习惯是,每次拿到这种树上差分题,先在草稿纸上画一棵 5 个点以内的小树,把所有端点和 lca 的标记手写出来,再开始敲代码。这样写出来的代码基本能一次过样例,而不是靠反复试错去蒙。闇の連鎖这道题的模型太经典,吃透之后,你会发现自己再看其他路径覆盖类题目,脑子里会直接浮现出diff[u]++, diff[v]++, diff[lca]-=2这一行,剩下的都只是套壳。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 15:13:44

Codex CLI 配 TaoToken:AI编程智能体 settings.json 骨架与实战验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 15:11:56

Xilinx FPGA选型与采购:XC7A75T/XC7Z020代理现货避坑指南

XC7A75T、XC7Z020这类芯片现货&#xff0c;加上“Xilinx总代理、一级代理”这些标签&#xff0c;最近在朋友圈和行业群里刷到的频率确实不低。很多硬件工程师、采购甚至初创团队看到型号就心痒&#xff0c;但心里又犯嘀咕&#xff1a;代理和现货之间到底是什么关系&#xff1f;…

作者头像 李华
网站建设 2026/10/1 15:11:30

深度学习睡眠状态检测:从EEG序列标注到PyTorch实践

简介&#xff1a;基于深度学习的睡眠状态检测项目&#xff0c;面向毕业设计、课程设计与期末大作业场景&#xff0c;适合计算机、人工智能、生物医学工程等相关专业学生或开发者完成脑电信号分类任务。项目以卷积神经网络&#xff08;CNN&#xff09;为核心&#xff0c;覆盖脑电…

作者头像 李华
网站建设 2026/10/1 15:11:26

AI数据中心算力与电力协同管控及全域风险防控体系研究

1. 从一次机房告警说起&#xff1a;这个项目到底在解决什么问题去年冬天&#xff0c;我参与了一个中型AI训练集群的运维复盘。凌晨两点&#xff0c;监控大屏上突然跳出一片红色&#xff1a;三台GPU服务器同时掉卡&#xff0c;训练任务中断。排查了整整四个小时&#xff0c;最后…

作者头像 李华
网站建设 2026/10/1 15:11:23

Linux磁盘配额实战指南:从挂载配置到强制限制

先说一个我踩过的坑。几年前在维护一台共享计算服务器时&#xff0c;一个用户的离线任务在 /home 下生成了几百 GB 的临时文件&#xff0c;直接把根分区写满&#xff0c;数据库服务连不上去&#xff0c;全组人登录都开始卡。查到最后&#xff0c;就是那个用户脚本里的循环忘了清…

作者头像 李华