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

最短路一:Dijkstra

边一带上权,上一章那个「一圈一圈往外扩」就不成立了。★ 关键一步是一个贪心:每次取当前最近的、还没定下来的那个点,它的距离当场就定死。

需要先学:第 30 章 图上的 DFS 与 BFS、连通性、第 31 章 拓扑排序例题:单源最短路建议用时:130 分钟
上一章章末那两句话,这一章要当场兑现

第 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 不就行了?

wrongBfs.cpp✗ 第 30 章那份 BFS,只把 +1 换成了 +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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

跑出 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:

brute.cpp(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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
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★ 关键一步:取当前最近的那个,它的距离当场就定死

naive.cpp朴素 O(n²) Dijkstra —— 关键一步最直白的样子
// ★ 这一章的关键一步,最直白的样子 —— 朴素 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

整个算法就三句话,一句一行:

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 个点。而「不断加进来、每次取最小」—— 这不正是上一章那个小根堆干的活吗?

fast.cpp堆优化:第 31 章那个堆,一个字没改地搬过来
// 堆优化 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 容器一个字没变,变的是往里放什么
// 第 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。

那句 `if (d > dist[u]) continue;` 在干什么

一个点的距离在被定死之前可能被刷小好几次,每次都往堆里塞一份新的 (距离, 编号)。 标准堆没法把旧的那份删掉,所以我们等它出来的时候再看一眼:

它带的距离比现在记录的还大 → 它是过期的 → 直接扔掉。

这一句同时也顶替了朴素版里 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.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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

★ 实测的入堆次数(这几个数字都钉在 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动画:一圈一圈地把点定死

每次取当前最近的那个 —— 取出来的一刻,它的距离就定死了
8 9 -1 6 0 2 5
第 1 / 10 步
231291743651∞2∞3∞4∞5起点06∞7∞
点上面的数字 = 目前知道的最短距离(∞ = 还没碰到)。边上的数字 = 边长。 绿色 = 已经定死
小根堆,里面放 (距离, 编号)
0·5
写法是「距离·编号」—— 排序看的是前面那个
★ 已经定死的点数
0松弛成功 0 次
边长都非负时,它最多涨到「走得到的点数」(这张图是 6),一个点只定死一次。
距离数组
1
∞
2
∞
3
∞
4
∞
5
0
6
∞
7
∞
起点 5 号进容器,距离 0。★ 堆里放的是 (距离, 编号) —— pair 先比第一维,所以堆按距离排。

点上面的数字是「目前知道的最短距离」(∞ = 还没碰到过),绿色表示已经定死。 右边那个大数字是 ★ 已经定死的点数 —— 正权图上它最多涨到「走得到的点数」, 一个点只定死一次。

下拉框里四个错误版本第 10 步逐个讲。 ★ 现在先切一下前两个(BFS / 堆里放编号)—— 它们在这张图上给出一模一样的错答案。

★ 两个完全不同的 bug,错得一模一样

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)——

naive.cpp(喂给它那张负权图)✗ 同一份正确代码,换了数据就错
// ★ 这一章的关键一步,最直白的样子 —— 朴素 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

跑出 0 1 5 4,而正确答案是 0 1 5 2。

★ 注意 2 号那一位它是对的(1)—— 因为后来那次松弛确实把 dist[2] 改小了。 错的是 4 号:dist[2] 被改小的时候,2 号早已定死出场, 没有人再拿新的 1 去更新它的下游。

每定死一个点,就把所有的路数一遍 —— 它说的到底是不是真的
一次都没被推翻
第 1 / 8 步
2312917436512345起点67
绿色 = 已经定死;绿色的边 = 暴力枚举找出来的那条最短的路; 红色 = 把「定死」推翻的那条负权边
这一步的对质
还没开始定死任何点。
★ 「定死」被推翻的次数
0
边长都非负时它恒为 0 —— 那就是这个贪心的全部内容。 有一条负权边就够了:它立刻不是 0。
为什么正权时它必然属实
更短的那条路,总要在某处第一次踏出已经定死的那堆点。 设它踏出去踩到的第一个点是 x,那么
|这条路| ≥ |走到 x 的那段| ≥ dist[x] ≥ dist[u]
⚠ 第一个 ≥ 用掉的正是「x 到 u 那一段 ≥ 0」,也就是边长非负 —— 全程只在这一处用到它。所以负权边一来,断的就是这一处。
起点是 5 号,距离 0。接下来每定死一个点,我们就把所有能走的路数一遍,当场检查它有没有说谎。(数路那份逻辑和 Dijkstra 毫不相干 —— 一个贪心,一个穷举。)

这个动画每定死一个点,就把「起点到它的所有简单路径」暴力枚举一遍,当场对质。 正权那张图上一次都推不翻;按一下按钮换成负权那张,第 2 步就被推翻, 而且推翻它的那条路上那条负权边会被标红 —— 那就是证明断掉的地方。

★ 但「Dijkstra 对负权不行」这句话,还得说得更准

现在把同一张负权图喂给堆优化那一份:

fast.cpp(同一张负权图)★ 它给出了正确答案 —— 这才是麻烦的地方
// 堆优化 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

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.cpp(那张 4 点负权图)★「真正处理的次数」超过了点数
// 把两种写法的「工作量」数出来 —— 光看秒表说不清谁在做什么
//
// 用法:./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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

4 个点,却被真正处理了 6 次。 一个点被处理不止一次, 「定死」这两个字在这张图上就已经不成立了。

⚠ 而且它换来的正确答案是有代价的:最坏情况下入堆次数可以爆炸, 碰上负环更是根本停不下来(count.cpp 里那个 POP_LIMIT 就是为此准备的刹车)。 负权的正经办法在第 33 章:Bellman-Ford / SPFA,外加判负环。

★ 怎么造出「有负权边、但没有负环」的数据 —— 一个很漂亮的办法

这一节有个绕不过去的技术问题:有负环的话最短路根本不存在 (绕着环走一圈更短,可以无限短),Floyd 也给不出答案,那就什么都验不了。

办法叫势函数(第 33 章讲 Johnson 算法时会正式登场):

  1. 先给每个点随机一个「势」h[v];
  2. 造边时先随机一个非负的 w0,再令 w(u → v) = w0 + h[u] − h[v]。

于是任意一个环上,所有的 h 首尾相消(望远镜求和):

Σ w = Σ w0 + (h[环起点] − h[环起点]) = Σ w0 ≥ 0

每个环的总长都等于它那些 w0 的和,一定非负 —— 绝不可能有负环。 可单条边的 w 完全可以是负的(h[u] 小、h[v] 大的时候)。

genNeg.cpp势函数:有负权边,但保证无负环

实测:这样造出来的 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 步已经见过了,这里只补上它们在默认图上的输出,然后看后四种。

wrongHeapId.cpp✗ 小根堆搬来了,可里面放的是编号
// ✗ 错误版本②:小根堆搬对了,**往里放的东西搬错了**
//
// ★ 这一份是 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

跑出 9 10 -1 6 0 2 5 —— 和「拿 BFS 当最短路」一模一样。 它每次取的是编号最小的点,于是 1 号(距离本该是 8)被过早定死在 9, 它的下游 2 号跟着一起错。

★ 它有一半是对的:dist[1] 后来确实被改回了 8(松弛照做), 但已经出过堆的点不会再往下传播。 ⚠ 「只错一部分」的版本,比全错的更难发现 —— 这正是要拿它对拍的理由。

wrongNoVis.cpp✗ 找最小值时,忘了排除已经定下来的点
// ✗ 错误版本③:找最小值的时候,忘了排除「已经定下来的点」
//
// 和 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

跑出 9 -1 -1 -1 0 2 -1 —— 错得触目惊心。 起点的距离是 0,是全场最小的,所以每一轮挑出来的都是起点, 松弛了 n 遍同样的几条边,图上其余部分根本没被碰过。

和上一份放在一起看:bug 的「可见度」有天壤之别。 这一份第 1 轮对拍就死,上一份要到第 4 轮。

wrongRelax.cpp✗ 松弛时无条件赋值,忘了「更近才更新」
// ✗ 错误版本④:松弛时忘了「更近才更新」,直接赋值
//
// 和 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

跑出 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」的样子),它就隐身了。

