1一句话问题
给一棵
n个点的树,以及指定的根root(n ≤ 5×10⁵)。
m条路径,每条给两个点u和v:把u到v这条路径上的每一个点都 +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 底下挂着 2 和 3;2 底下挂着 4 和 5;3 底下挂着 6,6 底下挂着 7。
四条路径分别经过:4-2-5、4-2-1-3-6-7、2-4、1-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暴力:一条路径就老老实实走一遍
// 暴力:每条路径老老实实走一遍,路过哪个点就给哪个点 +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;}点「运行 ▶」看结果
它是这道题最直白的翻译,也是这一章的标准答案。走法和第 51 章第 2 步一模一样: 深的那个先往上爬,爬到一样深了再一起往上,撞在一起的那个点就是 LCA。 只是这一次不光要知道撞在哪,还要沿途每个点都 +1。
⇒ 一条路径的代价,就是它自己有多长。总代价 = 所有路径长度之和。
3⚠⚠ 转折点:随机树上暴力反而赢
按惯例这一步该量「暴力有多慢」。这一章又量不出来 —— 而且是第 51 章那件事的原样重演。
// 换一把尺子:数「一共碰了多少格」//// ★ 为什么需要它:这一章的秒表在小数据上全是 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;}点「运行 ▶」看结果
本机实测(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.04 秒(把 m 改成 1 实测的)。
⇒ 剩下的 0.16 秒全是那 10 万次 LCA 查询。
⚠ 而同样 10 万次查询,在链上只要 0.05 秒(总 0.08 减去建表的 0.03)。
同一段代码、同样的次数,快了三倍 —— 因为链上 up[k][v] 访问的是 v − 2ᵏ,
一路都是挨着的内存;随机树上它到处乱跳,每一跳都是一次缓存未命中。
⇒ 第 45 章那条实测(顺序加法 82 亿次/秒、随机访问 1.0 亿次/秒,差 80 多倍) 在这里第二次登场。「操作次数一样」不等于「一样快」。
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]--只影响p和p以上(p 的子树和里包含它自己);d[fa[p]]--只影响p以上。
p 自己要恰好留下 1 次,p 以上要恰好留下 0 次 —— 一个格子减一次、另一个格子再减一次,
是唯一能同时满足这两条的写法。
⚠ 而并成 d[p] -= 2 也是一个正确的写法 —— 只不过它算的是另一道题:
那是边差分(第 12 步)。这两行代码差一个字,题目差一整道。
5四个标记打完长什么样
拿第 1 步那组样例(四条路径 4-5、4-7、2-4、1-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 | 2 | 3 | 4 | 5 | 6 | 7 |
| d[] | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
7正解
// 正解:树上差分(点差分)—— 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;}点「运行 ▶」看结果
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★ 对拍:五个写错的版本,外加一份试金石
// 正解:树上差分(点差分)—— 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 就一定有点被覆盖 |
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 |
- 打乱编号:⑤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 全是根」这两件事就永远没人替你验。 ★ 一句话:别拿抓获率当唯一的尺子 —— 有些数据是拿来堵洞的,不是拿来抓虫的。
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 条边)。
d[u]++ · d[v]++ · d[p]-- · d[fa[p]]--边差分:
d[u]++ · d[v]++ · d[p] -= 2| 错法 | 它把哪种写法用错了地方 | 后果 |
|---|---|---|
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 章那条), 它只是「常常不行」;到底行不行,只有量一遍才知道。
13这一章没讲的
这一章的四个标记,全都建立在一件事上:所有修改都做完了,才开始求子树和。 一旦题目变成「改一条路径、马上问一次、再改再问」,这套就整个失效了 —— 差分数组还没还原,你根本读不出任何一个点的当前值。
那一类题(边改边问)要的是树链剖分 + 线段树:
把树拆成若干条链,每条链上的一段就是线段树的一个区间,
于是「路径改一段」变成「O(log n) 段区间修改」。⚠ 它比这一章重得多,本书没有独立章节。
★ 另外两块也没讲:LCA 的另外几种求法(Tarjan 离线、欧拉序 + ST 表 —— 第 51 章列过),
以及点权 / 边权带权值的差分(把 ++ 换成 += w 就行,一个字都不用改逻辑)。
14自测
- 洛谷 P3128 [USACO15DEC] Max Flow P —— ★ 就是这一章那道题的原题(只问最大值)。第 51 章的练习题里点过这个洞,现在可以做了
- 洛谷 P3258 [JLOI2014] 松鼠的新家 —— ★★ 点差分的经典题,但有个坑:相邻两段的接头处会被算两次,要减掉 —— ⚠ 读题时先想清楚「每个点被访问几次」到底怎么定义
- 洛谷 P2680 [NOIP 2015 提高组] 运输计划 —— ⚠ 难题:二分答案(第 9 章)+ 边差分(本章)+ LCA(第 51 章)三合一。★ 它是「边差分」最标准的出场
- 洛谷 P3178 [HAOI2015] 树上操作 —— ⚠ 边改边问 —— ★ 放在这儿是为了让你亲眼看到本章这套为什么不够用(正解是树链剖分)
- 洛谷 P1352 没有上司的舞会 —— 第 27 章树形 DP 的原题。★ 放在这儿是想说明:不是每道树上题都要差分或倍增
- 洛谷 P1966 火柴排队 —— 和树无关,考的是「离散化 + 逆序对」。★ 拿它复习第 6 章那一对逆运算的思路:先想清楚要求的到底是什么
- ★ 一维差分换个尺子就是树上差分 —— 前缀和 → 子树和,
区间两个标记 → 路径四个标记(
d[u]++ · d[v]++ · d[p]-- · d[fa[p]]--)。 ⚠ 把后两个并成d[p] -= 2,它就变成边差分了 —— 差一个格子,差一整道题。 - ⚠⚠ 上一章的正确写法,可能就是这一章的 bug。
fa[root] = root在第 51 章是个方便的约定,在这里会让根被减两次。 ★ 而随机树上 45.1% 的路径 LCA 恰好是根,链上 0%,菊花上 100% —— 同一个 bug 在三种形状上分别是「一抓一个准」「完全抓不到」「必抓」。 - ★★★ 顺手写的生成器有两种漏法:它「多做」的和它「顺带」的。
那句
while (v == u)是多做的(亲手删掉了一个边界), 「把 i 挂到 1..i−1 上」是顺带的(白送了一条没人写下来的性质)。 两个错法各被其中一种挡成精确的 0,撤掉对应的旋钮又精确地落回去。