阶段 11 · 树上进阶 · 第 52 章提高组 S

树上差分:把「整条路径 +1」变成「四个格子 ±1」

★ 关键一步和第 6 章的一维差分是同一件事,只把「前缀和」换成了「子树和」。⚠ 而这一章的转折点在第 3 步:随机树上暴力反而赢(本机 0.06 秒对 0.20 秒),因为随机树的路径平均只有 21.31 个点。

需要先学:第 6 章 前缀和与差分第 51 章 LCA 与倍增:把「往上跳多少步」拆成二进制例题:给一棵树和 m 条路径,把每条路径上的点全部 +1,最后问每个点被覆盖了多少次建议用时:145 分钟

1一句话问题

给一棵 n 个点的树,以及指定的根 rootn ≤ 5×10⁵)。

m 条路径,每条给两个点 uv:把 uv 这条路径上的每一个点都 +1。

全部做完之后,输出每个点被覆盖了多少次,以及其中最大的那个。

输入

7 4 1
1 2
1 3
2 4
2 5
3 6
6 7
4 5
4 7
2 4
1 7

输出

2 3 2 3 1 2 2
3

这棵树是:1 底下挂着 232 底下挂着 453 底下挂着 66 底下挂着 7

四条路径分别经过:4-2-54-2-1-3-6-72-41-3-6-7。 点 2 和点 4 各被三条路径经过 ⇒ 最大值是 3。

输入

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

输出

2 3 2 2 1 1
3

★ 第二组特意换成一条链,根也不是 1 号,而且第一条路径的两端是同一个点2 2)。

⚠ 那条路径的长度是 0,但它仍然经过一个点 —— 点 2 要 +1。 这句话看着是废话,它却是第 10 步那个错法 ④ 的全部内容。

★ 为什么要多问一句「最大值」

只问「每个点几次」的话,这道题在洛谷上的原型(P3128) 只要那个最大值。两个都问,是为了让对拍有牙齿 —— 只比最大值的话,第 10 步里错法 ③(LCA 那个点少 1)能蒙混过去一大半: 它错的那个点,常常不是全局最大的那个。 ⇒ 这是第 51 章「题面多问一句距离」的同一个手法:输出得越细,错法越藏不住。

2暴力:一条路径就老老实实走一遍

