第 34 章讲 Kruskal 的时候,已经把并查集的基本操作讲完了(dsu.cpp 那一小节:
find 是「一路往上找祖宗」、unite 是「把两族的祖宗接起来」,
还顺带指出了「比爸爸不比祖宗」和「只挂点不挂族」这两个经典写法错在哪)。
这一章不再讲一遍(第 30 章不重复讲建图的同款)。
第 35 章章末白纸黑字预告的是这个:
★ 下一章专讲为什么它快到几乎是 O(1) —— 路径压缩 + 按秩合并的复杂度, 以及实测:不压缩 / 只压缩 / 压缩加按秩,三条曲线到底差多少。 ⚠ 顺带把均摊分析再推一步:
O(α(n))也是均摊出来的,而且比「每个元素进出各一次」难得多。
⚠ 于是这一章会撞上一件前 35 章都没碰到过的事:
★★ 这一章的主角(压不压缩、按不按秩、方向反没反)全都不影响答案。 八种写法给出的输出一模一样,300 轮对拍在它们身上 0 / 300。 所以这一章的尺子不是对拍,也不是秒表,而是计数器: find 一共往上跳了多少步。
1一句话问题
有 n 个点(
n ≤ 2×10⁵),一开始谁跟谁都不通。接下来 m 条操作(m ≤ 4×10⁵):
1 a b:把 a 所在的那一族和 b 所在的那一族合并;2 a b:问 a 和 b 现在连不连通。每个查询输出一行
Y或N;最后再输出一行:现在一共有多少个连通块。
这道题本来只需要输出 Y / N。加上「最后输出连通块个数」这一行,是故意的:
★ 第 35 章那条教训(题面多问一句,对拍就多一条腿)在这一章有一个极干净的现场: 第 12 步那个「忘了判已经同族就
cnt--」的 bug, 只看 Y / N 那半是 0 / 300,把最后那一行算上就是 300 / 300。
一行输出,把一个 bug 从「测不到」抬到「第 1 轮就抓住」。
2手算一遍:默认那组操作
8 12
1 1 2 合并 {1,2}
2 1 3 → N
1 2 3 合并 {1,2,3}
2 1 3 → Y
1 1 3 ★ 它俩已经是一族了 —— 这一步「什么都不该做」
1 4 5 合并 {4,5}
1 1 4 ★ 注意 a=1 此刻**不是**它那一族的祖宗
2 3 5 → Y
1 6 7 合并 {6,7}
1 5 6 合并成 {1,2,3,4,5,6,7}
2 1 7 → Y
2 8 1 → N(8 号一直是自己一族)答案:N Y Y Y N,最后一行 2({1..7} 和 {8})。
- 第 5 步那个重复合并,是「无条件
cnt--」这个 bug 的唯一现场; - 第 7 步那个「a 不是祖宗」,是「只挂点不挂族」和「比爸爸不比祖宗」的现场;
- 而第 8、11 步的查询,是把上面两件事读出来的地方 —— ★ bug 要现形,得「先埋下、后读出」两件事都发生,这正是第 12 步生成器调不动的根源。
3标准答案:一点历史都不攒
// 标准答案 —— 完全不碰并查集:每次查询现场 BFS 一遍//// 为什么它存在:正解(并查集)想的是「维护一片森林,把信息一路攒下来」。// 要是标准答案也维护森林,两份代码就是同一个思路写了两遍 ——// 只能验出打字错误,验不出想法错误(第 9、15、34、35 章那条)。//// 所以这一份**什么都不攒**:把每次「合并」当成一条边存下来,// 每次查询就拿现有的边**现场建图**、从 a 出发 BFS 一遍,看 b 在不在里面;// 最后那个连通块个数也是重新 BFS 数出来的。//// ★ 注意 BFS 里**一次 break 都没有**:哪怕半路就撞见了 b,也要把 a 的连通块整个走完。// (第 31 章:「找到第一个就 break」会让暴力假装自己不慢;// 第 35 章补的后半句:标准答案要挑「没有 break」的那个思路。)//// 复杂度 O(q × (n+m)):q 是查询次数。//// 输入:第一行 n m;接下来 m 行,每行 "1 a b"(把 a、b 所在的两族合并)// 或 "2 a b"(问 a、b 现在连不连通)。// 输出:每个查询一行 Y / N;最后一行输出当前的连通块个数。
#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, m; if (!(cin >> n >> m)) return 0;
vector<pair<int, int>> edges; // 已经执行过的合并,全都当成无向边攒着 vector<vector<int>> g(n + 1); vector<char> vis(n + 1, 0);
auto rebuild = [&]() { // ★ 每次查询都从零建一遍图:它一点历史都不留 for (int i = 1; i <= n; i++) g[i].clear(); for (auto [u, v] : edges) { g[u].push_back(v); g[v].push_back(u); } }; auto bfs = [&](int s) { // 从 s 出发把整个连通块标满(★ 中途绝不 break) vis[s] = 1; vector<int> q{s}; for (size_t h = 0; h < q.size(); h++) { int u = q[h]; for (int v : g[u]) if (!vis[v]) { vis[v] = 1; q.push_back(v); } } };
string out; for (int i = 0; i < m; i++) { int op, a, b; cin >> op >> a >> b; if (op == 1) { // 合并:只是把边记下来,什么都不算 edges.push_back({a, b}); continue; } rebuild(); fill(vis.begin(), vis.end(), 0); bfs(a); out += (vis[b] ? 'Y' : 'N'); out += '\n'; }
rebuild(); // 最后再数一遍连通块 fill(vis.begin(), vis.end(), 0); int cnt = 0; for (int i = 1; i <= n; i++) if (!vis[i]) { cnt++; bfs(i); } out += to_string(cnt); out += '\n';
cout << out; return 0;}点「运行 ▶」看结果
正解维护的是一片森林,把信息一路攒下来。要是标准答案也维护森林, 两份代码就是同一个思路写了两遍 —— 只能验出打字错误,验不出想法错误 (第 9、15、34、35 章那条)。
所以这一份什么都不攒:合并只是把边记下来,查询时拿现有的边从零建一遍图、 从 a 出发 BFS,看 b 在不在里面。
★ 而且 BFS 里一次 break 都没有 —— 哪怕半路撞见 b,也要把 a 的连通块整个走完。 (第 31 章:「找到第一个就 break」会让暴力假装自己不慢; 第 35 章补的后半句:标准答案要挑「没有 break」的那个思路。)
4实测:暴力有多慢
本机实测(./genBig n 1 造的链式数据,n 个点、2(n−1) 条操作):
| n | brute(每次查询重建图 + BFS) | naive(并查集,不压缩) | fast(压缩 + 按秩) |
|---|---|---|---|
| 4 000 | 0.08 秒 | 0.02 秒 | 0.00 秒 |
| 16 000 | 1.16 秒 | 0.30 秒 | 0.00 秒 |
| 32 000 | 5.48 秒 | 1.28 秒 | 0.00 秒 |
| 64 000 | — | 5.03 秒 | 0.00 秒 |
| 200 000 | — | 49.56 秒 | 0.03 秒 |
g++ -O2 -std=c++17 -o genBig genBig.cpp && g++ -O2 -std=c++17 -o fast fast.cpp
./genBig 200000 1 > big.txt && time ./fast < big.txt # 0.03 秒
./genBig 200000 1 > big.txt && time ./naive < big.txt # 49.56 秒
./count read < big.txt # ★ 只把输入读完就退出,什么都不算也是 0.03 秒。 也就是说:20 万个点、40 万条操作,正解跑完全程的时间和只读一遍输入一样, 算法那部分在秒表上根本量不出来。
★ 第 29、32、34、35 章那条「量之前先确认「你量的就是它」」的第五次。 ⚠ 所以
count.cpp的读入必须和fast.cpp写得一模一样 (都是cin+ 关掉sync_with_stdio),否则量出来的「读入耗时」不是它的读入耗时 (第 32 章那条:两份代码的 I/O 设置不一致,量的是读入速度不是算法)。
★ 这就是这一章必须换尺子的直接原因:秒表在正解身上已经失灵了。
5★ 关键一步(一):按秩合并 —— 这一章唯一能完整证明的那条界
合并两棵树时,只有一件事可以选:谁挂到谁下面。
if (rk[ra] > rk[rb]) swap(ra, rb); // 保证 ra 那棵不比 rb 那棵高
fa[ra] = rb; // ★ 矮的挂到高的
if (rk[ra] == rk[rb]) rk[rb]++; // 只有一样高,接起来才真的高了一层为什么方向是这个?把 A 挂到 B 下面,A 里所有点到根的距离都 +1,B 里一个都不变 —— 那当然要让矮的那边去承担这个 +1。(第 35 章「弹出方向由你要问的问题决定」的同一句话。)
★ 证明(对 r 做归纳):秩为 r 的树至少有 2^r 个点。
- r = 0:一个孤立点,2⁰ = 1 个,成立。
- 秩什么时候会涨?只有两棵秩都是 r−1 的树合并时,新树的秩才变成 r。 而这两棵按归纳假设各自至少有 2^(r−1) 个点,合起来至少 2^r 个。∎
于是:树高 ≤ 秩 ≤ log₂ n。n = 2×10⁵ 时,这个上界只有 17。
★ 这是这一章唯一一条能当场证完的界。 后面 α(n) 那条只给结论加实测 —— 说清楚哪句证明了、哪句没证,比含糊过去要紧得多。
拿最刁钻的合并顺序(./genBig n 2:两两配对、一层一层往上合)去打它:
| n | rankOnly 的最终树高 | log₂ n |
|---|---|---|
| 1 000 | 9 | 9.97 |
| 2 000 | 10 | 10.97 |
| 4 000 | 11 | 11.97 |
| 8 000 | 12 | 12.97 |
| 16 000 | 13 | 13.97 |
每一行都正好是 ⌊log₂ n⌋ —— 界是紧的,而且要专门造数据才顶得到 (第 33 章那条:要证明一个下界是紧的,就得自己造出那个最坏情况)。
./genBig 16000 2 | ./count # 看 rankOnly 那一行的「最终树高」6★ 关键一步(二):路径压缩,以及 α(n) 为什么是「均摊」
int find(int x) {
int r = x;
while (fa[r] != r) r = fa[r]; // 第一趟:找到祖宗
while (fa[x] != r) { // ★ 第二趟:路径压缩
int nx = fa[x]; fa[x] = r; x = nx;
}
return r;
}★ 它的账和第 35 章是同一种账,但难得多:
- 第 35 章:「每个元素一辈子只能出栈一次」—— 一句话就说完了,总量被一个常数管死;
- 这一章:一次 find 可以很贵(走一条长路),可它走过之后那条路就没了 —— 贵的那一次,把后面无数次变便宜了。
★ 这才是「均摊」的本意:不是「每次都便宜」,是「贵的那几次自己把账付了」。
两句话一起上,每次操作的均摊代价是 O(α(n))。α 是反阿克曼函数, n < 2^65536 时 α(n) ≤ 5 —— 所以它「几乎是常数」,但它不是常数。
⚠ 老实话写在这里:α 那条界这一章不证(完整证明要用势函数分层,超出这本书的范围)。 这一章用实测代替:第 8 步那张表会让你亲眼看到三条曲线的增长率。
// 正解 —— 路径压缩 + 按秩合并//// ⚠ 并查集的基本操作第 34 章已经讲完了(dsu.cpp 那一小节:find 是「一路往上找祖宗」,// unite 是「把两族的**祖宗**接起来」),这一章不再重复讲一遍。// 这一份要看的是**另外两句话**,它们俩才是这一章的全部内容://// ① 路径压缩:find 找到祖宗之后,把这一路上的点**全部直接挂到祖宗身上**,下次一步到位;// ② 按秩合并:合并时**矮的那棵挂到高的那棵下面**(rk[x] 是 x 这棵树高度的上界)。//// 两句加起来,每次操作的**均摊**代价是 O(α(n))。α 是反阿克曼函数,// 在 n < 2^65536 的范围里 α(n) ≤ 5 —— 所以它「几乎是常数」,但**它不是常数**,// 而且这个 α 和第 35 章那个「每个元素进出各一次」一样,是**均摊**出来的:// 单独某一次 find 完全可以很贵,贵的那几次会被后面变便宜的那些摊掉。//// ★ 这一章真正要讲的事情在这里:**上面两句话谁都不影响答案,只影响速度。**// 把 ② 写反、把 ① 写成一句什么都不干的空话,程序照样**每组都给对的答案** ——// 对拍一轮都抓不到(第 9 步有现场)。要抓它们只能靠计数器(count.cpp)。//// find 这里写成**迭代的两趟**(先找到根,再回头把路上的点挂过去):// 递归版更短,可在「不压缩」的那份上会爆栈(deep.cpp 是现场,接第 30 章那条)——// 为了让三条曲线量的是同一件事,这一章统一写迭代版。
#include <bits/stdc++.h>using namespace std;
int n, m;vector<int> fa, rk;
int find(int x) { int r = x; while (fa[r] != r) r = fa[r]; // 第一趟:一路往上,找到祖宗 while (fa[x] != r) { // 第二趟:★ 路径压缩,把这一路全挂到祖宗身上 int nx = fa[x]; fa[x] = r; x = nx; } return r;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
if (!(cin >> n >> m)) return 0; fa.resize(n + 1); rk.assign(n + 1, 0); for (int i = 1; i <= n; i++) fa[i] = i; int cnt = n; // 连通块个数:一开始每个点自成一块
string out; for (int i = 0; i < m; i++) { int op, a, b; cin >> op >> a >> b; int ra = find(a), rb = find(b); if (op == 1) { if (ra == rb) continue; // ⚠ 已经在一族里了,什么都别做(漏了这句,cnt 就错) if (rk[ra] > rk[rb]) swap(ra, rb); // ★ 按秩合并:保证 ra 那棵不比 rb 那棵高 fa[ra] = rb; // 矮的挂到高的下面 —— 树高就不会涨 if (rk[ra] == rk[rb]) rk[rb]++; // 只有一样高时,接起来才真的高了一层 cnt--; } else { out += (ra == rb ? 'Y' : 'N'); out += '\n'; } } out += to_string(cnt); out += '\n';
cout << out; return 0;}点「运行 ▶」看结果
half.cpp 那句 fa[x] = fa[fa[x]] 每路过一个点就把它挂到爷爷身上,这条路当场短一半;
它只走一趟,均摊复杂度和「压到根」同一个量级。
★ 第 35 章那条「
>=和>怎么写都对」的第二次,而这次「都对」的分量不一样: 那一章两种写法答案相同、过程不同;这一章两种写法连复杂度量级都相同,差的只是常数。 「都对」也分好几种,说清楚是哪一种才算说清楚。
7★ 换尺子:数一数 find 到底跳了多少步
// ★ 这一章的主角:**数一数 find 到底往上跳了多少步**//// 为什么它存在:这一章要区分的那些写法(压不压缩、按不按秩、方向反没反)// **全都不影响答案** —— 对拍在它们身上一轮都抓不到(第 9 步那张表)。// 秒表也不好使:数据小的时候都是 0.00 秒,数据大的时候又混进了读入和缓存的影响。//// ★ 所以这一章用的尺子是**计数器**:find 里那句「往上走一格」执行了多少次。// (第 21 章 stairsCount.cpp 同款:**能数次数就别掐表**,次数是可复现的,秒数不是。)//// 口径(正文里也要写清楚,否则这张表没法读):// · 「跳步数」= 执行 `x = fa[x]` 的总次数。**路径压缩的第二趟也算** ——// 那是实打实的开销,不能因为它是「顺手做的好事」就白送。// · 「单次最多」= 某一次 find 里跳得最多的那一次,用来对照第 6 步那条// 「只按秩合并 ⇒ 树高 ≤ log₂ n」的证明。// · 「最终树高」= 全部操作做完之后,森林里最深的那个点有多深。// ⚠ 量它的那趟遍历**不计进跳步数**(第 29、32 章那条:**量之前先确认你量的就是它**)。//// 用法:./count < 数据 打印那张对比表// ./count read < 数据 ★ 只把输入读完就退出,什么都不算// (用来确认「量到的确实是算法,不是读入」)// ./count csv < 数据 同一批数字,一行一种写法,给脚本和 check:viz 读
#include <bits/stdc++.h>using namespace std;
// 按显示宽度补空格:ASCII 算 1 格,汉字算 2 格(setw 数的是字节,对不齐)static string padDisp(const string& s, int width) { int disp = 0; for (unsigned char c : s) { if ((c & 0xC0) == 0x80) continue; disp += (c < 0x80) ? 1 : 2; } return s + string(max(0, width - disp), ' ');}
enum Mode { NAIVE, FAKE, ONCE, COMP, HALF, RANKONLY, WRANK, FAST };
struct Dsu { Mode mode; vector<int> fa, rk; long long steps = 0; // 往上走了多少格 int most = 0; // 单次 find 走得最多的那一次
void init(int n) { fa.resize(n + 1); rk.assign(n + 1, 0); for (int i = 1; i <= n; i++) fa[i] = i; }
int find(int x) { int walked = 0; int r = x; if (mode == HALF) { // 路径减半:一趟走完,边走边挂到爷爷身上 while (fa[r] != r) { fa[r] = fa[fa[r]]; r = fa[r]; walked++; } } else { while (fa[r] != r) { r = fa[r]; walked++; } // 第一趟:找到祖宗 if (mode == FAKE) { fa[r] = r; // ✗ 空话:此刻 r 就是根 } else if (mode == ONCE) { if (fa[x] != r) { fa[x] = r; } // ✗ 只压了起点这一个(写错顺序的下场) } else if (mode == COMP || mode == WRANK || mode == FAST) { int y = x; while (fa[y] != r) { // 第二趟:真正的路径压缩(这一趟也要记账) int ny = fa[y]; fa[y] = r; y = ny; walked++; } } } steps += walked; most = max(most, walked); return r; }
void unite(int ra, int rb) { // 传进来的已经是两个**不同的**祖宗 bool byRank = (mode == RANKONLY || mode == FAST || mode == WRANK); if (byRank) { if (mode == WRANK ? (rk[ra] < rk[rb]) : (rk[ra] > rk[rb])) swap(ra, rb); fa[ra] = rb; if (rk[ra] == rk[rb]) rk[rb]++; } else { fa[ra] = rb; } }
int deepest() const { // ⚠ 这趟遍历不计进 steps int mx = 0; for (size_t i = 1; i < fa.size(); i++) { int d = 0, x = (int)i; while (fa[x] != x) { x = fa[x]; d++; } mx = max(mx, d); } return mx; }};
int main(int argc, char** argv) { // ⚠ 读入的写法必须和 fast.cpp / naive.cpp 完全一致,否则 `./count read` 量出来的 // 「读入要多久」和它们对不上(第 32 章那条:两份代码的 I/O 设置不一样,量的就是读入速度)。 // 这里输出只用 printf、不用 cout,所以关掉 sync 是安全的(第 26 章那条混用的坑)。 ios::sync_with_stdio(false); cin.tie(nullptr);
int n, m; if (!(cin >> n >> m)) return 0; vector<array<int, 3>> ops(m); int merges = 0, queries = 0; for (auto& o : ops) { cin >> o[0] >> o[1] >> o[2]; (o[0] == 1 ? merges : queries)++; }
if (argc > 1 && string(argv[1]) == "read") { // ★ 只读入,什么都不算 printf("只读入:n = %d,操作 %d 次(合并 %d,查询 %d)—— 一步都没跳\n", n, m, merges, queries); return 0; }
struct Row { Mode mode; const char* name; const char* note; }; const vector<Row> rows = { {NAIVE, "naive", "不压缩、不按秩"}, {FAKE, "fakeCompress", "✗ 压了个寂寞"}, {ONCE, "wrongOnce", "✗ 只压了第一个点"}, {COMP, "compress", "只路径压缩"}, {HALF, "half", "路径减半(对的)"}, {RANKONLY, "rankOnly", "只按秩合并"}, {WRANK, "wrongRank", "✗ 按秩方向反 + 压缩"}, {FAST, "fast", "压缩 + 按秩(正解)"}, };
string gold; vector<long long> steps(rows.size()); vector<int> most(rows.size()), deep(rows.size()); vector<string> ans(rows.size());
for (size_t i = 0; i < rows.size(); i++) { Dsu d; d.mode = rows[i].mode; d.init(n); int cnt = n; string out; for (auto& o : ops) { int ra = d.find(o[1]), rb = d.find(o[2]); if (o[0] == 1) { if (ra == rb) continue; d.unite(ra, rb); cnt--; } else { out += (ra == rb ? 'Y' : 'N'); } } out += "/" + to_string(cnt); steps[i] = d.steps; most[i] = d.most; deep[i] = d.deepest(); ans[i] = out; if (rows[i].mode == FAST) gold = out; }
if (argc > 1 && string(argv[1]) == "csv") { // 给脚本读的:一行一种写法 for (size_t i = 0; i < rows.size(); i++) printf("%s,%lld,%d,%d,%s\n", rows[i].name, steps[i], most[i], deep[i], ans[i].c_str()); return 0; }
printf("n = %d,操作 %d 次(合并 %d 次,查询 %d 次)\n\n", n, m, merges, queries); printf("%s %s %s %s %s %s\n", padDisp("写法", 14).c_str(), padDisp("说明", 24).c_str(), padDisp("find 跳步数", 14).c_str(), padDisp("单次最多", 10).c_str(), padDisp("最终树高", 10).c_str(), padDisp("答案", 6).c_str()); for (size_t i = 0; i < rows.size(); i++) printf("%s %s %s %s %s %s\n", padDisp(rows[i].name, 14).c_str(), padDisp(rows[i].note, 24).c_str(), padDisp(to_string(steps[i]), 14).c_str(), padDisp(to_string(most[i]), 10).c_str(), padDisp(to_string(deep[i]), 10).c_str(), padDisp(ans[i] == gold ? "同正解" : "✗ 不同", 6).c_str());
printf("\n★ 八种写法的答案"); bool allSame = true; for (auto& a : ans) allSame &= (a == gold); printf("%s(正解给的是 %s)\n", allSame ? "**完全一样**" : "有不一样的", gold.c_str()); printf(" —— 所以这一整张表里的差别,对拍**一个都看不见**。\n"); printf("★ log2(%d) = %.2f,对照上面 rankOnly 那一行的「最终树高」。\n", n, log2((double)max(n, 1))); return 0;}点「运行 ▶」看结果
口径写在代码开头,正文里也说一遍,否则这张表没法读:
- 跳步数 = 执行
x = fa[x]的总次数,路径压缩的第二趟也算 (那是实打实的开销,不因为它是「顺手做的好事」就白送); - 最终树高 = 全部操作做完后森林里最深的点有多深, ⚠ 量它的那趟遍历不计进跳步数(第 29 章那条:量之前先确认你量的就是它)。
| 写法 | find 跳步数 | 单次最多 | 最终树高 |
|---|---|---|---|
| naive(什么都不加) | 18 | 4 | 4 |
| fakeCompress(✗ 压了个寂寞) | 18 | 4 | 4 |
| wrongOnce(✗ 只压了第一个点) | 12 | 3 | 3 |
| compress(只压缩) | 15 | 5 | 2 |
| half(路径减半) | 10 | 2 | 3 |
| rankOnly(只按秩) | 15 | 2 | 2 |
| wrongRank(✗ 方向反) | 13 | 3 | 2 |
| fast(正解) | 16 | 3 | 2 |
★ 正解在这组数据上是倒数第三名,比「什么都不加」只快 2 步。
这不是 bug,是复杂度这个词的定义:它说的是增长率,不是某一个数据点上的值。 n = 8 的时候,那条 O(n) 的曲线和那条 O(α(n)) 的曲线本来就还没分开; 而正解要多付「第二趟压缩」的钱,小数据上这笔钱甚至还没赚回来。
★★ 所以「三条曲线」必须真的是曲线 —— 一个数据点什么都证明不了。 ⚠ 这也顺带解释了第 11 步那件事:对拍用的都是这种小数据, 它连性能都区分不开,更别说抓 bug 了。
8★★ 三条曲线:把 n 翻倍,看谁跟着翻几倍
./genBig n 1 造链式合并顺序(这一章唯一能把曲线分开的形状,理由见第 9 步),种子固定:
| n | naive | wrongOnce | compress | half | fast |
|---|---|---|---|---|---|
| 1 000 | 663 885 | 20 182 | 10 003 | 3 481 | 2 496 |
| 2 000 | 2 632 450 | 57 472 | 20 882 | 7 065 | 4 953 |
| 4 000 | 10 639 853 | 159 124 | 45 005 | 14 990 | 9 961 |
| 8 000 | 42 759 103 | 464 076 | 96 775 | 31 024 | 20 004 |
| 16 000 | 170 598 030 | 1 313 353 | 204 304 | 64 197 | 39 984 |
| n 翻倍,它翻几倍 | 4.00 | 2.84 | 2.13 | 2.07 | 2.00 |
for n in 1000 2000 4000 8000 16000; do ./genBig $n 1 | ./count csv; done
- naive 4.00 —— n 翻倍它翻四倍,这是 Θ(n²) 的签名。16 000 个点就要跳 1.7 亿步;
- compress 2.13、half 2.07 —— 略微超线性,那个「略微」就是 log 级别的东西;
- fast 2.00 —— 干干净净的线性。每次操作的代价在这段范围里根本没涨。
★ 请注意 fast 那一列的绝对值:39 984 ≈ 操作条数 × 1.25。 平均每次 find 只往上跳一步多一点点 —— 这就是「几乎是 O(1)」长的样子。
⚠ 但别把 α(n) 读成「常数」:这段实测跨了 16 倍的 n,而 α 在这个范围里根本没变过 (α 要涨 1,n 要翻的是指数塔)。实测看不出它不是常数,这恰恰是它的可怕之处。
9★ 旋钮是「合并的顺序」—— 两种顺手写法各自把 bug 藏了起来
同样是 n = 4 000、同样的操作条数、同一串随机查询,只换「哪两个点被合到一起」的顺序:
| 形状 | naive 跳步数 | fast 跳步数 | 差几倍 |
|---|---|---|---|
2 ⚠ 两两配对、一层层往上合 |
10 958 | 7 704 | 1.4 倍 |
0 ⚠ 均匀随机的点对 |
863 355 | 12 554 | 68.8 倍 |
1 ★ 链式 1-2、2-3、3-4… |
10 639 853 | 9 961 | 1 068.2 倍 |
- 形状 2(先两两配对,再把配好的成对合并)—— 这是很多人随手就会写出来的顺序, 可它自己就长成了一棵平衡树(高 ⌊log₂ n⌋,第 5 步那张表就是拿它量的)。 在这种数据上,「不压缩」和正解只差 1.4 倍 —— 三条曲线全挤在一起,什么都量不出来。
- 形状 0(均匀随机的点对)看着最「公平」,也只把差距拉到 68.8 倍。
- 只有形状 1(故意串成一条链) 才让 naive 露出它真实的 Θ(n²)。
★ 要随机的是算法依赖的那个量。 并查集的复杂度依赖的是树能不能长深, 所以旋钮是合并的顺序(结构),不是 n、不是操作条数、更不是点的编号。 (第 26~33 章连着踩了八次的那条「顺手写法会悄悄给数据加一条题目里没有的性质」, 这一章又踩到了 —— 而这一次它加的那条性质是「树是平衡的」。)
⚠ 这三种形状的答案全都一样正确,三份代码也全都通过对拍。 藏起来的从来不是错误,是慢。
⚠ 这张表的前提也得写成断言。 上面那句「点数、操作条数、查询序列完全相同」 是这张表能成立的全部依据,所以
check:viz里专门有一条检查 三种形状的查询行逐字节相同 —— 而它第一次跑就把我抓了个正着: 形状 0 的合并本身要抽随机数,和查询共用一个随机数流,查询序列整个错位了。 修的是生成器(改成两个独立的流),不是那条断言。 ★ 「这两组数据只差一件事」这种话,不要凭代码看起来对就写进正文。
10★ 动画:同一串操作,两片森林并排长
左边 naive(不压缩、不按秩),右边 fast(压缩 + 按秩)。高亮的是这一步 find 走过的那条路。
播一遍你会看到:
- 画面下方那行结论(Y / N / 合并 / 已同族)两边永远一模一样;
- 右上角那两个「累计跳步」越拉越开;
- 右边那棵树被压缩「拍扁」的那一刻很好认:某个点的爸爸突然直接变成了根。
把数据切到「★ 链式合并」那一档,左边会长成一条越来越长的链,右边始终是一棵扁扁的星形树。 再切到「⚠ 两两配对」那一档 —— 两边几乎一样扁,这就是第 9 步那句 「顺手写法会把差距藏起来」在画面上的样子。
⚠ 动画和
trace.cpp在check:viz里是逐步对的:每一步两边的 find 路径、 这一步跳了几步、累计跳了几步、fa数组、rk数组,全部逐字节比 (不只对最终答案 —— 第 21 章以来那条规矩)。
11★★ 五种「对拍一辈子也抓不到」的写法
它们不是「碰巧没被抓到」,而是原理上抓不到:它们根本不改变任何一个答案。
| 写法 | 错在哪 | 对拍 | n=16 000 链上的跳步数 |
|---|---|---|---|
naive.cpp |
什么都不加(不算 bug,是基准线) | 0 / 300 | 170 598 030 |
fakeCompress.cpp |
✗ fa[x] = x,压了个寂寞 |
0 / 300 | 170 598 030 |
wrongOnce.cpp |
✗ 压缩时两行写反了顺序 | 0 / 300 | 1 313 353 |
wrongRank.cpp |
✗ 按秩合并方向反 | 0 / 300 | 204 304 |
half.cpp |
没错,是另一种正确写法 | 0 / 300 | 64 197 |
// ✗ 对拍看不见的错法之一:**压了个寂寞**//// int find(int x) { while (fa[x] != x) x = fa[x]; fa[x] = x; return x; }// ~~~~~~~~~ 这里的 x 早就是根了//// 循环停下来的时候 x 已经**变成了根**,于是 `fa[x] = x` 是把根挂到根自己身上 ——// 一句货真价实的空话。写这份代码的人以为自己加上了路径压缩,// 实际上得到的是一份原封不动的 naive.cpp。//// ★ 这一章的核心现场之一:// · 答案:和正解**一模一样**,300 轮对拍 **0 / 300**;// · 跳步数:和 naive.cpp **一个数字都不差**(check:viz 里钉成了恒等式)。//// > ★ 想抓它只有一条路:**数一数 find 到底往上跳了多少步**(count.cpp)。// 对拍验的是「两份代码想的是不是同一件事」,验不了「它跑得快不快」。// 这是第 35 章「对拍查不出溢出」之后,本教材第二类**对拍原理上看不见**的 bug ——// 而且这一类要普遍得多:**所有只影响复杂度、不影响答案的写法,对拍全都看不见。**
#include <bits/stdc++.h>using namespace std;
int n, m;vector<int> fa;
int find(int x) { while (fa[x] != x) x = fa[x]; fa[x] = x; // ✗ 空话:此刻 x 就是根,等于 fa[根] = 根 return x;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
if (!(cin >> n >> m)) return 0; fa.resize(n + 1); for (int i = 1; i <= n; i++) fa[i] = i; int cnt = n;
string out; for (int i = 0; i < m; i++) { int op, a, b; cin >> op >> a >> b; int ra = find(a), rb = find(b); if (op == 1) { if (ra == rb) continue; fa[ra] = rb; cnt--; } else { out += (ra == rb ? 'Y' : 'N'); out += '\n'; } } out += to_string(cnt); out += '\n';
cout << out; return 0;}点「运行 ▶」看结果
int find(int x) { while (fa[x] != x) x = fa[x]; fa[x] = x; return x; }
// ~~~~~~~~~ 此刻 x 早就是根了循环停下来时 x 已经变成了根,于是 fa[x] = x 是把根挂到根自己身上 —— 一句空话。
★ fakeCompress ≡ naive:不只是答案一样,跳步数一个数字都不差 (1 000 / 2 000 / 4 000 / 8 000 / 16 000 五个规模全部逐字节相同,钉在
check:viz里)。 这是本教材第十二条恒等式(前十一条在第 23~28、34、35 章)。
⚠ 而它和前十一条有一个本质区别:前面那些恒等式两边都是「答案」,这一条两边是「开销」。
// ✗ 对拍看不见的错法之二:**只压了路径上的第一个点**//// while (fa[x] != r) { fa[x] = r; x = fa[x]; }// ~~~~~~~~~ ~~~~~~~~~~ 赋值之后再往上走,x 直接变成了 r,循环立刻结束//// 正确的写法要先把「原来的爸爸」存下来://// while (fa[x] != r) { int nx = fa[x]; fa[x] = r; x = nx; }//// 这是一个**一眼看不出来**的顺序错误:两行代码谁先谁后,决定了这一趟压缩是压满整条路径,// 还是只压了最下面那一个点。//// ★ 和 fakeCompress.cpp 一样,它**不影响任何一个答案**(300 轮 0 / 300),// 只让路径压缩的效果打了个折。要看见它,还是只能数跳步数(count.cpp)。// ⚠ 它比 fakeCompress 温和得多:每次 find 至少还是把起点挂到了根上,// 所以跳步数介于 naive 和 compress 之间 —— 这也是它更难被发现的原因。
#include <bits/stdc++.h>using namespace std;
int n, m;vector<int> fa;
int find(int x) { int r = x; while (fa[r] != r) r = fa[r]; while (fa[x] != r) { fa[x] = r; // ✗ 先改了 fa[x]… x = fa[x]; // …再往上走,于是 x 直接跳到了 r,路径上的其他点没人管 } return r;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
if (!(cin >> n >> m)) return 0; fa.resize(n + 1); for (int i = 1; i <= n; i++) fa[i] = i; int cnt = n;
string out; for (int i = 0; i < m; i++) { int op, a, b; cin >> op >> a >> b; int ra = find(a), rb = find(b); if (op == 1) { if (ra == rb) continue; fa[ra] = rb; cnt--; } else { out += (ra == rb ? 'Y' : 'N'); out += '\n'; } } out += to_string(cnt); out += '\n';
cout << out; return 0;}点「运行 ▶」看结果
while (fa[x] != r) { fa[x] = r; x = fa[x]; } // ✗ x 直接跳到 r,循环立刻结束
while (fa[x] != r) { int nx = fa[x]; fa[x] = r; x = nx; } // ✓ 先把「原来的爸爸」存下来它比上一个温和:每次 find 至少还是把起点挂到了根上。 代价是跳步数从 20 万涨到 131 万(n = 16 000),介于 naive 和 compress 之间 —— 温和正是它更难被发现的原因。
// ✗ 对拍看不见的错法之三:**按秩合并的方向反了**(高的挂到矮的下面)//// if (rk[ra] < rk[rb]) swap(ra, rb); // ✗ 反了// if (rk[ra] > rk[rb]) swap(ra, rb); // ✓ 正解:保证 ra 那棵不比 rb 那棵高//// 一个不等号,两份代码的**答案完全相同**(300 轮 0 / 300),// 差别只在树长成什么样:正确的写法「矮的挂到高的」,树高纹丝不动;// 反过来「高的挂到矮的」,每合并一次就可能高一层。//// ★ 这一份还顺带说明一件事:**方向不是背下来的,是想出来的。**// 把 A 挂到 B 下面,A 里所有点到根的距离都 +1,B 里的一个都不变 ——// 所以当然要让**点少 / 树矮**的那边去承担这个 +1。// (第 35 章「弹出方向由你要问的问题决定」的同一句话,换了个题目。)//// ⚠ 注意它同时还开着路径压缩 —— 所以别指望它慢到天上去。// 实测(count.cpp 那张表)它比正解慢,但远没有 naive 那么难看:// **路径压缩很宽容,它会把按秩合并的错误一路补回来。**// 这正是「只写路径压缩」能在竞赛里活到今天的原因。
#include <bits/stdc++.h>using namespace std;
int n, m;vector<int> fa, rk;
int find(int x) { int r = x; while (fa[r] != r) r = fa[r]; while (fa[x] != r) { int nx = fa[x]; fa[x] = r; x = nx; } return r;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
if (!(cin >> n >> m)) return 0; fa.resize(n + 1); rk.assign(n + 1, 0); for (int i = 1; i <= n; i++) fa[i] = i; int cnt = n;
string out; for (int i = 0; i < m; i++) { int op, a, b; cin >> op >> a >> b; int ra = find(a), rb = find(b); if (op == 1) { if (ra == rb) continue; if (rk[ra] < rk[rb]) swap(ra, rb); // ✗ 反了:高的那棵被挂到了矮的下面 fa[ra] = rb; if (rk[ra] == rk[rb]) rk[rb]++; cnt--; } else { out += (ra == rb ? 'Y' : 'N'); out += '\n'; } } out += to_string(cnt); out += '\n';
cout << out; return 0;}点「运行 ▶」看结果
if (rk[ra] < rk[rb]) swap(ra, rb); // ✗ 反了
if (rk[ra] > rk[rb]) swap(ra, rb); // ✓实测(链式数据):它的跳步数在五个规模上和「根本没写按秩合并」的 compress.cpp 一模一样
(10 003 / 20 882 / 45 005 / 96 775 / 204 304)。
★ 把方向写反,效果等于没写。 那句
swap白写了,rk数组也白维护了。 ⚠ 但这条「相等」是实测出来的,不是证出来的 —— 第 2 步那组小数据上它俩就不相等 (13 vs 15)。一个方向能证明,反过来只是实测没碰到反例 (第 35 章「忘弹队头 vs 出界差一」的同款分寸)。
⚠ 还要注意它没有慢到天上去:路径压缩很宽容,它会把按秩合并的错误一路补回来。 这正是「只写路径压缩」能在竞赛里活到今天的原因。
★★ 对拍验的是「两份代码想的是不是同一件事」,验不了「它跑得快不快」。
所有只影响复杂度、不影响答案的写法,对拍原理上全都看不见。
这是随机对拍的第四个盲区,而且它比前三个都要普遍:
- 第 20 章:对拍只能证伪,不能证明「对」;
- 第 31 章:验证器证明不了「答案存在时你没漏报」;
- 第 33 章:随机数据碰不到最坏情况(下界紧不紧,随机对拍答不了);
- ★ 第 36 章(本章):对拍看不见「慢」。第 35 章那个溢出是它的一个特例 (溢出改答案、只是小数据碰不到;而这一章的五份代码连答案都不改)。
想抓这一类 bug,只有一条路:换尺子。 数次数(count.cpp),别只看答案。
12★ 对拍与生成器:三个真 bug,和一个被实测打脸的预判
// ✗ 真 bug 一:查询时比的是**爸爸**,不是**祖宗**//// if (fa[a] == fa[b]) → Y // ✗ 两个点的爸爸不同,爷爷完全可能是同一个// if (find(a) == find(b)) → Y // ✓//// ⚠ 第 34 章 dsu.cpp 那一小节已经点过这个写法的名(Kruskal 里它会把边错收成环),// 这一章不重复讲它**为什么错**,只做一件那一章没做的事:// **把它扔进 300 轮对拍里,量一量到底几轮能抓住** —— 以及,什么样的数据抓不住它。//// ★ 它需要的数据非常具体:**必须有点的深度 ≥ 2**。// 要是每次合并都恰好把一个孤立点挂到根上,森林里全是「爸爸就是祖宗」的两层树,// 这个 bug 一辈子都不会现形。// 所以生成器的旋钮又是**合并的顺序**(第 10 步那张档位表里,这一处贡献最大)。//// ⚠ 还有一个更隐蔽的原因让它容易隐身:**路径压缩会替它擦屁股**。// 合并时调用的 find 会把沿路的点直接挂到根上,被压过的点从此「爸爸就是祖宗」——// 于是同一个 bug,在开着压缩的代码里比在不压缩的代码里更难抓。
#include <bits/stdc++.h>using namespace std;
int n, m;vector<int> fa, rk;
int find(int x) { int r = x; while (fa[r] != r) r = fa[r]; while (fa[x] != r) { int nx = fa[x]; fa[x] = r; x = nx; } return r;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
if (!(cin >> n >> m)) return 0; fa.resize(n + 1); rk.assign(n + 1, 0); for (int i = 1; i <= n; i++) fa[i] = i; int cnt = n;
string out; for (int i = 0; i < m; i++) { int op, a, b; cin >> op >> a >> b; if (op == 1) { // 合并这一半是**对的** int ra = find(a), rb = find(b); if (ra == rb) continue; if (rk[ra] > rk[rb]) swap(ra, rb); fa[ra] = rb; if (rk[ra] == rk[rb]) rk[rb]++; cnt--; } else { out += (fa[a] == fa[b] ? 'Y' : 'N'); // ✗ 比爸爸:a 的爸爸和 b 的爸爸不同 ≠ 不连通 out += '\n'; } } out += to_string(cnt); out += '\n';
cout << out; return 0;}点「运行 ▶」看结果
第 2 步那组数据上它给 N Y N N N(正解 N Y Y Y N)。
⚠ 第 34 章已经讲过它为什么错,这一章只补那一章没做的事:量一量几轮能抓住。
// ✗ 真 bug 二:合并时只把**点 a** 挂了过去,a 那一族的其他人没跟着走//// fa[a] = find(b); // ✗ 左边忘了 find// fa[find(a)] = find(b); // ✓//// ⚠ 同样是第 34 章 dsu.cpp 点过名的写法,这一章只补那一章没做的实测。//// ★ 它错得比上一个隐蔽:a 自己确实连过去了,a 的**子孙**也跟着连过去了// (它们本来就要经过 a 才能往上走),**掉队的是 a 的祖宗那一支**。// 所以只有当 a 上面还挂着别人时它才现形 —— 又是「深度 ≥ 2」这个条件。//// ⚠ 顺带一件容易被忽略的事:这么写**不会**把森林接成环。// fa[a] 指向的是 b 的**根**;哪怕 a、b 本来就同族,那个根也在 a 的上方,// 指过去只是抄了条近路。(要是写成 `fa[find(a)] = b`、又漏了「已同族就跳过」那一句,// 才会真的成环,然后 find 里那个 while 直接死循环。)
#include <bits/stdc++.h>using namespace std;
int n, m;vector<int> fa, rk;
int find(int x) { int r = x; while (fa[r] != r) r = fa[r]; while (fa[x] != r) { int nx = fa[x]; fa[x] = r; x = nx; } return r;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
if (!(cin >> n >> m)) return 0; fa.resize(n + 1); rk.assign(n + 1, 0); for (int i = 1; i <= n; i++) fa[i] = i; int cnt = n;
string out; for (int i = 0; i < m; i++) { int op, a, b; cin >> op >> a >> b; int ra = find(a), rb = find(b); if (op == 1) { if (ra == rb) continue; fa[a] = rb; // ✗ 挂的是点 a,不是 a 的祖宗 if (rk[a] == rk[rb]) rk[rb]++; cnt--; } else { out += (find(a) == find(b) ? 'Y' : 'N'); out += '\n'; } } out += to_string(cnt); out += '\n';
cout << out; return 0;}点「运行 ▶」看结果
它给 N Y N Y N —— 只错在第 3 个查询上。
★ 它错得比上一个隐蔽:a 自己连过去了,a 的子孙也跟着过去了,掉队的是 a 的祖宗那一支。
// ✗ 真 bug 三:**无条件 cnt--** —— 合并之前忘了问一句「它俩本来就是一族吗」//// if (ra == rb) continue; // ✓ 少了这一句,下面那个 cnt-- 就会多减//// ★ 这一份是这一章最值得琢磨的一个 bug,因为它**只在题面的第二问上现形**:// · 所有 Y / N 查询:**全对**(fa[ra] = rb 在 ra == rb 时是 fa[ra] = ra,一句空话;// rk 也只是被抬高了一点,而秩本来就只是「高度的上界」,抬高了不影响正确性);// · 最后那一行连通块个数:**只要输入里出现过一次「把已经同族的两个点再合一次」,它就偏小**。//// > ★ 第 35 章那条教训的直接兑现:**题面多问一句,对拍就多一条腿。**// 要是这道题只输出 Y / N,这个 bug 的抓获率是 **0 / 300**;// 加上「最后输出连通块个数」这一行之后,它变成了 300 轮里的头号常客(第 10 步那张表)。//// ⚠ 而它对数据的要求也很具体:**必须有重复合并**。// n 一大、合并次数一少,随机点对几乎撞不到「已经同族」——// 这正是第 10 步那张档位表里「点数压小」那一档在干的事。
#include <bits/stdc++.h>using namespace std;
int n, m;vector<int> fa, rk;
int find(int x) { int r = x; while (fa[r] != r) r = fa[r]; while (fa[x] != r) { int nx = fa[x]; fa[x] = r; x = nx; } return r;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
if (!(cin >> n >> m)) return 0; fa.resize(n + 1); rk.assign(n + 1, 0); for (int i = 1; i <= n; i++) fa[i] = i; int cnt = n;
string out; for (int i = 0; i < m; i++) { int op, a, b; cin >> op >> a >> b; int ra = find(a), rb = find(b); if (op == 1) { // ✗ 这里少了 `if (ra == rb) continue;` if (rk[ra] > rk[rb]) swap(ra, rb); fa[ra] = rb; if (rk[ra] == rk[rb]) rk[rb]++; cnt--; // ✗ 本来就同族的也照减不误 } else { out += (ra == rb ? 'Y' : 'N'); out += '\n'; } } out += to_string(cnt); out += '\n';
cout << out; return 0;}点「运行 ▶」看结果
它的 Y / N 五个全对,只有最后一行是 1(正解 2)—— 第 2 步第 5 行那次重复合并多减了一次。
// 正解 —— 路径压缩 + 按秩合并//// ⚠ 并查集的基本操作第 34 章已经讲完了(dsu.cpp 那一小节:find 是「一路往上找祖宗」,// unite 是「把两族的**祖宗**接起来」),这一章不再重复讲一遍。// 这一份要看的是**另外两句话**,它们俩才是这一章的全部内容://// ① 路径压缩:find 找到祖宗之后,把这一路上的点**全部直接挂到祖宗身上**,下次一步到位;// ② 按秩合并:合并时**矮的那棵挂到高的那棵下面**(rk[x] 是 x 这棵树高度的上界)。//// 两句加起来,每次操作的**均摊**代价是 O(α(n))。α 是反阿克曼函数,// 在 n < 2^65536 的范围里 α(n) ≤ 5 —— 所以它「几乎是常数」,但**它不是常数**,// 而且这个 α 和第 35 章那个「每个元素进出各一次」一样,是**均摊**出来的:// 单独某一次 find 完全可以很贵,贵的那几次会被后面变便宜的那些摊掉。//// ★ 这一章真正要讲的事情在这里:**上面两句话谁都不影响答案,只影响速度。**// 把 ② 写反、把 ① 写成一句什么都不干的空话,程序照样**每组都给对的答案** ——// 对拍一轮都抓不到(第 9 步有现场)。要抓它们只能靠计数器(count.cpp)。//// find 这里写成**迭代的两趟**(先找到根,再回头把路上的点挂过去):// 递归版更短,可在「不压缩」的那份上会爆栈(deep.cpp 是现场,接第 30 章那条)——// 为了让三条曲线量的是同一件事,这一章统一写迭代版。
#include <bits/stdc++.h>using namespace std;
int n, m;vector<int> fa, rk;
int find(int x) { int r = x; while (fa[r] != r) r = fa[r]; // 第一趟:一路往上,找到祖宗 while (fa[x] != r) { // 第二趟:★ 路径压缩,把这一路全挂到祖宗身上 int nx = fa[x]; fa[x] = r; x = nx; } return r;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
if (!(cin >> n >> m)) return 0; fa.resize(n + 1); rk.assign(n + 1, 0); for (int i = 1; i <= n; i++) fa[i] = i; int cnt = n; // 连通块个数:一开始每个点自成一块
string out; for (int i = 0; i < m; i++) { int op, a, b; cin >> op >> a >> b; int ra = find(a), rb = find(b); if (op == 1) { if (ra == rb) continue; // ⚠ 已经在一族里了,什么都别做(漏了这句,cnt 就错) if (rk[ra] > rk[rb]) swap(ra, rb); // ★ 按秩合并:保证 ra 那棵不比 rb 那棵高 fa[ra] = rb; // 矮的挂到高的下面 —— 树高就不会涨 if (rk[ra] == rk[rb]) rk[rb]++; // 只有一样高时,接起来才真的高了一层 cnt--; } else { out += (ra == rb ? 'Y' : 'N'); out += '\n'; } } out += to_string(cnt); out += '\n';
cout << out; return 0;}300 轮实测(种子 1..300,最终档):
| 故意写错的地方 | 被抓 | 第几轮 |
|---|---|---|
只挂点不挂族(wrongUnite) |
300 / 300 | 第 1 轮 |
无条件 cnt--(wrongCnt) |
300 / 300 | 第 1 轮 |
比爸爸不比祖宗(wrongFa) |
267 / 300 | 第 1 轮 |
| 上一节那五份「只影响速度」的 | ★ 0 / 300 | — |
gen.cpp 带了八个档位(./gen 种子 档位),种子固定 1..300:
| 档位 | 相对档位 0 改了什么 | 比爸爸 | 挂点不挂族 | 无条件 cnt– |
|---|---|---|---|---|
| 0(顺手写法) | n ∈ [8,14]、m ∈ [8,14]、合并/查询各半、两端随机 | 11 | 46 | 179 |
| 1 | 点数压小 n ∈ [4,8] | 21 | 87 | 253 |
| 2 | 合并的两头都从「已经合过的点」里挑 | ★ 0 | ★ 0 | 295 |
| 3 | 1/4 的合并重复用过的点对 | 5 | 30 | 249 |
| 4 | 操作数顶到 m ∈ [70,110] | 223 | 296 | 300 |
| 5 | 点数放大 n ∈ [12,20] | ★ 3 | 19 | 117 |
★ 档位 2 是这一章最响的一记耳光。 我的推理是这样的: 三个 bug 都要「深度 ≥ 2」才现形,那就让合并的两头都落在已经被合过的点上, 新树自然长在旧树上 —— 听起来无懈可击。
实测:两个 bug 一起变成 0 / 300。
原因一句话:两头都是老点,合并就几乎总是撞上同一族 —— 300 轮里 295 轮出现重复合并,可合并全成了空转,森林压根没长起来。 我以为自己在造深度,其实是在造「什么都不做」。
★ 第 31 章那条「某一支占得太多,会吃掉别人」的第六次, 而且这次是我亲手把那一支喂到 295 / 300 的。 ⚠ 档位 3(重复点对)是同一个毛病的轻症版:5 / 30 / 249。
★ 档位 5 更刺眼:单看这一行,「点数放大」是个灾难 —— 比爸爸从 11 掉到 3,挂点不挂族从 46 掉到 19,无条件 cnt– 从 179 掉到 117。 任何一个只看单行表的人都会当场把它扔掉。 别急,看下一张表。
| 档位 | 内容 | 比爸爸 | 挂点不挂族 | 无条件 cnt– |
|---|---|---|---|---|
| 4 | 只加「操作数顶到 70~110」 | 223 | 296 | 300 |
| 6(在用) | 4 + 5(操作数顶上去 + 点数放大) | 267 | 300 | 300 |
| 7(对照) | 6 +「重复用过的点对」 | 251 | 298 | 300 |
- 「点数放大」单独加是 11 → 3(灾难),加在「操作数顶上去」之后是 223 → 267。
道理其实很直白:点多了,就需要更长的操作序列才攒得出一棵深树;
序列不够长时点越多越稀,序列够长时点多才有地方长。
★★ 第 32、34、35 章那条「调优不可加:一处改动值不值得留,取决于其它旋钮此刻在哪」 的第四次,而这次的形状最刺眼:一处单独看是负分的改动,最后被留下了。
- 档位 7 是在最终环境里重新量「重复点对」(第 34 章那条):267 → 251,仍然有害,撤回。 ⚠ 但请注意它在档位 3 那里是 11 → 5(几乎全灭),到最终环境只剩 −16 —— 同一处改动的危害,也是随环境变的。
★ 最后定档的标准照旧:让最弱的那一支尽量强(不是平均分最高)—— 最弱的一直是「比爸爸不比祖宗」,档位 6 把它顶到了 267。
把同一批数据(最终档,300 轮)只比 Y / N 那半,把最后那行连通块个数去掉:
| 故意写错的地方 | 两问都比 | ★ 只比 Y / N |
|---|---|---|
| 比爸爸不比祖宗 | 267 | 267 |
| 只挂点不挂族 | 300 | 297 |
无条件 cnt-- |
300 | ★ 0 |
★ 第 35 章那条「题目多问一句,对拍就多一条腿」的第二次现场,而这次更干净: 一行输出把一个 bug 从 0 / 300 抬到 300 / 300。
⚠ 顺带看「只挂点不挂族」那一行:300 → 297,有 3 轮只有连通块个数那一行抓得住它。 多问的那一句,不只救了它自己那个 bug。
13⚠ 递归版 find 会爆栈吗 —— 一个比想象中拧巴的实测
| 写法 | 编译 | 结果 |
|---|---|---|
| 带路径压缩的递归 find | -O2 |
60 万层活,70 万层段错误 |
不压缩的三行版(return fa[x]==x ? x : find(fa[x])) |
-O2 |
1 000 万层照样活 |
| 同一份不压缩的代码 | -O0 |
20 万层活,40 万层段错误 |
g++ -O2 -std=c++17 -o deep deep.cpp
./deep 600000 # 成功
./deep 700000 # Segmentation fault
./deep 10000000 0 # 不压缩的那份,一千万层也没事
g++ -O0 -std=c++17 -o deep0 deep.cpp # ★ 只换编译选项
./deep0 200000 0 # 成功
./deep0 400000 0 # Segmentation fault ← 同一份代码,同一个写法★ 两件意料之外的事:
- 爆的偏偏是「带路径压缩」的那一份。
不压缩的写法是尾调用(递归返回之后没活要干了),g++
-O2直接把它变成了循环; 带压缩的写法多了个fa[x] = …的赋值,尾调用没了,于是老老实实一层一帧。 - 所以「这段代码会不会爆栈」不是代码的属性,是代码 + 编译选项的属性 ——
同一份不压缩的代码,
-O0编译出来 40 万层就死。
★ 第 30 章那条「递归的深度上限是数据能拉出来的最长那条链」的第二次 (那一章是图上 DFS,一条路走掉了点数的 57%)。 ⚠ 而这一章多出来一句更实用的:竞赛里那些三行递归 find 从来没出过事, 靠的既不是运气也不是尾调用 —— 是「压缩之后链根本长不到那么深」。 你只会在第一次碰上一条几十万长的链时死掉,而那条链只有不按秩合并才造得出来。
14这一章可以带走的五样东西
【1】★★ 对拍看不见「慢」。 本章八种写法答案完全相同,对拍 0 / 300。 所有只影响复杂度、不影响答案的写法,对拍原理上全都抓不到。 这是随机对拍的第四个盲区(前三个:只能证伪 / 验证器证不了没漏报 / 碰不到最坏情况)。 ★ 换尺子:数次数,别只看答案;而且次数可复现,秒数不可复现。
【2】★ 按秩合并那条界能证,α(n) 那条不证。 秩为 r 的树至少 2^r 个点 ⇒ 树高 ≤ log₂ n(n = 2×10⁵ 时只有 17)—— 三行归纳就完了, 而且实测能顶到 ⌊log₂ n⌋(要专门造数据)。 α(n) 这一章只给结论 + 三条实测曲线。说清楚哪句证了、哪句没证,比含糊过去要紧。
【3】★ 均摊的第二课:不是「每次都便宜」,是「贵的那几次自己把账付了」。 一次 find 可以走很长一条路,但它走过之后那条路就没了。 第 35 章那句「每个元素一辈子只能出去一次」是这件事的入门版。
【4】★ 复杂度是增长率,不是某一个数据点。 n = 8 的时候正解排倒数第三(16 步 vs naive 的 18 步); 要看清差别,必须让 n 翻倍着走:翻倍比 4.00 / 2.84 / 2.13 / 2.00 才是那三条曲线的本体。
【5】★ 这一章的生成器把「调优不可加」推到了新的高度。
- 「合并两头都从老点里挑」本以为在造深度,实际造出了 295 / 300 的空转合并, 两个真 bug 一起变成 0 / 300;
- 「点数放大」单独加是灾难(11 → 3),配上「操作序列拉长」却是最好的一档(223 → 267)—— 一处单独看是负分的改动,最后被留下了。
★ 所以:每一处改动都要在最终环境里重新量一遍,而且别指望旋钮能一条条叠加。
第 37 章:堆与 priority_queue(手写堆 → STL)。
★ 关键一步是上浮 / 下沉各 O(log n),而这次的 log 是证得死死的 (完全二叉树的高度就是 log₂ n)—— 正好和这一章那个「证不了、只能实测」的 α 形成对照。 ⚠ 顺带回收第 12 章「第 k 小」那道题的另一种解法。
15自测
- 洛谷 P3367 【模板】并查集解析 → —— 本章那道题去掉最后一行输出。写完拿 count.cpp 的思路给自己数一遍跳步数
- 洛谷 P1551 亲戚解析 → —— 最裸的连通性查询,5 分钟的题 —— 用它确认你默写的版本是对的
- 洛谷 P1892 [BOI2003] 团伙解析 → —— ★ 「敌人的敌人是朋友」:扩展域并查集,把点数翻倍。第一次见会觉得很妙
- 洛谷 P2024 [NOI2001] 食物链解析 → —— ★★ 带权 / 扩展域并查集的经典题,难度上一个台阶。想清楚「三倍点」或者「到根的距离模 3」
- 洛谷 P1197 [JSOI2008] 星球大战解析 → —— ★ 并查集只能合并、不能拆开 —— 所以要把时间倒过来跑。这条限制正是这一章那片森林的必然结果
- 洛谷 P1955 [NOI2015] 程序自动分析解析 → —— ★ 先离散化再并查集;先做完所有「相等」再验「不等」—— 顺序错了就全错
- 洛谷 P3958 [NOIP2017 提高组] 奶酪解析 → —— 把「两球相交」当成一条边,就是连通性。练的是「看出这是并查集」这一步