阶段 7 · 数据结构 · 第 36 章普及组 J

并查集

基本操作第 34 章已经讲完了,这一章只回答一个问题:它为什么快到几乎是 O(1)。★ 关键一步是路径压缩 + 按秩合并,而那个 α(n) 是「均摊」出来的。

需要先学:第 30 章 图上的 DFS 与 BFS、连通性例题:连通性查询(合并 + 查询 + 数连通块)建议用时:130 分钟
这一章不重复讲用法 —— 以及它和前 35 章的一个根本区别

第 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手算一遍:默认那组操作

★ 这 12 步里埋了三个东西,第 11、12 步会挨个用到
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标准答案:一点历史都不攒

brute.cpp标准答案:每次查询现场建图 + BFS
// 标准答案 —— 完全不碰并查集:每次查询现场 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 为什么标准答案要用 BFS,而不是「另写一份并查集」

正解维护的是一片森林,把信息一路攒下来。要是标准答案也维护森林, 两份代码就是同一个思路写了两遍 —— 只能验出打字错误,验不出想法错误 (第 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 秒
⚠ 那 0.03 秒里,算法一秒都没占 —— 全是读入
./count read < big.txt      # ★ 只把输入读完就退出,什么都不算

也是 0.03 秒。 也就是说:20 万个点、40 万条操作,正解跑完全程的时间和只读一遍输入一样, 算法那部分在秒表上根本量不出来。

★ 第 29、32、34、35 章那条「量之前先确认「你量的就是它」」的第五次。 ⚠ 所以 count.cpp 的读入必须和 fast.cpp 写得一模一样 (都是 cin + 关掉 sync_with_stdio),否则量出来的「读入耗时」不是它的读入耗时 (第 32 章那条:两份代码的 I/O 设置不一致,量的是读入速度不是算法)。

★ 这就是这一章必须换尺子的直接原因:秒表在正解身上已经失灵了。

5★ 关键一步(一):按秩合并 —— 这一章唯一能完整证明的那条界

★★ 矮的挂到高的:秩为 r 的树,至少有 2^r 个点

合并两棵树时,只有一件事可以选:谁挂到谁下面。

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) 那条只给结论加实测 —— 说清楚哪句证明了、哪句没证,比含糊过去要紧得多。

rankOnly.cpp只按秩合并(不压缩)—— 上面那条界说的就是它
★ 这条界不只是「证出来的」,它还被顶到过 —— 实测

