阶段 6 · 图论 · 第 34 章提高组 S

最小生成树:Kruskal 与 Prim

问题从「两点之间最短」换成「把所有点连起来,总代价最小」。★ 关键一步是同一条性质的两个推论 —— 而那条性质的证明里,「边权非负」一次都没出现。

需要先学:第 29 章 图的存储:三种存法的对比与选型、第 32 章 最短路一:Dijkstra例题:最小生成树(可能有负权边,图不保证连通)建议用时:130 分钟
上一章欠下的两笔账,这一章一起还

第 33 章结尾白纸黑字写了两件事:

★ 关键一步是切割性质:横跨任意一个切割的最小边,一定在某棵最小生成树里 —— Kruskal 和 Prim 都是它的推论,只是「怎么选那个切割」不一样。 ⚠ 两种算法给出的树可能长得不一样,但权值和必须相同 —— 那么对拍该比什么?

第 4、5 步还①(而且第 5 步把那个证明做成了画面),第 10 步还②。

顺带一件计划里的事:Kruskal 要用并查集,而并查集本来排在第 36 章。 照第 27 章的先例(树形 DP 自带一小节邻接表),这一章自带一小节并查集(第 6 步), 第 36 章改成讲它的复杂度 —— 路径压缩 + 按秩合并为什么几乎是 O(1)。

1一句话问题

给一张无向带权图(n 个点、m 条边,-100 ≤ w ≤ 100)。 选出若干条边,使得所有点连通、且总权值最小 —— 输出这个最小总权值。 如果整张图本来就不连通(生成树根本不存在),输出一行 IMPOSSIBLE。

⚠ 可能有重边、自环,不保证连通,而且边权可以是负数。

★ 「生成树」这三个字里有两个限制,少看一个就写错
  • 生成:所有 n 个点都得连上(不是「连上一部分」);
  • 树:恰好 n−1 条边、不成环。

这两条其实是一件事的两面:n 个点、n−1 条边、连通 ⇔ 是一棵树。 所以代码里只要盯住一个数字:选中的边数有没有到 n−1。 到不了,就说明图本来不连通 —— 第 9 步那个错误版本漏的就是这一句。

⚠ 为什么「不连通」用 IMPOSSIBLE,而不是 -1 或者 0

因为边权可以是负数,权值和完全可能正好等于 −1,也完全可能是 0。

★ 答案的记号和数据的取值范围是一对,改了一边就得对一遍另一边。

第 33 章刚为这件事把「走不到」的记号从 -1 改成了 x(那一章距离可以是负的), 这是同一条规矩的第二次登场。它不会报错,只会让对拍在某些数据上莫名其妙地红。

2手算一遍:默认那张图

★ 图 A:左右两个三角,中间两条权值并列的桥
6 10
1 2 1      ┐
2 3 1      ├ 左边一个三角 1-2-3,★ 其中 1—3 是 -2(负权边)
1 3 -2     ┘
3 4 5      ← 桥一
4 5 2      ┐
5 6 3      ├ 右边一个三角 4-5-6
4 6 4      ┘
2 4 5      ← 桥二,★ 和桥一**权值并列**(都是 5)
1 1 -7     ← ★ 一条**负的自环**
3 4 9      ← ★ 3—4 的**重边**(更贵的那条)

手算(把边从小到大排一遍,能连就连):

边 权 收不收
1—1 −7 ✗ 自环,两端本来就是同一个点
1—3 −2 ✓
1—2 1 ✓
2—3 1 ✗ 1、2、3 已经连通了,再连成环
4—5 2 ✓
5—6 3 ✓
4—6 4 ✗ 成环
2—4 5 ✓ 左右两块接上了,第 5 条 —— 够 n−1 了

答案 9(= −2+1+2+3+5)。

★ 那条 −7 的自环是这张图的第一个陷阱:它是全图最小的边,可它一个新点都连不上, 那点「白送的负权」根本拿不到。第 9 步有一份代码就栽在这儿。

⚠ 顺带对照一下第 33 章:那一章一条负的自环就是一个负环,是灾难; 这一章它完全无害。同一样东西在两道题里的分量可以差得非常远。

★ 图 B:把中间那两条桥撤掉 —— 图就碎了
6 7
1 2 1
2 3 1
1 3 -2
4 5 2
5 6 3
4 6 4
1 1 -7

左边一块、右边一块,中间一条边都没有。答案:IMPOSSIBLE。

⚠ 但请注意:Kruskal 在这张图上照样跑得欢 —— 它长出来的是一片 最小生成森林(每块各一棵,权值和 4)。不崩溃、不越界、数还挺像话。 第 9 步那个错误版本报的就是这个 4。

3标准答案:把「生成树」的定义直接翻译成代码

