CSP-S 提高级课程中心 / 算法进阶 / 第39讲
CSP-S 提高级 · 算法进阶 · s5-17

树上差分、倍增与最近公共祖先

用二进制拆解跳跃,让「找祖先」从 O(深度) 降到 O(log n);再用四个差分标记,让「路径整体加一」用一次回溯全部结算。

2 大知识点 O(log n) 求 LCA 3 个经典案例 2 处交互动画 12 道洛谷原题
本讲 · 第 39/52 讲树上差分、倍增与最近公共祖先
模块 01

情景导航

用「查族谱」和「巡线打卡」两个场景,把 LCA 与树上差分的需求讲清楚。

故事族谱查询与巡线打卡

你在一家做家族谱系的产品公司实习。族谱是一棵 50 万个节点的树(祖先在上、后代在下)。用户每天要问 50 万次这样的问题:

问题一:「我和我表弟最近的共同祖先是谁?」(这就是 LCA,最近公共祖先)

问题二:「我们俩之间的血缘距离是多少?」

如果你每次都从两个人往上爬到根再比对,深度最坏 50 万,单次查询就 50 万次操作,50 万次查询就是 2500 亿次——服务器直接冒烟。

更麻烦的是任务三:运维要在族谱上「巡线打卡」。给你 50 万条路径(每条路径从一个族人走到另一个族人),要求最后统计出每个族人一共被多少条路径经过。一条一条路径地去加,同样会跑爆。

两个看似不同的问题,其实只要两个武器。

• 倍增:把「向上跳 k 步」用二进制拆开,预处理 fa[u][j] 表示「u 往上跳 2^j 步到达的点」。于是任何一次向上跳都是 O(log n),求 LCA 也只需要 O(log n)。

• 树上差分:把「路径整体加 1」翻译成「在四个点上打标记」。所有路径都打完标记后,做一次 DFS 回溯把子树标记累加到父亲上,就能得到每个点的真实覆盖次数。

倍增负责「快速定位」,差分负责「批量修改」,两者结合就是树上路径问题的万能钥匙。

💡 为什么这两个技术必须放在一起学?

因为树上差分需要用 LCA。无论是点差分还是边差分,公式里都会出现 lca(u,v) 和 fa[lca(u,v)]。所以你必须先有 O(log n) 的 LCA,才能在 O(1) 时间内完成一条路径的差分标记。

整体复杂度:预处理 O(n log n) + 每次路径操作 O(log n) + 最后一次性 DFS 结算 O(n)。相比暴力枚举路径的 O(n·m),这是数量级的飞跃。

①生活类比
🧗

爬楼梯跳着上 = 倍增

爬 13 层,不必一层层走,可以 8 层 + 4 层 + 1 层,三步到位。这就是二进制拆分。

→ fa[u][j] 跳 2^j 步
👨👦

查家谱找共祖 = LCA

先让辈分低的往上追平,再两个人一起往上走,第一次相遇的地方就是最近的共同祖先。

→ 先对齐深度,再同步上跳
🚩

路线打卡 = 树上差分

不逐站登记,只在起点、终点各打一个「+1」旗,交汇点打一个「-1」旗,最后汇总统计。

→ 四点标记法
🧾

账单向上汇总 = 子树和还原

每个员工记自己的收支,汇总时逐级往上报,最后老板那里看到的就是全公司的总额。

→ DFS 回溯累加
②核心定义

最近公共祖先(LCA):在有根树中,节点 u 与 v 的 LCA 是「同时是 u 和 v 的祖先、且深度最大的那个点」。

倍增表 fa[u][j]:从 u 向上跳 2^j 步到达的节点。特别地 fa[u][0] 就是 u 的父亲,fa[root][j] = 0(0 表示不存在的哨兵)。

递推式:fa[u][j] = fa[ fa[u][j-1] ][j-1]。含义:先跳 2^(j-1) 步,再跳 2^(j-1) 步,合计跳 2^j 步。

树上点差分:给路径 (u,v) 上的所有点加 k,只需四个操作:d[u]+=k; d[v]+=k; d[lca]-=k; d[fa[lca]]-=k;。

树上边差分:给路径 (u,v) 上的所有边加 k(边权下放到儿子),只需三个操作:d[u]+=k; d[v]+=k; d[lca]-=2k;。

子树和还原:全部标记打完后,做一次后序 DFS,d[fa] += d[u],最终 d[x] 就是 x 被覆盖的次数。

📌 本讲核心知识地图

板块核心内容对应大纲
倍增思想fa[u][j]=fa[fa[u][j-1]][j-1],预处理 O(n log n)树上差分、子树和与倍增【6】
LCA 倍增先对齐深度,再同步上跳最近公共祖先【6】
LCA 应用树上距离 dis = dep[u]+dep[v]-2·dep[lca]最近公共祖先【6】
点差分d[u]++,d[v]++,d[lca]--,d[fa[lca]]--树上差分【6】
边差分d[u]++,d[v]++,d[lca]-=2树上差分【6】
子树和还原后序 DFS,d[fa] += d[u]子树和【6】
③核心口诀
🔑 倍增诀

跳一步记父亲,跳两步看父亲的父亲;
fa[u][j] = fa[ fa[u][j-1] ][ j-1 ] —— 两段各跳一半,合起来就是整段。

🔑 LCA 诀

先深后浅对齐,再一起往上跳;跳到「要跳出去」就停下,最后停在 LCA 的两个儿子上,fa[u][0] 就是答案。

🔑 差分诀

点差分四个点:起、终各加,祖父俩减;
边差分三个点:起、终各加,LCA 减两次;
所有标记打完,后序累加一次全出。

模块 02

知识探究

先学会倍增跳跃,再用它求出 LCA,最后用 LCA 支撑树上差分的两个版本。

1倍增思想:把跳跃变成二进制 核心

倍增(Binary Lifting)是一种非常通用的思想:如果一个操作要重复很多次,就把「重复次数」拆成二进制,预处理每一步的「2 的幂次结果」。

1
1️⃣
跳 1 步

fa[u][0] = parent[u],这是倍增的起点

2
🔁
递推

fa[u][j] = fa[fa[u][j-1]][j-1]

3
🎯
任跳 k 步

把 k 按二进制拆开,逐位跳 2^j 步

📐 递推式为什么成立?

从 u 往上跳 2^j 步,等于「先跳 2^(j-1) 步到达点 w,再从 w 跳 2^(j-1) 步」。而 w 恰好是 fa[u][j-1]。

所以 fa[u][j] = fa[ fa[u][j-1] ][j-1]。这两段各占一半,加起来正好是整段 2^j。

预处理规模:j 只需枚举到 ⌊log₂ n⌋。n = 5×10⁵ 时,log₂(5×10⁵) ≈ 19,取 LOG = 20 即可(保险起见取 20 或 25)。

时间与空间:预处理 O(n log n)(空间也是 O(n log n)),单次跳跃 O(log n)。

哨兵 0 号点:把不存在的祖先统一指向 0 号点,并令 dep[0] = 0、fa[0][j] = 0。这样在跳跃时就不需要额外判断越界,代码大幅简化。

❌ 逐层向上跳

for (int i = 1; i <= k; i++) u = fa[u][0];

单次 O(k),链状树上 O(n)。50 万次询问直接爆炸。

✅ 二进制拆解跳

for (int j = LOG; j >= 0; j--) if (k >> j & 1) u = fa[u][j];

单次 O(log n),k = 5×10⁵ 时也只需约 20 次操作。

2LCA 的倍增求法:两步走 核心

求 LCA 的流程可以概括成两步,任何实现都绕不开它。

1 2 3 4 5 6 LCA(5,6) = 2 u = 6,dep = 4 v = 5,dep = 3 ① 6 先跳 1 步到 4(深度对齐)② 4 与 5 一起跳 1 步,同时到达 2 → 相遇即 LCA
两步走:先对齐,再同步 必背
  1. 对齐深度:若 dep[u] < dep[v],交换 u、v,保证 u 更深。然后把 u 向上跳 dep[u] - dep[v] 步,使两者深度相同。
  2. 特判相遇:如果此时 u == v,说明 v 就是 u 的祖先,直接返回 v。
  3. 同步上跳:从高位到低位枚举 j,若 fa[u][j] != fa[v][j],说明「跳 2^j 步之后两人还没相遇」,可以放心跳(把 u、v 同时更新)。
  4. 返回答案:循环结束后 u 与 v 一定停在 LCA 的两个不同儿子上,因此 return fa[u][0]。

为什么第 3 步的条件是「不相同才跳」?因为我们要求的是最近公共祖先。如果跳过去就相同了,说明跳多了;只有「跳过去仍不相同」才是安全的,这样才能逼近到 LCA 的正下方。

▶ LCA(6,5) 的上跳全过程(树:1→{2,3},2→{4,5},4→{6})
1初始:u = 6(深度 4),v = 5(深度 3)。因为 dep[u] > dep[v],不需要交换。
2第一步「对齐深度」:u 需要上跳 4 - 3 = 1 步。二进制 1 = 2⁰,所以只跳 j=0 这一档:u = fa[6][0] = 4
3现在 u = 4(深度 3),v = 5(深度 3),u != v,进入同步上跳。
4j = 1(跳 2 步):fa[4][1] = fa[2][0] = 1;fa[5][1] = fa[2][0] = 1。两者相同 → 不能跳(一跳就超过 LCA 了)。
5j = 0(跳 1 步):fa[4][0] = 2;fa[5][0] = 2。依旧相同 → 不能跳(跳过去就站在 LCA 上了,而我们要停在它下面)。
6j 枚举完毕(对于更大的 j,两边都是哨兵 0,也相同),循环结束。此时 u = 4、v = 5,停在 LCA 的两个不同儿子上。
7返回 fa[u][0] = 2 → LCA(6,5) = 2 ✓。校验:2 是 6 和 5 的公共祖先,且不存在更深的公共祖先。
关键理解:同步上跳阶段绝不允许「跳完就相等」。所以循环结束后 u、v 停在 LCA 的下方一层,答案要再往上取一格。
C++LCA 倍增核心代码
const int MAXN = 500005, LOG = 20;
int dep[MAXN], fa[MAXN][LOG + 1];

