第 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 步那个错误版本漏的就是这一句。
因为边权可以是负数,权值和完全可能正好等于 −1,也完全可能是 0。
★ 答案的记号和数据的取值范围是一对,改了一边就得对一遍另一边。
第 33 章刚为这件事把「走不到」的记号从 -1 改成了 x(那一章距离可以是负的),
这是同一条规矩的第二次登场。它不会报错,只会让对拍在某些数据上莫名其妙地红。
2手算一遍:默认那张图
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 章:那一章一条负的自环就是一个负环,是灾难; 这一章它完全无害。同一样东西在两道题里的分量可以差得非常远。
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标准答案:把「生成树」的定义直接翻译成代码
// 标准答案 —— 枚举**所有边的子集**,从中挑出「是生成树」的那些,取权值和最小的//// 输入:第一行 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;}点「运行 ▶」看结果
Kruskal 和 Prim 都是贪心,而且是同一条性质的两个推论。 拿它们互相验,只能验出「两处打字错误不一样」,验不出「那条性质本身是不是被我理解错了」。
所以这一份直接照着定义做:生成树 = 选 n−1 条边 + 所有点连通, 把 2^m 个子集全枚举一遍,合法的里面取最小。
第 9 章用 DP 验贪心、第 15 章用迭代加深验 BFS —— ★ 标准答案要用完全不同的思路写,同一个思路写两遍只能验出打字错误。
而「一个集合就是一个整数」也是第三次登场了:第 3 章拿它当枚举手段, 第 28 章升级成状态,这里又变回枚举手段 —— 只不过枚举的是边。
4★ 关键一步:切割性质
把 n 个点任意分成两半:S 和 V∖S(这就叫一个切割)。 一端在 S、另一端在 V∖S 的边,叫横跨这个切割的边。
★ 横跨它的边里最小的那一条 e,一定属于某一棵最小生成树。
证明只有三句话,而且是第 19 章那个交换论证的原样重演:
- 随便拿一棵最小生成树 T。如果 e 已经在里面,收工。
- 如果不在:把 e 加进 T,n 个点 n 条边必定出现一个环。 这个环从 S 出去、又回到 S,所以环上至少还有另一条横跨切割的边 f。
- 而 e 是横跨的边里最小的,所以
w(e) ≤ w(f)。 把 f 换成 e,还是一棵生成树,权值和≤原来 —— T 已经是最小的了, 所以新的这棵也是最小的,而它含 e。∎
⚠ 请数一数这三句话里用到了什么:加进去会成环(图论)、环上必有第二条横跨边(数数)、
w(e) ≤ w(f)(e 是最小的)。
★★ 「边权非负」一次都没有出现。
第 32 章证明 Dijkstra 时,反证的第三句是「后面那一段路的长度 ≥ 0,所以绕远只会更远」—— 「边长非负」恰好用在那一个不等号上,负权一来,那句话就断了,反例就长在那儿。
这一章的三句反证里没有那个位置可断。所以:
| 贪心 | 证明里用到 w ≥ 0 吗 | 负权 | |
|---|---|---|---|
| 第 32 章 Dijkstra | 取最近的未定点,当场定死 | ✓ 用在一个不等号上 | ✗ 断 |
| 本章 Kruskal / Prim | 取横跨切割的最小边 | 一次都没用到 | ✓ 完全没事 |
★ 第 20 章那句「证明断在哪一步,反例就长在哪里」,这一章给出的是它的反面: 证明里压根没用到的条件,放开它也不会有反例。 所以这一章的对拍数据里必须有负权边 —— 它验的就是这句话。
⚠ 但要说准:不怕负权 ≠ 什么都不怕。负的自环照样拿不到(第 2 步那条 −7), 因为「树」那个限制还在。
| 它每一步用的那个切割 S 是什么 | |
|---|---|
| Kruskal | 当前这条边左端所在的那个连通块。比它小的边都已经处理过了,所以它就是横跨的最小边 |
| Prim | 已经长进树里的那堆点。每次取横跨它的最小边,切割性质直接就是算法本身 |
★ 所以这一章不是「两个算法」,是一条性质 + 两种挑切割的方式。 Kruskal 到处开花(一堆连通块慢慢并成一棵),Prim 只有一棵树、一直在长。
5★ 动画一:把那三句反证变成画面
每往树里加一条边,画面就把当时那个切割摆出来(绿实心 = 树里的点), 把横跨它的边全部列在右边、按权值排好,然后当场核对一句话: 我选的,是不是最小的那条?
右边那个计数器是这个动画的灵魂:★ 切割性质被推翻的次数。
把写法切成「✗ 写成 Dijkstra(key[v] = key[u] + w)」,它立刻变成 1 次:
那一份比的不是「那条边本身」,而是「从 1 号一路走过来的总长」,
于是它选的边不是横跨切割里最小的那条。
⚠ 请把这个画面和第 32 章那个 DijkstraProof 摆在一起看 ——
两个动画的形状是一样的(每一步都暴力枚举、当场对质),结论正好相反:
| 正权图 | 负权图 | |
|---|---|---|
| 第 32 章 Dijkstra 的「定死」 | 0 次被推翻 | ★ 第 2 步就被推翻 |
| 本章 Prim 的「取最小横跨边」 | 0 次 | ★ 还是 0 次 |
这就是第 4 步那张表的画面版:差别不在代码里,在证明里。
6正解一:Kruskal(顺带把并查集讲了)
// 正解之一 —— 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;}点「运行 ▶」看结果
主循环只有两行,真正需要动脑的是那个并查集:它只回答一个问题 —— 「这两个点现在是不是已经连在一起了?」
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 章那两个必须小心的东西,在这里是白送的。
// 并查集小节的演示程序 —— 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;}点「运行 ▶」看结果
这张表的最后两列是并排跑的两种写法。请看「一样」那一列 ——
✗ 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。
// ✗ 错误版本②:并查集写成了「不 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;}点「运行 ▶」看结果
它输出 IMPOSSIBLE —— 而图 A 明明是连通的。
★ 因为它收了 8 条边,
cnt != n-1那一句就把它判成了「图不连通」。 一个 bug 同时污染两种输出(第 33 章那条的第三次)—— 看到 IMPOSSIBLE 千万别只盯着连通性去查,毛病在并查集里。
// ✗ 错误版本①: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;}点「运行 ▶」看结果
图 A 上它给 22(正解 9)。而它不是「随机地错」——
// **最大**生成树 —— 用 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;}点「运行 ▶」看结果
也是 22。而且 300 组随机数据,一组不差(钉在 check:viz 里)。
★ 排序反了 ≡ 最大生成树。 这是本教材第十条这样的恒等式 (前九条在第 23、24、25、26、27、28 章)。 ⚠ 验法照旧讲究:两份程序思路必须不同(一份 Kruskal、一份 Prim), 否则只是把同一个错抄了两遍。
顺带一句:最大生成树也是切割性质的推论 —— 把三句反证里的「最小」全换成「最大」, 一个字都不用改。贪心的方向可以反过来,性质的形状不变。
7正解二:Prim —— 它和第 32 章那份代码只差一个字
// 正解之二 —— 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;}点「运行 ▶」看结果
// 第 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 里放什么。
// ✗ 错误版本⑤:把 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;}点「运行 ▶」看结果
图 A 上它给 10(正解 9)。
它算出来的是最短路径树:从 1 号出发,每个点都用「最短路」连过来。 那是一棵完全合法的生成树 —— 只是通常不是最小的那棵。
★ 「它给了一棵合法的树」和「它给了最小的那棵」是两件事。 第 32 章那句「它给了对的答案 ≠ 这个算法成立」的同一个形状。
⚠ 写这个错误版本时我特意补了一句 g[u][v] < INF 的守卫。不补的话,
有负权时 key[u] + INF 会比 INF 小,「够不着」也被刷成一个数 —— 那正是第 33 章
wrongInf.cpp 那个坑。★ 错误版本也要干净:一份只错一件事,
否则量出来的抓获率说不清是谁的功劳。
// 堆优化 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;}点「运行 ▶」看结果
// 第 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 从哪个点开始,答案都一样。**//// 输入和 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;}点「运行 ▶」看结果
6 个起点,6 个 9。理由在证明里:切割性质对任何切割都成立,
起点只决定第一个 S 是 {谁},它从头到尾没进过任何一个不等号。
⚠ 对照第 32 章:那里「起点写死 1 号」是真 bug(300 轮抓 252)—— 因为最短路问的就是「从 s 出发」。 ★ 同一处「顺手写死 1 号」,一章里致命,另一章里毫无影响 —— 差别在于题目问的是什么。 第 11 步还会再撞见这句话一次。
8★ 动画二:两种算法并排长
切「算法」那个下拉框,盯着颜色看:
- Kruskal:一堆彩色小块到处开花,慢慢并成一块;
- Prim:一块绿色一直在长,从头到尾只有一棵树。
两条路线完全不像,最后那个数字一模一样 —— 因为它们是同一条性质的两个推论。
右边两个计数器都参与 check:viz 的交叉验证:
★ 已选中的边数(图 A 上停在 5 = n−1)和 ★ 因为成环被跳过的边数(图 A 上是 5)。
把图切成 B,第一个计数器停在 4,再也上不去 —— 这就是 IMPOSSIBLE 的由来。
9另外两种把它写错的方式
// ✗ 错误版本③:忘了检查「到底选够 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;}点「运行 ▶」看结果
它输出 4,而图 B 的正确答案是 IMPOSSIBLE。
★ 它错的不是算法,是题面:图不连通时 Kruskal 长出来的是一片最小生成森林, 那个 4 是森林的权值和。不崩溃、不报错、数还挺像话。 ⚠ 所以它是「不保证连通」这句话进了题面才存在的 bug —— 生成器要是「顺手保证图连通」(第 30 章那条),它就 0 / 300。第 11 步那张表第一行就是现场。
// ✗ 错误版本④:「负权边是白送的,先全收了再说」//// 这是一个**看起来很有道理**的错误直觉:既然总权值要最小,那负的边当然是能拿就拿。//// ★ 它错在哪:生成树的限制不是「权值最小」四个字,还有前半句 ——// **必须恰好是一棵树**(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;}点「运行 ▶」看结果
它输出 2(正解 9)。
错在把题目看成了「权值最小」四个字,漏掉了前半句:必须恰好是一棵树。 负权边照样会凑成环,凑成环就不能全要。
而图 A 上最刺眼的是那条 −7 的自环:它是全图最小的边, 可它一个新点都连不上 —— 那点白送的负权,你拿不到。
⚠ 这个 bug 对生成器提出了一个非常具体的要求:数据里得有负权边凑出来的环, 最好还有负的自环。全是正权的数据上它和正解一模一样(第 11 步那张表:档位 0/1 都是 0)。
10★ 兑现预告②:两棵树可能不一样,那对拍该比什么
// 不只报一个数,而是**把两种算法各自选中的那 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;}点「运行 ▶」看结果
图 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 章是靠给题面加一句「输出字典序最小的那个」硬钉的; 这一章白送 —— 这道题天生就有一个唯一的东西:权值和。 所以题面只要那一个数,主对拍就能逐字节比。
★ 「答案不唯一」时的第一个动作,是先找找有没有一个天生唯一的量 —— 有的话,题面就该只要它。
② 写验证器。 不比答案,比性质。check:viz 拿到 plan.cpp 那两棵树,各查四件事:
| 查什么 | 为什么 |
|---|---|
| 恰好 n−1 条边 | 「树」的一半 |
| 每条边都真的在原图里 | 不许凭空造边 |
| 连起来所有点连通 | 「树」的另一半(n−1 条边 + 连通 ⇔ 无环) |
权值和 = 第一行 = brute.cpp 枚举出来的最小值 |
「最小」那一半 |
300 组数据、每组两棵树,全部通过。
⚠ 第 31 章那个盲区在这里还在:验证器证明不了「答案存在时你没漏报」 —— 一份永远输出 IMPOSSIBLE 的程序能通过上面每一条检查。 所以「不连通」那一支必须靠主对拍单独对。 (第 20 章「对拍只能证伪」、第 31 章「验证器的盲区」、第 33 章「随机数据碰不到最坏情况」—— 这是随机对拍的第二个盲区在这一章的复现。)
★ 还有一个细节值得记:前四条里,前三条能自证清白,第四条不能。
「它是不是一棵合法的生成树」验证器自己就能查完;
「它是不是最小的那棵」只能靠那个数字,也就是靠 brute.cpp。
第 27、28 章那句「一份方案能自证清白,一个数字不能」,在这一章要反过来用一半。
11★ 对拍与生成器:五个 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 那笔账的同款。)
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 章那条「指数级的底数往往藏在密度里,不在规模里」的第二次。 那一章是简单路径数,这一章是边子集数。)
| 时间 | 空间 | 瓶颈在哪 | |
|---|---|---|---|
| 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)。
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 —— 读入一张图,报告三种写法各自的工作量// ./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;}点「运行 ▶」看结果
为什么稠密图上朴素 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自测
- 洛谷 P3366 【模板】最小生成树解析 → —— 本章模板题。⚠ 它不连通时要求输出 orz,正好对应本章那句 IMPOSSIBLE
- 洛谷 P1546 [USACO3.1] 最短网络解析 → —— ★ 邻接矩阵给的稠密图 —— 正好是本章第 12 步那张表里「朴素 Prim 占优」的那一档
- 洛谷 P1195 口袋的天空解析 → —— ★ 只要连成 k 棵树 —— 那就少合并 k−1 次。做完你会发现 Kruskal 的循环本来就在数这个
- 洛谷 P2820 局域网解析 → —— 要「删掉的边权和最大」—— 换个说法就是「留下的最小」。第 6 步那条恒等式的邻居
- 洛谷 P1547 [USACO05MAR] Out of Hay S解析 → —— ★ 问的是最小生成树里「最长的那条边」。想一想:为什么它一定是所有生成树里最小的「最长边」
- 洛谷 P2872 [USACO07DEC] Building Roads S解析 → —— 进阶:已有一些路(权值当 0)+ 坐标算距离。建图比算法难,正是本章说的「m 会很大」