brute.cpp暴力:路过哪个点就给哪个点 +1
// 暴力:每条路径老老实实走一遍,路过哪个点就给哪个点 +1
//
// 一条路径 u—v 怎么走:两个端点比深度,深的那个先往上爬,爬到一样深之后一起往上,
// 直到撞在一起(那个点就是 LCA)—— 和第 51 章第 2 步的暴力是同一段代码,
// 只是这里不光要知道撞在哪,还要把沿途每个点都 +1。
//
// ⇒ 一条路径的代价是**它自己的长度**。所以慢不慢完全看树长什么样:
// 随机树的路径平均只有几十个点,链上却能有几十万个 —— 正文第 3 步量了这笔账。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 500005;
int dep[MAXN], fa[MAXN], bfs[MAXN];
long long cnt[MAXN];
vector<int> g[MAXN];
int n, m, root;
void build() {
vector<char> vis(n + 1, 0);
int head = 0, tail = 0;
dep[root] = 0;
fa[root] = 0;
vis[root] = 1;
bfs[tail++] = root;
while (head < tail) {
int u = bfs[head++];
for (int v : g[u]) {
if (vis[v]) continue;
vis[v] = 1;
dep[v] = dep[u] + 1;
fa[v] = u;
bfs[tail++] = v;
}
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m >> root;
for (int i = 0; i < n - 1; i++) {
int u, v;
cin >> u >> v;
g[u].push_back(v);
g[v].push_back(u);
}
build();
for (int i = 0; i < m; i++) {
int u, v;
cin >> u >> v;
while (dep[u] > dep[v]) { cnt[u]++; u = fa[u]; }
while (dep[v] > dep[u]) { cnt[v]++; v = fa[v]; }
while (u != v) { cnt[u]++; cnt[v]++; u = fa[u]; v = fa[v]; }
cnt[u]++; // ⚠ 撞在一起的那个点(LCA)只加一次
}
long long best = 0;
for (int v = 1; v <= n; v++) {
cout << cnt[v] << (v == n ? '\n' : ' ');
best = max(best, cnt[v]);
}
cout << best << '\n';
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

它是这道题最直白的翻译,也是这一章的标准答案。走法和第 51 章第 2 步一模一样: 深的那个先往上爬,爬到一样深了再一起往上,撞在一起的那个点就是 LCA。 只是这一次不光要知道撞在哪,还要沿途每个点都 +1

⇒ 一条路径的代价,就是它自己有多长。总代价 = 所有路径长度之和。

3⚠⚠ 转折点:随机树上暴力反而赢

按惯例这一步该量「暴力有多慢」。这一章又量不出来 —— 而且是第 51 章那件事的原样重演。

count.cpp换一把尺子:数「一共碰了多少格」
两种做法各放一个计数器,数的是同一个动作:给某个格子 ±1。暴力碰的格数 = 所有路径长度之和;差分碰的格数 = 4m + (n−1),★ 和树长什么样完全无关。
// 换一把尺子:数「一共碰了多少格」
//
// ★ 为什么需要它:这一章的秒表在小数据上全是 0.00 秒,而**碰格数**是能数出来的、
// 换台机器也不变的东西。两种做法各放一个计数器,数的是同一个动作:**给某个格子 ±1**。
// · 暴力:路径经过几个点就碰几格 ⇒ 总数 = 所有路径的**长度之和**;
// · 差分:每条路径固定碰 4 格(四个标记),最后还原时每个点碰一次 ⇒ 总数 = 4m + (n−1)。
// ⇒ 后者**和树的形状无关**,前者完全被形状牵着走 —— 正文第 3 步那张表就是这么来的。
//
// 顺带把这组数据的几个性质量出来(正文第 12 步要用):
// 最大深度、平均路径长度、「LCA 恰好是根」的比例、「两端是同一个点」的比例。
//
// 用法:./count < 输入 (和主程序同一份输入)
// ./count csv < 输入 只打 `键,值`,给 check:viz 用
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 500005;
const int LOG = 20;
int up[LOG][MAXN];
int dep[MAXN], fa[MAXN], bfs_[MAXN];
long long d[MAXN];
vector<int> g[MAXN];
int n, m, root;
int lca(int u, int v) {
if (dep[u] < dep[v]) swap(u, v);
int t = dep[u] - dep[v];
for (int k = 0; k < LOG; k++) if ((t >> k) & 1) u = up[k][u];
if (u == v) return u;
for (int k = LOG - 1; k >= 0; k--) if (up[k][u] != up[k][v]) { u = up[k][u]; v = up[k][v]; }
return up[0][u];
}
int main(int argc, char** argv) {
ios::sync_with_stdio(false);
cin.tie(nullptr);
bool csv = (argc > 1 && string(argv[1]) == "csv");
cin >> n >> m >> root;
for (int i = 0; i < n - 1; i++) {
int u, v;
cin >> u >> v;
g[u].push_back(v);
g[v].push_back(u);
}
{
vector<char> vis(n + 1, 0);
int head = 0, tail = 0;
fa[root] = 0;
up[0][root] = root;
vis[root] = 1;
bfs_[tail++] = root;
while (head < tail) {
int u = bfs_[head++];
for (int v : g[u])
if (!vis[v]) { vis[v] = 1; dep[v] = dep[u] + 1; fa[v] = u; up[0][v] = u; bfs_[tail++] = v; }
}
}
for (int k = 1; k < LOG; k++)
for (int v = 1; v <= n; v++) up[k][v] = up[k - 1][up[k - 1][v]];
int maxDep = 0;
for (int v = 1; v <= n; v++) maxDep = max(maxDep, dep[v]);
long long touchBrute = 0, rootLca = 0, samePair = 0, sumLen = 0;
for (int i = 0; i < m; i++) {
int u, v;
cin >> u >> v;
int p = lca(u, v);
long long len = dep[u] + dep[v] - 2LL * dep[p] + 1; // 这条路径上有几个点
sumLen += len;
touchBrute += len; // 暴力就是一格一格地碰
if (p == root) rootLca++;
if (u == v) samePair++;
d[u]++; d[v]++; d[p]--; if (fa[p]) d[fa[p]]--;
}
long long touchFast = 4LL * m + (n - 1); // 四个标记 + 还原一趟
long long best = 0;
for (int i = n - 1; i >= 1; i--) { int u = bfs_[i]; d[fa[u]] += d[u]; }
for (int v = 1; v <= n; v++) best = max(best, d[v]);
double avgLen = (double)sumLen / m;
if (csv) {
cout << "maxdep," << maxDep << '\n';
cout << "avglen," << fixed << setprecision(2) << avgLen << '\n';
cout << "brute," << touchBrute << '\n';
cout << "fast," << touchFast << '\n';
cout << "rootlca," << (long long)llround(1000.0 * rootLca / m) << '\n';
cout << "same," << (long long)llround(1000.0 * samePair / m) << '\n';
cout << "best," << best << '\n';
} else {
cout << "点数 n = " << n << ",路径 m = " << m << ",根 = " << root << '\n';
cout << "最大深度 " << maxDep << '\n';
cout << "平均每条路径几个点 " << fixed << setprecision(2) << avgLen << '\n';
cout << "暴力一共碰了几格 " << touchBrute << '\n';
cout << "差分一共碰了几格 " << touchFast << " (4m + n−1,和树的形状无关)\n";
cout << "差了几倍 " << fixed << setprecision(2)
<< (double)touchBrute / touchFast << '\n';
cout << "最大覆盖次数 " << best << '\n';
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

本机实测(B 机:原生 Ubuntu 7.0.0-30-generic / i5-10210U 8 线程 / 18 GB,2026-08-26,独占; ./genBig 100000 10000 <形状> 1,也就是 10 万个点、1 万条随机路径):

树的形状 最大深度 ★ 平均每条路径几个点 暴力碰几格 差分碰几格
随机(顺手写法) 27 21.31 213 098 139 999
二叉(fa[i] = i/2 16 28.17 281 707 139 999
毛毛虫(一条主链挂满叶子) 50 000 16 681.45 166 814 530 139 999
99 999 33 359.89 333 598 912 139 999
星(一个点挂着所有人) 1 3.00 30 000 139 999
⚠⚠ 第一行和最后一行才是这一章真正的起点

随机树上,一条路径平均只有 21.31 个点。 于是暴力总共只碰 21 万格 —— 而差分雷打不动碰 14 万格4m + n − 1)。两者只差 1.52 倍而菊花图上差分还输了 4.67 倍(3 万 vs 14 万)。

秒表说得更直白(同一台机器、同样独占,n = m = 10⁵):

数据(n = m = 10⁵ 暴力 差分
★ 随机树 0.06 秒 / 20.8 MiB 0.20 秒 / 28.4 MiB
★ 菊花 0.04 秒 0.22 秒
毛毛虫 2.74 秒 0.16 秒
5.36 秒 0.08 秒

随机树上暴力赢了 3.3 倍,菊花上赢了 5.5 倍;链上反过来输 67 倍。

「所以要学树上差分」这句话,在你顺手造的树上是站不住的。 ★ 这是第 51 章那条的直接延续,但病根不一样:那一章是「随机树太浅」, 这一章是「随机树上两点之间的路径太短」—— 而路径短,正是因为树浅。 同一个性质,在两章里卡住的是两件不同的事。

★★ 顺带一笔账:差分那 0.20 秒花在哪儿了

随机树上差分要 0.20 秒,可建那张倍增表只要 0.04 秒(把 m 改成 1 实测的)。 ⇒ 剩下的 0.16 秒全是那 10 万次 LCA 查询

⚠ 而同样 10 万次查询,在链上只要 0.05 秒(总 0.08 减去建表的 0.03)。 同一段代码、同样的次数,快了三倍 —— 因为链上 up[k][v] 访问的是 v − 2ᵏ一路都是挨着的内存;随机树上它到处乱跳,每一跳都是一次缓存未命中

⇒ 第 45 章那条实测(顺序加法 82 亿次/秒、随机访问 1.0 亿次/秒,差 80 多倍) 在这里第二次登场。「操作次数一样」不等于「一样快」。

同题对比:暴力:一个点一个点地加 vs 树上差分
⚠ 这个生成器默认造的是「随机树」—— 你会看到左边(暴力)赢。想看到差分赢,得自己去 genBig.cpp 里把形状换成 chain。这一步就是这一章的整个论点。
暴力:一个点一个点地加
树上差分

4★ 关键的一步:回到第 6 章那一对逆运算

先把第 6 章的一维差分原样搬过来 —— 数组上「区间 [l, r] 全部 +1」怎么做到 O(1)

   d[l]++ ;  d[r+1]--          <- 只碰两个格子
   最后做一遍前缀和            <- 一趟把所有区间的账一起结清

它成立的理由只有一句:前缀和是「从左边一路加过来」,所以在 l 处 +1, 等于给 l 右边的每一个位置都 +1。 差分就是它的逆运算。

树上把「前缀和」换成「子树和」,整套东西就搬过来了。

   子树和:val[v] = v 自己的标记 + 它所有后代的标记

反过来看:在点 x 上 +1,等于给「x 到根」这一整条链上的每个点都 +1 —— 因为链上每一个点都是 x 的祖先,x 都落在它的子树里。

于是「路径 u—v 全部 +1」就能拆成四笔账(设 p = lca(u, v)):

   d[u]++          -> u 到根这条链,每个点 +1
   d[v]++          -> v 到根这条链,每个点 +1
   d[p]--          -> p 被上面两笔各加了一次,可路径只经过它一次,减掉一次
   d[fa[p]]--      -> p 以上那一整段被加了两次,可路径根本不经过它们,
                      这一笔把「第二次」整段减掉(第一次被上一行减掉了)
★★ 为什么第 ③ 和第 ④ 笔不能并成一句 d[p] -= 2

它们减的不是同一段

  • d[p]-- 只影响 pp 以上(p 的子树和里包含它自己);
  • d[fa[p]]-- 只影响 p 以上

p 自己要恰好留下 1 次,p 以上要恰好留下 0 次 —— 一个格子减一次、另一个格子再减一次, 是唯一能同时满足这两条的写法。

⚠ 而并成 d[p] -= 2 也是一个正确的写法 —— 只不过它算的是另一道题: 那是差分(第 12 步)。这两行代码差一个字,题目差一整道。

5四个标记打完长什么样

拿第 1 步那组样例(四条路径 4-54-72-41-7)真打一遍:

   点       1    2    3    4    5    6    7
   d[]     -3   -1    0   +3   +1    0   +2      <- 只碰了 4×4 = 16 次
   ------------------------------------------
   子树和   2    3    2    3    1    2    2      <- 一趟扫完,就是答案

⚠ 注意 d[1] = -3根上的标记是个很负的数,而它的答案是 2。 差分数组本身完全不可读 —— 它只有在「子树和」这把尺子下才有意义, 这一点和第 6 章那个 d[] 一模一样。

⚠⚠ 还有一处,第 51 章刚立的约定在这里必须推翻

⚠⚠ 根的父亲,这一章不能是它自己

第 51 章为了让「跳过头」老实停住,特意约定 fa[root] = root,那时它只带来方便。

可在这儿,路径的 LCA 恰好是根时,d[p]--d[fa[p]]-- 会落在同一个格子上 —— 根被减了两次,答案少 1。

⇒ 这一章让 fa[root] = 0(0 号是个不存在的点,减在它身上没人看得见)。 ★ 这就是第 10 步那个错法 ②:它不是写错了,它是把上一章的正确写法照搬了过来。 ⚠ 而随机树上「LCA 恰好是根」的路径占 45.1%,链上占 0%,菊花上占 100% —— 同一个 bug,在三种形状上分别是「一抓一个准」「完全抓不到」「必抓」。

6动画一:四个标记 → 一趟子树和

★ 盯两件事:打标记时只碰四个格子(路径多长都一样), 还原时每个点只被结算一次(顺序是「BFS 序倒着来」—— 儿子一定排在父亲后面)。

树上差分:四个标记 → 一趟子树和
第 1 / 25 步
10203040506070
1234567
d[]0000000
一开始所有标记都是 0。下面这 m 条路径,每条都只打四个标记。

7正解

fast.cpp正解:LCA + 树上差分,总共 O((n + m) log n)
// 正解:树上差分(点差分)—— m 条路径整条 +1,最后一趟求出每个点被覆盖多少次
//
// ★ 关键的一步和第 6 章的一维差分是同一件事,只换了「前缀和」这个词:
//
// 一维:区间 [l, r] 全 +1 ⇒ d[l]++、d[r+1]-- ,最后做一遍**前缀和**还原
// 树上:路径 u—v 全 +1 ⇒ 在四个点上打标记,最后做一遍**子树和**还原
//
// 四个标记是(设 p = lca(u, v)):
//
// d[u]++ ; d[v]++ ; d[p]-- ; d[fa[p]]--
//
// 为什么是这四个:把「子树和」想成「从这个点一直往上加到根」的逆运算 ——
// d[u]++ 让 u 到根这一条链全 +1,d[v]++ 让 v 到根这一条链全 +1,
// 于是 p 到根那一段被加了**两遍**,而路径本来只经过 p 一次、根本不经过 p 的祖先。
// ⇒ d[p]-- 把 p 多出来的那一次减掉;d[fa[p]]-- 把 p 以上整段多出来的两次里的第二次减掉。
//
// ⚠⚠ 根的父亲在这里**不能沿用第 51 章「根的父亲是它自己」那个约定** ——
// 路径的 LCA 恰好是根时,d[root]-- 和 d[fa[root]]-- 会落在同一个点上,根就被减了两次。
// 这里让 fa[root] = 0,而 0 号是个不存在的点,减在它身上没人看得见。
// (wrongFaRoot.cpp 就是照搬那个约定写的,正文第 10 步拿它对拍。)
//
// ⚠ 还原那一趟**按 BFS 序倒着扫**,不要递归 —— 理由和第 51 章第 9 步一模一样:
// 最坏形状是一条链,50 万层递归必然段错误。BFS 序倒着扫,儿子一定排在父亲后面,
// 所以「加给父亲」的时候,这个点自己的子树和已经算完了。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 500005;
const int LOG = 20; // 2^19 = 524288 > 5×10^5
int up[LOG][MAXN];
int dep[MAXN], fa[MAXN], bfs[MAXN];
long long d[MAXN];
vector<int> g[MAXN];
int n, m, root;
void build() {
vector<char> vis(n + 1, 0);
int head = 0, tail = 0;
dep[root] = 0;
fa[root] = 0; // ⚠ 不是 root 自己,是不存在的 0 号点
up[0][root] = root; // 倍增表照旧用「根的父亲是自己」,两者不冲突
vis[root] = 1;
bfs[tail++] = root;
while (head < tail) {
int u = bfs[head++];
for (int v : g[u]) {
if (vis[v]) continue;
vis[v] = 1;
dep[v] = dep[u] + 1;
fa[v] = u;
up[0][v] = u;
bfs[tail++] = v;
}
}
for (int k = 1; k < LOG; k++)
for (int v = 1; v <= n; v++)
up[k][v] = up[k - 1][up[k - 1][v]];
}
int lca(int u, int v) {
if (dep[u] < dep[v]) swap(u, v);
int t = dep[u] - dep[v];
for (int k = 0; k < LOG; k++)
if ((t >> k) & 1) u = up[k][u];
if (u == v) return u;
for (int k = LOG - 1; k >= 0; k--)
if (up[k][u] != up[k][v]) { u = up[k][u]; v = up[k][v]; }
return up[0][u];
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m >> root;
for (int i = 0; i < n - 1; i++) {
int u, v;
cin >> u >> v;
g[u].push_back(v);
g[v].push_back(u);
}
build();
for (int i = 0; i < m; i++) {
int u, v;
cin >> u >> v;
int p = lca(u, v);
d[u]++;
d[v]++;
d[p]--;
d[fa[p]]--; // ⚠ p 是根时落在 0 号点上,正好没人看得见
}
for (int i = n - 1; i >= 1; i--) { // ★ BFS 序倒着扫 = 从叶子往根累加
int u = bfs[i];
d[fa[u]] += d[u];
}
long long best = 0;
for (int v = 1; v <= n; v++) {
cout << d[v] << (v == n ? '\n' : ' ');
best = max(best, d[v]);
}
cout << best << '\n';
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

8⚠ 还原那一趟:为什么倒着扫 BFS 序

求子树和最自然的写法是递归:先递归所有儿子,再把它们的值加给自己。 这道题里不能这么写 —— 理由和第 51 章第 9 步一模一样:

最坏形状就是一条链,n = 5×10⁵ 的链要递归 50 万层。 第 30 章实测过:本机 8 MB 栈、一帧约 30 字节,递归到 28 万层就段错误

而 BFS 序有一条免费的性质:父亲一定排在儿子前面。 所以倒着扫它,轮到某个点时,它的子树早就全部结算完了 —— 一个循环搞定,一层栈都不用压。

for (int i = n - 1; i >= 1; i--) {   // bfs[0] 是根,不用给谁
    int u = bfs[i];
    d[fa[u]] += d[u];
}
⚠ 「按编号从大到小扫」为什么不行 —— 而它在你的数据上偏偏是对的

有个更省事的写法:for (int v = n; v >= 1; v--) d[fa[v]] += d[v];

它要求「父亲的编号一定比儿子小」。这条性质顺手写的生成器天然满足 —— for (i = 2; i <= n; i++) 把 i 挂到 1..i−1 上 造出来的树就是这样。

⇒ 于是它在顺手数据上是精确的 0 / 300(第 10 步的错法 ⑤)。 ★ 而洛谷上的真实数据当然不保证这件事:换个编号顺序,它就崩。

9★ 对拍:五个写错的版本,外加一份试金石

对拍器
★ 生成器不给档位时跑的就是最终档(第 11 步那张表里的档位 3):打乱编号 + 根随机 + 形状混着来 + 路径里塞「两端相同」的。
// 正解:树上差分(点差分)—— m 条路径整条 +1,最后一趟求出每个点被覆盖多少次
//
// ★ 关键的一步和第 6 章的一维差分是同一件事,只换了「前缀和」这个词:
//
// 一维:区间 [l, r] 全 +1 ⇒ d[l]++、d[r+1]-- ,最后做一遍**前缀和**还原
// 树上:路径 u—v 全 +1 ⇒ 在四个点上打标记,最后做一遍**子树和**还原
//
// 四个标记是(设 p = lca(u, v)):
//
// d[u]++ ; d[v]++ ; d[p]-- ; d[fa[p]]--
//
// 为什么是这四个:把「子树和」想成「从这个点一直往上加到根」的逆运算 ——
// d[u]++ 让 u 到根这一条链全 +1,d[v]++ 让 v 到根这一条链全 +1,
// 于是 p 到根那一段被加了**两遍**,而路径本来只经过 p 一次、根本不经过 p 的祖先。
// ⇒ d[p]-- 把 p 多出来的那一次减掉;d[fa[p]]-- 把 p 以上整段多出来的两次里的第二次减掉。
//
// ⚠⚠ 根的父亲在这里**不能沿用第 51 章「根的父亲是它自己」那个约定** ——
// 路径的 LCA 恰好是根时,d[root]-- 和 d[fa[root]]-- 会落在同一个点上,根就被减了两次。
// 这里让 fa[root] = 0,而 0 号是个不存在的点,减在它身上没人看得见。
// (wrongFaRoot.cpp 就是照搬那个约定写的,正文第 10 步拿它对拍。)
//
// ⚠ 还原那一趟**按 BFS 序倒着扫**,不要递归 —— 理由和第 51 章第 9 步一模一样:
// 最坏形状是一条链,50 万层递归必然段错误。BFS 序倒着扫,儿子一定排在父亲后面,
// 所以「加给父亲」的时候,这个点自己的子树和已经算完了。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 500005;
const int LOG = 20; // 2^19 = 524288 > 5×10^5
int up[LOG][MAXN];
int dep[MAXN], fa[MAXN], bfs[MAXN];
long long d[MAXN];
vector<int> g[MAXN];
int n, m, root;
void build() {
vector<char> vis(n + 1, 0);
int head = 0, tail = 0;
dep[root] = 0;
fa[root] = 0; // ⚠ 不是 root 自己,是不存在的 0 号点
up[0][root] = root; // 倍增表照旧用「根的父亲是自己」,两者不冲突
vis[root] = 1;
bfs[tail++] = root;
while (head < tail) {
int u = bfs[head++];
for (int v : g[u]) {
if (vis[v]) continue;
vis[v] = 1;
dep[v] = dep[u] + 1;
fa[v] = u;
up[0][v] = u;
bfs[tail++] = v;
}
}
for (int k = 1; k < LOG; k++)
for (int v = 1; v <= n; v++)
up[k][v] = up[k - 1][up[k - 1][v]];
}
int lca(int u, int v) {
if (dep[u] < dep[v]) swap(u, v);
int t = dep[u] - dep[v];
for (int k = 0; k < LOG; k++)
if ((t >> k) & 1) u = up[k][u];
if (u == v) return u;
for (int k = LOG - 1; k >= 0; k--)
if (up[k][u] != up[k][v]) { u = up[k][u]; v = up[k][v]; }
return up[0][u];
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m >> root;
for (int i = 0; i < n - 1; i++) {
int u, v;
cin >> u >> v;
g[u].push_back(v);
g[v].push_back(u);
}
build();
for (int i = 0; i < m; i++) {
int u, v;
cin >> u >> v;
int p = lca(u, v);
d[u]++;
d[v]++;
d[p]--;
d[fa[p]]--; // ⚠ p 是根时落在 0 号点上,正好没人看得见
}
for (int i = n - 1; i >= 1; i--) { // ★ BFS 序倒着扫 = 从叶子往根累加
int u = bfs[i];
d[fa[u]] += d[u];
}
long long best = 0;
for (int v = 1; v <= n; v++) {
cout << d[v] << (v == n ? '\n' : ' ');
best = max(best, d[v]);
}
cout << best << '\n';
return 0;
}
点一下即可编辑

300 轮实测(种子 1..300,最终档 3):

故意写错的地方 被抓 靠什么现形
wrongNoFa:四项写成三项,漏了 d[fa[p]]-- 248 / 300 随机就抓(LCA 以上整条祖先链全多 1)
wrongFaRoot:照搬上一章的 fa[root] = root 223 / 300 要「LCA 恰好是根」的路径
wrongTwice:点问题写成 d[p] -= 2 300 / 300 谁都躲不过(LCA 那个点少 1)
wrongSelf:两端相同的路径直接跳过 188 / 300 ★★ 要 u == v 的路径,顺手档上是精确的 0
wrongIdOrder:还原时按编号从大到小扫 202 / 300 ★★ 要「编号打乱」,顺手档上也是精确的 0
★ 试金石 alwaysZero:每个点都输出 0 300 / 300 下限确认:只要 m ≥ 1 就一定有点被覆盖
五个错误版本 + 一份试金石(点开看)
wrongNoFa.cpp① 四项写成了三项
wrongFaRoot.cpp② 照搬第 51 章「根的父亲是它自己」
wrongTwice.cpp③ 点问题用了边差分的写法
wrongSelf.cpp④ 两端相同就跳过
wrongIdOrder.cpp⑤ 按编号倒序还原
alwaysZero.cpp★ 试金石:什么都不做

10★★★ 两个精确的 0,而且原因完全不同

顺手写的生成器(档位 0:随机树、编号不打乱、1 号当根、路径随机挑两个不同的点) 跑 300 轮,五个错法的成绩是:

   ①NoFa 181   ②FaRoot 267   ③Twice 300   ④Self 0   ⑤IdOrder 0

两个精确的 0。⚠ 它们不是同一种病:

★★★ 一个是「生成器亲手排除了它」,一个是「顺手写法自带一条性质」

④ Self 的 0,来自生成器里那句 while (v == u) v = 随机挑;

挑两个点的时候顺手加一句「别撞在一起」,几乎是所有人的第一反应 —— 它看着无害(谁会关心 u == v 呢),可它亲手把这个错法唯一的现形路径删掉了。 ⇒ 教训:生成器里每一句「排除掉某种情况」,都要问一次「排掉的那种是不是正好是一个边界」。

⑤ IdOrder 的 0,来自「把 i 挂到 1..i−1 上」这句建树代码。

它没有排除任何东西,只是顺手带上了一条谁都没写下来的性质:父亲的编号一定更小。 ⇒ 教训:顺手写法自带的不成文性质,往往正好是某个 bug 的免死金牌。 (第 51 章 wrongRootOne 是同一类:那次的免死金牌是「1 号当根」。)

★★ 两条合起来才是完整的一句: 顺手写的生成器有两种漏法 —— 它「多做」的(排除边界)和它「顺带」的(隐含性质)。

11★★ 生成器:七个档位,以及一个负分旋钮

300 轮 × 七个档位实测(数字是「300 轮里抓到几轮」):

档位 ①NoFa ②FaRoot ③Twice ④Self ⑤IdOrder 试金石
0 顺手写法 181 267 300 0 0 300
1 +打乱编号、根随机 215 237 300 0 214 300
2 +掺形状 221 226 300 0 204 300
★★ 3 = 最终档(+两端相同的路径) 248 223 300 188 202 300
4 对照档 = 3 − 打乱编号 232 228 300 188 0 300
5 对照档 = 3 − 形状 243 232 300 195 210 300
6 对照档 = 3 − 两端相同 221 226 300 0 204 300
★ 两个旋钮,各自把一个精确的 0 抬起来 —— 撤掉就精确地落回去
  • 打乱编号:⑤IdOrder 0 → 214;撤掉(对照档 4)回到精确的 0
  • 两端相同:④Self 0 → 188;撤掉(对照档 6)也回到精确的 0

★ 「撤掉就落回 0」这件事很值钱:它证明这个旋钮和这个 bug 是一一对应的, 不是碰巧被别的旋钮带出来的。

⚠ 老实账:「掺形状」这个旋钮,四个错法里三个是负分

对比最终档(3)和撤掉形状的对照档(5):

   ①NoFa 248 ← 243   ②FaRoot 223 ← 232   ④Self 188 ← 195   ⑤IdOrder 202 ← 210
                          −9                   −7                −8

掺进链 / 菊花 / 毛毛虫之后,三个错法反而更难抓了。 原因不难想: 链上「LCA 是根」几乎不发生(②FaRoot 靠它),菊花上路径只有 3 个点(②④⑤ 都吃亏)。

那为什么还留着? 和第 51 章那条一样:留它的理由是覆盖面,不是抓获率。 链和菊花是这道题的两个极端形状,它们不进对拍, 「链上会不会爆栈」「菊花上 LCA 全是根」这两件事就永远没人替你验。 ★ 一句话:别拿抓获率当唯一的尺子 —— 有些数据是拿来堵洞的,不是拿来抓虫的。

gen.cpp(七个档位)五个错法各要什么,文件头一条条写着

12★ 题面改一个字:点差分 → 边差分

把题面里的「路径上的每一个 +1」改成「路径上的每一条 +1」, 其余一字不变。这时候标记从四个变成三个:

   点差分:d[u]++ ;  d[v]++ ;  d[p]-- ;  d[fa[p]]--
   边差分:d[u]++ ;  d[v]++ ;  d[p] -= 2

★ 诀窍是把每条边记在它下面那个端点身上(边 (v, fa[v]) 就记在 v 上)。 于是「路径经过哪些边」=「路径经过哪些点,除了 LCA 自己」—— p 头上那条边不在路径上,所以 p 这个格子要一次性减掉两个 1。

输入

7 4 1
1 2
1 3
2 4
2 5
3 6
6 7
4 5
4 7
2 4
1 7

输出

1 2 3 1 2 2
3

⚠ 输入和第 1 步那组一模一样,只是问的东西换成了边(按输入里边的顺序输出)。 对比一下两份输出:点版是 2 3 2 3 1 2 2,边版是 1 2 3 1 2 2 —— 长度都不一样了(n 个点、n−1 条边)。

点差分 vs 边差分:同一条路径 4—7(LCA = 1)
第 1 / 12 步
00000010203040506070
圆圈右边的数 = 这个被覆盖几次
连线中间的数 = 这条被覆盖几次
点差分:d[u]++ · d[v]++ · d[p]-- · d[fa[p]]--
边差分:d[u]++ · d[v]++ · d[p] -= 2
★ 两张答案只差一个格子:LCA 那里。
路径 4—7 的 LCA 是 1。它经过 6 个「点」,而「边」比点少一条 —— 少的正是 1 上面那条。
★★ 两个错法,是同一枚硬币的两面
错法 它把哪种写法用错了地方 后果
wrongTwice(第 9 步 ③) 问题用了的写法 LCA 那个少 1
extWrongPoint 问题用了的写法 LCA 头上那条多 1

⇒ 两种写法差的正好就是 LCA 那一个格子 —— 动画二最后一帧盯的就是它。 ⚠ 而 extWrongPoint菊花图上完全抓不到(LCA 全是根,根头上没有边)。

300 轮实测extGen,两个档位):

故意写错的地方 顺手档 0 最终档 1
extWrongPoint:边问题用了点差分的四项 242 / 300 257 / 300
extWrongLow:边记在了的那一端 283 / 300 278 / 300

老实说:这一节顺手写法就够用了(②在最终档上甚至还降了 5)—— 两个错法都不挑数据。「顺手写的生成器不行」不是定律(第 50 章那条), 它只是「常常不行」;到底行不行,只有量一遍才知道。

边版的四份代码(点开看)
ext.cpp边差分:只有一行和点差分不同
extBrute.cpp标准答案:一条边一条边地爬
extWrongPoint.cpp① 边问题用了点差分
extWrongLow.cpp② 边记在浅的那一端
extGen.cpp两个档位

13这一章没讲的

⚠ 树上差分只解决「先全部改完、最后统一问」这一类

这一章的四个标记,全都建立在一件事上:所有修改都做完了,才开始求子树和。 一旦题目变成「改一条路径、马上问一次、再改再问」,这套就整个失效了 —— 差分数组还没还原,你根本读不出任何一个点的当前值。

那一类题(边改边问)要的是树链剖分 + 线段树: 把树拆成若干条链,每条链上的一段就是线段树的一个区间, 于是「路径改一段」变成「O(log n) 段区间修改」。⚠ 它比这一章重得多,本书没有独立章节。

★ 另外两块也没讲:LCA 的另外几种求法(Tarjan 离线、欧拉序 + ST 表 —— 第 51 章列过), 以及点权 / 边权带权值的差分(把 ++ 换成 += w 就行,一个字都不用改逻辑)。

14自测

自测清单0 / 15
配套练习
  • 洛谷 P3128 [USACO15DEC] Max Flow P —— ★ 就是这一章那道题的原题(只问最大值)。第 51 章的练习题里点过这个洞,现在可以做了
  • 洛谷 P3258 [JLOI2014] 松鼠的新家 —— ★★ 点差分的经典题,但有个坑:相邻两段的接头处会被算两次,要减掉 —— ⚠ 读题时先想清楚「每个点被访问几次」到底怎么定义
  • 洛谷 P2680 [NOIP 2015 提高组] 运输计划 —— ⚠ 难题:二分答案(第 9 章)+ 边差分(本章)+ LCA(第 51 章)三合一。★ 它是「边差分」最标准的出场
  • 洛谷 P3178 [HAOI2015] 树上操作 —— ⚠ 边改边问 —— ★ 放在这儿是为了让你亲眼看到本章这套为什么不够用(正解是树链剖分)
  • 洛谷 P1352 没有上司的舞会 —— 第 27 章树形 DP 的原题。★ 放在这儿是想说明:不是每道树上题都要差分或倍增
  • 洛谷 P1966 火柴排队 —— 和树无关,考的是「离散化 + 逆序对」。★ 拿它复习第 6 章那一对逆运算的思路:先想清楚要求的到底是什么
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)
这一章记住三句话
  1. 一维差分换个尺子就是树上差分 —— 前缀和 → 子树和, 区间两个标记 → 路径四个标记(d[u]++ · d[v]++ · d[p]-- · d[fa[p]]--)。 ⚠ 把后两个并成 d[p] -= 2,它就变成差分了 —— 差一个格子,差一整道题。
  2. ⚠⚠ 上一章的正确写法,可能就是这一章的 bug。 fa[root] = root 在第 51 章是个方便的约定,在这里会让根被减两次。 ★ 而随机树上 45.1% 的路径 LCA 恰好是根,链上 0%,菊花上 100% —— 同一个 bug 在三种形状上分别是「一抓一个准」「完全抓不到」「必抓」。
  3. ★★★ 顺手写的生成器有两种漏法:它「多做」的和它「顺带」的。 那句 while (v == u) 是多做的(亲手删掉了一个边界), 「把 i 挂到 1..i−1 上」是顺带的(白送了一条没人写下来的性质)。 两个错法各被其中一种挡成精确的 0,撤掉对应的旋钮又精确地落回去。