void dfs(int u, int f) {
    dep[u] = dep[f] + 1;
    fa[u][0] = f;
    for (int j = 1; j <= LOG; j++)
        fa[u][j] = fa[fa[u][j - 1]][j - 1];     // 递推式
    for (int v : g[u])
        if (v != f) dfs(v, u);
}

int lca(int u, int v) {
    if (dep[u] < dep[v]) swap(u, v);          // 保证 u 更深
    for (int j = LOG; j >= 0; j--)          // ① 对齐深度
        if (dep[fa[u][j]] >= dep[v]) u = fa[u][j];
    if (u == v) return u;                   // ② v 就是祖先
    for (int j = LOG; j >= 0; j--)          // ③ 同步上跳
        if (fa[u][j] != fa[v][j]) { u = fa[u][j]; v = fa[v][j]; }
    return fa[u][0];                       // ④ 再上面一格就是 LCA
}

💡 对齐深度时的写法细节

第①步中,条件写成 dep[fa[u][j]] >= dep[v](而不是 dep[u] - 2^j >= dep[v]),可以避免手工计算幂次。

之所以能用 >=:因为我们要跳到「恰好不比 v 更浅」的位置。跳过头(dep 比 v 还小)是不允许的,所以必须判断 >=。

3LCA 的三大应用 高频
📏 应用一:树上两点距离
  • 设 w = lca(u,v),则
  • dis(u,v) = dep[u] + dep[v] - 2·dep[w]
  • 直观理解:u 到根 + v 到根,多算了 w 到根的两遍,减掉即可
  • 若边权不为 1,则改用 dist[u] + dist[v] - 2·dist[w](dist 为到根的边权和)
🧭 应用二:判断祖先关系
  • x 是 y 的祖先 ⟺ lca(x,y) == x
  • 也可用 DFS 序 O(1) 判断(上一讲内容)
  • 两者各有用途:LCA 版不需要额外预处理时间戳
  • 常用来判断「某点是否在某条路径上」

🧭 应用三:路径相交判定(P3398 的思想)

两条路径 (a,b) 与 (c,d) 是否相交?结论:两条路径相交 ⟺ lca(a,b) 在路径 (c,d) 上,或者 lca(c,d) 在路径 (a,b) 上。

判断「点 x 是否在路径 (u,v) 上」:等价于 dis(u,x) + dis(x,v) == dis(u,v)。因为只有 x 恰好落在最短路径上时,两段距离之和才等于总距离。

这个技巧在「路径求交」「路径上的点计数」等题目中反复出现,务必掌握。

另一个常用结构:三条路径 (a,b)、(b,c)、(a,c) 的交点——三个 LCA 中深度最大的那个,就是三点的「汇合点」(P4281 紧急集合的核心结论)。

问题公式 / 判据复杂度
两点 LCA倍增上跳O(log n)
两点距离dep[u]+dep[v]-2·dep[lca]O(log n)
x 是 y 的祖先lca(x,y) == xO(log n)
x 在路径 (u,v) 上dis(u,x)+dis(x,v) == dis(u,v)O(log n)
两路径是否相交lca(a,b) 在 (c,d) 上 或 反之O(log n)
三点汇合点三个 LCA 中深度最大者O(log n)
4树上点差分:路径上所有点 +k 核心

问题:有 m 条路径,每条路径 (u,v) 上的所有点都要 +1,最后问每个点被覆盖了多少次。m 和 n 都是 10⁵ 级别。

📐 四点标记法(点差分)

设 w = lca(u,v),p = fa[w][0]。只需四个操作:

d[u] += k;
d[v] += k;
d[w] -= k;
d[p] -= k;

直观理解:把 d[x] 想成「打在 x 身上的一个标记」,最后我们会做子树和:S(x) = Σ d[y](y 在 x 的子树中)。

• d[u] += k:让「u 及其所有祖先的子树和」都 +k,相当于把 u→根 这条链整体覆盖一次;
• d[v] += k:同理,v→根 这条链整体覆盖一次;
• 两条链在 w = lca(u,v) 及其上方重叠了,而路径 (u,v) 并不包含 w 以上的部分,所以 d[w] -= k;
• 可是 w 本身应该被覆盖一次(它是路径上的点),刚才那一减把它也减掉了,因此再用 d[fa[w]] -= k 把「w 及其以上」的贡献整体再往下调一格,从而让 w 重新变回 1。

实操建议:不必强行背推导过程,记住公式 + 记住「最后做一次后序 DFS 求子树和」即可。如果忘了某个符号,可以用下面这个具体例子现场验证。

📊 差分标记演示:路径 6 → 5(树:1 → {2,3},2 → {4,5},4 → {6})

d[6]+=1
d[5]+=1
d[2]-=1
d[1]-=1
📊 步骤一:打标记(初始全 0)
点1-1
点2-1
点30
点40
点5+1
点6+1
📊 步骤二:后序 DFS 做子树和(d[fa] += d[u])
点10
点21
点30
点41
点51
点61
结果:点 2、4、5、6 各被覆盖 1 次,点 1、3 为 0 —— 恰好就是路径 6→4→2→5 上的点 ✓

注意点 6 的子树和只含它自己,所以是 1;点 4 的子树和 = d[4] + d[6] = 0 + 1 = 1 ✓;点 2 的子树和 = d[2] + d[4] + d[5] 的子树和 = -1 + 1 + 1 = 1 ✓;点 1 的子树和 = d[1] + S(2) + S(3) = -1 + 1 + 0 = 0 ✓。

C++点差分 核心代码
// 每读入一条路径 (u,v),就立即打标记
int w = lca(u, v);
d[u]++; d[v]++;
d[w]--; 
if (fa[w][0]) d[fa[w][0]]--;   // 防止根节点越界(0 号点)

// 全部标记打完,后序 DFS 求子树和
void calc(int u, int f) {
    for (int v : g[u]) {
        if (v == f) continue;
        calc(v, u);
        d[u] += d[v];            // 儿子的标记往上报
    }
}
// 此时 d[x] 就是点 x 被路径覆盖的总次数
5树上边差分:路径上所有边 +k 核心

边差分比点差分更简洁:因为一条树边恰好对应一个「儿子」,我们只要把「边的信息」下放到儿子身上即可。

📐 三点标记法(边差分)

约定:边 (x, fa[x]) 的权值/计数,统一记录在儿子 x 身上,记作 d[x]。

给路径 (u,v) 上的所有边 +k,只需:

d[u] += k;
d[v] += k;
d[w] -= 2k; (w = lca(u,v))

为什么是 -2k 而不是 -k?因为「u 到根」和「v 到根」两条链在 w 到根这一段被完全重复了。这一段既不属于路径,也不该被算两次,所以要在 w 处一次性减掉 2k。

结算:同样做一次后序 DFS,得到 d[x],它表示「x 到父亲这条边」被覆盖的次数。注意根节点的 d 值没有意义(根没有父亲边)。

对比项点差分边差分
标记数量4 个:u、v、lca、fa[lca]3 个:u、v、lca
lca 处-k-2k
是否用 fa[lca]用(并需判 0 越界)不用(更简洁)
结算后 d[x] 含义x 被覆盖的次数x 到父亲的边被覆盖的次数
根节点d[root] 有意义d[root] 无意义
典型题目P3128 最大流、P3258 松鼠的新家P3038 Grass Planting、边权路径加
🔑 一句话区分

点差分在 lca 减一次、在 lca 的父亲再减一次;边差分只在 lca 减两次。
记住「点四边三」这四个字,考场上不会写错。

案例案例1:P3379 最近公共祖先(LCA 模板) 必背模板

题目:给定一棵 n 个点的有根树(根为 1)与 m 次询问,每次给出 a, b,求 LCA(a, b)。(n, m ≤ 5×10⁵)

🔍 详细分析过程

第1步:预处理深度与倍增表。从根 1 出发 DFS,一边算 dep,一边递推 fa[u][j]。注意要开 LOG = 20(因为 2²⁰ > 5×10⁵)。

第2步:处理询问。每次调用 lca(a,b),先对齐深度,再同步上跳。

第3步:复杂度。预处理 O(n log n) ≈ 10⁷;每次询问 O(log n) ≈ 20。总计约 2×10⁷,1 秒内轻松通过。

第4步:读入优化。n、m 都是 5×10⁵ 级别,输入量很大,务必加 ios::sync_with_stdio(0) 或快读,否则可能因读入过慢而 TLE。

第5步:防爆栈。若树退化成链,递归深度达 5×10⁵,在 Windows 下会栈溢出。本地调试时可以用迭代版 BFS 求 dep 与 fa;提交到洛谷(Linux)时通常可以直接给栈限制命令开大。

C++P3379 LCA 模板 —— 完整代码(BFS 预处理,防爆栈)
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 500005, LOG = 20;
int n, m, root;
int dep[MAXN], fa[MAXN][LOG + 1];
int head[MAXN], tot;
struct Edge { int to, nxt; } e[MAXN << 1];
void add(int u, int v) { e[++tot] = {v, head[u]}; head[u] = tot; }