brute.cpp标准答案:枚举所有边的子集
// 标准答案 —— 枚举**所有边的子集**,从中挑出「是生成树」的那些,取权值和最小的
//
// 输入:第一行 n m
// 接下来 m 行 `u v w`,表示一条连接 u、v 的**无向**边,权值 w(⚠ **w 可以是负数**,|w| ≤ 100)
// ⚠ 可能有重边、自环,也**不保证连通**
// 输出:最小生成树的权值和;如果整张图不连通(生成树根本不存在),输出一行 `IMPOSSIBLE`
//
// ⚠ 为什么「不连通」用 IMPOSSIBLE 而不是 -1:这一章的边权**可以是负数**,
// 权值和完全可能正好等于 -1。**答案的记号和数据的取值范围是一对** ——
// 第 33 章刚踩过一次(那一章走不到的点不能再用 -1,改成了 x),这是第二次。
//
// ★ 为什么标准答案要用一个和贪心毫无关系的思路写:
// kruskal.cpp 和 prim.cpp 都是**贪心**,而且是同一条性质(切割性质)的两个推论 ——
// 拿它们互相验,只能验出「两处打字错误不一样」,验不出「那条性质本身是不是被我理解错了」。
// 这一份直接照着**定义**做:生成树 = 选出 n−1 条边、且所有点连通。
// (第 9 章用 DP 验贪心、第 15 章用迭代加深验 BFS,都是这个道理。)
//
// ★ 「一个集合就是一个整数」第三次登场(第 3 章二进制枚举 → 第 28 章状压 DP → 这里):
// m 条边就是 m 个二进制位,枚举 0 .. 2^m−1 就枚举完了所有选法。
// 复杂度 O(2^m × n α),所以对拍的数据里 m 必须很小(生成器把 m 压在 14 条以内)。
//
// ⚠ 一个容易漏的地方:n == 1 时生成树是「一条边都不要」,答案是 0(而不是 IMPOSSIBLE)。
#include <bits/stdc++.h>
using namespace std;
int n, m;
vector<int> fa;
int find(int x) { return fa[x] == x ? x : fa[x] = find(fa[x]); }
int main() {
if (!(cin >> n >> m)) return 0;
vector<array<int, 3>> es(m);
for (auto& e : es) cin >> e[0] >> e[1] >> e[2];
const long long NONE = LLONG_MAX;
long long best = NONE;
// ⚠ 用 long long 数这个循环变量:m 到 31 时 `1 << m` 就溢出了。
// 这本身就是「暴力只能当标准答案」的一部分 —— 边数刚过三十,它连**数都数不完**。
for (long long S = 0; S < (1LL << m); S++) {
if (__builtin_popcountll((unsigned long long)S) != n - 1) continue; // 生成树恰好 n−1 条边
fa.assign(n + 1, 0);
for (int i = 1; i <= n; i++) fa[i] = i;
long long sum = 0;
int comp = n; // 还剩几个连通块
for (int i = 0; i < m; i++)
if (S >> i & 1) {
sum += es[i][2];
int a = find(es[i][0]), b = find(es[i][1]);
if (a != b) { fa[a] = b; comp--; } // ⚠ 自环 / 重边会让 comp 减不下去
}
if (comp != 1) continue; // 不连通 —— 那它就不是生成树
if (best == NONE || sum < best) best = sum;
}
if (best == NONE) cout << "IMPOSSIBLE\n";
else cout << best << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 为什么标准答案非要用一个和贪心毫无关系的思路

Kruskal 和 Prim 都是贪心,而且是同一条性质的两个推论。 拿它们互相验,只能验出「两处打字错误不一样」,验不出「那条性质本身是不是被我理解错了」。

所以这一份直接照着定义做:生成树 = 选 n−1 条边 + 所有点连通, 把 2^m 个子集全枚举一遍,合法的里面取最小。

第 9 章用 DP 验贪心、第 15 章用迭代加深验 BFS —— ★ 标准答案要用完全不同的思路写,同一个思路写两遍只能验出打字错误。

而「一个集合就是一个整数」也是第三次登场了:第 3 章拿它当枚举手段, 第 28 章升级成状态,这里又变回枚举手段 —— 只不过枚举的是边。

4★ 关键一步:切割性质

★★ 切割性质:横跨任意一个切割的最小边,一定在某棵最小生成树里

把 n 个点任意分成两半:S 和 V∖S(这就叫一个切割)。 一端在 S、另一端在 V∖S 的边,叫横跨这个切割的边。

★ 横跨它的边里最小的那一条 e,一定属于某一棵最小生成树。

证明只有三句话,而且是第 19 章那个交换论证的原样重演:

  1. 随便拿一棵最小生成树 T。如果 e 已经在里面,收工。
  2. 如果不在:把 e 加进 T,n 个点 n 条边必定出现一个环。 这个环从 S 出去、又回到 S,所以环上至少还有另一条横跨切割的边 f。
  3. 而 e 是横跨的边里最小的,所以 w(e) ≤ w(f)。 把 f 换成 e,还是一棵生成树,权值和 ≤ 原来 —— T 已经是最小的了, 所以新的这棵也是最小的,而它含 e。∎

⚠ 请数一数这三句话里用到了什么:加进去会成环(图论)、环上必有第二条横跨边(数数)、 w(e) ≤ w(f)(e 是最小的)。

★★ 「边权非负」一次都没有出现。

★ 对照第 32 章:同样是贪心,那一个怕负权,这一个不怕

第 32 章证明 Dijkstra 时,反证的第三句是「后面那一段路的长度 ≥ 0,所以绕远只会更远」—— 「边长非负」恰好用在那一个不等号上,负权一来,那句话就断了,反例就长在那儿。

这一章的三句反证里没有那个位置可断。所以:

贪心 证明里用到 w ≥ 0 吗 负权
第 32 章 Dijkstra 取最近的未定点,当场定死 ✓ 用在一个不等号上 ✗ 断
本章 Kruskal / Prim 取横跨切割的最小边 一次都没用到 ✓ 完全没事

★ 第 20 章那句「证明断在哪一步,反例就长在哪里」,这一章给出的是它的反面: 证明里压根没用到的条件,放开它也不会有反例。 所以这一章的对拍数据里必须有负权边 —— 它验的就是这句话。

⚠ 但要说准:不怕负权 ≠ 什么都不怕。负的自环照样拿不到(第 2 步那条 −7), 因为「树」那个限制还在。

★ 两个算法都是它的推论 —— 只是「选哪个切割」不一样
它每一步用的那个切割 S 是什么
Kruskal 当前这条边左端所在的那个连通块。比它小的边都已经处理过了,所以它就是横跨的最小边
Prim 已经长进树里的那堆点。每次取横跨它的最小边,切割性质直接就是算法本身

★ 所以这一章不是「两个算法」,是一条性质 + 两种挑切割的方式。 Kruskal 到处开花(一堆连通块慢慢并成一棵),Prim 只有一棵树、一直在长。

5★ 动画一:把那三句反证变成画面

每一步都当场对质:我选的是不是横跨切割的最小边
第 1 / 7 步
11-252345-79123456
绿实心 = 切割这一侧(树里的点)。橙虚线 = 横跨切割的边。绿粗 = 这一步选中的那条
★ 切割性质被推翻的次数
0一次都没有
横跨这个切割的边(按权值排)
(这一帧没有要对质的)
★ 切割性质:横跨任意一个切割的最小边,一定属于某棵最小生成树。 它的反证里,「边权非负」一次都没用到 —— 所以图 A 那条 −2 一点影响都没有。
把点分成两半:树里的(一开始只有 1 号)和树外的。★ 切割性质说:横跨这个分法的边里最小的那条,一定属于某棵最小生成树。所以每一步只要取那条最小的,就不会错。

每往树里加一条边,画面就把当时那个切割摆出来(绿实心 = 树里的点), 把横跨它的边全部列在右边、按权值排好,然后当场核对一句话: 我选的,是不是最小的那条?

右边那个计数器是这个动画的灵魂:★ 切割性质被推翻的次数。

★★ 它在图 A 上是 0 —— 而图 A 里有一条 −2 的负权边

把写法切成「✗ 写成 Dijkstra(key[v] = key[u] + w)」,它立刻变成 1 次: 那一份比的不是「那条边本身」,而是「从 1 号一路走过来的总长」, 于是它选的边不是横跨切割里最小的那条。

⚠ 请把这个画面和第 32 章那个 DijkstraProof 摆在一起看 —— 两个动画的形状是一样的(每一步都暴力枚举、当场对质),结论正好相反:

正权图 负权图
第 32 章 Dijkstra 的「定死」 0 次被推翻 ★ 第 2 步就被推翻
本章 Prim 的「取最小横跨边」 0 次 ★ 还是 0 次

这就是第 4 步那张表的画面版:差别不在代码里,在证明里。

6正解一:Kruskal(顺带把并查集讲了)

kruskal.cpp正解:按权排序,能连就连
// 正解之一 —— Kruskal:把边按权值从小到大排,能连就连
//
// ★ 它为什么对,只有一句话(本章的关键一步):**切割性质**。
//
// 把点集任意分成两半(S 和 V∖S),**横跨这个分法的边里最小的那条,
// 一定属于某一棵最小生成树。**
//
// Kruskal 每次拿出的那条边 (u, v),如果两端还不在同一个连通块里,
// 那就以「u 所在的那个连通块」当 S —— 因为比它更小的边**都已经处理过了**,
// 而它们要么在块内、要么两端也不横跨这个 S。
// 于是它就是横跨 (S, V∖S) 的最小边 → 由切割性质,选它不会错。
//
// ★ 需要的唯一工具是**并查集**(第 36 章的正主,这里先用它最朴素的样子):
// 它只回答一个问题 ——「这两个点现在是不是已经连在一起了?」
// find(x) :x 所在集合的代表元(顺手做**路径压缩**,把一路上的点直接挂到根上)
// unite(a,b):把两个集合并成一个,成功返回 true(说明它们本来不连通)
//
// ⚠ 两处初学者最常写错的地方,正文第 6 步各有一份代码:
// ① 比较时忘了 find,直接写 `if (fa[u] != fa[v])` —— fa[u] 只是「爸爸」,不是「祖宗」;
// ② 合并时写 `fa[u] = v`,也一样,那只是把两个**点**挂起来,不是把两个**集合**并起来。
//
// ⚠ 自环和重边不用特殊处理:自环两端本来就同块,find 一比就自己被跳过了;
// 重边里更小的那条先被处理,之后那条更大的自然成环。
// ★ 注意这一点和第 33 章正好相反:那一章**负的自环就是一个负环**,是灾难;
// 这一章负的自环完全无害 —— 它一个新点都连不上,白送的负权拿不到。
// (正文第 4 步那个 wrongNeg.cpp 就是没想明白这件事。)
//
// ★ 边权是负数也完全不影响:切割性质的证明里,**「w ≥ 0」这个条件一次都没用到**。
// 对照第 32 章:Dijkstra 的证明里非负性恰好用在一个不等号上,负权一来就断。
// 同一个「贪心」,一个怕负权、一个不怕 —— 差别在证明里看得清清楚楚。
//
// 复杂度 O(m log m)(瓶颈是排序)。
#include <bits/stdc++.h>
using namespace std;
vector<int> fa;
// 路径压缩:回来的路上,把这一路的点全部直接挂到根上
int find(int x) { return fa[x] == x ? x : fa[x] = find(fa[x]); }
// ⚠ 一定是 fa[find(a)] = find(b),把两个**根**接起来
bool unite(int a, int b) {
a = find(a), b = find(b);
if (a == b) return false; // 本来就连通 —— 再连就成环了
fa[a] = b;
return true;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
if (!(cin >> n >> m)) return 0;
vector<array<int, 3>> es(m); // (w, u, v) —— 权值放在最前面,直接 sort 就是按权排
for (auto& e : es) cin >> e[1] >> e[2] >> e[0];
sort(es.begin(), es.end()); // ★ 从小到大。反过来排就是「最大生成树」,见 wrongSort.cpp
fa.assign(n + 1, 0);
for (int i = 1; i <= n; i++) fa[i] = i;
long long ans = 0;
int cnt = 0; // 已经选中的边数
for (auto& e : es)
if (unite(e[1], e[2])) { ans += e[0]; cnt++; }
// ★ 选够 n−1 条才是一棵树。少一条都说明图本来就不连通
if (cnt != n - 1) cout << "IMPOSSIBLE\n";
else cout << ans << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

主循环只有两行,真正需要动脑的是那个并查集:它只回答一个问题 —— 「这两个点现在是不是已经连在一起了?」

★ 并查集:一片森林,fa[x] 是 x 的「爸爸」
int find(int x) { return fa[x] == x ? x : fa[x] = find(fa[x]); }   // 一路往上找祖宗,顺手压缩

bool unite(int a, int b) {
    a = find(a), b = find(b);
    if (a == b) return false;      // 本来就连通 —— 再连就成环
    fa[a] = b;                     // ⚠ 接的是两个**祖宗**
    return true;
}

路径压缩就是 fa[x] = find(fa[x]) 那半句:回来的路上,把这一路的点 全部直接挂到祖宗身上,下次一步到位。 (它为什么快到「几乎 O(1)」,第 36 章会算给你看 —— 这一章只用它。)

★ 用它之后,「自环」和「重边」根本不用特殊处理: 自环两端本来同族,find 一比就自己跳过了;重边里更小的那条先被收下, 更大的那条之后自然成环。第 29 章那两个必须小心的东西,在这里是白送的。

dsu.cpp逐条边看一遍:谁的祖宗是谁,收下还是跳过
// 并查集小节的演示程序 —— Kruskal 唯一依赖的那个工具,到底在干什么
//
// 输入和 kruskal.cpp 一样。它把边按权值从小到大过一遍,每条边打印一行:
// 这条边的两端各自的**祖宗**是谁 → 合并还是跳过 → 合并之后 fa 数组长什么样
// 并且**同时跑一份「不 find、直接比 fa」的错误写法**,把两者结论不同的那些行标出来。
//
// ★ 并查集只回答一个问题:「这两个点现在是不是已经连在一起了?」
// 它的实现是一片森林:fa[x] 是 x 的**爸爸**,一路往上走到头就是这一族的**祖宗**。
//
// find(x) :一路往上找到祖宗。⚠ 回来的路上顺手**路径压缩** ——
// 把这一路的点全部直接挂到祖宗身上,下次就一步到位。
// unite(a,b):fa[find(a)] = find(b),把两族的**祖宗**接起来。
//
// ★ 初学者最容易写错的就是这两句里的 find:
// ✗ `if (fa[u] != fa[v])` —— 比的是「爸爸」,不是「祖宗」。两个点的爸爸不同,
// 完全可能爷爷是同一个 —— 于是它以为不连通,把边收下,成了环。
// ✗ `fa[u] = v` —— 只把两个**点**挂了起来,u 那一族的其它人没跟着过去。
// 下面这张表就是拿来看这件事的:**「一样」那一列出现「不」的那一行,就是 bug 现场。**
// (对应正文第 6 步的 wrongFa.cpp。)
//
// ⚠ 输出全部用 printf,含中文的列按显示宽度补空格(第 25、26 章那条:setw 数的是字节)。
#include <bits/stdc++.h>
using namespace std;
// 按显示宽度补空格:ASCII 算 1 格,非 ASCII(这里只有汉字)算 2 格
static string padDisp(const string& s, int width) {
int disp = 0;
for (unsigned char c : s) {
if ((c & 0xC0) == 0x80) continue; // UTF-8 续字节,不算宽度
disp += (c < 0x80) ? 1 : 2;
}
return s + string(max(0, width - disp), ' ');
}
int n, m;
vector<int> fa, bad; // fa:正确写法;bad:不 find 的错误写法
int find(int x) { return fa[x] == x ? x : fa[x] = find(fa[x]); }
static string faLine(const vector<int>& f) {
string s;
for (int i = 1; i <= n; i++) s += to_string(f[i]) + (i == n ? "" : " ");
return s;
}
int main() {
if (!(cin >> n >> m)) return 0;
vector<array<int, 3>> es(m);
for (auto& e : es) cin >> e[1] >> e[2] >> e[0]; // (w, u, v)
sort(es.begin(), es.end());
fa.assign(n + 1, 0);
bad.assign(n + 1, 0);
for (int i = 1; i <= n; i++) fa[i] = bad[i] = i;
printf("按权值从小到大处理每一条边(fa[x] = x 的爸爸,一路往上走到头才是祖宗)\n\n");
printf(" %s %s %s %s %s\n",
padDisp("边", 10).c_str(), padDisp("find(u) find(v)", 17).c_str(),
padDisp("正确写法", 14).c_str(), padDisp("不 find 直接比 fa", 20).c_str(),
padDisp("一样", 6).c_str());
printf(" %s %s %s %s %s\n", string(10, '-').c_str(), string(17, '-').c_str(),
string(14, '-').c_str(), string(20, '-').c_str(), string(6, '-').c_str());
long long ans = 0, ansBad = 0;
int cnt = 0, cntBad = 0, diff = 0;
for (auto& e : es) {
int u = e[1], v = e[2], w = e[0];
int a = find(u), b = find(v);
bool takeOk = (a != b);
if (takeOk) { fa[a] = b; ans += w; cnt++; }
bool takeBad = (bad[u] != bad[v]); // ✗ 只比爸爸
if (takeBad) { bad[u] = bad[v]; ansBad += w; cntBad++; } // ✗ 只挂点
if (takeOk != takeBad) diff++;
char edge[32], roots[32];
snprintf(edge, sizeof(edge), "%d-%d(%d)", u, v, w);
snprintf(roots, sizeof(roots), "%d %d", a, b);
printf(" %s %s %s %s %s\n",
padDisp(edge, 10).c_str(), padDisp(roots, 17).c_str(),
padDisp(takeOk ? "收下" : "跳过(成环)", 14).c_str(),
padDisp(takeBad ? "收下" : "跳过(成环)", 20).c_str(),
padDisp(takeOk == takeBad ? "是" : "★ 不", 6).c_str());
}
printf("\n fa 数组最后长这样:%s\n", faLine(fa).c_str());
printf(" ★ 路径压缩之后,大多数点都被直接挂到了祖宗身上 —— 下次 find 一步到位。\n\n");
printf(" 正确写法:收了 %d 条边,权值和 %lld(要 %d 条才是一棵树)\n", cnt, ans, n - 1);
printf(" 不 find :收了 %d 条边,权值和 %lld\n", cntBad, ansBad);
if (diff) printf(" ★ 两者在 %d 条边上给出了不同的结论 —— 那几行就是 bug 现场。\n", diff);
else printf(" 这张图上两者恰好一致 —— ⚠ 不代表那个写法是对的,只代表这组数据没打中它。\n");
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

这张表的最后两列是并排跑的两种写法。请看「一样」那一列 ——

⚠ 初学者最常写错的两处,都出在同一个误解上:fa[x] 是「爸爸」,不是「祖宗」
✗ if (fa[u] != fa[v]) { ans += w; fa[u] = fa[v]; }
✓ if (find(u) != find(v)) { ans += w; fa[find(u)] = find(v); }
  • 比较:两个点的爸爸不一样,完全可能爷爷是同一个 —— 于是它以为不连通,收下,成了环;
  • 合并:fa[u] = fa[v] 只把 u 这一个点挂了过去,u 那一族的其他人原地不动。

图 A 上这两种写法在 3 条边上给出了不同的结论(表里那三行 ★ 不), 最后:正确写法收 5 条、权值和 9;不 find 那份收了 8 条、权值和 19。

wrongFa.cpp✗ 不 find,直接比 fa
// ✗ 错误版本②:并查集写成了「不 find,直接比 fa」
//
// ✗ if (fa[u] != fa[v]) { ans += w; cnt++; fa[u] = fa[v]; }
// ✓ if (find(u) != find(v)) { ans += w; cnt++; fa[find(u)] = find(v); }
//
// ★ 两处错都出在同一个误解上:**fa[x] 是「爸爸」,不是「祖宗」。**
// · 比较:两个点的爸爸不一样,完全可能爷爷是同一个 —— 于是它以为不连通,把边收下,成了环;
// · 合并:`fa[u] = fa[v]` 只把 u 这**一个点**挂了过去,u 那一族的其他人原地不动。
//
// ⚠ 它的症状很有意思,而且**不止一种**:
// · 收多了边(成环)→ 权值和偏大;
// · 收到 n−1 条时可能还有点没连上 → 权值和偏小;
// · 更常见的是最后 cnt ≠ n−1 → 它把一张连通图报成 **IMPOSSIBLE**。
// ★ 一个 bug 同时污染两种输出(第 33 章那条第三次)—— 看到 IMPOSSIBLE 千万别只去查连通性。
//
// dsu.cpp 把这两种写法逐条边并排跑了一遍,「一样」那一列出现「不」的行就是现场。
#include <bits/stdc++.h>
using namespace std;
vector<int> fa;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
if (!(cin >> n >> m)) return 0;
vector<array<int, 3>> es(m);
for (auto& e : es) cin >> e[1] >> e[2] >> e[0];
sort(es.begin(), es.end());
fa.assign(n + 1, 0);
for (int i = 1; i <= n; i++) fa[i] = i;
long long ans = 0;
int cnt = 0;
for (auto& e : es) {
int u = e[1], v = e[2];
if (fa[u] != fa[v]) { // ✗ 比的是爸爸
fa[u] = fa[v]; // ✗ 挂的是点
ans += e[0];
cnt++;
}
}
if (cnt != n - 1) cout << "IMPOSSIBLE\n";
else cout << ans << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

它输出 IMPOSSIBLE —— 而图 A 明明是连通的。

★ 因为它收了 8 条边,cnt != n-1 那一句就把它判成了「图不连通」。 一个 bug 同时污染两种输出(第 33 章那条的第三次)—— 看到 IMPOSSIBLE 千万别只盯着连通性去查,毛病在并查集里。

★ 第十条恒等式:把排序反过来,它精确地解了「最大生成树」
wrongSort.cpp✗ 排序反了(从大到小)
// ✗ 错误版本①:Kruskal 的排序**反了**(从大到小)
//
// 除了 sort 那一行加了个 greater<>,和 kruskal.cpp 一个字不差。
//
// ★ 它不是「随机地错」—— 它精确地解了另一道题:**最大生成树**。
// maxst.cpp(用 Prim 写的最大生成树)和它 300 组数据一组不差。
// 这是本教材第十条这样的恒等式,正文第 6 步专门摆了这张表。
//
// ⚠ 为什么这个 bug 特别值得留:它是唯一一个**在「非负权 + 稠密」的数据上也几乎必被抓**的,
// 所以它反过来是**生成器的试金石** —— 连它都抓不住的数据,什么都证明不了
// (第 31 章那份 wrongIdentity.cpp 是同一个用途)。
#include <bits/stdc++.h>
using namespace std;
vector<int> fa;
int find(int x) { return fa[x] == x ? x : fa[x] = find(fa[x]); }
bool unite(int a, int b) {
a = find(a), b = find(b);
if (a == b) return false;
fa[a] = b;
return true;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
if (!(cin >> n >> m)) return 0;
vector<array<int, 3>> es(m);
for (auto& e : es) cin >> e[1] >> e[2] >> e[0];
sort(es.begin(), es.end(), greater<array<int, 3>>()); // ✗ 就是这里
fa.assign(n + 1, 0);
for (int i = 1; i <= n; i++) fa[i] = i;
long long ans = 0;
int cnt = 0;
for (auto& e : es)
if (unite(e[1], e[2])) { ans += e[0]; cnt++; }
if (cnt != n - 1) cout << "IMPOSSIBLE\n";
else cout << ans << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

图 A 上它给 22(正解 9)。而它不是「随机地错」——

maxst.cpp用 Prim 写的最大生成树
// **最大**生成树 —— 用 Prim 写的(故意不用 Kruskal)
//
// 输入输出和 prim.cpp 一样,只是要的是「权值和最大的那棵生成树」。
// 改动只有两处:key 的初值从 +INF 变成 −INF,两个 `<` 变成 `>`。
//
// ★ 它存在的唯一理由,是钉住这一章的那条**恒等式**:
//
// 把 Kruskal 的排序反过来(从大到小),得到的**恰好**是最大生成树。
//
// wrongSort.cpp 就是「排序反了」那个错误版本 —— 而它 300 组数据的输出,
// 和这一份**一模一样**。所以正文里那句话可以说得很硬气:
// ★ **排序反了不是「随机地错」,它精确地解了另一道题。**
//
// 这是本教材第十条这样的恒等式(前九条在第 23、24、25、26、27、28 章)。
// ⚠ 而且验法和前面几章一样必须讲究:两份程序得用**不同的思路**写
// (这一份是 Prim,wrongSort.cpp 是 Kruskal),否则只是把同一个错抄了两遍。
//
// ★ 顺带说明一件事:最大生成树也是切割性质的推论 —— 把「最小」全部换成「最大」,
// 那三句反证一个字都不用改。**贪心的方向可以反,性质的形状不变。**
#include <bits/stdc++.h>
using namespace std;
const int INF = 0x3f3f3f3f;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
if (!(cin >> n >> m)) return 0;
vector<vector<int>> g(n + 1, vector<int>(n + 1, -INF));
for (int i = 0; i < m; i++) {
int u, v, w;
cin >> u >> v >> w;
g[u][v] = max(g[u][v], w); // ★ 重边这次取 max
g[v][u] = max(g[v][u], w);
}
vector<int> key(n + 1, -INF);
vector<char> in(n + 1, 0);
key[1] = 0;
long long ans = 0;
for (int it = 0; it < n; it++) {
int u = -1;
for (int v = 1; v <= n; v++)
if (!in[v] && (u == -1 || key[v] > key[u])) u = v; // ★ 取最大
if (key[u] <= -INF) { cout << "IMPOSSIBLE\n"; return 0; }
in[u] = 1;
ans += key[u];
for (int v = 1; v <= n; v++)
if (!in[v] && g[u][v] > key[v]) key[v] = g[u][v]; // ★ 更大才更新
}
cout << ans << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

也是 22。而且 300 组随机数据,一组不差(钉在 check:viz 里)。

★ 排序反了 ≡ 最大生成树。 这是本教材第十条这样的恒等式 (前九条在第 23、24、25、26、27、28 章)。 ⚠ 验法照旧讲究:两份程序思路必须不同(一份 Kruskal、一份 Prim), 否则只是把同一个错抄了两遍。

顺带一句:最大生成树也是切割性质的推论 —— 把三句反证里的「最小」全换成「最大」, 一个字都不用改。贪心的方向可以反过来,性质的形状不变。

7正解二:Prim —— 它和第 32 章那份代码只差一个字

prim.cpp朴素 Prim:从 1 号点开始,让树一点点长大
// 正解之二 —— Prim:从一个点开始,让树一点一点长大
//
// ★ 它和 kruskal.cpp 是**同一条性质**的两个推论,只是「选哪个切割」不一样:
// Kruskal:切割由「当前这条边左端所在的连通块」定 —— 到处开花,最后连成一棵;
// Prim: 切割永远是「已经长进树里的那堆点 S」和外面 —— 只有一棵树,一直在长。
// 每一步都取**横跨切割的最小边**,切割性质保证这一步不会错。
//
// ★★ 这一份和第 32 章的 naive.cpp(朴素 Dijkstra)**只差一个字**,值得并排看:
//
// Dijkstra: if (dist[u] + g[u][v] < dist[v]) dist[v] = dist[u] + g[u][v];
// Prim: if ( g[u][v] < key[v] ) key[v] = g[u][v];
// ↑ 少了「dist[u] +」这一截
//
// 一句话解释这个差别:
// Dijkstra 的 dist[v] 记的是「**从起点走到 v** 有多远」—— 所以要一路累加;
// Prim 的 key[v] 记的是「**从树上够到 v** 要花多少」—— 只看那一条边。
//
// ★ 而这个差别正好解释了另一件事:**为什么 Prim 不怕负权,Dijkstra 怕。**
// Dijkstra 的贪心要「路越走越长」才成立(负权一来就断,第 32 章那个反证的第三句);
// Prim 压根不累加,切割性质的证明里一次都没用到 w ≥ 0。
// ⚠ 所以这一章的对拍数据里**必须有负权边** —— 它验的就是这句话。
// 把 Prim 手滑写成 Dijkstra 就是 wrongDij.cpp,正文第 7 步专门讲。
//
// ⚠ 不连通怎么办:某一轮所有还在外面的点 key 都是 INF(谁都够不着),
// 说明剩下的点和树之间一条边都没有 —— 直接 IMPOSSIBLE。
// ★ 这比 Kruskal 那句 `cnt != n-1` 早,但说的是同一件事。
//
// 复杂度 O(n²)(每轮扫一遍找最小)。稠密图上它不怕 m 大 —— 因为它压根不看 m。
// 堆优化的版本见 primHeap.cpp(那一份是把第 32 章的 fast.cpp 改一个字改出来的)。
#include <bits/stdc++.h>
using namespace std;
const int INF = 0x3f3f3f3f;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
if (!(cin >> n >> m)) return 0;
// 邻接矩阵,重边取 min(自环留着也没关系:轮到 u 时它早就在树里了)
vector<vector<int>> g(n + 1, vector<int>(n + 1, INF));
for (int i = 0; i < m; i++) {
int u, v, w;
cin >> u >> v >> w;
g[u][v] = min(g[u][v], w);
g[v][u] = min(g[v][u], w); // ⚠ 无向图,两个方向都要存
}
vector<int> key(n + 1, INF);
vector<char> in(n + 1, 0);
key[1] = 0; // 从 1 号开始长。★ 从谁开始都行,见 primAny.cpp
long long ans = 0;
for (int it = 0; it < n; it++) {
int u = -1;
for (int v = 1; v <= n; v++) // 找「外面的点里,够得最便宜的那个」
if (!in[v] && (u == -1 || key[v] < key[u])) u = v;
if (key[u] >= INF) { cout << "IMPOSSIBLE\n"; return 0; } // 谁都够不着 → 不连通
in[u] = 1;
ans += key[u]; // 把「够到它的那条边」的权计进去(第一轮加的是 0)
for (int v = 1; v <= n; v++)
if (!in[v] && g[u][v] < key[v]) key[v] = g[u][v]; // ★ 就是这一行,没有 key[u] +
}
cout << ans << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★★ 把它和第 32 章的朴素 Dijkstra 并排放:差别只有一截
// 第 32 章 naive.cpp(朴素 Dijkstra)
if (dist[u] + g[u][v] < dist[v]) dist[v] = dist[u] + g[u][v];

// 这一章 prim.cpp
if (          g[u][v] < key[v] ) key[v]  =           g[u][v];
//      ↑ 少了「dist[u] +」这一截

一句话解释这个差别:

那个数组记的是什么
Dijkstra 的 dist[v] 从起点走到 v 有多远 —— 所以要一路累加
Prim 的 key[v] 从树上够到 v 要花多少 —— 只看那一条边

★ 而这正好解释了第 4 步那张表:Dijkstra 要累加,所以它的贪心需要「路越走越长」; Prim 压根不累加,切割性质的证明里一次都没用到 w ≥ 0。

第 33 章说「SPFA 就是第 32 章那份堆优化,只差用什么容器」; 这一章说的是另一半:★ 容器可以一模一样,差的是往 key 里放什么。

wrongDij.cpp✗ Prim 手滑写成了 Dijkstra
// ✗ 错误版本⑤:把 Prim 手滑写成了 Dijkstra
//
// ✓ Prim: if ( g[u][v] < key[v]) key[v] = g[u][v];
// ✗ 这一份: if (key[u] + g[u][v] < key[v]) key[v] = key[u] + g[u][v];
// ↑ 多了「key[u] +」这一截
//
// ★ 一个字之差,它算的就成了**最短路径树**(从 1 号出发的那棵)——
// 一棵完全合法、但通常**不是最小**的生成树。
// ⚠ 所以这一份的答案里,`ans` 我特意加的是「真正用到的那条边的权」,而不是 key[u],
// 否则它连「一棵树的权值和」都不是了 —— 那样错得太明显,反而没有教学价值。
// ★ 这个版本的意思是:**它给出的是一棵真的树,只是选错了那棵。**
//
// ★ 顺带把第 32 章那笔账翻出来对照:
// Dijkstra 的贪心要「路越走越长」才成立 —— 负权一来就断(那一章那个反证的第三句);
// Prim 压根不累加,切割性质的证明里一次都没用到 w ≥ 0 —— 负权对它毫无影响。
// 所以这一份**顺手把「怕负权」这个毛病也一起带了回来**:它在负权数据上还会额外错一批。
//
// ⚠ 内层那句里特意补上了 `g[u][v] < INF`(「不是一条边就别算」)。
// 不补的话,有负权时 `key[u] + INF` 会比 INF **小**,于是「够不着」也被刷成一个数 ——
// 那正是第 33 章 wrongInf.cpp 那个坑。★ **错误版本也要干净:一份只错一件事。**
// 两个 bug 混在一份代码里,量出来的抓获率就说不清是谁的功劳了。
//
// ⚠ 它也是「答案不唯一」那件事的一块试金石:最短路径树和最小生成树**权值和不同**,
// 所以主对拍(只比一个数)抓得住它;要是当初图省事去比「选了哪些边」,
// 那就会连正确的 Prim 都一起判红。
#include <bits/stdc++.h>
using namespace std;
const int INF = 0x3f3f3f3f;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
if (!(cin >> n >> m)) return 0;
vector<vector<int>> g(n + 1, vector<int>(n + 1, INF));
for (int i = 0; i < m; i++) {
int u, v, w;
cin >> u >> v >> w;
g[u][v] = min(g[u][v], w);
g[v][u] = min(g[v][u], w);
}
vector<int> key(n + 1, INF), from(n + 1, 0); // from[v] = 把 v 够进来的那条边的权
vector<char> in(n + 1, 0);
key[1] = 0;
long long ans = 0;
for (int it = 0; it < n; it++) {
int u = -1;
for (int v = 1; v <= n; v++)
if (!in[v] && (u == -1 || key[v] < key[u])) u = v;
if (key[u] >= INF) { cout << "IMPOSSIBLE\n"; return 0; }
in[u] = 1;
ans += from[u]; // 加的是边权(第一轮 from[1] = 0)
for (int v = 1; v <= n; v++)
if (!in[v] && g[u][v] < INF && key[u] + g[u][v] < key[v]) { // ✗ 累加了 —— 这就成了 Dijkstra
key[v] = key[u] + g[u][v];
from[v] = g[u][v];
}
}
cout << ans << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

图 A 上它给 10(正解 9)。

★ 它给出的是一棵真的树,只是选错了那棵

它算出来的是最短路径树:从 1 号出发,每个点都用「最短路」连过来。 那是一棵完全合法的生成树 —— 只是通常不是最小的那棵。

★ 「它给了一棵合法的树」和「它给了最小的那棵」是两件事。 第 32 章那句「它给了对的答案 ≠ 这个算法成立」的同一个形状。

⚠ 写这个错误版本时我特意补了一句 g[u][v] < INF 的守卫。不补的话, 有负权时 key[u] + INF 会比 INF 小,「够不着」也被刷成一个数 —— 那正是第 33 章 wrongInf.cpp 那个坑。★ 错误版本也要干净:一份只错一件事, 否则量出来的抓获率说不清是谁的功劳。

★ 堆优化:还是第 32 章那份 fast.cpp,还是只改那一个字
primHeap.cpp堆优化 Prim:O(m log n)
// 堆优化 Prim —— ★ 把第 32 章那份 fast.cpp 抄过来,改一个字
//
// 朴素 Prim 每一轮都要扫一遍 n 个点去找「最便宜的那个」,那正是第 32 章处理过的事情:
// 交给小根堆。于是两份代码长得几乎一模一样:
//
// 第 32 章 fast.cpp(堆优化 Dijkstra) 这一份(堆优化 Prim)
// ------------------------------------------------------------------
// auto [d, u] = q.top(); q.pop(); auto [d, u] = q.top(); q.pop();
// if (d > dist[u]) continue; if (in[u]) continue; ← ⚠ 判重的理由不同
// for (auto [v, w] : g[u]) for (auto [v, w] : g[u])
// if (d + w < dist[v]) { if (!in[v] && w < key[v]) {
// dist[v] = d + w; key[v] = w; ← ★ 就是这一个字
// q.push({dist[v], v}); q.push({key[v], v});
// } }
//
// ★ 「dist[u] + w」变成「w」,一个算法就变成了另一个算法。
// 第 33 章说「SPFA 就是第 32 章那份堆优化,只差用什么容器」;
// 这一章说的是另一半:**容器一模一样,差的是往 key 里放什么。**
//
// ⚠ 判重那一句这里写成 `if (in[u]) continue;`,而不是照抄 `if (d > key[u]) continue;`。
// 两种写法在这道题上都是对的(key[u] 一旦进树就不会再被刷小,下面那句 `!in[v]` 挡着),
// 但 in[] 更直白:它说的正是「**已经进树的点不能再进第二次**」,
// 和朴素版 prim.cpp 里那个 in[] 是同一个东西 —— 两份代码摆在一起时,这一点看得见。
// ★ 第 33 章那条「**这个标记到底在记什么**,比它叫 vis 还是 inq 重要一百倍」,这里又用上了。
//
// 复杂度 O(m log n)。稀疏图上它比朴素的 O(n²) 快得多;稠密图上谁快 —— 正文第 9 步实测。
#include <bits/stdc++.h>
using namespace std;
const int INF = 0x3f3f3f3f;
typedef pair<int, int> PII; // (够到它要花多少, 点的编号)
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
if (!(cin >> n >> m)) return 0;
vector<vector<PII>> g(n + 1);
for (int i = 0; i < m; i++) {
int u, v, w;
cin >> u >> v >> w;
g[u].push_back({v, w});
g[v].push_back({u, w}); // ⚠ 无向图,存两遍
}
vector<int> key(n + 1, INF);
vector<char> in(n + 1, 0);
priority_queue<PII, vector<PII>, greater<PII>> q;
key[1] = 0;
q.push({0, 1});
long long ans = 0;
int cnt = 0;
while (!q.empty()) {
auto [d, u] = q.top();
q.pop();
if (in[u]) continue; // 过期的那一份,扔掉
in[u] = 1;
ans += d;
cnt++;
for (auto [v, w] : g[u])
if (!in[v] && w < key[v]) { // ★ 不是 d + w,就是 w
key[v] = w;
q.push({key[v], v});
}
}
if (cnt != n) cout << "IMPOSSIBLE\n"; // 有点永远进不了树 → 图不连通
else cout << ans << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
// 第 32 章 fast.cpp                        这一份
if (d + w < dist[v]) {                     if (!in[v] && w < key[v]) {
    dist[v] = d + w;                           key[v] = w;          // ★ 就是这一个字
    q.push({dist[v], v});                      q.push({key[v], v});
}                                          }

⚠ 判重那一句这里用 if (in[u]) continue; 而不是照抄 if (d > key[u]) continue; —— 两种写法在这道题上都对,但 in[] 说的正是「已经进树的点不能再进第二次」, 和朴素版里那个 in[] 是同一个东西。 (第 33 章那条:这个标记到底在记什么,比它叫什么重要一百倍。)

顺带一件可以量出来的事:Prim 从哪个点开始,答案都一样
primAny.cpp从每个点各长一遍
// 一份专门用来「把一句话变成一个可验证的数字」的程序:
// ★ **Prim 从哪个点开始,答案都一样。**
//
// 输入和 prim.cpp 完全一样,输出 n 个 token:第 i 个是「从 i 号点开始长」得到的答案
// (不连通时那一项写 IMPOSSIBLE)。check:viz 断言这 n 个 token **全部相同**,
// 而且等于 kruskal.cpp 的答案。
//
// ★ 为什么这句话值得单独验一遍:
// Prim 的每一步都取「横跨切割 (S, V∖S) 的最小边」,而**切割性质对任何切割都成立** ——
// 起点只是决定了第一个 S 是 {谁},它从头到尾没进过任何一个不等号。
// ⚠ 对照第 32 章:那里起点写死 1 号是**真 bug**(wrongStart1.cpp,300 轮抓 252),
// 因为最短路问的就是「从 s 出发」。同样一处「顺手写死 1 号」——
// 一章里是致命的,另一章里毫无影响。**代码有没有毛病,取决于题目在问什么。**
//
// ⚠ 它也顺便挡住一类假正确:如果哪天有人把 Prim 写成了「依赖起点」的东西
// (比如 wrongDij.cpp 那样偷偷累加起来),这 n 个数立刻就不一样了。
#include <bits/stdc++.h>
using namespace std;
const int INF = 0x3f3f3f3f;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
if (!(cin >> n >> m)) return 0;
vector<vector<int>> g(n + 1, vector<int>(n + 1, INF));
for (int i = 0; i < m; i++) {
int u, v, w;
cin >> u >> v >> w;
g[u][v] = min(g[u][v], w);
g[v][u] = min(g[v][u], w);
}
for (int s = 1; s <= n; s++) {
vector<int> key(n + 1, INF);
vector<char> in(n + 1, 0);
key[s] = 0;
long long ans = 0;
bool ok = true;
for (int it = 0; it < n; it++) {
int u = -1;
for (int v = 1; v <= n; v++)
if (!in[v] && (u == -1 || key[v] < key[u])) u = v;
if (key[u] >= INF) { ok = false; break; }
in[u] = 1;
ans += key[u];
for (int v = 1; v <= n; v++)
if (!in[v] && g[u][v] < key[v]) key[v] = g[u][v];
}
if (ok) cout << ans;
else cout << "IMPOSSIBLE";
cout << " \n"[s == n];
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

6 个起点,6 个 9。理由在证明里:切割性质对任何切割都成立, 起点只决定第一个 S 是 {谁},它从头到尾没进过任何一个不等号。

⚠ 对照第 32 章:那里「起点写死 1 号」是真 bug(300 轮抓 252)—— 因为最短路问的就是「从 s 出发」。 ★ 同一处「顺手写死 1 号」,一章里致命,另一章里毫无影响 —— 差别在于题目问的是什么。 第 11 步还会再撞见这句话一次。

8★ 动画二:两种算法并排长

一条一条地长:Kruskal 到处开花,Prim 只有一棵树
答案 9
第 1 / 12 步
11-252345-79123456
同色 = 同一个连通块(并查集里的同一族)。绿粗 = 已经选中的边,红 = 这条成环、跳过了
★ 已选中的边数
0/ 5
★ 成环跳过
0
当前权值和 0
已经选中的边
(还没有)
Kruskal:先把边按权值从小到大排好,一开始 6 个点各自成一块。

切「算法」那个下拉框,盯着颜色看:

  • Kruskal:一堆彩色小块到处开花,慢慢并成一块;
  • Prim:一块绿色一直在长,从头到尾只有一棵树。

两条路线完全不像,最后那个数字一模一样 —— 因为它们是同一条性质的两个推论。

右边两个计数器都参与 check:viz 的交叉验证: ★ 已选中的边数(图 A 上停在 5 = n−1)和 ★ 因为成环被跳过的边数(图 A 上是 5)。 把图切成 B,第一个计数器停在 4,再也上不去 —— 这就是 IMPOSSIBLE 的由来。

9另外两种把它写错的方式

wrongCount.cpp(喂给它图 B)✗ 忘了检查「选够 n−1 条没有」
// ✗ 错误版本③:忘了检查「到底选够 n−1 条没有」
//
// 和 kruskal.cpp 只差最后那一句 `if (cnt != n - 1)`。
//
// ★ 它错在**题面**上,不在算法上:图不连通的时候,Kruskal 照样跑得欢 ——
// 它长出来的是一片**最小生成森林**,每个连通块里各一棵。
// 把那片森林的权值和当答案报出去,一点异常都没有:不崩溃、不越界、数还挺像话。
//
// ⚠ 所以它是**只有「不保证连通」这句话进了题面才存在的 bug** ——
// 而生成器要是「顺手保证图连通」(第 30 章那条),它就 0 / 300。
// 本章生成器的档位 1 干的就是这件事,正文第 9 步那张表第一行。
#include <bits/stdc++.h>
using namespace std;
vector<int> fa;
int find(int x) { return fa[x] == x ? x : fa[x] = find(fa[x]); }
bool unite(int a, int b) {
a = find(a), b = find(b);
if (a == b) return false;
fa[a] = b;
return true;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
if (!(cin >> n >> m)) return 0;
vector<array<int, 3>> es(m);
for (auto& e : es) cin >> e[1] >> e[2] >> e[0];
sort(es.begin(), es.end());
fa.assign(n + 1, 0);
for (int i = 1; i <= n; i++) fa[i] = i;
long long ans = 0;
for (auto& e : es)
if (unite(e[1], e[2])) ans += e[0];
cout << ans << "\n"; // ✗ 不管选够没有,直接报
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

它输出 4,而图 B 的正确答案是 IMPOSSIBLE。

★ 它错的不是算法,是题面:图不连通时 Kruskal 长出来的是一片最小生成森林, 那个 4 是森林的权值和。不崩溃、不报错、数还挺像话。 ⚠ 所以它是「不保证连通」这句话进了题面才存在的 bug —— 生成器要是「顺手保证图连通」(第 30 章那条),它就 0 / 300。第 11 步那张表第一行就是现场。

wrongNeg.cpp(喂给它图 A)✗ 以为负权边白拿,先全收了
// ✗ 错误版本④:「负权边是白送的,先全收了再说」
//
// 这是一个**看起来很有道理**的错误直觉:既然总权值要最小,那负的边当然是能拿就拿。
//
// ★ 它错在哪:生成树的限制不是「权值最小」四个字,还有前半句 ——
// **必须恰好是一棵树**(n−1 条边、不成环)。负权边照样会凑成环,凑成环就不能全要。
// ⚠ 最刺眼的一种情况是**负的自环**:它一个新点都连不上,那点「白送的负权」根本拿不到。
// (对照第 33 章:那一章负的自环就是一个负环,是灾难;这一章它完全无害 ——
// 同一样东西在两章里的分量完全不同。)
//
// ★ 这个 bug 对生成器提出了一个很具体的要求:数据里得有**负权边凑出来的环**
// (或者负的自环)。全是正权的数据上它和正解一模一样 —— 生成器的档位 2 就是干这个的。
//
// ⚠ 注意它「无脑收下」的那部分连 cnt 都算不准,所以症状同样可能是 IMPOSSIBLE。
#include <bits/stdc++.h>
using namespace std;
vector<int> fa;
int find(int x) { return fa[x] == x ? x : fa[x] = find(fa[x]); }
bool unite(int a, int b) {
a = find(a), b = find(b);
if (a == b) return false;
fa[a] = b;
return true;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
if (!(cin >> n >> m)) return 0;
vector<array<int, 3>> es(m);
for (auto& e : es) cin >> e[1] >> e[2] >> e[0];
fa.assign(n + 1, 0);
for (int i = 1; i <= n; i++) fa[i] = i;
long long ans = 0;
int cnt = 0;
for (auto& e : es)
if (e[0] < 0) { // ✗ 负的先无脑收下(连自环也收)
ans += e[0];
if (unite(e[1], e[2])) cnt++;
}
sort(es.begin(), es.end());
for (auto& e : es)
if (e[0] >= 0 && unite(e[1], e[2])) { ans += e[0]; cnt++; }
if (cnt != n - 1) cout << "IMPOSSIBLE\n";
else cout << ans << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

它输出 2(正解 9)。

★ 「负的当然能拿就拿」——错在哪

错在把题目看成了「权值最小」四个字,漏掉了前半句:必须恰好是一棵树。 负权边照样会凑成环,凑成环就不能全要。

而图 A 上最刺眼的是那条 −7 的自环:它是全图最小的边, 可它一个新点都连不上 —— 那点白送的负权,你拿不到。

⚠ 这个 bug 对生成器提出了一个非常具体的要求:数据里得有负权边凑出来的环, 最好还有负的自环。全是正权的数据上它和正解一模一样(第 11 步那张表:档位 0/1 都是 0)。

10★ 兑现预告②:两棵树可能不一样,那对拍该比什么

plan.cpp把两种算法各自选中的边都打印出来
// 不只报一个数,而是**把两种算法各自选中的那 n−1 条边都打印出来**
//
// 输入和 kruskal.cpp 一样。输出:
// 第一行:权值和(不连通就只有一行 IMPOSSIBLE)
// 然后 `KRUSKAL` + n−1 行 `u v w`:Kruskal 选中的边(按它选中的顺序)
// 然后 `PRIM` + n−1 行 `u v w`:Prim 从 1 号点开始长出来的边(按它选中的顺序)
//
// ★ 为什么要有这一份(本章第二个关键点):
// **最小生成树可能不唯一,但权值和唯一。** 边权有并列时,两份都正确的程序
// 会长出**形状不同的树** —— 拿「选了哪些边」去逐字节对拍,立刻红一片,可谁都没错。
// 这是第 31 章「答案不唯一」的第二次登场,那一章给的两条出路,这一章两条都用上了:
//
// ① **把答案钉唯一**:这道题天生就有一个唯一的东西 —— **权值和**。
// 题面只要那一个数,主对拍就能逐字节比。
// (第 31 章是靠给题面加一句「输出字典序最小的那个」才钉住的,这一章白送。)
// ② **写验证器**:不比答案,比性质。check:viz 拿到这两棵树之后各查四件事 ——
// · 恰好 n−1 条边;
// · 每一条都真的是原图里的边(不许凭空造边);
// · 连起来之后所有点连通(n−1 条边 + 连通 ⇔ 是一棵树);
// · 权值和等于第一行,也等于 brute.cpp 枚举出来的最小值。
//
// ⚠ 第 31 章那个盲区在这里还在:**验证器证明不了「答案存在时你没漏报」** ——
// 一份永远输出 IMPOSSIBLE 的程序能通过所有合法性检查。
// 所以「不连通」这一支必须靠主对拍单独对。
//
// ★ 正文第 8 步把这两棵树并排画了出来:**在默认那张图上它们真的不一样**
// (Kruskal 用 2−5,Prim 用 3−4,两条边权值都是 5),而权值和都是 9。
// 第 27、28 章那句「一份方案能自证清白,一个数字不能」第三次登场 ——
// ⚠ 只不过这一次要反过来用:**能自证清白的是「它是不是一棵合法的生成树」,
// 而「它是不是最小的」还是只能靠那个数字。**
#include <bits/stdc++.h>
using namespace std;
const int INF = 0x3f3f3f3f;
vector<int> fa;
int find(int x) { return fa[x] == x ? x : fa[x] = find(fa[x]); }
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
if (!(cin >> n >> m)) return 0;
vector<array<int, 3>> es(m); // (w, u, v)
for (auto& e : es) cin >> e[1] >> e[2] >> e[0];
/* ---------- Kruskal ---------- */
vector<array<int, 3>> sorted = es;
sort(sorted.begin(), sorted.end());
fa.assign(n + 1, 0);
for (int i = 1; i <= n; i++) fa[i] = i;
long long sumK = 0;
vector<array<int, 3>> pickK;
for (auto& e : sorted) {
int a = find(e[1]), b = find(e[2]);
if (a == b) continue;
fa[a] = b;
sumK += e[0];
pickK.push_back({e[1], e[2], e[0]});
}
if ((int)pickK.size() != n - 1) { cout << "IMPOSSIBLE\n"; return 0; }
/* ---------- Prim(从 1 号开始)---------- */
vector<vector<int>> g(n + 1, vector<int>(n + 1, INF));
for (auto& e : es) {
g[e[1]][e[2]] = min(g[e[1]][e[2]], e[0]);
g[e[2]][e[1]] = min(g[e[2]][e[1]], e[0]);
}
vector<int> key(n + 1, INF), pre(n + 1, 0); // pre[v] = 把 v 够进来的那个点
vector<char> in(n + 1, 0);
key[1] = 0;
long long sumP = 0;
vector<array<int, 3>> pickP;
for (int it = 0; it < n; it++) {
int u = -1;
for (int v = 1; v <= n; v++)
if (!in[v] && (u == -1 || key[v] < key[u])) u = v;
in[u] = 1;
if (pre[u]) { sumP += key[u]; pickP.push_back({pre[u], u, key[u]}); }
for (int v = 1; v <= n; v++)
if (!in[v] && g[u][v] < key[v]) { key[v] = g[u][v]; pre[v] = u; }
}
cout << sumK << "\n";
cout << "KRUSKAL\n";
for (auto& e : pickK) cout << e[0] << " " << e[1] << " " << e[2] << "\n";
cout << "PRIM\n";
for (auto& e : pickP) cout << e[0] << " " << e[1] << " " << e[2] << "\n";
// ⚠ 两个和必须相等 —— 不等就是这一章最核心的那句话出了问题
if (sumK != sumP) cout << "★ 两个权值和居然不一样:" << sumK << " vs " << sumP << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

图 A 上,两棵树真的不一样:

KRUSKAL:1—3(-2)  1—2(1)  4—5(2)  5—6(3)  2—4(5)     ← 用桥二
PRIM   :1—3(-2)  1—2(1)  3—4(5)  4—5(2)  5—6(3)     ← 用桥一

两条桥权值并列(都是 5),谁被选中只看「谁先轮到」。而两棵树的权值和都是 9。

★ 第 31 章那两条出路,这一章两条都用上了

第 31 章(拓扑排序)是本教材第一次碰到「答案不唯一」,当时给了两条出路:

① 把答案钉唯一。 第 31 章是靠给题面加一句「输出字典序最小的那个」硬钉的; 这一章白送 —— 这道题天生就有一个唯一的东西:权值和。 所以题面只要那一个数,主对拍就能逐字节比。

★ 「答案不唯一」时的第一个动作,是先找找有没有一个天生唯一的量 —— 有的话,题面就该只要它。

② 写验证器。 不比答案,比性质。check:viz 拿到 plan.cpp 那两棵树,各查四件事:

查什么 为什么
恰好 n−1 条边 「树」的一半
每条边都真的在原图里 不许凭空造边
连起来所有点连通 「树」的另一半(n−1 条边 + 连通 ⇔ 无环)
权值和 = 第一行 = brute.cpp 枚举出来的最小值 「最小」那一半

300 组数据、每组两棵树,全部通过。

⚠ 第 31 章那个盲区在这里还在:验证器证明不了「答案存在时你没漏报」 —— 一份永远输出 IMPOSSIBLE 的程序能通过上面每一条检查。 所以「不连通」那一支必须靠主对拍单独对。 (第 20 章「对拍只能证伪」、第 31 章「验证器的盲区」、第 33 章「随机数据碰不到最坏情况」—— 这是随机对拍的第二个盲区在这一章的复现。)

★ 还有一个细节值得记:前四条里,前三条能自证清白,第四条不能。 「它是不是一棵合法的生成树」验证器自己就能查完; 「它是不是最小的那棵」只能靠那个数字,也就是靠 brute.cpp。 第 27、28 章那句「一份方案能自证清白,一个数字不能」,在这一章要反过来用一半。

11★ 对拍与生成器:五个 bug,五样它们各自要的东西

对拍器
★ 这个生成器调了八次,其中「有一次是撤回」。它要同时造出:不连通的图、负权边(而且要能凑成环)、自环和重边 —— 而「不连通」这一支一放开就会把别的 bug 全挤没。
// 正解之一 —— Kruskal:把边按权值从小到大排,能连就连
//
// ★ 它为什么对,只有一句话(本章的关键一步):**切割性质**。
//
// 把点集任意分成两半(S 和 V∖S),**横跨这个分法的边里最小的那条,
// 一定属于某一棵最小生成树。**
//
// Kruskal 每次拿出的那条边 (u, v),如果两端还不在同一个连通块里,
// 那就以「u 所在的那个连通块」当 S —— 因为比它更小的边**都已经处理过了**,
// 而它们要么在块内、要么两端也不横跨这个 S。
// 于是它就是横跨 (S, V∖S) 的最小边 → 由切割性质,选它不会错。
//
// ★ 需要的唯一工具是**并查集**(第 36 章的正主,这里先用它最朴素的样子):
// 它只回答一个问题 ——「这两个点现在是不是已经连在一起了?」
// find(x) :x 所在集合的代表元(顺手做**路径压缩**,把一路上的点直接挂到根上)
// unite(a,b):把两个集合并成一个,成功返回 true(说明它们本来不连通)
//
// ⚠ 两处初学者最常写错的地方,正文第 6 步各有一份代码:
// ① 比较时忘了 find,直接写 `if (fa[u] != fa[v])` —— fa[u] 只是「爸爸」,不是「祖宗」;
// ② 合并时写 `fa[u] = v`,也一样,那只是把两个**点**挂起来,不是把两个**集合**并起来。
//
// ⚠ 自环和重边不用特殊处理:自环两端本来就同块,find 一比就自己被跳过了;
// 重边里更小的那条先被处理,之后那条更大的自然成环。
// ★ 注意这一点和第 33 章正好相反:那一章**负的自环就是一个负环**,是灾难;
// 这一章负的自环完全无害 —— 它一个新点都连不上,白送的负权拿不到。
// (正文第 4 步那个 wrongNeg.cpp 就是没想明白这件事。)
//
// ★ 边权是负数也完全不影响:切割性质的证明里,**「w ≥ 0」这个条件一次都没用到**。
// 对照第 32 章:Dijkstra 的证明里非负性恰好用在一个不等号上,负权一来就断。
// 同一个「贪心」,一个怕负权、一个不怕 —— 差别在证明里看得清清楚楚。
//
// 复杂度 O(m log m)(瓶颈是排序)。
#include <bits/stdc++.h>
using namespace std;
vector<int> fa;
// 路径压缩:回来的路上,把这一路的点全部直接挂到根上
int find(int x) { return fa[x] == x ? x : fa[x] = find(fa[x]); }
// ⚠ 一定是 fa[find(a)] = find(b),把两个**根**接起来
bool unite(int a, int b) {
a = find(a), b = find(b);
if (a == b) return false; // 本来就连通 —— 再连就成环了
fa[a] = b;
return true;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
if (!(cin >> n >> m)) return 0;
vector<array<int, 3>> es(m); // (w, u, v) —— 权值放在最前面,直接 sort 就是按权排
for (auto& e : es) cin >> e[1] >> e[2] >> e[0];
sort(es.begin(), es.end()); // ★ 从小到大。反过来排就是「最大生成树」,见 wrongSort.cpp
fa.assign(n + 1, 0);
for (int i = 1; i <= n; i++) fa[i] = i;
long long ans = 0;
int cnt = 0; // 已经选中的边数
for (auto& e : es)
if (unite(e[1], e[2])) { ans += e[0]; cnt++; }
// ★ 选够 n−1 条才是一棵树。少一条都说明图本来就不连通
if (cnt != n - 1) cout << "IMPOSSIBLE\n";
else cout << ans << "\n";
return 0;
}
点一下即可编辑

300 轮实测,五个错误版本:

故意写错的地方 被抓 第几轮
排序反了(≡ 最大生成树) 191 / 300 第 1 轮
并查集不 find,直接比 fa 165 / 300 第 3 轮
负权边先全收 107 / 300 第 1 轮
忘了判「选够 n−1 条没有」 69 / 300 第 3 轮
Prim 写成 Dijkstra 67 / 300 第 6 轮

(这 300 轮里:69 轮图不连通,2095 条边里 988 条是负的、201 条自环、306 条重边; 有解的那 231 轮里有 10 轮 Kruskal 和 Prim 长出了不同的树。五个数字都钉在 check:viz 里。)

★ 第一张表:五处改动,一次只改一处

gen.cpp 带了九个档位(./gen 种子 档位),种子固定 1..300:

档位 改了什么 图不连通 排序反 不 find 忘了判够 负边全收 写成 Dijkstra
0(最初) 保证连通 + 非负权 + 简单图 + 编号有序 0 210 201 0 0 120
1 不再保证连通 224 48 104 224 0 28
2 边权可以是负数 224 51 93 224 9 19
3 允许自环和重边 224 60 92 224 33 19
4 打乱编号 224 60 91 224 33 20
5 边权值域拉开([-9,9] → [-20,20]) 224 60 104 224 34 24

★ 档位 1 那一行是这一章最刺眼的地方,而且它是「双向」的: 「忘了判够」从 0 一下子变成 224(这一支终于有了), 可别的三个 bug 全被腰斩(210→48、201→104、120→28)—— 因为一旦图不连通,所有程序一律输出 IMPOSSIBLE,别的 bug 连出场机会都没有。

⚠ 第 31、33 章那条「某一支占得太多,会把别人挤没」的第四次。 这次占到了 224 / 300(七成半),比第 31 章那次还狠。

⚠ 另外两笔老实账:

  • 档位 2 只把「负边全收」从 0 抬到 9。 光有负权边不够 —— 它要的是负边凑成的环。 档位 3 一放开自环和重边,才跳到 33(★ 大头是负的自环,第 29 章那条兑现)。 「加了个好东西」不等于「数据变好了」(第 33 章那条的第二次)。
  • 档位 4(打乱编号)五个数字几乎一个没动(60/91/224/33/20 对比档位 3 的 60/92/224/33/19)。 而第 27、31、32 章里同样一处改动是决定性的(0 / 300 → 两百多)。下一张表会说清为什么。
★★ 第二张表:一次调平衡 + 一次撤回 + 一个对照
档位 改了什么 图不连通 排序反 不 find 忘了判够 负边全收 写成 Dijkstra 两棵树不同
5 (上一张表的最后一行) 224 60 104 224 34 24 0
6 不连通的比例降到 1/3 69 191 169 69 109 66 1
7(在用) ★ 把档位 5 那处改动撤回来 69 191 165 69 107 67 10
8(对照) 和档位 7 一样,只是编号不打乱 69 191 168 69 107 59 3

★ 档位 7 是这一章最值得说的一档:它是一次撤回。

档位 5 那处「把边权值域拉开」,在当时(八成的组都不连通)看着是有效的 (不 find 从 92 涨到 104)。可等档位 6 把不连通降下来之后再对照一量: 五个 bug 的抓获率几乎一个数都没变(191/169/69/109/66 → 191/165/69/107/67), 而它还有一个副作用 —— 值域一宽,并列的边权就少了, 「两棵最小生成树长得不一样」的组数从 10 掉到 1。 而「答案不唯一」正是这一章的第二个主题。于是这处改动被撤回了。

★ 第 32 章那条「调优不可加:一处改动值不值得留,取决于其它旋钮此刻在哪」的第二次, 而且这一次的结论是减法。 别把调生成器当成「把好点子一条条加上去」 —— 有的点子后来是要拿掉的。

★ 档位 8 那个对照回答了上一张表留下的问题:为什么「打乱编号」在这一章这么弱?

那几章问的是什么 编号打乱有没有用
第 27 章 谁是根 ★ 0 / 300 → 257 / 300
第 31 章 什么顺序 ★ 0 / 300 → 265 / 300
第 32 章 从哪个点出发 ★ 0 / 300 → 252 / 300
本章 一个和编号无关的权值和 几乎没动(59 → 67,只有最弱那一支受益)

⚠ 所以「顺手写法会悄悄给数据加一条题目里没有的性质」这条规律,要补一句: ★ 它危不危险,取决于题目在问什么。 这和第 33 章那条「同一句代码危不危险,取决于数据的取值范围」是一对。

(它最后还是留下了:定档标准照旧是第 32 章那条 —— 让最弱的那一支尽量强, 档位 8 最弱的是 59,档位 7 是 67。而且退化数据防的是还没写出来的 bug, 第 27 章档位 3 那笔账的同款。)

gen.cpp(九个档位)八次改动全部可重跑,包括那次撤回

12实测:暴力有多慢,三种正解怎么选

先看暴力。./genBig <n> 造的是稀疏图(m = 4n − 1):

g++ -O2 -std=c++17 -o genBig genBig.cpp && g++ -O2 -std=c++17 -o brute brute.cpp
./genBig 9 0 > big.txt && time ./brute < big.txt     # 55.86 秒
n m 枚举边子集 O(2^m) Kruskal
4 15 0.00 秒 0.00 秒
5 19 0.00 秒 0.00 秒
6 23 0.02 秒 0.00 秒
7 27 0.46 秒 0.00 秒
8 31 3.97 秒 0.00 秒
9 35 55.86 秒 0.00 秒

★ 点数每多 1 个,边数多 4 条,耗时就 ×16 —— 底数在边上,不在点上。 (第 30 章那条「指数级的底数往往藏在密度里,不在规模里」的第二次。 那一章是简单路径数,这一章是边子集数。)

同题对比:枚举所有边子集 vs Kruskal
7 → 27 条边(约 0.5 秒);8 → 31 条边(约 4 秒)。⚠ 9 就要 56 秒了,超过网页 15 秒上限,只能在终端跑
枚举所有边子集
Kruskal
★ 三种正解的选型:先算一遍账,再去量(第 29 章那条)
时间 空间 瓶颈在哪
Kruskal O(m log m) O(m) 存边 排序
朴素 Prim O(n²) ★ O(n²) —— 邻接矩阵 每轮扫一遍找最小
堆优化 Prim O(m log n) O(m) 堆

⚠ 朴素 Prim 那个 O(n²) 空间是最容易被忽略的一栏,而它往往先出事 —— 邻接矩阵要 4(n+1)² 字节,这正是第 29 章那个公式。

本机实测 · 稀疏图(./genBig n 0,m = 4n−1):

n m 朴素 Prim Kruskal 堆优化 Prim
1 000 3 999 0.00 秒 / 7.9 MB 0.00 秒 / 4.1 MB 0.00 秒 / 4.2 MB
4 000 15 999 0.06 秒 / 66.6 MB 0.00 秒 / 4.2 MB 0.00 秒 / 4.7 MB
16 000 63 999 1.65 秒 / 1004.6 MB 0.01 秒 / 4.7 MB 0.01 秒 / 6.6 MB
64 000 255 999 ★ 开不出来 0.04 秒 / 7.1 MB 0.06 秒 / 14.2 MB
256 000 1 023 999 — 0.19 秒 / 16.8 MB 0.43 秒 / 45.2 MB
1 000 000 3 999 999 — 0.81 秒 / 54.5 MB 2.34 秒 / 165.4 MB

★ 第 29 章那个公式原样成立:4 × 16001² = 1.024 GB,实测 1004.6 MB。 到 n = 64 000 就要 16.4 GB —— 本机总共只有 8 GB,实测直接 std::bad_alloc。 稀疏大图上朴素 Prim 不是慢,是根本开不出来。

★ 所以选型表里那一栏「空间」不是走过场: 朴素 Prim 先出事的是空间,不是时间(n=16000 时它只要 1.65 秒,可已经吃掉 1 GB)。

⚠ 稀疏图上 Kruskal 比堆优化 Prim 快三倍 —— 这不是复杂度能解释的

O(m log m) 和 O(m log n) 几乎是一回事,可实测是 0.81 vs 2.34 秒。

原因和第 29、33 章那两次一模一样:缓存。 Kruskal 是「排一次序 + 在一个连续数组上顺序扫」, 堆优化 Prim 要维护堆、还要顺着邻接表跳来跳去。

★ 第 33 章那句「Bellman-Ford 比 SPFA 还快,因为它在连续数组上顺序扫边」的同款。 口诀要拿实测复核,这是第四次。

本机实测 · 稠密图(./genBig n 1,完全图 m = n(n−1)/2):

n m ⚠ 只读入 朴素 Prim Kruskal 堆优化 Prim
1 000 499 500 0.03 秒 0.03 秒 0.07 秒 0.04 秒
2 000 1 999 000 0.15 秒 0.16 秒 0.33 秒 0.17 秒
3 000 4 498 500 0.34 秒 0.37 秒 0.77 秒 0.40 秒
★★ 这张表如果不量那一列「只读入」,会得出完全错误的结论

光看后三列,结论是「三者差不多」(0.37 / 0.77 / 0.40)。 可读入本身就吃掉了 0.34 秒 —— 减掉它之后:

减去读入之后(n = 3000)
朴素 Prim 0.03 秒
堆优化 Prim 0.06 秒
Kruskal 0.43 秒

★ 算法部分差了十四倍,而不是「差不多」。 ./count io 这个开关就是为这件事加的(count.cpp)。

★ 第 29 章那条「量之前先确认「你量的就是它」」的第三次 (第一次是量内存时把去重的 set 也量进去了,第二次是第 32 章两份代码 I/O 设置不一致)。 这一次的教训更直白:稠密图的输入本身就是主要开销,不单独量出来, 你比的就不是算法,是 cin。

⚠ 顺带:这一次那句口诀(「稠密图该用朴素 Prim」)终于复现出来了 —— 第 29、32、33 章连着三次都没复现出各自那句口诀,这是第一次量到相符的。 口诀不是都错,是都得量。

count.cpp把三种写法的工作量数出来(附「只读入」开关)
// 把三种写法的**工作量**数出来 —— 秒表只告诉你「谁快」,这个告诉你「为什么」
//
// 用法:./count —— 读入一张图,报告三种写法各自的工作量
// ./count io —— ★ **只读入,什么都不算**,用来量「读入本身要多久」
//
// ⚠ 为什么非要有那个 `io` 开关(第 29 章那条:**量之前先确认「你量的就是它」**):
// 稠密图上光是读入 450 万条边就要好几秒,三份程序的耗时里有一大半是同一笔读入开销。
// 不把它单独量出来,那张耗时表比的就不是算法,而是 cin。
// (第 29 章量内存时也是这样:给程序加一个「只做要量的那部分」的开关才对得上公式。)
//
// 三个计数器分别是什么:
// · Kruskal :排序后**扫到第几条边就选够 n−1 条了** ——
// ★ 后面那一大截边根本没用上,可它们**已经被排过序了**。
// 这解释了 Kruskal 的常数:瓶颈在排序,不在并查集。
// · 堆优化 Prim:**入堆次数**(第 32 章那个数的同款)——
// 一条边只有「真的把某个点刷便宜了」才入堆,所以 m log n 是个很松的上界。
// · 朴素 Prim :扫描次数恒等于 n²,**和 m 一点关系都没有** ——
// 这正是它在稠密图上有机会的原因。
#include <bits/stdc++.h>
using namespace std;
const int INF = 0x3f3f3f3f;
vector<int> fa;
int find(int x) { return fa[x] == x ? x : fa[x] = find(fa[x]); }
int main(int argc, char** argv) {
ios::sync_with_stdio(false);
cin.tie(nullptr);
bool ioOnly = (argc > 1 && string(argv[1]) == "io");
int n, m;
if (!(cin >> n >> m)) return 0;
vector<array<int, 3>> es(m);
for (auto& e : es) cin >> e[1] >> e[2] >> e[0]; // (w, u, v)
if (ioOnly) { // ★ 只读入,立刻收工
printf("只读入:%d 个点、%d 条边,什么都没算\n", n, m);
return 0;
}
/* Kruskal:排序之后,扫到第几条边就选够了 */
sort(es.begin(), es.end());
fa.assign(n + 1, 0);
for (int i = 1; i <= n; i++) fa[i] = i;
long long scanned = 0;
int cnt = 0;
for (auto& e : es) {
scanned++;
int a = find(e[1]), b = find(e[2]);
if (a != b) { fa[a] = b; cnt++; }
if (cnt == n - 1) break;
}
/* 堆优化 Prim:入堆次数 */
vector<vector<pair<int, int>>> g(n + 1);
for (auto& e : es) {
g[e[1]].push_back({e[2], e[0]});
g[e[2]].push_back({e[1], e[0]});
}
vector<int> key(n + 1, INF);
vector<char> in(n + 1, 0);
priority_queue<pair<int, int>, vector<pair<int, int>>, greater<pair<int, int>>> q;
key[1] = 0;
q.push({0, 1});
long long pushes = 1;
while (!q.empty()) {
auto [d, u] = q.top();
q.pop();
if (in[u]) continue;
in[u] = 1;
for (auto [v, w] : g[u])
if (!in[v] && w < key[v]) { key[v] = w; q.push({w, v}); pushes++; }
}
printf("这张图:%d 个点、%d 条边\n\n", n, m);
printf(" Kruskal :排序 %d 条边,但只扫到第 %lld 条就选够 n−1 条了(%.1f%%)\n",
m, scanned, m ? 100.0 * (double)scanned / m : 0.0);
printf(" 堆优化 Prim :入堆 %lld 次(边数的 %.1f%%)—— m log n 是个很松的上界\n",
pushes, m ? 100.0 * (double)pushes / m : 0.0);
printf(" 朴素 Prim :扫描 %lld 次(= n²),和 m 一点关系都没有\n", (long long)n * n);
printf("\n★ 稀疏图上 n² 远大于 m,稠密图上正好反过来 —— 选型就是在比这两个数。\n");
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

为什么稠密图上朴素 Prim 反而占优,这个计数器一句话说清(./genBig 1000 1,1000 点、499 500 条边):

工作量
Kruskal 排序 499 500 条边 —— 可只扫到第 4 392 条就选够了(0.9%)★ 剩下 99% 白排
堆优化 Prim 入堆 7 138 次(边数的 1.4%)—— m log n 又一次是很松的上界(第 32 章那条)
朴素 Prim 扫描 1 000 000 次(= n²)—— 和 m 一点关系都没有

★ 一句话:稠密图上 m 比 n² 还大,而朴素 Prim 压根不看 m。

13三种写法怎么选

★ 关键的一步
时间 空间 什么时候用它
Kruskal O(m log m) O(m) 默认就用它 —— 代码最短、缓存友好,稀疏图上最快
堆优化 Prim O(m log n) O(m) 和 Kruskal 半斤八两;题目已经建好邻接表时顺手
朴素 Prim O(n²) ⚠ O(n²) 只在稠密图(m 接近 n²)且 n 不大(几千)时用

★ 一句话:先看图稀不稀疏。 稀疏(m ≈ n)用 Kruskal; 稠密(m ≈ n²)且 n 只有几千,朴素 Prim 反而最快 —— 但先确认 4(n+1)² 的内存开得出来。

14这一章可以带走的五样东西

★ 关键的一步

【1】★ 切割性质:横跨任意切割的最小边,一定在某棵最小生成树里。 三句反证(加进去成环 → 环上必有第二条横跨边 → 换掉它不会更差),就是第 19 章那个交换论证。 Kruskal 和 Prim 都是它的推论,只是选的切割不一样: Kruskal 用「这条边左端所在的连通块」,Prim 用「已经长进树里的那堆点」。

【2】★ 那三句话里「边权非负」一次都没出现 —— 所以这一章的贪心不怕负权。 对照第 32 章:Dijkstra 的反证里非负性恰好用在一个不等号上,负权一来就断。

★ 第 20 章「证明断在哪一步,反例就长在哪里」的反面: 证明里压根没用到的条件,放开它也不会有反例。 ⚠ 但「不怕负权」≠「什么都不怕」:负的自环照样拿不到,因为「树」那个限制还在。

【3】★ Prim 和 Dijkstra 只差一截「dist[u] +」。 dist[v] 记的是「从起点走到 v 多远」(要累加),key[v] 记的是「从树上够到 v 多少钱」(只看一条边)。 写混了就得到最短路径树 —— 一棵合法的、但通常不是最小的生成树。

★ 「它给了一棵合法的树」和「它给了最小的那棵」是两件事。

【4】★ 答案不唯一时,先找有没有一个「天生唯一」的量。 最小生成树可能不止一棵,但权值和唯一 —— 于是题面只要那个数,主对拍就能逐字节比 (第 31 章是靠加一句「字典序最小」硬钉的,这一章白送)。 方案本身交给验证器(n−1 条边 + 都在原图里 + 连通 + 权值和对得上)。 ⚠ 验证器的盲区还在:它证明不了「答案存在时你没漏报」。

【5】★ 调生成器有时候要做减法。 这一章那个「拉开边权值域」的改动,加的时候有效、环境变了之后就没用了,还有害 (并列权值一少,「两棵树不同」从 10 掉到 1),最后被撤回。

★ 第 32 章「调优不可加」的第二次,而这次结论是减法。 同一件事的另一面:「顺手写法」危不危险,取决于题目在问什么 —— 打乱编号在第 27、31、32 章是 0 → 两百多,在这一章几乎没动, 因为这一章问的是一个和编号无关的数。

下一章预告

第 35 章:栈与队列 → 单调栈、单调队列。

★ 关键一步是均摊分析:每个元素进出各一次,所以那个「看起来有两层循环」的东西其实是 O(n) —— 接的是第 7 章双指针那一段。

⚠ 还有一笔欠了很久的账要还:第 24 章说过「多重背包还能做到 O(nW), 等第 35 章讲完单调队列再回来收尾」—— 写到那里必须回头把它补上。

15自测

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