wrongUnreach.cpp✗ 走不到的点,忘了输出 -1
// ✗ 错误版本⑤:走不到的点,忘了输出 -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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

跑出 8 9 1061109567 6 0 2 5 —— 那个 1061109567 就是 0x3f3f3f3f。 六个里它最不「聪明」,算法一个字没错,错的是收尾。 留着它是因为它专门用来检验生成器: 数据要是保证了「从起点能走到所有点」,它就 300 轮全对。

⚠ 一个我自己想当然、然后被实测打脸的地方

这一份最初我是想写成另一个样子的:「不判可达就拿 INF 去松弛」, 以为会把 INF + w 传染出去。

实测发现根本不会。松弛的条件是 dist[u] + w < dist[v], 而 INF + w 比 INF 大,这一句压根不成立 —— 那份代码和正解一模一样。 (真会溢出成负数的是 INT_MAX 那种写法,那属于未定义行为,不放进教材。)

★ 「我以为它会错」和「它真的错了」之间,隔着一次实测。 这本教材里被同一件事教育的次数已经数不过来了,这是最新的一次。

wrongStart1.cpp✗ 从 1 号出发,而不是题目给的 s
// ✗ 错误版本⑥:从 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

跑出 0 1 -1 4 -1 -1 -1。算法一点毛病没有,错的是读题。 它也不是真正的 bug,是一块试金石(第 31 章 wrongIdentity.cpp 那件工具的第二次使用): 它专门检查生成器有没有把起点固定成 1 号。

第 30 章刚在这上面栽过(「从 1 号出发」在固定起点的数据上是 0 / 300)。 同一个坑,隔了两章又来一次 —— 所以把它立成硬规矩: 凡是题目里出现「起点 / 根 / 第一个」这种角色,就不许让它固定在 1 号。

11★ 对拍

对拍器
★ 这个生成器调了七次。灵魂有四条:边权不能全一样、起点不固定 1 号、必须造得出走不到的点、而边数又不能太少。下一步整张表都在。
// 堆优化 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★ 生成器调了七次,每次只改一处

★ 第一张表:四处「结构性」的改动,每一处都把一个 0 变成非 0

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 章那条「要的是对比度」的回归。

★ 第二张表:同一个旋钮连拧三次 —— 这不是修 bug,是在找平衡点

档位 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 章那句「值域小才是灵魂」,在这一章得到的答复是:要看你先把别的调到了哪一档。)

gen.cpp(九个档位)七次改动 + 一个对照组,全部可重跑
genBig.cpp第 7 步那两张耗时表的数据源(两个旋钮)

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自测

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