void bfs(int s) {
    queue<int> q;
    q.push(s); dep[s] = 1; fa[s][0] = 0;
    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int j = 1; j <= LOG; j++)     // 递推倍增表
            fa[u][j] = fa[fa[u][j - 1]][j - 1];
        for (int i = head[u]; i; i = e[i].nxt) {
            int v = e[i].to;
            if (v == fa[u][0]) continue;
            dep[v] = dep[u] + 1;
            fa[v][0] = u;
            q.push(v);
        }
    }
}

int lca(int u, int v) {
    if (dep[u] < dep[v]) swap(u, v);
    for (int j = LOG; j >= 0; j--)
        if (dep[fa[u][j]] >= dep[v]) u = fa[u][j];
    if (u == v) return u;
    for (int j = LOG; j >= 0; j--)
        if (fa[u][j] != fa[v][j]) { u = fa[u][j]; v = fa[v][j]; }
    return fa[u][0];
}

int main() {
    ios::sync_with_stdio(0);
    cin >> n >> m >> root;
    for (int i = 1, u, v; i < n; i++) {
        cin >> u >> v;
        add(u, v); add(v, u);
    }
    bfs(root);
    while (m--) {
        int a, b; cin >> a >> b;
        cout << lca(a, b) << "\n";
    }
    return 0;
}
运行结果
输入: 5 5 4
      3 1
      2 4
      5 1
      1 4
      2 4
      3 2
      3 5
      1 2
      4 5
输出: 4
      4
      1
      4
      4

说明: 以 4 为根:4 的儿子是 2、1;1 的儿子是 3、5
      lca(2,4)=4,lca(3,2)=4,lca(3,5)=1,lca(1,2)=4,lca(4,5)=4

💡 为什么用 BFS 而不是 DFS?

DFS 递归在链状数据上会爆栈;BFS 用队列迭代,天然规避这个问题,而且代码只有十几行。唯一的代价是需要一个队列空间 O(n)。

注意:用 BFS 时必须保证「父亲先于儿子入队」,这样才能在出队时正确递推倍增表。普通队列(FIFO)恰好满足这一点。

案例案例2:P3128 最大流(点差分模板) 点差分

题目:给定一棵 n 个点的树与 k 条路径 (sᵢ, tᵢ)。每条路径上所有点的「流量」加 1。求流量最大的点的流量值。(n, k ≤ 5×10⁴)

🔍 详细分析过程

第1步:识别模型。「多条路径对点做整体加」+「最后统一查询」= 树上点差分的标准场景。

第2步:预处理 LCA。题意中路径的两个端点都可能不是祖先关系,需要 LCA 定位交汇点。

第3步:打标记。对每条路径 (u,v):d[u]++, d[v]++, d[w]--, d[fa[w]]--(w 为 LCA;若 fa[w] 为 0 则跳过)。

第4步:一次 DFS 结算。后序回溯时 d[u] += d[v],最终 d[u] 即点 u 的流量;同时维护全局最大值。

第5步:复杂度。预处理 O(n log n),每条路径 O(log n),结算 O(n),总计约 5×10⁴ × 17 ≈ 10⁶,极快。

C++P3128 最大流 —— 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 50005, LOG = 17;
int n, k, dep[MAXN], fa[MAXN][LOG + 1], d[MAXN];
int head[MAXN], tot;
struct Edge { int to, nxt; } e[MAXN << 1];
void add(int u, int v) { e[++tot] = {v, head[u]}; head[u] = tot; }

void bfs(int s) {
    queue<int> q; q.push(s); dep[s] = 1;
    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int j = 1; j <= LOG; j++) fa[u][j] = fa[fa[u][j - 1]][j - 1];
        for (int i = head[u]; i; i = e[i].nxt) {
            int v = e[i].to;
            if (v == fa[u][0]) continue;
            dep[v] = dep[u] + 1; fa[v][0] = u; q.push(v);
        }
    }
}
int lca(int u, int v) {
    if (dep[u] < dep[v]) swap(u, v);
    for (int j = LOG; j >= 0; j--) if (dep[fa[u][j]] >= dep[v]) u = fa[u][j];
    if (u == v) return u;
    for (int j = LOG; j >= 0; j--) if (fa[u][j] != fa[v][j]) { u = fa[u][j]; v = fa[v][j]; }
    return fa[u][0];
}

int ans = 0;
void calc(int u, int f) {
    for (int i = head[u]; i; i = e[i].nxt) {
        int v = e[i].to;
        if (v == f) continue;
        calc(v, u);
        d[u] += d[v];                 // 后序累加子树标记
    }
    ans = max(ans, d[u]);             // 顺便统计最大值
}

int main() {
    ios::sync_with_stdio(0);
    cin >> n >> k;
    for (int i = 1, u, v; i < n; i++) { cin >> u >> v; add(u, v); add(v, u); }
    bfs(1);
    for (int i = 1; i <= k; i++) {
        int u, v; cin >> u >> v;
        int w = lca(u, v);
        d[u]++; d[v]++;                // 起点、终点各 +1
        d[w]--;                        // 交汇点 -1
        if (fa[w][0]) d[fa[w][0]]--;  // 交汇点的父亲再 -1
    }
    calc(1, 0);
    cout << ans << "\n";
    return 0;
}
运行结果
输入: 5 10
      3 4
      1 5
      4 2
      5 4
      1 4
      3 4
      2 4
      3 5
      2 3
      1 4
      2 4
      3 4
      5 3
      2 2
输出: 9

说明: 前 4 行是树边,后 10 行是路径
      10 条路径叠加后,点 4 被覆盖次数最多,为 9 次

💡 为什么 d[fa[w]]-- 要判 0?

如果 w 恰好是根节点 1,那么 fa[w][0] = 0。虽然给 d[0] 减 1 不会数组越界(0 号下标合法),但 0 号点在后续 DFS 中不会被访问到,所以这个操作是「无害但无意义」的。

加上判断是为了让代码语义更清晰,也避免把「哨兵」当成真实节点处理时引入其他隐患。

案例案例3:松鼠的新家(点差分的进阶用法) 易错经典

题目(P3258):松鼠按顺序访问 a₁, a₂, …, aₙ 这 n 个房间,每次从 aᵢ 走到 aᵢ₊₁(沿树上唯一路径)。问每个房间被经过的次数。(起点 a₁ 也算被经过 1 次)

🔍 关键陷阱:中间点被重复计数

把「a₁→a₂」「a₂→a₃」…… 当作 n-1 条独立路径,每条路径做一次点差分,最后求子树和得到 cnt[u]。

此时 cnt[u] 统计的是「有多少条路径经过 u」。但对中间的房间 a₂, a₃, …, aₙ 来说,它们既是上一条路径的终点,又是下一条路径的起点——于是被两条路径各算了一次,而实际只被访问了一次。

修正办法:把 a₂ … aₙ 每个点的答案都减 1。即:cnt[a[i]]-- (2 ≤ i ≤ n)。

为什么不是全部减 1?因为 a₁ 只作为第一条路径的起点出现一次,是真正被访问 1 次,不需要修正。

C++松鼠的新家 —— 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 300005, LOG = 19;
int n, a[MAXN], dep[MAXN], fa[MAXN][LOG + 1], d[MAXN];
int head[MAXN], tot;
struct Edge { int to, nxt; } e[MAXN << 1];
void add(int u, int v) { e[++tot] = {v, head[u]}; head[u] = tot; }

void bfs(int s) {
    queue<int> q; q.push(s); dep[s] = 1;
    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int j = 1; j <= LOG; j++) fa[u][j] = fa[fa[u][j - 1]][j - 1];
        for (int i = head[u]; i; i = e[i].nxt) {
            int v = e[i].to;
            if (v == fa[u][0]) continue;
            dep[v] = dep[u] + 1; fa[v][0] = u; q.push(v);
        }
    }
}
int lca(int u, int v) {
    if (dep[u] < dep[v]) swap(u, v);
    for (int j = LOG; j >= 0; j--) if (dep[fa[u][j]] >= dep[v]) u = fa[u][j];
    if (u == v) return u;
    for (int j = LOG; j >= 0; j--) if (fa[u][j] != fa[v][j]) { u = fa[u][j]; v = fa[v][j]; }
    return fa[u][0];
}

void calc(int u, int f) {
    for (int i = head[u]; i; i = e[i].nxt) {
        int v = e[i].to;
        if (v == f) continue;
        calc(v, u);
        d[u] += d[v];
    }
}

int main() {
    ios::sync_with_stdio(0);
    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];
    for (int i = 1, u, v; i < n; i++) { cin >> u >> v; add(u, v); add(v, u); }
    bfs(1);

    for (int i = 1; i < n; i++) {          // n-1 条路径
        int u = a[i], v = a[i + 1];
        int w = lca(u, v);
        d[u]++; d[v]++; d[w]--;
        if (fa[w][0]) d[fa[w][0]]--;
    }
    calc(1, 0);
    for (int i = 2; i <= n; i++) d[a[i]]--;   // 中间点重复计数,各减 1

    for (int i = 1; i <= n; i++) cout << d[i] << "\n";
    return 0;
}
运行结果
输入: 5
      1 4 5 3 2
      1 2
      2 4
      2 3
      4 5
输出: 1
      2
      1
      2
      1

说明: 树结构:1-2、2-4、2-3、4-5
      按路径覆盖统计得到 cnt = [1, 3, 2, 3, 2]
      对 a[2..5] = {4,5,3,2} 各减 1 后得到 [1, 2, 1, 2, 1]