拿最刁钻的合并顺序(./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 步那张表会让你亲眼看到三条曲线的增长率。

fast.cpp正解:路径压缩 + 按秩合并
// 正解 —— 路径压缩 + 按秩合并
//
// ⚠ 并查集的基本操作第 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 另外三份「也对」的写法
naive.cpp什么都不加(基准线)
compress.cpp只路径压缩 —— 竞赛里最常见的写法
half.cpp路径减半:一趟走完,边走边挂到爷爷身上

half.cpp 那句 fa[x] = fa[fa[x]] 每路过一个点就把它挂到爷爷身上,这条路当场短一半; 它只走一趟,均摊复杂度和「压到根」同一个量级。

★ 第 35 章那条「>= 和 > 怎么写都对」的第二次,而这次「都对」的分量不一样: 那一章两种写法答案相同、过程不同;这一章两种写法连复杂度量级都相同,差的只是常数。 「都对」也分好几种,说清楚是哪一种才算说清楚。

7★ 换尺子:数一数 find 到底跳了多少步

count.cpp八种写法并排跑,数跳步数(附「只读入」开关)
// ★ 这一章的主角:**数一数 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

口径写在代码开头,正文里也说一遍,否则这张表没法读:

  • 跳步数 = 执行 x = fa[x] 的总次数,路径压缩的第二趟也算 (那是实打实的开销,不因为它是「顺手做的好事」就白送);
  • 最终树高 = 全部操作做完后森林里最深的点有多深, ⚠ 量它的那趟遍历不计进跳步数(第 29 章那条:量之前先确认你量的就是它)。
⚠ 第 2 步那组数据上的结果,反直觉到值得单独摆一张表
写法 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 的合并本身要抽随机数,和查询共用一个随机数流,查询序列整个错位了。 修的是生成器(改成两个独立的流),不是那条断言。 ★ 「这两组数据只差一件事」这种话,不要凭代码看起来对就写进正文。

genBig.cpp(三种合并顺序)点数、操作条数、查询序列完全相同,只有合并顺序不同

10★ 动画:同一串操作,两片森林并排长

同一串操作,两片森林并排长 —— 答案永远一样,差的只是「跳了多少步」
两边答案 一样(NYYYN/2)
第 1 / 13 步
naive:不压缩、不按秩
这一步跳 0★ 累计跳步 0
1
根
2
根
3
根
4
根
5
根
6
根
7
根
8
根
fast:路径压缩 + 按秩合并
这一步跳 0★ 累计跳步 0
1
根
2
根
3
根
4
根
5
根
6
根
7
根
8
根
高亮 = 这一步 find 走过的路(含起点和祖宗) 缩进 = 这个点离根有多远 ★ 两边的结论永远相同:开局
开局:8 个点各自成族,谁的爸爸都是自己

左边 naive(不压缩、不按秩),右边 fast(压缩 + 按秩)。高亮的是这一步 find 走过的那条路。

★ 这个动画只有一件事要看:两个「累计跳步」

播一遍你会看到:

  • 画面下方那行结论(Y / N / 合并 / 已同族)两边永远一模一样;
  • 右上角那两个「累计跳步」越拉越开;
  • 右边那棵树被压缩「拍扁」的那一刻很好认:某个点的爸爸突然直接变成了根。

把数据切到「★ 链式合并」那一档,左边会长成一条越来越长的链,右边始终是一棵扁扁的星形树。 再切到「⚠ 两两配对」那一档 —— 两边几乎一样扁,这就是第 9 步那句 「顺手写法会把差距藏起来」在画面上的样子。

⚠ 动画和 trace.cpp 在 check:viz 里是逐步对的:每一步两边的 find 路径、 这一步跳了几步、累计跳了几步、fa 数组、rk 数组,全部逐字节比 (不只对最终答案 —— 第 21 章以来那条规矩)。

trace.cpp动画照着它画:每一步两边的路径 / 跳步 / fa 数组

11★★ 五种「对拍一辈子也抓不到」的写法

⚠ 下面五份代码,在 300 轮对拍里全都是 0 / 300

它们不是「碰巧没被抓到」,而是原理上抓不到:它们根本不改变任何一个答案。

写法 错在哪 对拍 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
✗ 一、压了个寂寞 —— ★ 本教材第十二条恒等式
fakeCompress.cpp✗ 自以为加了路径压缩
// ✗ 对拍看不见的错法之一:**压了个寂寞**
//
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
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 章)。

⚠ 而它和前十一条有一个本质区别:前面那些恒等式两边都是「答案」,这一条两边是「开销」。

✗ 二、只压了第一个点 —— 两行代码换个顺序而已
wrongOnce.cpp✗ 先改 fa[x] 再往上走
// ✗ 对拍看不见的错法之二:**只压了路径上的第一个点**
//
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
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 之间 —— 温和正是它更难被发现的原因。

✗ 三、按秩合并方向反 —— 一个不等号,等于白写
wrongRank.cpp✗ 高的挂到矮的下面
// ✗ 对拍看不见的错法之三:**按秩合并的方向反了**(高的挂到矮的下面)
//
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
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 出界差一」的同款分寸)。

⚠ 还要注意它没有慢到天上去:路径压缩很宽容,它会把按秩合并的错误一路补回来。 这正是「只写路径压缩」能在竞赛里活到今天的原因。

