第 31 章结尾我写了这么两句:
① 第 30 章的 BFS 已经能求最短路了,但那是每条边都一样长的情况。 边一带权,「一圈一圈往外扩」就不成立了 —— 走三条短边可能比走一条长边还近。 ② 把第 31 章那个小根堆原样搬过去,就是「堆优化 Dijkstra」。容器换了,套路没变。
第 3 步兑现第一句(拿一张具体的图,把 8 和 9 这两个数摆出来), 第 6 步兑现第二句(两段代码并排,只有一行不一样)。
而这一章真正的新东西在第 9 步:为什么有负权边就不行。 到那里你会发现,连「不行」这两个字都得说得更小心 —— 不行的不是那份代码,是那句「取出来就定死」。
1一句话问题
给一张有向图:
n个点、m条边,每条写成u v w, 表示「从 u 到 v 有一条路,长w」(1 ≤ w ≤ 100)。再给一个起点s。求 s 到每个点的最短距离;走不到的输出
-1(s 到自己是 0)。⚠ 可能有重边,也可能有自环;⚠ 也可能有从 s 根本走不到的点。
和第 30、31 章一样,这三句限制不是为了刁难:
- 可能有重边 → 逼你在建图时想清楚「两条 u → v 该留哪条」(第 29 章那条);
- 可能有自环 → 边长为正时它对答案毫无影响,但代码里必须经得住它;
- 可能有走不到的点 →
-1那一支必须真的被走到。 ⚠ 而这一句最容易在写生成器时被自己偷偷取消(「有一串 -1 的数据看着不像话」)—— 第 12 步那张表里,它值 51 / 300。
⚠ 还有一句没写在题面里、但同样重要的:起点 s 不一定是 1 号。
第 30 章刚刚在这上面栽过一次,这一章又准备了一份 wrongStart1.cpp 专门盯它。
2手算一遍:7 个点、11 条边,起点是 5 号
7 11 5
5 6 2 ┐
6 7 3 │ 一条链:5 →(2) 6 →(3) 7 →(1) 4 →(2) 1 →(1) 2
7 4 1 │
4 1 2 ┘
5 1 9 ← ★ 一条「抄近路」的长边:5 直接到 1,长 9
1 2 1
7 2 7 ← 另一条通往 2 号的路(5+7 = 12,绕远了)
3 2 4 ← ⚠ 3 号自己有出边,但**没人指向它** → 从 5 号走不到 3 号
2 4 3 ← ⚠ 一条「回指」的边:从远处(9)指回近处(6)
5 6 6 ← ⚠ 重边:5 → 6 已经有一条长 2 的了,这条长 6
4 4 5 ← ⚠ 自环
一步一步往外定(每次挑「已知距离里最小、而且还没定下来的那个」):
| 这一步定死谁 | 它的距离 | 定死之后,谁变近了 |
|---|---|---|
| 5(起点) | 0 | 6 → 2,1 → 9(走那条长边) |
| 6 | 2 | 7 → 5 |
| 7 | 5 | 4 → 6,2 → 12 |
| 4 | 6 | ★ 1 → 8(2+3+1+2,比那条长边的 9 还近!) |
| 1 | 8 | 2 → 9 |
| 2 | 9 | 4 → 9+3 = 12,比 6 大,不动 |
3 号走不到,输出 -1。最终答案:
8 9 -1 6 0 2 5
★ 请把第 4 行那个 8 和 9 记住 —— 上一章欠下的那句「走三条短边可能比一条长边还近」, 在这张图上就是这两个数字。整章有一半的内容都挂在它上面。
3★ 兑现预告①:把上一章那份 BFS 原样搬来,会错在哪
第 30 章的 BFS 求的是「最少几步」。这道题看着就像它,很多人的第一反应是:
把 dist[v] = dist[u] + 1 改成 dist[v] = dist[u] + w 不就行了?
// ✗ 错误版本①:把第 30 章那份 BFS 原样搬来,只把 `+1` 改成 `+w`//// ★ 这一份是**整章的引子**,也是第 31 章章末那句预告的正主://// 第 30 章的 BFS 已经能求最短路了 —— 但那是「每条边都一样长」的情况。// 边一带上权,**一圈一圈往外扩就不成立了。**//// 它和第 30 章那份 BFS 的差别只有一个加号后面的东西://// 第 30 章:dist[v] = dist[u] + 1; // 边数// 这里: dist[v] = dist[u] + w; // 边长//// 其余一字未改:普通队列、**入队时打 vis、每个点只入队一次**。// 而毛病就出在那句「只入队一次」上 —— 它等于宣布:// **谁先被碰到,谁的距离就定死。** BFS 里「先被碰到」= 边数最少,// 可这道题要的是「边长之和最小」,两者根本不是一回事。//// 正文那张默认图就是照着这一点造的:// 起点 5 到 1 号有两条路 —— 一条边直达(长 9),或者绕四条短边(2+3+1+2 = 8)。// BFS 一眼看到那条直达的边,就把 dist[1] 定成了 9。**走三条短边比一条长边近**,// 这句话在这张图上是一个具体的数字:8 和 9。//// ⚠ 而且它在「每条边都一样长」的数据上是**完全正确**的(那时它就是第 30 章的 BFS)。// 所以生成器要是顺手把边权全写成 1,这个 bug 会 300 轮一次都抓不到 ——// 正文第 11 步那张表的第一行就是这么来的。
#include <bits/stdc++.h>using namespace std;
const int INF = 0x3f3f3f3f;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, m, s; if (!(cin >> n >> m >> s)) return 0; vector<vector<pair<int, int>>> g(n + 1); for (int i = 0; i < m; i++) { int u, v, w; cin >> u >> v >> w; g[u].push_back({v, w}); }
vector<int> dist(n + 1, INF), vis(n + 1, 0); queue<int> q; dist[s] = 0; vis[s] = 1; // ✗ 第 30 章的写法:入队时就打 vis q.push(s);
while (!q.empty()) { int u = q.front(); q.pop(); for (auto [v, w] : g[u]) { if (vis[v]) continue; // ✗ 「来过就不再看」—— 距离就此定死 vis[v] = 1; dist[v] = dist[u] + w; // ✗ 只把 +1 换成了 +w,别的什么都没想 q.push(v); } }
for (int i = 1; i <= n; i++) cout << (dist[i] >= INF ? -1 : dist[i]) << " \n"[i == n]; return 0;}点「运行 ▶」看结果
跑出 9 10 -1 6 0 2 5 —— 1 号是 9,2 号是 10,都比正解大了 1。
BFS 里有一句几乎没人多看一眼的话:入队时打 vis,每个点只入队一次。
翻译过来是:
谁先被碰到,谁的距离就定死。
在第 30 章那里这是对的,因为每条边都一样长,「先被碰到」就等于「边数最少」, 而边数最少就是最短。可现在 ——
- 5 号一出队,就顺着那条长边碰到了 1 号,
dist[1]当场定成 9; - 等它绕完
6 → 7 → 4走到 1 号时,那条 8 的路已经没人听了。
★ BFS 排的是「边数」,这道题要的是「边长之和」。 两者一致,只在「每条边都一样长」的时候。
⚠ 顺带记一笔:这也说明边权全相等的数据什么都验不出来 —— 那种数据上这份代码是完全正确的。第 12 步那张表的第一行就是这么来的(0 / 300)。
4标准答案:Floyd —— 一个完全不一样的思路
对拍要的是思路不同的两份代码(第 9 章那条规矩)。这一章挑的是 Floyd:
// 标准答案 —— Floyd:不「一个个确定」,而是「允许中转的点越来越多」//// 输入:第一行 n m s(n 个点、m 条边、起点 s)// 接下来 m 行 `u v w`,表示一条 u → v 的**有向**边,长度 w(1 ≤ w ≤ 100)// ⚠ 可能有重边,也可能有自环,⚠ 也可能有从 s 走不到的点// 输出:一行 n 个数,第 i 个是「s 到 i 的最短距离」;走不到的输出 -1(s 到自己是 0)//// ★ 为什么标准答案挑 Floyd:它和 Dijkstra 的想法**完全不一样**。// Dijkstra 是「每次挑一个点,把它的距离定死」—— 一个贪心;// Floyd 是「d[i][j] 只允许拿前 k 个点当中转站,k 从 0 涨到 n」—— 一个 DP。// 一个贪心、一个 DP,两边不可能一起错(第 9 章那条规矩)。// 而且下一章(第 33 章)正好要讲它,到时候会解释「为什么 k 必须在最外层」。// 这一章先把它当黑盒用:它慢(O(n³)),但它对。//// ⚠ 两个和「重边 / 自环」有关的细节,两个都是坑:// ① 邻接矩阵存图时**必须取 min**(`e[u][v] = min(e[u][v], w)`)。// 写成直接赋值的话,后一条重边会把前一条更短的覆盖掉。// 正文那张默认图特意放了一条更长的重边(5 → 6 长度 6,而前面已经有一条长度 2 的),// 就是为了让这个坑在第一组数据上就现形。// ② 自环(u == v)可以直接扔掉:边长是正的,绕自己一圈只会更远。// 而 e[i][i] 一开始就该是 0。//// ⚠ INF 取 0x3f3f3f3f(约 10.6 亿)而不是 INT_MAX,理由就在下面这一行:// d[i][k] + d[k][j]// 两个 INF 相加 = 21.2 亿,**还没溢出 int**(上限 21.47 亿)。// 换成 INT_MAX 的话这一句当场变成负数,「走不到的点」会被刷出一个负距离,// 而且顺着边传染出去。0x3f3f3f3f 这个惯用法就是为了这一句而存在的。//// ⚠ 尽管如此,三重循环里还是加了「两头都得走得到才松弛」的守卫。为什么:// ★ 正文第 9 步要拿这份代码去当**负权图**的标准答案(那是本章唯一能验负权的东西)。// 一旦有负权边,`INF + (-7)` 就比 INF 小,Floyd 会把「走不到」误当成一条 10.6 亿长的路,// 再顺着它接下去。**这个守卫不是为正权数据加的,是为第 9 步加的。**// (Floyd 在有负权、但没有负环的图上仍然完全正确 —— 这也是第 33 章的内容。)
#include <bits/stdc++.h>using namespace std;
const int INF = 0x3f3f3f3f;
int main() { int n, m, s; if (!(cin >> n >> m >> s)) return 0; vector<vector<int>> d(n + 1, vector<int>(n + 1, INF)); for (int i = 1; i <= n; i++) d[i][i] = 0;
for (int i = 0; i < m; i++) { int u, v, w; cin >> u >> v >> w; if (u == v) continue; // 自环:边长为正,绕一圈只会更远 d[u][v] = min(d[u][v], w); // ★ 重边取 min,不能直接赋值 }
// ★ k 在最外层:「允许拿 1..k 当中转站」是这个 DP 的阶段。第 33 章会讲透为什么。 for (int k = 1; k <= n; k++) for (int i = 1; i <= n; i++) { if (d[i][k] >= INF) continue; // ⚠ 见下面那段注释:这是留给负权那一节的 for (int j = 1; j <= n; j++) if (d[k][j] < INF) d[i][j] = min(d[i][j], d[i][k] + d[k][j]); }
for (int i = 1; i <= n; i++) cout << (d[s][i] >= INF ? -1 : d[s][i]) << " \n"[i == n]; return 0;}点「运行 ▶」看结果
| Dijkstra | Floyd | |
|---|---|---|
| 想法 | 每次挑一个点,把它的距离定死 | d[i][j] 只允许拿前 k 个点当中转站,k 从 0 涨到 n |
| 属于 | 贪心 | DP |
| 复杂度 | O(n²) 或 O(m log n) | O(n³) |
一个贪心、一个 DP,不可能一起错。这一章先把它当黑盒用, 第 33 章会讲透它(尤其是「为什么 k 必须在最外层」)。
① d[u][v] = min(d[u][v], w) —— 重边必须取 min。写成直接赋值的话,
默认那张图里那条 5 6 6 会把前面那条 5 6 2 覆盖掉,答案当场错。
② INF 取 0x3f3f3f3f(约 10.6 亿)而不是 INT_MAX。理由就在三重循环那一行:
d[i][j] = min(d[i][j], d[i][k] + d[k][j]);两个 INF 相加是 21.2 亿,还没溢出 int(上限 21.47 亿)。
换成 INT_MAX 的话这一句当场变成负数,「走不到」会被刷成一个负距离、还会顺着边传染。
★ 0x3f3f3f3f 这个惯用法,整个就是为了这一句而存在的。
5★ 关键一步:取当前最近的那个,它的距离当场就定死
// ★ 这一章的关键一步,最直白的样子 —— 朴素 O(n²) Dijkstra//// 和 brute.cpp 解同一道题,答案必须一模一样。//// ★ 一句话:**每次取「当前最近的、还没定下来的点」,它的距离当场就定死了。**//// 为什么这个贪心是对的(反证法,三句话):// 设 u 是此刻 dist 最小的未确定点。假如真有一条更短的 s → u 的路,// 那条路总要在某个地方**第一次离开「已确定的那堆点」**,设它踏出来的第一个未确定点是 x。// 于是:那条路的长度 ≥ 走到 x 的长度 ≥ dist[x] ≥ dist[u](最后一步因为 u 是当前最小)。// ——「更短」不成立,矛盾。//// ⚠ 请盯住中间那个「≥」:**从 x 走到 u 的那一段长度 ≥ 0**,这一步用掉了// 「边长非负」这个条件,而且**只用在这一处**。// 所以负权边一出现,这个证明当场就断在这里 —— 反例也正好长在这里// (第 20 章那句「交换论证断在哪一步,反例就长在哪里」的第三次登场)。// 正文第 9 步用一张 3 个点的图把它演示出来。//// 复杂度:外层挑 n 次,每次扫 n 个点找最小 → O(n²);再加上每条边松弛一次 O(m)。// 总共 O(n² + m)。// ★ 注意它对 m 完全不敏感 —— 边再多,那个 n² 也一分不多、一分不少。//// ⚠ 教科书上常有一句「稠密图就用朴素 O(n²),稀疏图才用堆优化」。// ★ **这一章没能把它实测出来。** 我拧到 m ≈ n²(3000 个点、900 万条边)时,// 两者仍然是 0.82 秒 vs 0.80 秒,堆优化**还是没输**。// 原因在 count.cpp 数出来的那个数字上:那 900 万条边只带来 **19847 次入堆** ——// 堆优化真正的代价不是「m log n」,而是「**成功松弛的次数** × log n」,// 而随机图上绝大多数边根本刷不动任何人。// (第 29 章那条「口诀要拿实测复核」的第二次。正文第 7 步把这笔账摊开了。)
#include <bits/stdc++.h>using namespace std;
const int INF = 0x3f3f3f3f;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, m, s; if (!(cin >> n >> m >> s)) return 0; vector<vector<pair<int, int>>> g(n + 1); // g[u] = {(v, w), …} for (int i = 0; i < m; i++) { int u, v, w; cin >> u >> v >> w; g[u].push_back({v, w}); // 有向图只存一遍;重边、自环都原样留着 }
vector<int> dist(n + 1, INF), vis(n + 1, 0); dist[s] = 0;
for (int step = 0; step < n; step++) { // ★ 第一步:在**还没定下来的点**里挑距离最小的那个 int u = -1; for (int j = 1; j <= n; j++) if (!vis[j] && (u == -1 || dist[j] < dist[u])) u = j;
// 剩下的点全是 INF —— 从 s 根本走不到它们,可以收工了 if (u == -1 || dist[u] == INF) break;
// ★ 第二步:它的距离从此定死,不会再变 vis[u] = 1;
// 第三步:拿它去「松弛」邻居 —— 经过 u 到 v 会不会更近? for (auto [v, w] : g[u]) if (dist[u] + w < dist[v]) dist[v] = dist[u] + w; }
for (int i = 1; i <= n; i++) cout << (dist[i] >= INF ? -1 : dist[i]) << " \n"[i == n]; return 0;}点「运行 ▶」看结果
整个算法就三句话,一句一行:
int u = -1; // ① 在还没定下来的点里,挑距离最小的
for (int j = 1; j <= n; j++)
if (!vis[j] && (u == -1 || dist[j] < dist[u])) u = j;
vis[u] = 1; // ② ★ 它的距离从此定死
for (auto [v, w] : g[u]) // ③ 拿它去松弛邻居
if (dist[u] + w < dist[v]) dist[v] = dist[u] + w;
设 u 是此刻 dist 最小的未确定点。假如真有一条更短的 s → u 的路,那么:
- 那条路总要在某处第一次离开「已经定死的那堆点」,设它踏出来踩到的第一个未确定点是 x;
- 于是 那条路的长度 ≥ 走到 x 的那一段 ≥ dist[x] ≥ dist[u] (最后一个 ≥ 是因为 u 是当前最小的);
- ——「更短」不成立。矛盾。
★ 现在请盯住第一个 ≥:它说的是「从 x 接着走到 u 的那一段 ≥ 0」。
这一步用掉了「边长非负」这个条件,而且全程只用在这一处。
记住这句话。第 9 步会把它拆掉,然后你会看到反例正好长在这个位置上 —— 第 20 章那句「交换论证断在哪一步,反例就长在哪里」,这是它第三次登场。
dist[v] = dist[u] + w 前面那个 if 不是可有可无的检查,它就是「松弛」两个字本身:
如果经过 u 更近,就把 v 的距离「放松」到这个更小的值。
它天生是单向的:只许变小,不许变大。
把那个 if 去掉,就是第 10 步那个 wrongRelax.cpp —— 而它在默认那张图上错得相当难看。
6★ 兑现预告②:把第 31 章的小根堆原样搬过来
朴素版慢在第 ① 句:为了找一个最小值,扫了 n 个点。而「不断加进来、每次取最小」—— 这不正是上一章那个小根堆干的活吗?
// 堆优化 Dijkstra —— ★ 把第 31 章那个小根堆**原样搬过来**就是它//// 和 naive.cpp 是同一个算法、同一个贪心,只换了一件事:// 「在还没定下来的点里找距离最小的那个」这件事,// naive.cpp 是每次**扫一遍 n 个点**(O(n)),这里交给**小根堆**(O(log n))。//// ★ 第 31 章章末那句预告,就在下面这一行兑现://// priority_queue<pair<int,int>, vector<pair<int,int>>, greater<>> q;//// 第 31 章的拓扑排序里写的是 priority_queue<int, vector<int>, greater<int>>。// **容器一个字没变,变的是往里放什么:**//// 第 31 章:堆里放**编号** → 每次取出「编号最小」的点// 这一章: 堆里放 **(距离, 编号)** → 每次取出「距离最小」的点//// pair 的比较是先比第一维,所以把距离放在前面,堆就按距离排。// ⚠ 只搬容器、忘了改放进去的东西,就是 wrongHeapId.cpp 那个错误版本 ——// 它每次取的还是「编号最小」,于是编号小的点被过早定死。//// ★ 关于那句 `if (d > dist[u]) continue;`(「懒删除」):// 一个点的距离在被确定之前可能被松弛好几次,每次都往堆里塞一个新的 (距离, 编号)。// 我们不去堆里把旧的删掉(标准堆做不到),而是等它出来的时候看一眼:// **它带的距离比现在记录的还大,说明它是过期的,直接扔掉。**// 这一句同时也充当了 naive.cpp 里 vis[] 的角色 —— 一个点真正被处理只会有一次。//// 复杂度:每条边最多往堆里塞一次 → 堆里最多 O(m) 个元素 → O(m log m),通常写作 O(m log n)。// ⚠ 但 `m log n` 是个**很松的上界**:一条边只有在「真的把某个点刷小了」的时候才入堆。// 实测(正文第 7 步):3000 个点、900 万条边的图上,入堆只发生了 **19847** 次 ——// 不到边数的千分之三。所以「稠密图上堆优化会输给 O(n²)」这句常见的话,// 在随机数据上**我没能复现出来**(0.80 秒 vs 朴素的 0.82 秒)。// 第 29 章那句「口诀要拿实测复核,别默认它到处成立」在这一章又应验了一次。
#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, s; if (!(cin >> n >> m >> s)) return 0; vector<vector<PII>> g(n + 1); // g[u] = {(v, w), …} for (int i = 0; i < m; i++) { int u, v, w; cin >> u >> v >> w; g[u].push_back({v, w}); }
vector<int> dist(n + 1, INF); priority_queue<PII, vector<PII>, greater<PII>> q; // ★ 第 31 章那个小根堆,原样搬来 dist[s] = 0; q.push({0, s});
while (!q.empty()) { auto [d, u] = q.top(); q.pop(); if (d > dist[u]) continue; // ★ 过期的那一份,扔掉(它同时充当 vis) for (auto [v, w] : g[u]) if (d + w < dist[v]) { dist[v] = d + w; q.push({dist[v], v}); // 距离变小了才重新入堆 } }
for (int i = 1; i <= n; i++) cout << (dist[i] >= INF ? -1 : dist[i]) << " \n"[i == n]; return 0;}点「运行 ▶」看结果
// 第 31 章(拓扑排序)
priority_queue<int, vector<int>, greater<int>> q; // 堆里放:编号
q.push(v); // 入堆条件:入度减到 0
// 这一章(Dijkstra)
priority_queue<PII, vector<PII>, greater<PII>> q; // 堆里放:(距离, 编号)
q.push({dist[v], v}); // 入堆条件:距离变小了同一个容器、同一个 greater<>、同一套写法。 变的只有两件事:
| 第 31 章 | 这一章 | |
|---|---|---|
| 堆里放什么 | 编号 | ★ (距离, 编号) |
| 什么时候入堆 | 入度减到 0 | 距离变小了 |
pair 的比较是先比第一维,所以把距离放在前面,堆就按距离排 —— 就这么一件事。
⚠ 只搬容器、忘了改放进去的东西,就是第 10 步那个 wrongHeapId.cpp。
一个点的距离在被定死之前可能被刷小好几次,每次都往堆里塞一份新的 (距离, 编号)。
标准堆没法把旧的那份删掉,所以我们等它出来的时候再看一眼:
它带的距离比现在记录的还大 → 它是过期的 → 直接扔掉。
这一句同时也顶替了朴素版里 vis[] 的角色:一个点真正被处理,只会有一次。
★ 请把这句话记牢,第 9 步会拿它做文章 —— 那时你会发现, 正是这一句,让这份代码在负权图上「不小心」变成了另一个算法。
7实测:朴素 O(n²) 和堆优化,到底差多少
本机实测(./genBig <n>,边数取 2n,固定种子,只能在终端里跑 ——
十几 MB 的数据传不进网页那个小工具):
g++ -O2 -std=c++17 -o genBig genBig.cpp && g++ -O2 -std=c++17 -o naive naive.cpp
g++ -O2 -std=c++17 -o fast fast.cpp
./genBig 40000 > big.txt
time ./naive < big.txt > /dev/null # 3.5 秒
time ./fast < big.txt > /dev/null # 0.02 秒
| 命令 | 点数 n | 边数 | 朴素 O(n²) | 堆优化 |
|---|---|---|---|---|
./genBig 10000 |
1 万 | 29 999 | 0.16 秒 | 0.00 秒 |
./genBig 20000 |
2 万 | 59 999 | 0.90 秒 | 0.01 秒 |
./genBig 40000 |
4 万 | 119 999 | 3.53 秒 | 0.02 秒 |
./genBig 80000 |
8 万 | 239 999 | 13.56 秒 | 0.05 秒 |
./genBig 160000 |
16 万 | 479 999 | 56.63 秒 | 0.18 秒 |
★ n 翻一倍,朴素慢四倍,堆优化只慢一倍。 16 万个点时差了三百多倍。
常见的说法是:朴素是 O(n²)、对边数完全不敏感,而堆优化是 O(m log n);
所以 m 接近 n² 的稠密图上,朴素反而更快。
我本来是打算把这张表做出来的。做不出来。
(./genBig <n> <m>,第二个参数就是边数,同样只能在终端跑:)
| 点数 | 边数 | 朴素 | 堆优化 |
|---|---|---|---|
| 3000 | 30 万 | 0.03 秒 | 0.02 秒 |
| 3000 | 100 万 | 0.09 秒 | 0.08 秒 |
| 3000 | 300 万 | 0.27 秒 | 0.26 秒 |
| 3000 | 900 万(≈ n²) | 0.86 秒 | 0.79 秒 |
一直拧到 m ≈ n²,堆优化还是没输过。为什么?count.cpp 一跑就明白了:
// 把两种写法的「工作量」数出来 —— 光看秒表说不清谁在做什么//// 用法:./count < 输入//// 它把 naive.cpp 和 fast.cpp 各跑一遍,同时数三件事:// · 朴素版「找当前最近的未确定点」总共扫过多少个格子 —— 这就是那个 n²// · 堆优化版往堆里塞了多少次、取出多少次、其中多少次是过期的 —— 这就是那个 m log n// · 两边的答案(★ 在正权图上必须一模一样)//// ★ 它有两个用处,第二个才是真正值钱的://// ① 正权图上:让 n² 和 m 这两个数字**当场可见**,// 于是「稀疏图该用堆、稠密图未必」这句话不再是口诀,而是两个数的大小比较。//// ② 负权图上(正文第 9 步):★ **入堆次数会失控。**// 堆优化版靠的是「距离变小了就重新入堆」,正权时每个点最多被真正处理一次;// 一旦有负权边,一个已经处理过的点可能被反复刷小、反复入堆,// 这个数字就从「≈ 点数」涨上去。// **它证明了一件事:那份代码在负权图上就算给出了正确答案,它也已经不是 Dijkstra 了。**//// ⚠ 所以这里必须有个刹车:有负环时「距离变小」可以永远进行下去,// 堆优化版根本停不下来。POP_LIMIT 就是那个刹车 —— 撞上它本身就是一条结论。
#include <bits/stdc++.h>using namespace std;
const int INF = 0x3f3f3f3f;typedef pair<int, int> PII;
int main() { int n, m, s; if (scanf("%d %d %d", &n, &m, &s) != 3) return 0; vector<vector<PII>> g(n + 1); for (int i = 0; i < m; i++) { int u, v, w; if (scanf("%d %d %d", &u, &v, &w) != 3) break; g[u].push_back({v, w}); }
/* ---------- 朴素 O(n²) ---------- */ long long scanCells = 0, relaxTry = 0; vector<int> d1(n + 1, INF), vis(n + 1, 0); d1[s] = 0; for (int step = 0; step < n; step++) { int u = -1; for (int j = 1; j <= n; j++) { scanCells++; // ★ 这一行加起来就是那个 n² if (!vis[j] && (u == -1 || d1[j] < d1[u])) u = j; } if (u == -1 || d1[u] == INF) break; vis[u] = 1; for (auto [v, w] : g[u]) { relaxTry++; if (d1[u] + w < d1[v]) d1[v] = d1[u] + w; } }
/* ---------- 堆优化 ---------- */ const long long POP_LIMIT = 20LL * (n + m) + 1000; // ⚠ 有负环时的刹车 long long pushes = 0, pops = 0, stale = 0, settled = 0, relaxOk = 0; bool overflow = false; vector<int> d2(n + 1, INF); priority_queue<PII, vector<PII>, greater<PII>> q; d2[s] = 0; q.push({0, s}); pushes++; while (!q.empty()) { auto [d, u] = q.top(); q.pop(); pops++; if (pops > POP_LIMIT) { overflow = true; break; } if (d > d2[u]) { stale++; continue; } settled++; // ★ 真正被「处理」的一次 for (auto [v, w] : g[u]) if (d + w < d2[v]) { d2[v] = d + w; q.push({d2[v], v}); pushes++; relaxOk++; } }
/* ---------- 报告 ---------- */ printf("n = %d 个点,m = %d 条边,起点 s = %d\n\n", n, m, s);
printf("朴素 O(n^2):找最小值一共扫过 %lld 个点(n*n = %d),松弛尝试 %lld 次\n", scanCells, n * n, relaxTry); if (overflow) printf("堆优化 :★ 出堆超过 %lld 次还没停 —— 这张图里有**负环**,它根本停不下来\n", POP_LIMIT); else printf("堆优化 :入堆 %lld 次,出堆 %lld 次(%lld 次是过期的),真正处理 %lld 次,松弛成功 %lld 次\n", pushes, pops, stale, settled, relaxOk);
if (!overflow) { printf("\n"); // ⚠ 这里比的是「真正处理的次数」,不是入堆次数 —— // 入堆次数在正权图上本来就可以超过点数(一个点的距离被刷小几次就入堆几次)。 // 我第一版拿入堆次数来判,默认那张正权图上就打了自己的脸(入堆 8 > 点数 7)。 // ★ Dijkstra 保证的从来不是「只入堆一次」,而是「**只被处理一次**」。 if (settled > (long long)n) printf("★ 真正处理了 %lld 次 > 点数 %d —— 有点被处理了不止一次。\n" " 「取出来的那个,距离就定死了」这句话在这张图上**已经不成立**。\n", settled, n); else printf("★ 真正处理了 %lld 次 ≤ 点数 %d —— 每个点最多处理一次,这正是「定死了就不再变」。\n" " (入堆 %lld 次可以比点数多:一个点的距离被刷小几次,就重新入堆几次。)\n", settled, n, pushes); }
printf("\n朴素版的答案:"); for (int i = 1; i <= n; i++) printf("%d%c", d1[i] >= INF ? -1 : d1[i], i == n ? '\n' : ' '); if (!overflow) { printf("堆优化的答案:"); for (int i = 1; i <= n; i++) printf("%d%c", d2[i] >= INF ? -1 : d2[i], i == n ? '\n' : ' '); bool same = true; for (int i = 1; i <= n; i++) if ((d1[i] >= INF ? -1 : d1[i]) != (d2[i] >= INF ? -1 : d2[i])) same = false; printf("\n%s\n", same ? "两边一致。" : "★ 两边不一致!同一个算法的两种写法给出了不同的答案 —— 说明这张图上「Dijkstra」这三个字已经不成立了。"); } return 0;}点「运行 ▶」看结果
★ 实测的入堆次数(这几个数字都钉在 check:viz 里):
| 图 | 边数 | 入堆次数 |
|---|---|---|
./genBig 2000 200000 |
20 万 | 8 385 |
./genBig 2000 2000000 |
200 万 | 12 251 |
./genBig 3000 9000000 |
900 万 | 19 847 |
边数翻了十倍,入堆次数只涨了四成。
原因很简单,但很容易被 O(m log n) 这个记号盖住:
一条边只有在「真的把某个点刷小了」的时候才入堆。
随机图上绝大多数边刚看一眼就被 if 挡回去了,m log n 是个非常松的上界。
而朴素版那个 n² 是结结实实的 n²,一次都省不掉。
★ 口诀要拿实测复核,别默认它到处成立。 第 29 章刚在「链式前向星常数最小」上栽过一次(稠密图上它慢四倍),这是第二次。 ⚠ 但也别把我的结论反过来当口诀背:
m log n那个上界是能被吃满的, 只是要专门构造数据。我说的是「随机数据上复现不出来」,不是「它不存在」。
8动画:一圈一圈地把点定死
点上面的数字是「目前知道的最短距离」(∞ = 还没碰到过),绿色表示已经定死。 右边那个大数字是 ★ 已经定死的点数 —— 正权图上它最多涨到「走得到的点数」, 一个点只定死一次。
下拉框里四个错误版本第 10 步逐个讲。 ★ 现在先切一下前两个(BFS / 堆里放编号)—— 它们在这张图上给出一模一样的错答案。
9 10 -1 6 0 2 5:一个是按边数排的,一个是按编号排的,
可它们的共同点是 ——「没按距离排」。
这也顺带演示了第 20 章那个盲区的另一面: 对拍看到两份程序答案相同,并不等于它们都对。 它们只是错在同一个地方。
9★ 为什么有负权边就不行 —— 这一章真正的新东西
回到第 5 步那个证明。它一共用了三个 ≥,而「边长非负」只在第一个里出现过一次:
那条路的长度 ≥ 走到 x 的那一段 ≥ dist[x] ≥ dist[u]
↑
这里用掉了「x 走到 u 的那一段 ≥ 0」
把这一处拆掉,反例就该长在这里。四个点就够了:
4 4 1
1 2 3 ← 1 到 2 有一条直达的,长 3
1 3 5 ← 1 到 3 长 5
3 2 -4 ← ★ 3 到 2 是 -4
2 4 1
真相:到 2 号最近的是 1 → 3 → 2 = 5 − 4 = 1,于是到 4 号是 2。
可朴素 Dijkstra 一上来就把 2 号定死在 3(因为 3 < 5)——
// ★ 这一章的关键一步,最直白的样子 —— 朴素 O(n²) Dijkstra//// 和 brute.cpp 解同一道题,答案必须一模一样。//// ★ 一句话:**每次取「当前最近的、还没定下来的点」,它的距离当场就定死了。**//// 为什么这个贪心是对的(反证法,三句话):// 设 u 是此刻 dist 最小的未确定点。假如真有一条更短的 s → u 的路,// 那条路总要在某个地方**第一次离开「已确定的那堆点」**,设它踏出来的第一个未确定点是 x。// 于是:那条路的长度 ≥ 走到 x 的长度 ≥ dist[x] ≥ dist[u](最后一步因为 u 是当前最小)。// ——「更短」不成立,矛盾。//// ⚠ 请盯住中间那个「≥」:**从 x 走到 u 的那一段长度 ≥ 0**,这一步用掉了// 「边长非负」这个条件,而且**只用在这一处**。// 所以负权边一出现,这个证明当场就断在这里 —— 反例也正好长在这里// (第 20 章那句「交换论证断在哪一步,反例就长在哪里」的第三次登场)。// 正文第 9 步用一张 3 个点的图把它演示出来。//// 复杂度:外层挑 n 次,每次扫 n 个点找最小 → O(n²);再加上每条边松弛一次 O(m)。// 总共 O(n² + m)。// ★ 注意它对 m 完全不敏感 —— 边再多,那个 n² 也一分不多、一分不少。//// ⚠ 教科书上常有一句「稠密图就用朴素 O(n²),稀疏图才用堆优化」。// ★ **这一章没能把它实测出来。** 我拧到 m ≈ n²(3000 个点、900 万条边)时,// 两者仍然是 0.82 秒 vs 0.80 秒,堆优化**还是没输**。// 原因在 count.cpp 数出来的那个数字上:那 900 万条边只带来 **19847 次入堆** ——// 堆优化真正的代价不是「m log n」,而是「**成功松弛的次数** × log n」,// 而随机图上绝大多数边根本刷不动任何人。// (第 29 章那条「口诀要拿实测复核」的第二次。正文第 7 步把这笔账摊开了。)
#include <bits/stdc++.h>using namespace std;
const int INF = 0x3f3f3f3f;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, m, s; if (!(cin >> n >> m >> s)) return 0; vector<vector<pair<int, int>>> g(n + 1); // g[u] = {(v, w), …} for (int i = 0; i < m; i++) { int u, v, w; cin >> u >> v >> w; g[u].push_back({v, w}); // 有向图只存一遍;重边、自环都原样留着 }
vector<int> dist(n + 1, INF), vis(n + 1, 0); dist[s] = 0;
for (int step = 0; step < n; step++) { // ★ 第一步:在**还没定下来的点**里挑距离最小的那个 int u = -1; for (int j = 1; j <= n; j++) if (!vis[j] && (u == -1 || dist[j] < dist[u])) u = j;
// 剩下的点全是 INF —— 从 s 根本走不到它们,可以收工了 if (u == -1 || dist[u] == INF) break;
// ★ 第二步:它的距离从此定死,不会再变 vis[u] = 1;
// 第三步:拿它去「松弛」邻居 —— 经过 u 到 v 会不会更近? for (auto [v, w] : g[u]) if (dist[u] + w < dist[v]) dist[v] = dist[u] + w; }
for (int i = 1; i <= n; i++) cout << (dist[i] >= INF ? -1 : dist[i]) << " \n"[i == n]; return 0;}点「运行 ▶」看结果
跑出 0 1 5 4,而正确答案是 0 1 5 2。
★ 注意 2 号那一位它是对的(1)—— 因为后来那次松弛确实把 dist[2] 改小了。
错的是 4 号:dist[2] 被改小的时候,2 号早已定死出场,
没有人再拿新的 1 去更新它的下游。
|这条路| ≥ |走到 x 的那段| ≥ dist[x] ≥ dist[u]
⚠ 第一个 ≥ 用掉的正是「x 到 u 那一段 ≥ 0」,也就是边长非负 —— 全程只在这一处用到它。所以负权边一来,断的就是这一处。
这个动画每定死一个点,就把「起点到它的所有简单路径」暴力枚举一遍,当场对质。 正权那张图上一次都推不翻;按一下按钮换成负权那张,第 2 步就被推翻, 而且推翻它的那条路上那条负权边会被标红 —— 那就是证明断掉的地方。
现在把同一张负权图喂给堆优化那一份:
// 堆优化 Dijkstra —— ★ 把第 31 章那个小根堆**原样搬过来**就是它//// 和 naive.cpp 是同一个算法、同一个贪心,只换了一件事:// 「在还没定下来的点里找距离最小的那个」这件事,// naive.cpp 是每次**扫一遍 n 个点**(O(n)),这里交给**小根堆**(O(log n))。//// ★ 第 31 章章末那句预告,就在下面这一行兑现://// priority_queue<pair<int,int>, vector<pair<int,int>>, greater<>> q;//// 第 31 章的拓扑排序里写的是 priority_queue<int, vector<int>, greater<int>>。// **容器一个字没变,变的是往里放什么:**//// 第 31 章:堆里放**编号** → 每次取出「编号最小」的点// 这一章: 堆里放 **(距离, 编号)** → 每次取出「距离最小」的点//// pair 的比较是先比第一维,所以把距离放在前面,堆就按距离排。// ⚠ 只搬容器、忘了改放进去的东西,就是 wrongHeapId.cpp 那个错误版本 ——// 它每次取的还是「编号最小」,于是编号小的点被过早定死。//// ★ 关于那句 `if (d > dist[u]) continue;`(「懒删除」):// 一个点的距离在被确定之前可能被松弛好几次,每次都往堆里塞一个新的 (距离, 编号)。// 我们不去堆里把旧的删掉(标准堆做不到),而是等它出来的时候看一眼:// **它带的距离比现在记录的还大,说明它是过期的,直接扔掉。**// 这一句同时也充当了 naive.cpp 里 vis[] 的角色 —— 一个点真正被处理只会有一次。//// 复杂度:每条边最多往堆里塞一次 → 堆里最多 O(m) 个元素 → O(m log m),通常写作 O(m log n)。// ⚠ 但 `m log n` 是个**很松的上界**:一条边只有在「真的把某个点刷小了」的时候才入堆。// 实测(正文第 7 步):3000 个点、900 万条边的图上,入堆只发生了 **19847** 次 ——// 不到边数的千分之三。所以「稠密图上堆优化会输给 O(n²)」这句常见的话,// 在随机数据上**我没能复现出来**(0.80 秒 vs 朴素的 0.82 秒)。// 第 29 章那句「口诀要拿实测复核,别默认它到处成立」在这一章又应验了一次。
#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, s; if (!(cin >> n >> m >> s)) return 0; vector<vector<PII>> g(n + 1); // g[u] = {(v, w), …} for (int i = 0; i < m; i++) { int u, v, w; cin >> u >> v >> w; g[u].push_back({v, w}); }
vector<int> dist(n + 1, INF); priority_queue<PII, vector<PII>, greater<PII>> q; // ★ 第 31 章那个小根堆,原样搬来 dist[s] = 0; q.push({0, s});
while (!q.empty()) { auto [d, u] = q.top(); q.pop(); if (d > dist[u]) continue; // ★ 过期的那一份,扔掉(它同时充当 vis) for (auto [v, w] : g[u]) if (d + w < dist[v]) { dist[v] = d + w; q.push({dist[v], v}); // 距离变小了才重新入堆 } }
for (int i = 1; i <= n; i++) cout << (dist[i] >= INF ? -1 : dist[i]) << " \n"[i == n]; return 0;}点「运行 ▶」看结果
0 1 5 2 —— 它是对的。 而且不是碰巧:genNeg.cpp 造的 300 组负权数据里,
| 和 Floyd 不一致的轮数 | |
|---|---|
| 朴素 O(n²) | 35 / 300(第 3 轮就抓到) |
| 堆优化 | 0 / 300 |
★ 所以「Dijkstra 对负权不成立」这句话,准确的说法是:
不成立的是那句「取出来的一刻,它的距离就定死」。 而堆优化那份代码,早就不遵守这句话了。
回头看第 6 步那句 if (d > dist[u]) continue;:它扔掉的只是过期的记录。
一个点被刷小之后会带着新距离重新入堆、重新被处理 ——
于是它悄悄退化成了「优先队列版的 Bellman-Ford」(第 33 章的内容)。
count.cpp 把这件事量了出来:
// 把两种写法的「工作量」数出来 —— 光看秒表说不清谁在做什么//// 用法:./count < 输入//// 它把 naive.cpp 和 fast.cpp 各跑一遍,同时数三件事:// · 朴素版「找当前最近的未确定点」总共扫过多少个格子 —— 这就是那个 n²// · 堆优化版往堆里塞了多少次、取出多少次、其中多少次是过期的 —— 这就是那个 m log n// · 两边的答案(★ 在正权图上必须一模一样)//// ★ 它有两个用处,第二个才是真正值钱的://// ① 正权图上:让 n² 和 m 这两个数字**当场可见**,// 于是「稀疏图该用堆、稠密图未必」这句话不再是口诀,而是两个数的大小比较。//// ② 负权图上(正文第 9 步):★ **入堆次数会失控。**// 堆优化版靠的是「距离变小了就重新入堆」,正权时每个点最多被真正处理一次;// 一旦有负权边,一个已经处理过的点可能被反复刷小、反复入堆,// 这个数字就从「≈ 点数」涨上去。// **它证明了一件事:那份代码在负权图上就算给出了正确答案,它也已经不是 Dijkstra 了。**//// ⚠ 所以这里必须有个刹车:有负环时「距离变小」可以永远进行下去,// 堆优化版根本停不下来。POP_LIMIT 就是那个刹车 —— 撞上它本身就是一条结论。
#include <bits/stdc++.h>using namespace std;
const int INF = 0x3f3f3f3f;typedef pair<int, int> PII;
int main() { int n, m, s; if (scanf("%d %d %d", &n, &m, &s) != 3) return 0; vector<vector<PII>> g(n + 1); for (int i = 0; i < m; i++) { int u, v, w; if (scanf("%d %d %d", &u, &v, &w) != 3) break; g[u].push_back({v, w}); }
/* ---------- 朴素 O(n²) ---------- */ long long scanCells = 0, relaxTry = 0; vector<int> d1(n + 1, INF), vis(n + 1, 0); d1[s] = 0; for (int step = 0; step < n; step++) { int u = -1; for (int j = 1; j <= n; j++) { scanCells++; // ★ 这一行加起来就是那个 n² if (!vis[j] && (u == -1 || d1[j] < d1[u])) u = j; } if (u == -1 || d1[u] == INF) break; vis[u] = 1; for (auto [v, w] : g[u]) { relaxTry++; if (d1[u] + w < d1[v]) d1[v] = d1[u] + w; } }
/* ---------- 堆优化 ---------- */ const long long POP_LIMIT = 20LL * (n + m) + 1000; // ⚠ 有负环时的刹车 long long pushes = 0, pops = 0, stale = 0, settled = 0, relaxOk = 0; bool overflow = false; vector<int> d2(n + 1, INF); priority_queue<PII, vector<PII>, greater<PII>> q; d2[s] = 0; q.push({0, s}); pushes++; while (!q.empty()) { auto [d, u] = q.top(); q.pop(); pops++; if (pops > POP_LIMIT) { overflow = true; break; } if (d > d2[u]) { stale++; continue; } settled++; // ★ 真正被「处理」的一次 for (auto [v, w] : g[u]) if (d + w < d2[v]) { d2[v] = d + w; q.push({d2[v], v}); pushes++; relaxOk++; } }
/* ---------- 报告 ---------- */ printf("n = %d 个点,m = %d 条边,起点 s = %d\n\n", n, m, s);
printf("朴素 O(n^2):找最小值一共扫过 %lld 个点(n*n = %d),松弛尝试 %lld 次\n", scanCells, n * n, relaxTry); if (overflow) printf("堆优化 :★ 出堆超过 %lld 次还没停 —— 这张图里有**负环**,它根本停不下来\n", POP_LIMIT); else printf("堆优化 :入堆 %lld 次,出堆 %lld 次(%lld 次是过期的),真正处理 %lld 次,松弛成功 %lld 次\n", pushes, pops, stale, settled, relaxOk);
if (!overflow) { printf("\n"); // ⚠ 这里比的是「真正处理的次数」,不是入堆次数 —— // 入堆次数在正权图上本来就可以超过点数(一个点的距离被刷小几次就入堆几次)。 // 我第一版拿入堆次数来判,默认那张正权图上就打了自己的脸(入堆 8 > 点数 7)。 // ★ Dijkstra 保证的从来不是「只入堆一次」,而是「**只被处理一次**」。 if (settled > (long long)n) printf("★ 真正处理了 %lld 次 > 点数 %d —— 有点被处理了不止一次。\n" " 「取出来的那个,距离就定死了」这句话在这张图上**已经不成立**。\n", settled, n); else printf("★ 真正处理了 %lld 次 ≤ 点数 %d —— 每个点最多处理一次,这正是「定死了就不再变」。\n" " (入堆 %lld 次可以比点数多:一个点的距离被刷小几次,就重新入堆几次。)\n", settled, n, pushes); }
printf("\n朴素版的答案:"); for (int i = 1; i <= n; i++) printf("%d%c", d1[i] >= INF ? -1 : d1[i], i == n ? '\n' : ' '); if (!overflow) { printf("堆优化的答案:"); for (int i = 1; i <= n; i++) printf("%d%c", d2[i] >= INF ? -1 : d2[i], i == n ? '\n' : ' '); bool same = true; for (int i = 1; i <= n; i++) if ((d1[i] >= INF ? -1 : d1[i]) != (d2[i] >= INF ? -1 : d2[i])) same = false; printf("\n%s\n", same ? "两边一致。" : "★ 两边不一致!同一个算法的两种写法给出了不同的答案 —— 说明这张图上「Dijkstra」这三个字已经不成立了。"); } return 0;}点「运行 ▶」看结果
4 个点,却被真正处理了 6 次。 一个点被处理不止一次, 「定死」这两个字在这张图上就已经不成立了。
⚠ 而且它换来的正确答案是有代价的:最坏情况下入堆次数可以爆炸,
碰上负环更是根本停不下来(count.cpp 里那个 POP_LIMIT 就是为此准备的刹车)。
负权的正经办法在第 33 章:Bellman-Ford / SPFA,外加判负环。
这一节有个绕不过去的技术问题:有负环的话最短路根本不存在 (绕着环走一圈更短,可以无限短),Floyd 也给不出答案,那就什么都验不了。
办法叫势函数(第 33 章讲 Johnson 算法时会正式登场):
- 先给每个点随机一个「势」
h[v]; - 造边时先随机一个非负的
w0,再令w(u → v) = w0 + h[u] − h[v]。
于是任意一个环上,所有的 h 首尾相消(望远镜求和):
Σ w = Σ w0 + (h[环起点] − h[环起点]) = Σ w0 ≥ 0每个环的总长都等于它那些 w0 的和,一定非负 —— 绝不可能有负环。
可单条边的 w 完全可以是负的(h[u] 小、h[v] 大的时候)。
实测:这样造出来的 5288 条边里有 1910 条是负的,而 Floyd 每一轮都给得出答案。
w0 的上限就是「负权浓度」的旋钮,也调过(./genNeg 种子 上限):
w0 上限 |
负权边 | 朴素 Dijkstra 错的轮数 |
|---|---|---|
| 16 | 959 / 5288 | 16(第 33 轮才第一次抓到) |
| 11 | 1322 / 5288 | 26(第 3 轮) |
| 6 | 1699 / 5288 | 32(第 1 轮) |
| 4(在用) | 1910 / 5288 | 35(第 3 轮) |
10六种把它写错的方式
前两种第 3 步和第 6 步已经见过了,这里只补上它们在默认图上的输出,然后看后四种。
// ✗ 错误版本②:小根堆搬对了,**往里放的东西搬错了**//// ★ 这一份是 fast.cpp 的影子。第 31 章章末说「把那个小根堆原样搬过来就是堆优化 Dijkstra」——// 这份代码就是把那句话**照字面**执行的结果://// 第 31 章:priority_queue<int, vector<int>, greater<int>> q; // 堆里放编号// 这里: priority_queue<int, vector<int>, greater<int>> q; // ✗ 一个字都没改// fast.cpp:priority_queue<PII, vector<PII>, greater<PII>> q; // ✓ 堆里放 (距离, 编号)//// 于是它每次取出的是「**编号**最小的点」,而不是「**距离**最小的点」——// 而 Dijkstra 的整个正确性都押在「取出来的那个必须是距离最小的」上面。// 编号小的点被过早地定死,它后面那一串就跟着一起错。//// ⚠ 它有一半是对的:松弛照做、dist 也会被后来更好的值更新// (正文默认图上 4 号点的距离就被事后修正过一次)。// 但**已经出过堆的点不会再往下传播**,所以错误留在了它的下游。// 一个「只错一部分」的版本,比全错的更难发现 —— 这正是要拿它对拍的理由。//// ⚠ 还有一个「顺手」会让它隐身:如果生成器造出来的图,编号顺序恰好和距离顺序一致// (比如起点固定 1 号、边只连编号小 → 大),它就是对的。第 27、31 章那条// 「别让编号自己带上一层题目没给的含义」在这一章仍然有效。
#include <bits/stdc++.h>using namespace std;
const int INF = 0x3f3f3f3f;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, m, s; if (!(cin >> n >> m >> s)) return 0; vector<vector<pair<int, int>>> g(n + 1); for (int i = 0; i < m; i++) { int u, v, w; cin >> u >> v >> w; g[u].push_back({v, w}); }
vector<int> dist(n + 1, INF), vis(n + 1, 0); priority_queue<int, vector<int>, greater<int>> q; // ✗ 堆里放的是**编号** dist[s] = 0; q.push(s);
while (!q.empty()) { int u = q.top(); // ✗ 取出来的是编号最小的,不是距离最小的 q.pop(); if (vis[u]) continue; vis[u] = 1; for (auto [v, w] : g[u]) if (dist[u] + w < dist[v]) { dist[v] = dist[u] + w; q.push(v); } }
for (int i = 1; i <= n; i++) cout << (dist[i] >= INF ? -1 : dist[i]) << " \n"[i == n]; return 0;}点「运行 ▶」看结果
跑出 9 10 -1 6 0 2 5 —— 和「拿 BFS 当最短路」一模一样。 它每次取的是编号最小的点,于是 1 号(距离本该是 8)被过早定死在 9, 它的下游 2 号跟着一起错。
★ 它有一半是对的:dist[1] 后来确实被改回了 8(松弛照做),
但已经出过堆的点不会再往下传播。
⚠ 「只错一部分」的版本,比全错的更难发现 —— 这正是要拿它对拍的理由。
// ✗ 错误版本③:找最小值的时候,忘了排除「已经定下来的点」//// 和 naive.cpp 只差三个字符://// naive.cpp: if (!vis[j] && (u == -1 || dist[j] < dist[u])) u = j;// 这里: if ( u == -1 || dist[j] < dist[u] ) u = j; // ✗ 少了 !vis[j]//// 后果比想象的严重得多:起点的 dist 是 0,是全场最小的,// 所以**每一轮挑出来的都是起点**。松弛 n 遍起点的出边,图上其余部分根本没被碰过。// 最后只有「起点的直接邻居」有距离,别的全是 -1。//// ★ 它错得这么彻底,是件好事 —— 对拍第 1 轮就会抓到它。// 真正难缠的是 wrongHeapId 那种「只错一部分」的。把两种都留着,// 是为了让学生看见「bug 的可见度」本身就有天壤之别。//// ⚠ 顺带说一句它为什么不会死循环:外层是 `for (step < n)` 的固定 n 轮,不是 while。// 要是写成「一直挑到没有未确定的点为止」,这一份就会转不出来了。
#include <bits/stdc++.h>using namespace std;
const int INF = 0x3f3f3f3f;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, m, s; if (!(cin >> n >> m >> s)) return 0; vector<vector<pair<int, int>>> g(n + 1); for (int i = 0; i < m; i++) { int u, v, w; cin >> u >> v >> w; g[u].push_back({v, w}); }
vector<int> dist(n + 1, INF), vis(n + 1, 0); dist[s] = 0;
for (int step = 0; step < n; step++) { int u = -1; for (int j = 1; j <= n; j++) if (u == -1 || dist[j] < dist[u]) u = j; // ✗ 没排除已经定下来的点 if (u == -1 || dist[u] == INF) break; vis[u] = 1; for (auto [v, w] : g[u]) if (dist[u] + w < dist[v]) dist[v] = dist[u] + w; }
for (int i = 1; i <= n; i++) cout << (dist[i] >= INF ? -1 : dist[i]) << " \n"[i == n]; return 0;}点「运行 ▶」看结果
跑出 9 -1 -1 -1 0 2 -1 —— 错得触目惊心。 起点的距离是 0,是全场最小的,所以每一轮挑出来的都是起点, 松弛了 n 遍同样的几条边,图上其余部分根本没被碰过。
和上一份放在一起看:bug 的「可见度」有天壤之别。 这一份第 1 轮对拍就死,上一份要到第 4 轮。
// ✗ 错误版本④:松弛时忘了「更近才更新」,直接赋值//// 和 naive.cpp 只差一个 if://// naive.cpp: if (dist[u] + w < dist[v]) dist[v] = dist[u] + w;// 这里: dist[v] = dist[u] + w; // ✗ 无条件覆盖//// 「松弛」这个词本身就说明了那个 if 不能省:// **松弛的意思是「如果经过 u 更近,就把 v 的距离放松到这个更小的值」** ——// 它天生是单向的,只许变小,不许变大。//// ⚠ 有两种数据能让它现形,正文那张默认图两种都留了(都是实测确认过的):// ① **重边**:`5 → 6` 有两条,长度 2 和 6。正确的松弛第二次看一眼 6 > 2 就走了,// 这一份直接把 6 号点的距离从 2 改成了 6 —— 而 6 号是整条链的起头,// 于是它下游那一串全跟着变大。这也是它在默认图上错得那么惨的原因// (`12 16 -1 19 0 6 9`,只有起点是对的)。// ② **回指的边**:`2 → 4`,2 号距离 9、4 号距离 6。正确的松弛看一眼 9 + 3 = 12 > 6 就走了,// 这一份把一个**已经算对的**距离改成了更大的值。//// ★ 所以这一份是给**生成器**出的两道题:数据里得有重边,也得有「从远处指回近处」的边。// 只造「越走越远、而且没有重边」的图(这正是「顺手写个简单 DAG」的样子),它就隐身了。// 第 29 章那条「顺手去掉自环和重边」的教训,在这一章换了个身份又出现了一次。
#include <bits/stdc++.h>using namespace std;
const int INF = 0x3f3f3f3f;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, m, s; if (!(cin >> n >> m >> s)) return 0; vector<vector<pair<int, int>>> g(n + 1); for (int i = 0; i < m; i++) { int u, v, w; cin >> u >> v >> w; g[u].push_back({v, w}); }
vector<int> dist(n + 1, INF), vis(n + 1, 0); dist[s] = 0;
for (int step = 0; step < n; step++) { int u = -1; for (int j = 1; j <= n; j++) if (!vis[j] && (u == -1 || dist[j] < dist[u])) u = j; if (u == -1 || dist[u] == INF) break; vis[u] = 1; for (auto [v, w] : g[u]) dist[v] = dist[u] + w; // ✗ 没问「是不是更近」就写了进去 }
for (int i = 1; i <= n; i++) cout << (dist[i] >= INF ? -1 : dist[i]) << " \n"[i == n]; return 0;}点「运行 ▶」看结果
跑出 12 16 -1 19 0 6 9 —— 七个数里只有起点是对的。 默认那张图里有两处能让它现形,而且是两种不同的机理:
- 重边:
5 → 6有两条(长 2 和长 6)。正确的松弛第二次看一眼 6 > 2 就走了, 这一份把 6 号从 2 改成了 6 —— 而 6 号是整条链的起头,下游全跟着涨。 - 回指的边:
2 → 4,2 号距离 9、4 号距离 6。正确的松弛看一眼 9 + 3 = 12 > 6 就走了, 这一份把一个已经算对的距离改成了 12。
★ 所以它其实是给生成器出的两道题:数据里得有重边,也得有「从远处指回近处」的边。 只造「越走越远、而且没有重边」的图(这正是「顺手写个简单 DAG」的样子),它就隐身了。
// ✗ 错误版本⑤:走不到的点,忘了输出 -1//// 和 naive.cpp 只差最后一行://// naive.cpp: cout << (dist[i] >= INF ? -1 : dist[i]);// 这里: cout << dist[i]; // ✗ 直接把 INF 打了出去//// 六个错误版本里它最不「聪明」——算法本身一个字没错,错的是收尾。// 留着它是因为它**专门用来检验生成器**,而且检验的正是这一章最容易被顺手做掉的那件事://// ★ 如果数据保证「从起点能走到所有点」,这一份就 300 轮全对。//// 而「保证处处可达」几乎是每个人写图生成器的条件反射 ——// 因为一串 -1 的输出「看着不像话」。第 30 章刚刚在同一件事上栽过// (那一章「走不到就输出 0」的 bug 在保证连通的数据上是 0 / 300)。// 正文第 11 步那张表里,它就是从 0 跳到非 0 的那一行。//// ⚠ 顺带戳破一个常见的想当然:很多人以为「不判可达就去松弛」会把 INF 传染出去// (`dist[邻居] = INF + w`)。用 0x3f3f3f3f 时**不会** ——// 因为松弛的条件是 `dist[u] + w < dist[v]`,而 INF + w 比 INF **大**,这一句根本不成立。// 我第一版就是这么写的,实测发现它和正解一模一样,只好改成现在这样。// (真会溢出成负数的是 INT_MAX 那种写法,那属于未定义行为,教材里不演示。)// **「我以为它会错」和「它真的错了」之间,隔着一次实测。**
#include <bits/stdc++.h>using namespace std;
const int INF = 0x3f3f3f3f;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, m, s; if (!(cin >> n >> m >> s)) return 0; vector<vector<pair<int, int>>> g(n + 1); for (int i = 0; i < m; i++) { int u, v, w; cin >> u >> v >> w; g[u].push_back({v, w}); }
vector<int> dist(n + 1, INF), vis(n + 1, 0); dist[s] = 0;
for (int step = 0; step < n; step++) { int u = -1; for (int j = 1; j <= n; j++) if (!vis[j] && (u == -1 || dist[j] < dist[u])) u = j; if (u == -1 || dist[u] == INF) break; vis[u] = 1; for (auto [v, w] : g[u]) if (dist[u] + w < dist[v]) dist[v] = dist[u] + w; }
for (int i = 1; i <= n; i++) cout << dist[i] << " \n"[i == n]; // ✗ 走不到的点应该输出 -1,这里原样打了 INF return 0;}点「运行 ▶」看结果
跑出 8 9 1061109567 6 0 2 5 —— 那个 1061109567 就是 0x3f3f3f3f。
六个里它最不「聪明」,算法一个字没错,错的是收尾。
留着它是因为它专门用来检验生成器:
数据要是保证了「从起点能走到所有点」,它就 300 轮全对。
这一份最初我是想写成另一个样子的:「不判可达就拿 INF 去松弛」,
以为会把 INF + w 传染出去。
实测发现根本不会。松弛的条件是 dist[u] + w < dist[v],
而 INF + w 比 INF 大,这一句压根不成立 —— 那份代码和正解一模一样。
(真会溢出成负数的是 INT_MAX 那种写法,那属于未定义行为,不放进教材。)
★ 「我以为它会错」和「它真的错了」之间,隔着一次实测。 这本教材里被同一件事教育的次数已经数不过来了,这是最新的一次。
// ✗ 错误版本⑥:从 1 号点出发,而不是从题目给的 s//// 和 naive.cpp 只差一个字符:`dist[s] = 0` 写成了 `dist[1] = 0`。// 算法一点毛病没有,**错的是读题**。//// ★ 它其实不是「bug」,是一块**试金石**(第 31 章 wrongIdentity.cpp 那件工具的第二次使用):// 它专门用来检查**生成器有没有把起点固定成 1 号**。// 如果生成器顺手写了 `s = 1`("反正题目没说不行"),这一份就 300 轮全对,// 而你会以为自己的数据挺好。//// 第 30 章刚在这上面栽过一次:那一章「从 1 号出发」这个 bug 在// 「起点永远是 1」的数据上是 0 / 300,把起点随机化之后立刻 244 / 300。// **同一个坑,隔了两章又来了一次** —— 所以这条要当成硬规矩:// **凡是题目里出现「起点 / 根 / 第一个」这种角色,就不许让它固定在 1 号。**
#include <bits/stdc++.h>using namespace std;
const int INF = 0x3f3f3f3f;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n, m, s; if (!(cin >> n >> m >> s)) return 0; vector<vector<pair<int, int>>> g(n + 1); for (int i = 0; i < m; i++) { int u, v, w; cin >> u >> v >> w; g[u].push_back({v, w}); }
vector<int> dist(n + 1, INF), vis(n + 1, 0); dist[1] = 0; // ✗ 题目给的起点是 s,不是 1
for (int step = 0; step < n; step++) { int u = -1; for (int j = 1; j <= n; j++) if (!vis[j] && (u == -1 || dist[j] < dist[u])) u = j; if (u == -1 || dist[u] == INF) break; vis[u] = 1; for (auto [v, w] : g[u]) if (dist[u] + w < dist[v]) dist[v] = dist[u] + w; }
for (int i = 1; i <= n; i++) cout << (dist[i] >= INF ? -1 : dist[i]) << " \n"[i == n]; return 0;}点「运行 ▶」看结果
跑出 0 1 -1 4 -1 -1 -1。算法一点毛病没有,错的是读题。
它也不是真正的 bug,是一块试金石(第 31 章 wrongIdentity.cpp 那件工具的第二次使用):
它专门检查生成器有没有把起点固定成 1 号。
第 30 章刚在这上面栽过(「从 1 号出发」在固定起点的数据上是 0 / 300)。 同一个坑,隔了两章又来一次 —— 所以把它立成硬规矩: 凡是题目里出现「起点 / 根 / 第一个」这种角色,就不许让它固定在 1 号。
11★ 对拍
// 堆优化 Dijkstra —— ★ 把第 31 章那个小根堆**原样搬过来**就是它//// 和 naive.cpp 是同一个算法、同一个贪心,只换了一件事:// 「在还没定下来的点里找距离最小的那个」这件事,// naive.cpp 是每次**扫一遍 n 个点**(O(n)),这里交给**小根堆**(O(log n))。//// ★ 第 31 章章末那句预告,就在下面这一行兑现://// priority_queue<pair<int,int>, vector<pair<int,int>>, greater<>> q;//// 第 31 章的拓扑排序里写的是 priority_queue<int, vector<int>, greater<int>>。// **容器一个字没变,变的是往里放什么:**//// 第 31 章:堆里放**编号** → 每次取出「编号最小」的点// 这一章: 堆里放 **(距离, 编号)** → 每次取出「距离最小」的点//// pair 的比较是先比第一维,所以把距离放在前面,堆就按距离排。// ⚠ 只搬容器、忘了改放进去的东西,就是 wrongHeapId.cpp 那个错误版本 ——// 它每次取的还是「编号最小」,于是编号小的点被过早定死。//// ★ 关于那句 `if (d > dist[u]) continue;`(「懒删除」):// 一个点的距离在被确定之前可能被松弛好几次,每次都往堆里塞一个新的 (距离, 编号)。// 我们不去堆里把旧的删掉(标准堆做不到),而是等它出来的时候看一眼:// **它带的距离比现在记录的还大,说明它是过期的,直接扔掉。**// 这一句同时也充当了 naive.cpp 里 vis[] 的角色 —— 一个点真正被处理只会有一次。//// 复杂度:每条边最多往堆里塞一次 → 堆里最多 O(m) 个元素 → O(m log m),通常写作 O(m log n)。// ⚠ 但 `m log n` 是个**很松的上界**:一条边只有在「真的把某个点刷小了」的时候才入堆。// 实测(正文第 7 步):3000 个点、900 万条边的图上,入堆只发生了 **19847** 次 ——// 不到边数的千分之三。所以「稠密图上堆优化会输给 O(n²)」这句常见的话,// 在随机数据上**我没能复现出来**(0.80 秒 vs 朴素的 0.82 秒)。// 第 29 章那句「口诀要拿实测复核,别默认它到处成立」在这一章又应验了一次。
#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, s; if (!(cin >> n >> m >> s)) return 0; vector<vector<PII>> g(n + 1); // g[u] = {(v, w), …} for (int i = 0; i < m; i++) { int u, v, w; cin >> u >> v >> w; g[u].push_back({v, w}); }
vector<int> dist(n + 1, INF); priority_queue<PII, vector<PII>, greater<PII>> q; // ★ 第 31 章那个小根堆,原样搬来 dist[s] = 0; q.push({0, s});
while (!q.empty()) { auto [d, u] = q.top(); q.pop(); if (d > dist[u]) continue; // ★ 过期的那一份,扔掉(它同时充当 vis) for (auto [v, w] : g[u]) if (d + w < dist[v]) { dist[v] = d + w; q.push({dist[v], v}); // 距离变小了才重新入堆 } }
for (int i = 1; i <= n; i++) cout << (dist[i] >= INF ? -1 : dist[i]) << " \n"[i == n]; return 0;}300 轮实测,六个错误版本:
| 故意写错的地方 | 被抓 | 第几轮 |
|---|---|---|
| 无条件松弛(忘了「更近才更新」) | 298 / 300 | 第 1 轮 |
| 没排除已确定的点 | 275 / 300 | 第 1 轮 |
| 拿 BFS 当最短路 | 255 / 300 | 第 1 轮 |
| 从 1 号出发(试金石) | 252 / 300 | 第 2 轮 |
| 走不到的点忘了输出 -1 | 51 / 300 | 第 8 轮 |
| 堆里放编号,不是距离 | 42 / 300 | 第 4 轮 |
(第五行那个 51 正好等于「这 300 轮里有 51 轮存在走不到的点」——
它只在有 -1 时才可能错,所以 51 / 51,一轮不漏。这个恒等式也钉在 check:viz 里。)
12★ 生成器调了七次,每次只改一处
gen.cpp 带了九个档位(./gen 种子 档位)。种子固定 1..300。
前四档改的都不是数值范围,而是数据的结构和角色分配:
| 档位 | 改了什么 | 有 -1 的轮数 | BFS | 堆放编号 | 没排除 | 无条件松弛 | 忘了 -1 | 从 1 号出发 |
|---|---|---|---|---|---|---|---|---|
| 0(最初) | 边权全 1 + 起点固定 1 号 + 保证处处可达 | 0 | 0 | 4 | 287 | 299 | 0 | 0 |
| 1 | 边权拉开(w ∈ [1,20]) | 0 | 154 | 17 | 292 | 298 | 0 | 0 |
| 2 | 起点不再固定 1 号 | 0 | 154 | 30 | 292 | 298 | 0 | 252 |
| 3 | 不再保证可达(去掉那条串起所有点的链) | 258 | 40 | 1 | 116 | 155 | 258 | 252 |
三处改动,三个 0 变成非 0:154、252、258。
★ 档位 0 那三样,每一样都是「顺手」写出来的,而且每一样都白送了一条题目里没有的性质:
- 边权全写 1(「先跑通再说」)→ 这道题退化成第 30 章的 BFS, 「拿 BFS 当最短路」根本不是 bug;
- 起点写死 1 号(「题目又没说不行」)→ 第 27、30、31 章那条的第四次;
- 保证处处可达(「有一串 -1 看着不像话」)→
-1那一支永远走不到。
⚠ 注意第 1 档:它改的是数值范围,可它要的不是「值域小」也不是「值域大」, 而是 值域不能只有一个数。第 26 章那条「要的是对比度」的回归。
档位 3 把「不再保证可达」换来了 258,可你看那一行的其它列: BFS 从 154 掉到 40,没排除从 292 掉到 116,堆放编号掉到 1。
★ 这是第 31 章那条「某一支占得太多也是坑」的第二次,而且更露骨:
链一去掉,n 只有几个、随机边又可能只有 1 条,
1772 个点里有 1001 个(57%)从起点根本走不到 ——
而走不到的点上所有程序一律输出 -1,别的 bug 就没机会现形了。
修法是把边数的下界往上提。这个旋钮我故意分三次拧:
| 档位 | 边数下界 | 有 -1 的轮数 | BFS | 堆放编号 | 没排除 | 无条件松弛 | 忘了 -1 |
|---|---|---|---|---|---|---|---|
| 3 | +0 | 258 | 40 | 1 | 116 | 155 | 258 |
| 4 | +n | 203 | 115 | 9 | 201 | 253 | 203 |
| 5 | +2n | 112 | 188 | 23 | 258 | 290 | 112 |
| 6 | +3n | 51 | 241 | 36 | 272 | 298 | 51 |
| (试过 +4n,没留) | +4n | 21 | 276 | 54 | 278 | 300 | 21 |
边越多,前面五个越好抓,「忘了输出 -1」越难抓 —— 这是一条权衡曲线,不是「改对了」。 一次拧到位的话,我根本不会知道「拧一格只补回了一半」。
★ 停在 +3n 的理由不是「平均分最高」,而是 让最弱的那一支尽量强: +3n 时最弱的是 36,+2n 时是 23,+4n 时最弱的变成了「忘了 -1」的 21。
第七处改动:在档位 6 之上,把边权值域从 [1,20] 拉到 [1,60](那就是在用的档位 7)。
| BFS | 堆放编号 | 没排除 | 无条件松弛 | |
|---|---|---|---|---|
| 档位 6(+3n,值域 [1,20]) | 241 | 36 | 272 | 298 |
| 档位 7(+3n,值域 [1,60]) | 255 | 42 | 275 | 298 |
有效。可同一处改动搬到档位 4 上(那就是留作对照的档位 8:+n,值域 [1,60]):
| BFS | 堆放编号 | 没排除 | 无条件松弛 | |
|---|---|---|---|---|
| 档位 4(+n,值域 [1,20]) | 115 | 9 | 201 | 253 |
| 档位 8(+n,值域 [1,60]) | 112 | 10 | 201 | 253 |
等于没改。
★ 所以这一章多出一条以前没写过的规矩: 一处改动值不值得留,取决于其它旋钮此刻在哪。 调生成器不是「把好点子一条条加上去」 —— 改动之间是会互相吃掉的。 (第 22 章那句「值域小才是灵魂」,在这一章得到的答复是:要看你先把别的调到了哪一档。)
13这一章可以带走的五样东西
【1】★ 关键一步:取当前最近的、还没定下来的那个点,它的距离当场就定死。 反证只有三句话,而「边长非负」只在其中一处用到 —— 证明断在哪一步,反例就长在哪里(第 20 章那句话的第三次登场)。
【2】边一带权,「一圈一圈往外扩」就不成立了。 BFS 排的是边数,这道题要的是边长之和; 两者一致只在「每条边都一样长」的时候。默认那张图上就是 8 和 9 这两个数。
【3】把第 31 章的小根堆原样搬过来,就是堆优化。 容器一个字没变,变的是往里放什么(编号 → (距离, 编号)) 和什么时候放(入度减到 0 → 距离变小了)。
【4】「Dijkstra 对负权不行」这句话要说准。 不行的不是那份代码,是那句「取出来就定死」。 堆优化那份因为允许「重新入堆」,在负权图上反而给出正确答案(35 / 300 vs 0 / 300)—— 可它已经悄悄变成了优先队列版的 Bellman-Ford,遇上负环根本停不下来。 ★ 「它给了对的答案」和「这个算法成立」,是两件事。
【5】调生成器不是把好点子一条条加上去。
同一处改动(把边权值域拉到 [1,60]),在一档上是 36 → 42,在另一档上是 9 → 10。
一处改动值不值得留,取决于其它旋钮此刻在哪。
以及第 29 章那条又应验一次:口诀要拿实测复核 ——
「稠密图该用朴素」我拧到 m ≈ n² 都没能复现出来,因为 m log n 是个很松的上界
(900 万条边只带来 19847 次入堆)。
第 33 章:最短路二 —— Floyd、Bellman-Ford / SPFA、负环。
这一章欠了三笔账,下一章一起还:
- Floyd 为什么对,以及那个最经典的错误:
k为什么必须在最外层; - 负权的正经办法:Bellman-Ford(松弛 n−1 轮就够了,为什么)和它的队列优化 SPFA —— 你会发现 SPFA 和这一章那份「在负权图上不小心变对了」的堆优化,是同一件事;
- 负环怎么判:第 n 轮还能松弛成功,就有负环。
这一章的
count.cpp里那个POP_LIMIT刹车,到时候可以扔掉了。
14自测
- 洛谷 P3371 【模板】单源最短路径(弱化版)解析 → —— 本章模板题。朴素 O(n²) 就能过,先拿它把三句话写熟
- 洛谷 P4779 【模板】单源最短路径(标准版)解析 → —— ★ 同一道题卡了朴素版,必须堆优化。正好把第 7 步那张表在评测机上再验一次
- 洛谷 P1339 [USACO09OCT] Heat Wave G解析 → —— 无向图版:一条边存两遍。写完想一想为什么无向图不影响这一章的任何结论
- 洛谷 P1629 邮递员送信解析 → —— ★ 去程一遍 Dijkstra,回程把所有边反向再跑一遍。「反向建图」是很值钱的一招
- 洛谷 P1462 通往奥格瑞玛的道路解析 → —— 进阶:二分答案 + Dijkstra 判可行。第 9 章那套二分答案在图上的第一次登场
- 洛谷 P1073 [NOIP2009 提高组] 最优贸易解析 → —— 进阶:需要分层图 / 正反两遍最短路。想清楚「状态」是什么,这道题就塌了