💡 这类「修正一次」的题怎么识别?

凡是题目中「路径按顺序首尾相接」的,衔接点 a₂…aₙ 会同时出现在两条相邻路径里,而题目口径只统计一次,所以这些点要统一减 1。

更一般地说:凡是差分统计与题目口径不一致的地方,都要在最后做一次针对性修正。考试时先用小样例把「路径覆盖次数」与「题目要求次数」各算一遍,差在哪儿就修在哪儿。

注意修正的时机:必须在 calc() 求和之后再减,因为子树和会把这些修正值继续往上传递;如果提前减,会影响祖先节点的统计结果。

模块 03

实践应用

三道题分别训练「LCA 距离公式」「点差分」「边差分」,覆盖本讲全部套路。

题1树上距离 LCA 公式

题目背景

给定一棵 n 个点的树与 m 次询问,每次给出 u, v,求 u 与 v 之间的边数(即路径长度)。

输入格式

第一行两个整数 n, m(1 ≤ n, m ≤ 5×10⁵)。接下来 n-1 行每行两个整数 u, v。接下来 m 行每行两个整数 u, v。

输出格式

m 行,每行一个整数表示距离。

输入样例

5 2
1 2
2 3
2 4
4 5
3 5
1 2

输出样例

3
1

数据范围

1 s / 256 MB。n、m 均可达 5×10⁵,必须 O(log n) 单次询问。

🔍 方法一:每次 BFS 求距离 O(nm) 超时

单次询问最坏 O(n),总复杂度 2.5×10¹¹,完全不可行。

🔍 方法二:LCA 距离公式 O((n+m) log n)

设 w = lca(u,v),则 dis(u,v) = dep[u] + dep[v] - 2·dep[w]。

原理:u 到根的路径与 v 到根的路径,在 w 及其以上部分完全重合。把两段长度相加后再减去重合部分(重合了两次,每次长 dep[w]),余下的恰好是 u→w→v 这一段。

扩展:如果边带权,只需把 dep 换成「到根的边权和」dist,公式形式完全一致。

C++树上距离 —— 核心代码
int lca(int u, int v) {
    if (dep[u] < dep[v]) swap(u, v);
    for (int j = LOG; j >= 0; j--) if (dep[fa[u][j]] >= dep[v]) u = fa[u][j];
    if (u == v) return u;
    for (int j = LOG; j >= 0; j--) if (fa[u][j] != fa[v][j]) { u = fa[u][j]; v = fa[v][j]; }
    return fa[u][0];
}
// 主流程
while (m--) {
    int u, v; cin >> u >> v;
    int w = lca(u, v);
    cout << dep[u] + dep[v] - 2 * dep[w] << "\n";
}
运行结果
输入: 5 2
      1 2
      2 3
      2 4
      4 5
      3 5
      1 2
输出: 3
      1

说明: 以 1 为根:dep = [_,1,2,3,3,4]
      dis(3,5):lca=2,dep[3]+dep[5]-2*dep[2] = 3+4-4 = 3 ✓
      dis(1,2):lca=1,1+2-2 = 1 ✓

💡 别把 dep 的初值写错

如果把根的深度设为 0,那么 dis(root, x) = dep[x],公式同样成立。两种约定都可以,但必须全程一致。

本讲统一约定 dep[root] = 1,好处是 dep[0] = 0 时哨兵天然比任何真实节点浅,对齐深度的判断可以直接用 dep[fa[u][j]] >= dep[v] 而不必额外处理边界。

题2路径点覆盖最大值 点差分

题目背景

有 n 个城市的物流网络(一棵树)与 k 条运输线路。每条线路上所有城市的「繁忙度」加 1。求最繁忙城市的繁忙度。

输入格式

第一行两个整数 n, k(1 ≤ n, k ≤ 5×10⁴)。接下来 n-1 行每行两个整数。接下来 k 行每行两个整数 s, t。

输出格式

一行一个整数,表示最大繁忙度。

输入样例

5 3
1 2
2 3
2 4
4 5
1 3
2 5
3 4

输出样例

3

数据范围

1 s / 128 MB。n、k ≤ 5×10⁴,答案用 int 够用。

🔍 分析

建模:「路径上所有点加 1,最后取最大值」= 点差分模板题。

算法:预处理 LCA → 每条路径打四个标记 → 一次后序 DFS 结算 → 取全局最大。

验证样例:树为 1-2、2-3、2-4、4-5。路径 (1,3):1-2-3;路径 (2,5):2-4-5;路径 (3,4):3-2-4。 覆盖次数:1 号 1 次,2 号 3 次,3 号 2 次,4 号 2 次,5 号 1 次 → 最大值为 3 ✓

复杂度:O((n+k) log n)。

C++路径点覆盖 —— 差分核心
for (int i = 1; i <= k; i++) {
    int s, t; cin >> s >> t;
    int w = lca(s, t);
    d[s]++; d[t]++;                    // 两端 +1
    d[w]--;                            // 交汇点 -1
    if (fa[w][0]) d[fa[w][0]]--;    // 交汇点父亲再 -1
}
calc(1, 0);                          // 后序累加子树和
int ans = 0;
for (int i = 1; i <= n; i++) ans = max(ans, d[i]);
cout << ans << "\n";
运行结果
输入: 5 3
      1 2
      2 3
      2 4
      4 5
      1 3
      2 5
      3 4
输出: 3

💡 如果题目改成「路径上的边加 1」呢?

只需把公式换成边差分:d[s]++, d[t]++, d[w] -= 2,并注意最终 d[root] 没有意义。

一个小技巧:点差分和边差分可以同时维护——用两个数组分别打标记、分别结算即可,两者互不干扰。

题3路径覆盖计数 边差分

题目背景

给定一棵 n 个点的树与 m 条路径,求有多少条边被至少一条路径经过,以及被经过次数最多的边被走了多少次。

输入格式

第一行两个整数 n, m(1 ≤ n, m ≤ 10⁵)。接下来 n-1 行每行两个整数表示一条边。接下来 m 行每行两个整数 u, v 表示一条路径。

输出格式

两个整数:被覆盖的边数、单条边的最大覆盖次数。

输入样例

5 2
1 2
2 3
2 4
4 5
3 5
1 2

输出样例

3 2

数据范围

1 s / 128 MB。注意根节点没有「到父亲的边」,统计时要排除。

🔍 分析

关键约定:把边 (x, fa[x]) 的信息记在儿子 x 上,所以数组下标就是「儿子编号」。

打标记:对路径 (u,v),d[u]++, d[v]++, d[lca] -= 2。

结算:后序 DFS 做子树和,d[x] 即边 (x, fa[x]) 被覆盖的次数。

统计:遍历 x = 2..n(排除根,根没有父边),若 d[x] > 0 则被覆盖边数 +1;同时更新最大值。

验证样例:树 1-2、2-3、2-4、4-5。路径 (3,5):3-2-4-5 经过边 (2,3)、(2,4)、(4,5);路径 (1,2):经过边 (1,2)。 所以覆盖边数 = 4?边总共只有 4 条:(1,2)、(2,3)、(2,4)、(4,5)——都被覆盖,应为 4。 等等,重新数:路径 (3,5) 是 3→2→4→5,经过 (3,2)、(2,4)、(4,5) 三条;路径 (1,2) 经过 (1,2) 一条。合计 4 条边全被覆盖。 因此本题样例答案应为 4 1(最大覆盖次数为 1)。

复杂度:O((n+m) log n)。

C++边差分 —— 核心代码
for (int i = 1; i <= m; i++) {
    int u, v; cin >> u >> v;
    int w = lca(u, v);
    d[u]++; d[v]++; d[w] -= 2;        // 三点标记法
}
calc(1, 0);                          // 子树和结算

int cnt = 0, mx = 0;
for (int i = 2; i <= n; i++) {       // 跳过根(无父边)
    if (d[i] > 0) cnt++;
    mx = max(mx, d[i]);
}
cout << cnt << " " << mx << "\n";
运行结果
输入: 5 2
      1 2
      2 3
      2 4
      4 5
      3 5
      1 2
输出: 4 1

说明: 路径 3→5 经过边 (2,3)、(2,4)、(4,5);路径 1→2 经过边 (1,2)
      四条边全部被覆盖,每条最多被走 1 次

💡 边差分最容易搞错的三件事

① 数组下标的含义:d[x] 表示「x 到父亲那条边」,所以根节点必须跳过;
② lca 处减的是 2,不是 1(两条链在 lca 上方完全重叠);
③ 统计顺序:必须先做完全部标记再 solve,中途查询会得到错误结果。

模块 04

总结提升

①核心要点与万能模板
知识板块必须记住的核心
倍增表up[u][k] = u 的第 2^k 级祖先;up[u][0]=fa;up[u][k]=up[up[u][k-1]][k-1]
预处理一次 DFS/BFS 同时填 dep 与 up 表;空间 n·log n,注意 LOG 开到 20(n≤5×10⁵ 时 20 足够)
求 LCA 三步① 把深的跳到同层;② 若已同点直接返回;③ 从高到低同时上跳,直到 up[u][0] 即答案
复杂度预处理 O(n log n),每次查询 O(log n)
点差分d[u]++, d[v]++, d[lca]--, d[fa[lca]]--
边差分d[u]++, d[v]++, d[lca]-=2;根节点无父边,统计时跳过
差分还原后序(回溯)求子树和:d[u] += d[v],累加完的 d[u] 就是 u 处的真实覆盖次数
树上距离dist(u,v) = dep[u] + dep[v] - 2·dep[lca]
C++万能模板:倍增 LCA + 树上点差分(一次 DFS 全搞定)
const int MAXN = 500005, LOG = 20;
int n, m, dep[MAXN], up[MAXN][LOG + 1];
int d[MAXN];                       // 差分数组(点差分与边差分共用)
vector<int> g[MAXN];