★★ 这一节真正要带走的那句话

★★ 对拍验的是「两份代码想的是不是同一件事」,验不了「它跑得快不快」。

所有只影响复杂度、不影响答案的写法,对拍原理上全都看不见。

这是随机对拍的第四个盲区,而且它比前三个都要普遍:

  1. 第 20 章:对拍只能证伪,不能证明「对」;
  2. 第 31 章:验证器证明不了「答案存在时你没漏报」;
  3. 第 33 章:随机数据碰不到最坏情况(下界紧不紧,随机对拍答不了);
  4. ★ 第 36 章(本章):对拍看不见「慢」。第 35 章那个溢出是它的一个特例 (溢出改答案、只是小数据碰不到;而这一章的五份代码连答案都不改)。

想抓这一类 bug,只有一条路:换尺子。 数次数(count.cpp),别只看答案。

12★ 对拍与生成器:三个真 bug,和一个被实测打脸的预判

★ 先看三个「对拍抓得到」的真 bug
wrongFa.cpp✗ 查询比的是爸爸不是祖宗
// ✗ 真 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

第 2 步那组数据上它给 N Y N N N(正解 N Y Y Y N)。 ⚠ 第 34 章已经讲过它为什么错,这一章只补那一章没做的事:量一量几轮能抓住。

wrongUnite.cpp✗ 只挂点不挂族(左边忘了 find)
// ✗ 真 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

它给 N Y N Y N —— 只错在第 3 个查询上。 ★ 它错得比上一个隐蔽:a 自己连过去了,a 的子孙也跟着过去了,掉队的是 a 的祖宗那一支。

wrongCnt.cpp✗ 无条件 cnt--(少了「已同族就跳过」)
// ✗ 真 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

它的 Y / N 五个全对,只有最后一行是 1(正解 2)—— 第 2 步第 5 行那次重复合并多减了一次。

对拍器
★ 这个生成器我一共试了五个旋钮,其中两个把真 bug 打成了 0 / 300,一个单独看是灾难、最后却留下了。下面两张表把每一处的账都摆出来。
// 正解 —— 路径压缩 + 按秩合并
//
// ⚠ 并查集的基本操作第 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。

gen.cpp(八个档位)五处改动全部可重跑,包括两次「本以为聪明」的失败
★★ 这一章最该记住的一格:一整行输出值多少

把同一批数据(最终档,300 轮)只比 Y / N 那半,把最后那行连通块个数去掉:

故意写错的地方 两问都比 ★ 只比 Y / N
比爸爸不比祖宗 267 267
只挂点不挂族 300 297
无条件 cnt-- 300 ★ 0

★ 第 35 章那条「题目多问一句,对拍就多一条腿」的第二次现场,而这次更干净: 一行输出把一个 bug 从 0 / 300 抬到 300 / 300。

⚠ 顺带看「只挂点不挂族」那一行:300 → 297,有 3 轮只有连通块个数那一行抓得住它。 多问的那一句,不只救了它自己那个 bug。

13⚠ 递归版 find 会爆栈吗 —— 一个比想象中拧巴的实测

deep.cpp⚠ 只能在终端里跑:它靠真的把栈压爆来演示
⚠ 本机实测(栈 8 MB,`ulimit -s` = 8192)
写法 编译 结果
带路径压缩的递归 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 ← 同一份代码,同一个写法

★ 两件意料之外的事:

  1. 爆的偏偏是「带路径压缩」的那一份。 不压缩的写法是尾调用(递归返回之后没活要干了),g++ -O2 直接把它变成了循环; 带压缩的写法多了个 fa[x] = … 的赋值,尾调用没了,于是老老实实一层一帧。
  2. 所以「这段代码会不会爆栈」不是代码的属性,是代码 + 编译选项的属性 —— 同一份不压缩的代码,-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自测

自测清单0 / 12
配套练习
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)