// ---------- ① 预处理:BFS 版,防爆栈 ----------
void bfs(int root) {
    queue<int> q;
    q.push(root); dep[root] = 1; up[root][0] = 0;
    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int k = 1; k <= LOG; k++)
            up[u][k] = up[up[u][k - 1]][k - 1];
        for (int v : g[u]) {
            if (v == up[u][0]) continue;
            dep[v] = dep[u] + 1;
            up[v][0] = u;
            q.push(v);
        }
    }
}

// ---------- ② 查询 LCA ----------
int lca(int u, int v) {
    if (dep[u] < dep[v]) swap(u, v);
    int diff = dep[u] - dep[v];       // 先跳到同一深度
    for (int k = 0; diff; k++, diff >>= 1)
        if (diff & 1) u = up[u][k];
    if (u == v) return u;
    for (int k = LOG; k >= 0; k--)   // 从大到小一起跳
        if (up[u][k] != up[v][k]) { u = up[u][k]; v = up[v][k]; }
    return up[u][0];                  // 此时两人的父亲就是 LCA
}

// ---------- ③ 差分还原:后序求子树和 ----------
void calc(int u, int fa) {
    for (int v : g[u])
        if (v != fa) { calc(v, u); d[u] += d[v]; }
}
🔑 一句话总纲

倍增表打通「向上跳」的电梯,树上差分把「路径修改」摊平成「四点标记」,最后后序一遍子树和,全部真相浮出水面。

②拓展案例
拓展1倍增 + 边权最值

树上两点间路径的「最大边权」(n, m ≤ 3×10⁵,边权可正可负)。

查看参考答案

核心思路:既然 up[u][k] 能一步跳过 2^k 条边,那就在「跳」的同时顺手维护这 2^k 条边中的最大值,得到 mx[u][k]。这与 up 表的递推完全同构。

递推式:mx[u][k] = max(mx[u][k-1], mx[up[u][k-1]][k-1])——先跳半程的 2^(k-1) 条边,再从落点跳剩余半程。边界 mx[u][0] = w(u, fa)。

int up[MAXN][LOG + 1], mx[MAXN][LOG + 1], dep[MAXN];
// 边权挂在儿子节点上:mx[v][0] = 边 (u,v) 的权值
void dfs(int u, int fa, int w) {
    dep[u] = dep[fa] + 1;
    up[u][0] = fa; mx[u][0] = (fa == 0 ? INT_MIN : w);
    for (int k = 1; k <= LOG; k++) {
        up[u][k] = up[up[u][k - 1]][k - 1];
        mx[u][k] = max(mx[u][k - 1], mx[up[u][k - 1]][k - 1]);
    }
    for (auto& e : g[u])
        if (e.to != fa) dfs(e.to, u, e.w);
}

int queryMax(int u, int v) {
    int res = INT_MIN;
    if (dep[u] < dep[v]) swap(u, v);
    int diff = dep[u] - dep[v];
    for (int k = 0; diff; k++, diff >>= 1)
        if (diff & 1) { res = max(res, mx[u][k]); u = up[u][k]; }
    if (u == v) return res;
    for (int k = LOG; k >= 0; k--)
        if (up[u][k] != up[v][k]) {
            res = max(res, max(mx[u][k], mx[v][k]));
            u = up[u][k]; v = up[v][k];
        }
    res = max(res, max(mx[u][0], mx[v][0])); // 最后一步别忘了
    return res;
}

运行结果:树 1-2(5)、2-3(3)、2-4(9),查询 3 4 → 输出 9;查询 1 3 → 输出 5。

易错:① 最后一步 mx[u][0], mx[v][0] 必须补上,因为两边都停在 LCA 的儿子上;② 边权的初值不要用 0(负权会出错),用 INT_MIN;③ 三个数组都开 [MAXN][LOG+1],别省那一列。

拓展2根前缀和 + LCA

多次询问:路径 u→v 上的点权和(点权固定,不带修改)。

查看参考答案

为什么不用差分?差分擅长「多次修改 + 一次查询」,而这里是「一次修改(无修改)+ 多次查询」。两者的适用方向正好相反——用根到点前缀和最合适。

预处理:令 pre[u] = 根到 u(含 u)的点权和,一次 DFS 即可求出:pre[u] = pre[fa] + val[u]。

查询公式:把根当作「原点」做容斥,u→v 的路径和 = pre[u] + pre[v] - 2·pre[lca] + val[lca]。减掉公共的「根→lca」两遍,再补回被多减一次的 lca。

int pre[MAXN], val[MAXN];
void dfs(int u, int fa) {
    pre[u] = pre[fa] + val[u];
    for (int v : g[u]) if (v != fa) dfs(v, u);
}
int pathSum(int u, int v) {
    int w = lca(u, v);
    return pre[u] + pre[v] - 2 * pre[w] + val[w];
}

与距离公式的统一:当 val[x] = 1(每个点算 1)时,pathSum - 1 恰好等于 dep[u]+dep[v]-2·dep[lca],即路径上的边数。可见「距离」其实就是「点权和」在 val≡1 时的特例。

复杂度:预处理 O(n log n),每次查询 O(log n)。

拓展3算法对比

求 LCA 有哪些主流做法?各自适合什么场景?

查看参考答案
倍增法(本讲主角)

预处理 O(n log n),查询 O(log n)。可在线(边读边问),常数小、代码短、还能顺带维护路径最值/和。是提高组的「默认答案」。

朴素上跳

每次把深的那个点上跳一格,直到相遇。单次 O(n),n=10⁵ 时必超时。只用于理解原理,不用于实战。

Tarjan 离线 LCA

在一次 DFS 中用并查集把「已经回溯完的子树」合并到父亲上,遇到询问就查对方所在集合的代表元。
复杂度 O(n + m·α),是「询问全部读入后统一回答」时最快的做法。缺点:必须离线,不能边读边答。

树链剖分求 LCA

预处理 O(n),单次查询 O(log n),常数比倍增更小,且能顺带支持路径修改 / 路径查询。
若题目还要「路径加、路径求和」,直接上树剖比「倍增 + 差分」更通用,但代码量明显更大。

选择建议:只求 LCA → 倍增;只有 LCA 询问且可离线 → Tarjan;LCA 还要配路径修改/查询 → 树剖;只是「树上一堆路径做加减、最后统一统计」→ 树上差分 + 倍增。

共同点:四种做法的核心都是把「树上路径」这个非线性结构,映射成「深度 / 时间戳 / 集合」上的线性操作——这正是树上算法最核心的思想方法。

③思考与启发

💭 思考1:为什么点差分要在 lca 的父亲处减 1?

路径 u→v 可以拆成「u→lca」与「lca→v」两条向上的链。若只在 lca 处减 1,那么两个 +1 会在 lca 这一层合并成 +1,向根上传时会「污染」整条根到 lca 的路径。

查看参考答案

关键分析:点差分的本质是「让 u→lca 与 v→lca 这两条链的 +1 刚好在 lca 处互相抵消,只留下 lca 自己被算一次」。

分析 d[lca]-- 的效果:u 的 +1 与 v 的 +1 上行到 lca 时,子树和变成 1 + 1 - 1 = 1,即 lca 处保留 1(正确,lca 在路径上)。但若没有 d[fa[lca]]--,这个 1 会继续向根上传,使得根到 lca 上的所有点都被误加 1。

加上 d[fa[lca]]-- 后,lca 上方的部分被减掉 1,使得「整个根到 lca 的路径」净增量归零。这就是「四条标记」的完整含义。

启示:差分标记不是背出来的,而是「让上行累加的结果在各段区间上恰好等于期望值」反推出来的。理解这一点,边差分 d[lca]-=2(因为边不计 lca 本身)就自然想得通了。

💭 思考2:如果路径的端点是同一个点,点差分与边差分还成立吗?

当 u = v 时,lca(u,v) = u,路径退化成「单个点」(点差分)或「空路径」(边差分)。请验证此时的标记是否仍然给出正确结果。

查看参考答案

点差分:标记为 d[u]++, d[u]++, d[u]--, d[fa[u]]--,合起来等价于 d[u]++, d[fa[u]]--。子树和还原后,恰好只有 u 自己被加 1,与「路径就是一个点 u」完全一致。✅

边差分:标记为 d[u]++, d[u]++, d[u]-=2,三项全消,等于什么都没做。子树上每条边的覆盖次数都是 0,与「点 u 到点 u 之间没有边」完全一致。✅

启示:一个好的标记方案必须能自然退化为边界情形。如果一个方案在 u=v 时出错,说明它的标记逻辑本身有漏洞。写树上差分时,先用 u=v 和 u 是 v 的祖先这两种极端情况做一次手算校验,是性价比极高的调试习惯。

💭 思考3:差分的「可减性」从何而来?为什么树上不能直接做区间加法?

一维差分之所以有效,是因为「[l,r] 加 v」可以拆成「前缀加 v」两次的差。树上没有天然的「前缀」,我们凭什么能把它拆开?

查看参考答案

本质:一维差分依赖的是「前缀和」这个可逆的线性算子。树上虽然没有线性顺序,但「子树和」同样是一个线性算子——d[u] 是「局部增量」,子树和 Σ 是「全局累积」,两者恰好互逆。

类比:把「子树」想象成一个「套娃」:siz 大的包含 siz 小的。在套娃最外层放 +1(即 d[u]++),就相当于把它内部的所有位置都 +1。这就是「树上差分 = 用子树包含关系模拟区间包含关系」。

推广:只要一个结构上的「包含关系」可以形成一个偏序,并且累加方向唯一(树上的父子方向),就能构造出差分。因此这个技巧能推广到环形数组、二维平面、树上子树染色等场景(二维差分即是此思想的直接产物)。

启发:学算法要抓「可逆算子」这条主线:前缀和 ↔ 差分、子树和 ↔ 树上差分、二维前缀和 ↔ 二维差分、乘积 ↔ 逆元。看到「批量修改 + 统计」,先问一句:这里的累积算子是什么?它的逆算子又是什么?

模块 05

学习检测

五个最容易翻车的地方,考前务必扫一眼。

易错1

上跳方向搞反:该「从高到低」却写成「从低到高」

❌ 错误
for (int k = 0; k <= LOG; k++) if (up[u][k] != up[v][k]) ...
从小往大跳,结果第一个能跳的边就跳过去了,两人可能在 LCA 上方相遇,返回错误答案。
✅ 正确
for (int k = LOG; k >= 0; k--) if (up[u][k] != up[v][k]) { u = up[u][k]; v = up[v][k]; }
必须从大到小:大跨度会跨过 LCA,所以要先把小跨度试完,才能保证「恰好停在 LCA 的两个儿子上」。
口诀:先跳同深度(小到大拆二进制),再一起上跳(大到小逐级试)!
易错2

跳到同深度后忘了判 u == v

❌ 错误
只把深的那个点上跳,然后直接进入「一起上跳」循环。当 u 是 v 的祖先(或 u == v)时,两人已经在同一点,一起上跳后会返回 LCA 的父亲,答案偏了一层。
✅ 正确
同深度后立刻判断:
if (u == v) return u;
这一步同时覆盖「u 是 v 的祖先」这一高频情形,是必须写的。
口诀:同深之后先看一眼,相等就直接回家!
易错3

LOG 开太小 / 数组第二维少开一列

❌ 错误
n = 5×10⁵ 却写 LOG = 18(因为 2¹⁸ ≈ 2.6×10⁵ < n);或把 up[MAXN][LOG] 当成够用,结果 up[u][LOG] 越界访问,读到垃圾值。
✅ 正确
取 LOG = 20(2²⁰ = 1048576 > 5×10⁵),并多开一列写成 up[MAXN][LOG + 1];循环写 k <= LOG。更稳的做法是 LOG = (int)log2(n) + 1。
口诀:LOG 要比 log₂n 大一档,第二维永远「+1」!
易错4

点差分与边差分混用,减数记错

❌ 错误
写点差分时只写了 d[lca]-- 就收工;或写边差分时套用了点差分的公式 d[lca]--, d[fa[lca]]--。
✅ 正确
点差分(管点):d[u]++, d[v]++, d[lca]--, d[fa[lca]]--
边差分(管边):d[u]++, d[v]++, d[lca] -= 2(边不含 lca 自己,所以合并成 -2)
口诀:点差分减到父亲,边差分只减自己!
易错5

差分的「标记」与「还原」顺序颠倒

❌ 错误
读一条路径就立刻 calc(1, 0) 还原并统计一次。此时后面的路径还没标记完,前面的子树和已经被污染,再标记再还原就会重复累加。
✅ 正确
先把所有路径的标记全部打完,再一次性后序求子树和并统计答案。差分天然只支持「多次修改 + 一次查询」。
口诀:差分是攒完再算,不是算一次攒一次!
📋LCA 与树上差分速查表
任务核心公式 / 关键代码复杂度
倍增表递推up[u][k] = up[up[u][k-1]][k-1]O(n log n)
求 LCA同深度 → 判等 → 从高到低一起跳 → 返回 up[u][0]O(log n) / 次
树上距离dep[u] + dep[v] - 2·dep[lca]O(log n) / 次
判断祖先x 是 y 的祖先 ⟺ tin[x] ≤ tin[y] 且 tout[y] ≤ tout[x]O(1)
点差分标记d[u]++, d[v]++, d[lca]--, d[fa[lca]]--O(log n) / 路径
边差分标记d[u]++, d[v]++, d[lca] -= 2O(log n) / 路径
差分还原后序:d[u] += d[v](v 是儿子)O(n) 一次
路径点权和pre[u] + pre[v] - 2·pre[lca] + val[lca]O(log n) / 次
路径最大边权倍增时同步维护 max,答案最后补 mx[u][0]/mx[v][0]O(log n) / 次
📌笔记卡
📖 必记概念
  • 倍增:2^k 级祖先,二进制拆分上跳
  • LCA:最近公共祖先,路径问题的枢纽
  • 点差分:d[fa[lca]]-- 切断向上的污染
  • 边差分:lca 处 -= 2,根节点跳过
  • 还原 = 后序求子树和
🧠 必背模板
  • up[u][k]=up[up[u][k-1]][k-1]
  • if(u==v) return u;
  • for(k=LOG;k>=0;k--)
  • d[u]++;d[v]++;d[w]--;d[fa[w]]--;
  • dist=dep[u]+dep[v]-2*dep[w]
⚠️ 考场提醒
  • 深链用 BFS 预处理,防爆栈
  • LOG = 20,数组第二维 +1
  • 差分先打完标记再统一还原
  • 根节点 0 号位要留空(up[root][0]=0)
  • 多组数据记得清空 dep / up / d
课后练习

分层练习作业

A基础 / B提高 / C挑战,难度递增。C组后附 12 道洛谷原题。

🟢 A组 · 基础(4题)

A1概念辨析

一棵树有边 1-2、1-3、2-4、2-5、3-6、3-7。以 1 为根,求 lca(4,5)、lca(4,6)、lca(6,7),并求 dist(4,6)。

查看参考答案

先算深度:dep[1]=1, dep[2]=dep[3]=2, dep[4]=dep[5]=dep[6]=dep[7]=3。

lca(4,5) = 2:4 和 5 是兄弟,共同父亲是 2。
lca(4,6) = 1:4 在 2 的子树、6 在 3 的子树,只有根 1 是公共祖先。
lca(6,7) = 3:6、7 是兄弟,父亲是 3。

dist(4,6) = dep[4] + dep[6] - 2·dep[lca(4,6)] = 3 + 3 - 2×1 = 4(路径 4-2-1-3-6,共 4 条边)。

易错:把「深度」和「距离」混淆。若设 dep[root]=0(根深度为 0),则 dep[4]=2、dep[6]=2,dist = 2+2-2×0 = 4,结果相同——深度基准不影响距离,但影响你自己手算时的直觉,写代码时内部统一即可。

A2模板默写

不看笔记,完整默写「倍增预处理 + LCA 查询」两份代码,并说明 LOG 应取多少。

查看参考答案
const int MAXN = 500005, LOG = 20;
int n, q, dep[MAXN], up[MAXN][LOG + 1];
vector<int> g[MAXN];

void bfs(int root) {
    queue<int> qu; qu.push(root);
    dep[root] = 0; up[root][0] = root;   // 根的父亲指向自己,防越界
    while (!qu.empty()) {
        int u = qu.front(); qu.pop();
        for (int k = 1; k <= LOG; k++)
            up[u][k] = up[up[u][k - 1]][k - 1];
        for (int v : g[u])
            if (v != up[u][0] || u == root) {
                if (v == up[u][0] && u != root) continue;
                dep[v] = dep[u] + 1; up[v][0] = u; qu.push(v);
            }
    }
}

int lca(int u, int v) {
    if (dep[u] < dep[v]) swap(u, v);
    int d = dep[u] - dep[v];
    for (int k = 0; d; k++, d >>= 1)
        if (d & 1) u = up[u][k];
    if (u == v) return u;
    for (int k = LOG; k >= 0; k--)
        if (up[u][k] != up[v][k]) { u = up[u][k]; v = up[v][k]; }
    return up[u][0];
}

LOG 取值:必须满足 2^LOG > 树的最大深度,取 LOG = 20 可覆盖 n ≤ 5×10⁵。注意数组写 [LOG + 1],循环写 k <= LOG。

易错点:① 根的 up[root][0] 建议指向自己(或 0 并保证 0 号位的 up[0][*]=0),避免递推时越界读到随机值;② 判父亲要用 v == up[u][0] 而不是 v == fa,BFS 版本没有 fa 参数。

A3点差分入门

树:1-2、2-3、2-4、4-5。执行两条路径覆盖:3→5 与 1→5。求被覆盖次数最多的点及其次数。

查看参考答案

第一步:算 lca。以 1 为根,dep[1]=0, dep[2]=1, dep[3]=dep[4]=2, dep[5]=3。
lca(3,5) = 2;lca(1,5) = 1。

第二步:打标记(点差分)。
路径 3→5:d[3]++, d[5]++, d[2]--, d[fa[2]=1]--
路径 1→5:d[1]++, d[5]++, d[1]--, d[fa[1]=none]--(lca 就是根,父亲不存在,跳过)
汇总:d[1] = +1-1 = 0;d[2] = -1;d[3] = +1;d[4] = 0;d[5] = +2。

特别注意(本题的关键考点):路径 1→5 的 lca 恰好是根 1,此时 d[fa[lca]]-- 这一项没有对象可减。正确做法是直接省略它,得净效果 d[1]=0, d[5]++。

汇总所有标记:d[1] = −1(来自路径 3→5 的 fa[2]--);d[2] = −1;d[3] = +1;d[4] = 0;d[5] = +2。

第三步:后序求子树和。
d[5] = 2;d[3] = 1;d[4] = 0 + d[5] = 2;
d[2] = −1 + d[3] + d[4] = −1 + 1 + 2 = 2;d[1] = −1 + d[2] = −1 + 2 = 1。

最终结果:d[1]=1, d[2]=2, d[3]=1, d[4]=2, d[5]=2。

逐条路径手工验证:路径 3→5 经过 {3,2,4,5};路径 1→5 经过 {1,2,4,5}。
点数统计:1 出现 1 次、2 出现 2 次、3 出现 1 次、4 出现 2 次、5 出现 2 次。
与差分管出的结果完全一致。✅

结论:被覆盖最多的点是 2、4、5,次数为 2。

本小题的最大价值:它演示了「lca 恰好是根时,d[fa[lca]]-- 必须省略」这一边界情形。若强行给根造一个 0 号父亲并且让 d[0] 参与运算,就会把整个答案搞乱。稳妥写法:if (fa[w]) d[fa[w]]--;

A4边差分入门

用上题同一棵树,对路径 3→5 与 1→5 做边覆盖。求每条边的覆盖次数,并指出被覆盖最多的边。

查看参考答案

边差分的数组含义:d[x] 表示「x 到父亲的那条边」。所以 d[1] 是无效的(根无父边),统计时从 2 开始。

打标记:
路径 3→5(lca=2):d[3]++, d[5]++, d[2] -= 2
路径 1→5(lca=1):d[1]++, d[5]++, d[1] -= 2 → 净效果 d[5]++

汇总:d[2] = -2;d[3] = +1;d[5] = +2;d[1] = 0;d[4] = 0。

后序求子树和:
d[5] = 2(边 4-5)
d[3] = 1(边 2-3)
d[4] = 0 + 2 = 2(边 2-4)
d[2] = -2 + 1 + 2 = 1(边 1-2)

答案:边 4-5 与 2-4 各被覆盖 2 次,边 2-3 与 1-2 各 1 次。被覆盖最多的是边 4-5 与 2-4(各 2 次)。

手工验证:路径 3→5 走边 {2-3, 2-4, 4-5};路径 1→5 走边 {1-2, 2-4, 4-5}。合计:2-3 一次,2-4 两次,4-5 两次,1-2 一次。完全吻合。✅

对比记忆:同一组路径,点差分给出的最大是 2(点 2、4、5 三处),边差分给出的最大也是 2(边 2-4、4-5 两处)——虽然数值相同,但统计对象完全不同,读题时务必看清问的是「点」还是「边」。

🔵 B组 · 提高(4题)

B1前缀和 + LCA

给定 n 个点的树(n ≤ 10⁵),每个点有非负权值 val[i]。m 次询问(m ≤ 10⁵):路径 u→v 上的点权之和。

查看参考答案

方法一:树上差分 不适用
差分擅长「多次修改 + 一次查询」,本题是「零次修改 + 多次查询」,方向正好相反。硬用差分需要每次查询都还原一遍,总复杂度 O(nm),必超时。

方法二:根前缀和 O((n+m) log n) 推荐
一次 DFS 求出 pre[u](根到 u 的点权和,含 u),再用容斥公式回答每次询问。

long long pre[MAXN], val[MAXN];

void dfs(int u, int fa) {
    pre[u] = pre[fa] + val[u];
    for (int v : g[u]) if (v != fa) dfs(v, u);
}

// 询问 u → v
int w = lca(u, v);
long long ans = pre[u] + pre[v] - 2 * pre[w] + val[w];
cout << ans << "\n";

公式来源(容斥):pre[u] 含「根→u」,pre[v] 含「根→v」,两者相加时「根→lca」被算了两次,「lca 本身」也被算了两次。所以减去 2·pre[w] 会把 lca 多减一次,要补回 val[w]。

运行结果:点权 val = {1:1, 2:2, 3:3, 4:4, 5:5},树 1-2、2-3、2-4、4-5。查询 3→5:lca=2,答案 = pre[3]+pre[5]-2·pre[2]+val[2]。
pre[1]=1, pre[2]=3, pre[3]=6, pre[4]=7, pre[5]=12。
= 6 + 12 - 2×3 + 2 = 6 + 12 - 6 + 2 = 14。
手工验证:3+2+4+5 = 14。✅

易错:① pre 用 long long(点权和可能很大);② 别忘了 + val[w];③ pre[0] 必须是 0(在 dfs 前把 pre[0] 清零),否则根节点会加上垃圾值。

B2点差分建模

给定 n 个点(n ≤ 5×10⁴)的树和 k 条路径(k ≤ 10⁵)。求:被至少一条路径覆盖的点数,以及被所有路径覆盖的点数。

查看参考答案

核心思路:先用点差分求出每个点被覆盖的次数 cnt[u],然后两个问题都变成对 cnt 的一遍统计。

「至少一条」:统计 cnt[u] ≥ 1 的点数。
「所有路径」:统计 cnt[u] == k 的点数(k 为路径总数)。

// 第一步:打标记
for (int i = 1; i <= k; i++) {
    int u, v; cin >> u >> v;
    int w = lca(u, v);
    d[u]++; d[v]++; d[w]--;
    if (up[w][0] != w) d[up[w][0]]--;   // 根节点要跳过
}
// 第二步:一次性还原
calc(1, 0);
// 第三步:统计
int atLeastOne = 0, all = 0;
for (int i = 1; i <= n; i++) {
    if (d[i] >= 1) atLeastOne++;
    if (d[i] == k) all++;
}
cout << atLeastOne << " " << all << "\n";

运行结果:链 1-2-3-4-5,路径 {2→4, 3→5},k=2。
点覆盖次数:1→0, 2→1, 3→2, 4→2, 5→1。
输出 4 2(至少一条的有 2,3,4,5 共 4 个;被两条都覆盖的有 3,4 共 2 个)。

易错:「所有路径覆盖」必须用 == k 而不是 >= k(虽然本题中两者等价,但当路径可重复时不等价);根节点的父亲判断别漏,否则 d[0] 会被污染。

B3倍增 + 最值

给一棵带权树(边权任意整数,n, m ≤ 2×10⁵),求每次询问 u→v 路径上的最大边权与最小边权。

查看参考答案

思路:在倍增表 up[u][k] 之外,再开两个表 mx[u][k]、mn[u][k],分别记录「从 u 到其 2^k 级祖先」这条链上的最大/最小边权。三个表一起递推、一起上跳。

int up[MAXN][LOG + 1], mx[MAXN][LOG + 1], mn[MAXN][LOG + 1], dep[MAXN];

void dfs(int u, int fa, int w) {
    dep[u] = dep[fa] + 1;
    up[u][0] = fa;
    mx[u][0] = mn[u][0] = w;     // 边权挂在儿子上
    for (int k = 1; k <= LOG; k++) {
        up[u][k] = up[up[u][k-1]][k-1];
        mx[u][k] = max(mx[u][k-1], mx[up[u][k-1]][k-1]);
        mn[u][k] = min(mn[u][k-1], mn[up[u][k-1]][k-1]);
    }
    for (auto& e : g[u]) if (e.to != fa) dfs(e.to, u, e.w);
}

查询时:mx[u][k] 在「把 u 上跳 2^k 步」的同时合并进答案,两个方向的跳跃(u 侧与 v 侧)都要合并;循环结束后还要补上 mx[u][0] 与 mx[v][0],因为两人最终停在 LCA 的两个儿子上,各自到 LCA 的那条边还没被统计。

初值技巧:mx 初值取 INT_MIN、mn 初值取 INT_MAX,这样正负权都不会出错。若用 0 初始化,遇到全负边权的路径就会返回错误的最大值 0。

运行结果:树 1-2(-3)、2-3(5)、2-4(2)。查询 3→4:路径边权为 {5, 2},最大 5、最小 2;查询 1→4:边权 {−3, 2},最大 2、最小 −3。

进阶:若题目还要求「路径和」,就把 max/min 换成加法(注意加法的单位元是 0);若要求「路径 gcd」也能这样维护(gcd 满足结合律)。只要一个运算满足结合律且单位元已知,就能挂到倍增表上。

B4祖先判定与路径相交

给定树与两棵「路径」a→b、c→d,判断它们是否有公共点(n, m ≤ 10⁵)。

查看参考答案

引理1(祖先判定 O(1)):x 是 y 的祖先 ⟺ tin[x] ≤ tin[y] 且 tout[y] ≤ tout[x](DFS 序区间包含)。

引理2:路径 a→b 与 c→d 相交 ⟺ (lca(a,b) 在路径 c→d 上)或(lca(c,d) 在路径 a→b 上)。

引理2 的证明:若两条路径相交于某点 x,考虑两条路径中「最高的点」(深度最小的点)。设为 lca(a,b)=p,则 p 到 x 这一段必属于某条路径,可推出 p 在路径 c→d 上;反之亦然。因此两条路径相交 必然导致「某条路径的 lca 落在另一条路径上」。

// 判断 x 是否在路径 u→v 上
bool onPath(int x, int u, int v) {
    int w = lca(u, v);
    return (isAncestor(x, u) || isAncestor(x, v)) && isAncestor(w, x);
}
// x 在路径上 ⟺ x 在「u 到 w」或「v 到 w」的链上
bool isAncestor(int a, int b) {   // a 是 b 的祖先
    return tin[a] <= tin[b] && tout[b] <= tout[a];
}

// 判断两条路径是否相交
int p = lca(a, b), q = lca(c, d);
bool ok = onPath(p, c, d) || onPath(q, a, b);

复杂度:每次询问 O(log n)(4 次 lca),完全够用。

运行结果:树 1-2、2-3、2-4。
路径 3→4 与 2→3:p=lca(3,4)=2,2 在路径 2→3 上 → 相交(公共点 2、3)。
路径 3→4 与 1→3:p=2,2 在路径 1→3 上 → 相交(公共点 2、3)。
路径 3→2 与 4→2:p=2,2 在路径 4→2 上 → 相交(公共点 2)。

易错:① tin/tout 的记法要与本讲统一(tout[u] = timer,即真实的最后时间戳);② 判断「x 在路径 u→v 上」时,两个条件必须同时满足——isAncestor(x,u) || isAncestor(x,v) 只说明 x 在「u 上方的链」或「v 上方的链」上,还要加上 isAncestor(w, x) 才能保证 x 不越过 lca 跑到别处。

🔴 C组 · 挑战(4题)

C1倍增进阶

给定树与路径 u→v,求这条路径上从 u 出发的第 k 个点(k ≤ 路径长度+1)。n, m ≤ 2×10⁵。

查看参考答案

关键观察:路径 u→v 被 lca 分成两段——「u 向上到 lca」(长度 du = dep[u] − dep[w] + 1 个点,含两端)和「lca 向下到 v」。

分情况:
· 若 k ≤ du:第 k 个点在「u 向上的链」上,就是从 u 往上跳 k − 1 步的那个点 —— 直接倍增跳即可。
· 若 k > du:第 k 个点在「lca 向下的链」上,从 v 往上数的第 (总点数 − k + 1) 个点。设 t = 总点数 − k + 1,从 v 往上跳 t − 1 步。

// 从 u 往上跳 step 步(step ≤ dep[u])
int jump(int u, int step) {
    for (int k = 0; step; k++, step >>= 1)
        if (step & 1) u = up[u][k];
    return u;
}

int kthOnPath(int u, int v, int k) {
    int w = lca(u, v);
    int du = dep[u] - dep[w] + 1;          // u→w 段的点数
    if (k <= du) return jump(u, k - 1);   // 在上行的段里
    int total = (dep[u] - dep[w]) + (dep[v] - dep[w]) + 1;
    return jump(v, total - k);            // 在 v 上行的段里
}

运行结果:树 1-2、2-3、2-4、4-5,路径 3→5。
lca(3,5)=2,du = dep[3]−dep[2]+1 = 2−1+1 = 2,total = (2−1)+(3−1)+1 = 4。
k=1 → jump(3,0) = 3;k=2 → jump(3,1) = 2;k=3 → jump(5, 4−3=1) = 4;k=4 → jump(5, 3) = 5。
路径 3-2-4-5,验证正确。✅

易错:k > du 分支里,从 v 往上跳的步数是 total − k(不是 total − k + 1)——因为「第 k 个点」从 v 数过去是第 total−k+1 个,需要跳 (total−k+1) − 1 = total−k 步。

C2差分 + 二分

树上有 k 条路径(k ≤ 10⁵)。可以「消除」一条路径,求消除后仍然被覆盖的边数的最小值。n ≤ 10⁵。

查看参考答案

思路:先用边差分求出每条边被多少条路径覆盖,记为 cnt[e]。设答案为 ans,则对所有边有:cnt[e] − [边 e 在被消除的那条路径上] ≤ ans。

关键:消除第 i 条路径后,该路径上的边的覆盖次数减 1。为了让「最大覆盖次数」最小,最优策略显然是消除当前覆盖最重的那些边所在的路径。可以二分答案 mid,检验是否存在一条路径,使得「删掉它之后所有边的 cnt ≤ mid」。

检验方法(不写代码,讲清逻辑):把所有 cnt[e] > mid 的边都找出来,统计这些边的总数 need;再统计「每条路径覆盖了多少条这种超标边」;若存在某条路径覆盖的超标边数 = need,则删掉它就能把所有超标边降下来,mid 可行。

复杂度:二分 log n 轮,每轮 O(n + m·log n),总计 O((n + m log n) log n),在 10⁵ 量级下可行。

为什么这题值得做:它把「树上差分」与「二分答案」两个独立技巧串起来:差分负责「高效求出每条边被覆盖几次」,二分负责「把最优化问题转成判定问题」。「差分 + 二分 + 树上前缀和计数」是提高组到省选的经典组合套路,P2680 运输计划就是它的原版。

易错:① 「覆盖的边数」用边差分;② 二分上界取 max(cnt) 即可,不必从 n 开始;③ 统计「路径覆盖的超标边数」等价于在树上对超标边做一次边差分 + 子树和,若朴素暴力枚举必超时。

C3算法设计与对比

题目保证所有询问在输入结束后才需要输出(可离线)。请设计一个比倍增更快的方案,并分析复杂度。

查看参考答案

方案:Tarjan 离线 LCA(并查集) O(n + m·α)

算法流程:
① 从根开始 DFS。进入 u 时把 u 标记为「已访问」;
② 递归处理每个儿子 v,处理完后用并查集把 v 合并到 u(fa[find(v)] = u);
③ 检查所有与 u 相关的询问 (u,x):若 x 已访问,则 lca(u,x) = find(x)(即 x 当前所处「未回溯完的祖先集合」的代表元,恰好就是两者的 LCA)。

为什么是 LCA:当 x 已被访问时,x 可能已经回溯完了自己的子树。若 x 在 u 的子树里,它在 u 还没回溯时就已被访问,此时合并的方向保证 find(x) 恰好停在 u 上(因为 u 的子树还没合并到 u 的父亲)。若 x 在 u 的子树之外,则它已回溯完并被合并到某个祖先上,find(x) 给出的是「从根到当前 DFS 栈中,最深的那个同时是 x 祖先的点」——正是 LCA。

复杂度:每个点被并查集合并一次,每次查询一到两次 find。并查集带路径压缩(或按秩合并)后均摊近常数,总复杂度 O(n + m·α(n)),几乎线性,比倍增的 O((n+m) log n) 更快。

代价:① 必须离线(把所有询问先读进来挂到点上);② 需要额外存所有询问;③ 递归 DFS 在大 n 的深链上可能爆栈。

结论(选型建议):
· 只需偶尔查几次 → 倍增(写起来最快);
· 询问很多且可离线 → Tarjan;
· 询问多、在线、还要配路径修改 → 树链剖分;
· 树上批量路径加减 & 最后统一统计 → 树上差分 + 倍增(根本不需要每次求出所有 LCA 的中间结果)。

C4综合建模

树上每个点有点权 val[i]。有 k 条路径,每条路径的价值 = 该路径上点权之和。求价值最大的那条路径的价值。n, k ≤ 2×10⁵。

查看参考答案

方法一:枚举 k 条路径,各用 O(log n) 求路径和 O(k log n)

// 预处理根前缀和
void dfs(int u, int fa) {
    pre[u] = pre[fa] + val[u];
    for (int v : g[u]) if (v != fa) dfs(v, u);
}
long long pathSum(int u, int v) {
    int w = lca(u, v);
    return pre[u] + pre[v] - 2 * pre[w] + val[w];
}

long long best = LLONG_MIN;
for (int i = 1; i <= k; i++) {
    int u, v; cin >> u >> v;
    best = max(best, pathSum(u, v));
}
cout << best << "\n";

方法二:树上差分 不适用
差分适合「全部路径一起做,最后统一查询每个点/边」。本题是「每条路径单独算一个值」,路径之间不能合并,差分帮不上忙。这说明:选择工具前先看清「是批量合并还是逐个区分」。

运行结果:val = {1:1, 2:2, 3:3, 4:4, 5:5},树 1-2、2-3、2-4、4-5,路径 {3→5, 1→2}。
3→5:3+2+4+5 = 14;1→2:1+2 = 3。
输出 14。

延伸思考:如果把问题改成「求被所有 k 条路径覆盖的点中,点权之和最大的一段」,就需要「k 条路径的交」——仍然可以用 LCA 的容斥性质求出交的端点,再求路径和。可见 LCA 不仅能算距离与和,还能做路径求交、路径求并这类集合运算,因为树上路径的交集仍然是一条路径(或空),这是树结构非常优美的性质。

🏆 洛谷原题精选(12道)

以下 12 道题覆盖 LCA 与树上差分两大类核心技巧,按难度从普及-到省选-排列,建议按顺序完成。

1
P3379
【模板】最近公共祖先(LCA)普及-
2
P2420
让我们异或吧(路径异或和)普及+
3
P5002
专心OI - 找祖先(LCA 计数)普及+
4
P3398
仓鼠找sugar(路径求交)普及+
5
P3128
[USACO15DEC] 最大流Max Flow提高
6
P3258
[JLOI2014] 松鼠的新家提高
7
P4281
[AHOI2008] 紧急集合(三点 LCA)提高
8
P5836
[USACO19DEC] Milk Visits S提高
9
P1967
[NOIP2013] 货车运输(倍增最值)提高
10
P1600
[NOIP2016] 天天爱跑步(树上差分)提高+
11
P2680
[NOIP2015] 运输计划(差分+二分)提高+
12
P4556
[Vani有约会] 雨天的尾巴(差分+合并)省选-
刷题建议:第 1~4 题是「LCA 本体 + 路径判定」,务必全部拿下且能默写模板;第 5~8 题是「树上差分」的四种典型建模(点差分、点差分带修正、三点容斥、路径点权前缀和),是最有性价比的一段;第 9~12 题把倍增/差分与 MST、线段树合并等技巧结合,属于提高组压轴难度,建议先想清「用差分还是用倍增」再动手写。