阶段 6 · 图论 · 第 33 章

最短路二:Floyd、Bellman-Ford、SPFA 与负环

上一章那个贪心断在了负权边上。这一章把三笔账一起还清:Floyd 为什么对(以及 k 为什么必须在最外层)、负权的正经办法是什么、负环怎么判。还会看到一件事:上一章那份「在负权图上不小心答对了」的堆优化,其实就是这一章的 SPFA。

例题:带负权的单源最短路 + 判负环 建议用时:135 分钟
上一章欠下的三笔账,这一章一起还

第 32 章结尾白纸黑字写了三件事:

Floyd 为什么对,以及那个最经典的错误:k 为什么必须在最外层; ② 负权的正经办法:Bellman-Ford 和它的队列优化 SPFA —— 而且你会发现 SPFA 和上一章那份「在负权图上不小心变对了」的堆优化,是同一件事; ③ 负环怎么判:第 n 轮还能松弛成功,就有负环。

第 5 步还①,第 6、7 步还②,第 4 步还③。

上一章把 brute.cpp(Floyd)当了一整章的黑盒 —— 这一章先把盒子打开。

1 一句话问题

和第 32 章同一道题,只放开一个条件:边权可以是负数-100 ≤ w ≤ 100)。

给一张有向图(n 个点、m 条边)和起点 s

  • 如果从 s 出发能走到某个负环,输出一行 NEGATIVE
  • 否则输出 n 个数,第 i 个是 s 到 i 的最短距离,走不到的输出 x

⚠ 可能有重边、自环,也可能有从 s 走不到的点。

⚠ 为什么走不到用 x,而不是上一章那个 -1

上一章边权都是正的,-1 不可能是一个真实的距离,拿它当「走不到」的记号很安全。 这一章距离可以是负数 —— -1 是一个完全合法的答案。

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

这类事故不会报错,只会让对拍在某些数据上莫名其妙地红,然后你去查算法 —— 查一整晚。 (第 19 章那条「题目对边界的约定要抄进注释」的另一种形态。)

★ 「能走到的负环」这五个字,是这一章最容易写错的地方

为什么不是「图里有没有负环」?因为走不到的负环不影响答案: 图的角落里躺着一个负环,可 s 根本过不去,那这道题的答案照样是一串老老实实的距离。

而这句话对三种算法的代价完全不一样

「只算 s 能走到的」这件事
Floyd手动补一句 d[s][k] < INF —— 它算的是全图,天生不知道 s 是谁
Bellman-Ford / SPFA白送 —— dist 从 s 初始化,走不到的点永远是 INF

★ 同一个题面,一个天生满足、一个必须自己补 —— 这种地方最容易在两份代码之间写出不一致,而且两边各自看都「没毛病」。 第 8 步那个 wrongGlobalNeg.cpp 就是漏了那半句。

2 手算一遍:三张图,因为一张装不下

这一章要验四件事,而它们互相排斥,所以正文从头到尾用三张图。 「需要三张」本身就是这一章的一个结论,第 9 步会说清楚为什么。

★ 图 A:有走不到的点,而那片走不到的地方还藏着一个负环
7 9 5
5 6 4      ┐
6 7 -2     ├ 5 →(4) 6 →(-2) 7 →(3) 4     ← 拐两个弯才最近
7 4 3      ┘
5 4 9      ← 一步直达 4 号,长 9(更差)
6 4 8      ← 从 6 号一步到 4 号,4+8 = 12(也更差)
1 2 -5     ┐
2 3 2      ├ 1 → 2 → 3 → 1,总长 -5+2+1 = -2 ★ 一个负环
3 1 1      ┘
2 5 7      ← 只有这一条把那块连过来,而且是单向的:s 过不去

手算:d[5]=0d[6]=4d[7]=4-2=2d[4]=2+3=5(比 9 和 12 都小)。 1、2、3 号从 5 号根本走不到。答案:

x x x 5 0 4 2

★ 注意 4 号那个 5:它是拐了两个弯得来的,而两条「一步到位」的近路都更差 —— 这是第 5 步用来照 Floyd 那个 bug 的。

★ 图 B:s 走得到的负环 → 答案就是 NEGATIVE
4 4 1
1 2 3
2 3 -2
3 2 -2     ← 2 → 3 → 2 绕一圈是 -4,而 s = 1 走得到 2 号
3 4 1

答案:NEGATIVE。绕那个圈可以让距离无限小,「最短路」这三个字失去意义。

★ 图 C:一条链,而且边是倒着给的
5 5 1
4 5 1      ┐
3 4 1      │ 边故意倒着排 —— 第 6 步会看到这一点有多要命
2 3 1      │
1 2 1      ┘  链:1 →(1) 2 →(1) 3 →(1) 4 →(1) 5
1 5 10     ← 一步直达 5 号,长 10(更差)

答案 0 1 2 3 4。★ 到 5 号的最短路必须走满 4 条边 —— 也就是 n−1 条。 这张图是专门用来说明「n−1 轮一轮都不能少」的。

3 标准答案:Floyd —— 允许中转的点越来越多

brute.cpp(Floyd)标准答案:一个 DP
输入(stdin)
输出
点「运行 ▶」看结果
★ 关键一步①:k 是阶段,i 和 j 只是表格里的格子

Floyd 看起来是三行循环,其实它本来是个三维 DP

f[k][i][j] = 只允许拿 1..k 当中转站时,i 到 j 的最短距离

f[k][i][j] = min( f[k-1][i][j],  f[k-1][i][k] + f[k-1][k][j] )
                    ↑ 不用 k        ↑ 用 k(而且只用一次,用两次没意义)

k 是阶段,i、j 只是表格里的格子。 阶段必须在最外层 —— 这就是第 21 章那句「依赖谁,就先填谁」,这是它第七次登场。

而「转移右边用的必须是 k−1 那一层」,和第 23 章 01 背包那句 「转移右边的第一维必须是 i−1」是同一个形状: 那里滚动掉的是物品,这里滚动掉的是中转站。

⚠ 第一维能滚掉(写成二维 d[i][j])是因为 f[k][i][k] == f[k-1][i][k]: 多一个 k 可以中转,对「到 k 的距离」毫无帮助 —— 绕经自己只会更远。

⚠ 这份代码里另外两处,都是被负权逼出来的

自环这一次不能扔。 上一章边权都是正的,自环绕一圈只会更远,直接跳过就行。 这一章一条负的自环本身就是一个负环(「我到我自己是 -3」),扔了就漏判。

三重循环里那句「两头都走得到才松弛」不是保险,是必须的。

if (d[i][k] >= INF) continue;
if (d[k][j] < INF) …

有负权时 INF + (-7) 比 INF —— 不挡住的话,Floyd 会把「走不到」 误当成一条 10.6 亿长的路,然后顺着它接下去。 (这一句其实上一章就写进去了,当时的注释里说「这个守卫不是为正权数据加的」—— 现在兑现了。)

4 ★ 兑现预告③:负环怎么判

Floyd 跑完之后,判负环只要一行:

for (int k = 1; k <= n; k++)
    if (d[k][k] < 0 && d[s][k] < INF) { cout << "NEGATIVE\n"; return 0; }

d[k][k] < 0 的意思是「从 k 出发绕一圈回到 k,居然是负的」—— 那不就是 k 在一个负环上吗。后半句 d[s][k] < INF 就是第 1 步说的那个必须手动补的过滤。

brute.cpp(喂给它图 B)s 走得到的负环 → NEGATIVE
输入(stdin)
输出
点「运行 ▶」看结果

5 ★ 动画:k 一层一层加上去,以及把它放错层会怎样

k 一层一层加上去 —— 阶段必须在最外层
x x x 5 0 4 2
第 1 / 9 步
距离表 d[i][j]
i\j1234567
10-5
2027
310
40
5904
680-2
730
绿色 = 这一层刚被改小的格子。第 5 行就是最后要输出的那一行
★ 已经算对的格子数
25/ 49
一层一层往上涨,最后一定停在 n²。
它本来是三维的
f[k][i][j] = min(
  f[k−1][i][j],
  f[k−1][i][k] + f[k−1][k][j])
k 是阶段,i、j 只是表格里的格子。阶段必须在最外层 —— 和第 21 章那句「依赖谁,就先填谁」是同一句话。
一开始表里只有「一条边直达」的距离(走不到就是 ∞,自己到自己是 0)。接下来允许中转的点一个一个多起来。

右边那个计数器是这个动画的灵魂:★ 已经算对的格子数。 正确写法下它一层一层往上涨,最后停在 (图 A 上是 49 / 49)。

现在把下拉框切成「✗ 把 k 挪到最内层」——

wrongK.cpp✗ for (i) for (j) for (k) —— 三行一个字没改,只换了顺序
输入(stdin)
输出
点「运行 ▶」看结果

它在图 A 上跑出 x x x 9 0 4 2:4 号本该是 5(拐两个弯:4−2+3), 它给了 9 —— 那条一步直达的近路。计数器也停在了 28 / 49

★ 为什么放错层就错了 —— 而且错得这么安静

外层固定 i、j 之后,内层把 k 从 1 扫到 n。 可它用到的 d[i][k]d[k][j] 里,很多格子这时候压根还没被更新过 —— 拿半成品去算,结果就是半成品。

⚠ 它最可怕的地方是大部分时候是对的:不崩溃、不报错, 只有当最短路必须拐好几个弯、而且那些弯的编号顺序不巧时才现形。 第 9 步那张表里,300 轮它只错 85 轮 —— 也就是说, 你随手造几组数据试一试,很可能一次都碰不到。

★ 所以它是给生成器出的一道题:数据里得有「绕好几个中转点才最短」的路。

6 ★ 兑现预告②之一:Bellman-Ford,以及 n−1 这个数字

Floyd 是 O(n³),而且它一口气算了所有点对之间的距离 —— 这道题只要 s 那一行,太浪费了。

bell.cppBellman-Ford:把所有边挨个松弛一遍,叫一轮
输入(stdin)
输出
点「运行 ▶」看结果
★ 关键一步②:跑完第 i 轮,dist 就不差于「最多走 i 条边」的答案

不变量只有一句,归纳一行就证完了:

跑完第 i 轮之后,dist[v] ≤「从 s 出发、最多走 i 条边能到 v 的最短长度」。

(第 i 轮松弛边 (u,v) 时,dist[u] 已经不差于「最多 i−1 条边」的答案。)

没有负环时,最短路一定是一条简单路径 —— 它最多经过 n 个点、也就是 n−1 条边

★ 所以「n−1」不是背下来的,它就是「简单路径最多 n−1 条边」这句话。

一轮一轮地松弛所有边 —— 第 i 轮之后,就不差于「最多 i 条边」的答案
0 1 2 3 4
第 1 / 6 步
1111101起点02345
点上面的数字 = 目前的 dist(∞ = 还没碰到)。绿框 = 这一轮刚变近
还没开始
12345
dist0
≤0 条边0
第三行是另外独立算的「最多走这么多条边」的答案。 ★ 第二行永远不差于第三行 —— 这就是「n−1 轮够用」的全部理由。
★ 这一轮松弛成功了几次
0
它归零的那一刻,就是收敛了。
起点 1 号是 0,别的都是 ∞。接下来一轮一轮地「把所有边挨个松弛一遍」。

这个动画把不变量的两边并排画出来:第二行是 Bellman-Ford 的 dist, 第三行是另外独立算的「最多走这么多条边」的真值。 两边每一轮都对得上 —— 那句话就不是我说的,是画面上摆着的。 (check:viz 会把三张图的每一帧都验一遍。)

默认停在图 C:链上每一段都是 1,而边是倒着给的,所以一轮只能往前推一格, 到第 4 轮才算完。现在把「轮数」切成「✗ 只跑 n−2 轮」——

wrongRounds.cpp(喂给它图 C)✗ 只跑 n−2 轮
输入(stdin)
输出
点「运行 ▶」看结果
★ 少跑一轮,不是「答案错」,是「误报负环」

它输出 NEGATIVE。可图 C 里一条负权边都没有。

道理很简单,但值得停一下:判负环的方法是「再多跑一轮,看还能不能松弛成功」。 少跑一轮 ⇒ 还没收敛 ⇒ 那一轮当然还能松弛成功 ⇒ 它把「没收敛」当成了「有负环」。

一个 bug 同时污染两种输出 —— 这类 bug 最难从现象倒推回原因, 因为你会盯着「为什么误报负环」去查判环那段代码,而毛病根本不在那儿。

★ 判负环:多跑一轮
for (auto [u, v, w] : es) {
    if (dist[u] == INF) continue;
    if (dist[u] + w < dist[v]) { cout << "NEGATIVE\n"; return 0; }
}

为什么它对,两句话:能松弛成功说明存在一条「用了 n 条边还更短」的走法; n 条边的走法必然重复经过某个点、也就是绕了一个环,而绕它让距离变小 —— 那就是负环。

⚠ 而且它天生只报告「从 s 能走到的」负环dist 从 s 初始化,走不到的永远是 INF)—— 这就是第 1 步那张表里 Floyd 要手动补、它却白送的那件事。

7 ★ 兑现预告②之二:SPFA —— 而它就是上一章那份代码

Bellman-Ford 每一轮都把 m 条边全扫一遍。可其中绝大多数是白扫的: 一条边 (u, v) 只有在 dist[u] 刚刚变小的时候才可能松弛成功。

先把这句话量出来:

count.cpp把三种算法的工作量数出来
输入(stdin)
输出
点「运行 ▶」看结果

本机实测./genBig 2000,2000 个点、7999 条边,只能在终端里跑):

工作量
Floyd三重循环 80 亿次(n³)—— 这一档根本没法跑
Bellman-Ford松弛尝试 63 195 次,其中成功只有 5 796 次(9.2%)
SPFA入队 3 043 次,松弛尝试 12 166

九成的松弛是白做的。SPFA 的全部内容就是「别做那九成」:谁的 dist 变小了,就把谁排队。

spfa.cppSPFA:Bellman-Ford 的队列优化
输入(stdin)
输出
点「运行 ▶」看结果
★★ 上一章那个伏笔:SPFA 就是那份「不小心答对了」的堆优化 Dijkstra

第 32 章的 fast.cpp 在负权图上 300 轮一次都没错,当时给的解释是 「它已经不是 Dijkstra 了」。现在可以把话说完:

// 第 32 章 fast.cpp(堆优化 Dijkstra)
auto [d, u] = q.top(); q.pop();
if (d > dist[u]) continue;                 // 过期的就扔掉
for (auto [v, w] : g[u])
    if (d + w < dist[v]) { dist[v] = d + w; q.push({dist[v], v}); }

// 这一章 spfa.cpp
int u = q.front(); q.pop(); inq[u] = 0;    // 出队就清标记
for (auto [v, w] : g[u])
    if (dist[u] + w < dist[v]) { dist[v] = dist[u] + w; if (!inq[v]) q.push(v); }

同一个算法,只差用什么容器。 把 SPFA 的队列换成小根堆,它就长成上一章那副样子; 而那句 if (d > dist[u]) continue; 顶替的正是这里的 inq 判重。

★ 所以第 32 章那句结论要这样收尾: 上一章的堆优化在负权图上给出正确答案,不是因为 Dijkstra 对负权成立, 而是因为它执行的是这一章的 SPFA。

⚠ inq 说的是「在不在队里」,不是「来过没有」 —— 别和第 30 章记混
那个标记在记什么什么时候清
第 30 章 BFS 的 vis来过没有永不清(一个点只需进一次)
这一章 SPFA 的 inq现在在不在队里出队时就清

两句话不打架,因为它们是两个不同的变量。 BFS 里一个点确实只需进一次(边权都一样长,第一次碰到就是最优); SPFA 里一个点可能进很多次 —— 每次有人把它刷得更小。

「这个标记到底在记什么」比「它叫 vis 还是 inq」重要一百倍。

wrongSpfaInq.cpp(喂给它图 B)✗ 出队时忘了清 inq
输入(stdin)
输出
点「运行 ▶」看结果

图 B 的正确答案是 NEGATIVE,它却输出 0 -1 1 2 —— ★ 漏报了负环。 因为点进不了第二次,那个「绕一圈更短」的过程走不下去,cnt 也就永远到不了 n。

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

wrongInf.cpp(喂给它图 A)✗ 不判「走不到」就去松弛
输入(stdin)
输出
点「运行 ▶」看结果

它输出 NEGATIVE,而图 A 的正确答案是一串距离。

★★ 这一份是上一章那笔账的正面回应

第 32 章我写过一个几乎一模一样的错误版本,然后被实测打脸: 那一章边权非负,INF + w 比 INF ,松弛条件 dist[u] + w < dist[v] 根本不成立 —— 所以「不判可达」在那里一点事都没有,那份代码和正解一模一样。

这一章边权可以是负的。INF + (-5) 比 INF ,那一句当场成立:

走不到的点会被刷出一个「比 INF 小一点」的距离,然后顺着边一路传染出去。

而图 A 里走不到的那三个点之间还有一个负环 —— 于是它一路刷下去, 最后连负环都误报了。一个 bug 同时污染两种输出(第二次)。

同一句代码危不危险,取决于数据的取值范围。 「这句判断是不是多余的」这种问题,没有脱离数据的答案。

wrongGlobalNeg.cpp(喂给它图 A)✗ 判负环时不管 s 走不走得到
输入(stdin)
输出
点「运行 ▶」看结果

也输出 NEGATIVE —— 但原因和上一份完全不同:它看见了那个负环, 只是没问一句「s 过得去吗」。少的就是半句 && d[s][k] < INF

★ 它错的不是算法,是题面。而 Bellman-Ford / SPFA 那两份天生不会犯这个错 —— 这就是第 1 步那张表想说的事。

9 ★ 对拍与生成器:它要同时满足四件互相打架的事

对拍器
★ 这个生成器调了九次。它得同时造出:负权边、走不到的点、s 走不到的负环、以及「必须走满 n−1 条边」的长链 —— 而这四样互相打架。

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

故意写错的地方被抓第几轮
SPFA 出队忘了清 inq135 / 300第 1 轮
Floyd 的 k 放到最内层85 / 300第 11 轮
不判「走不到」就松弛85 / 300第 3 轮
Bellman-Ford 只跑 n−2 轮71 / 300第 4 轮
判负环不看 s 走不走得到47 / 300第 7 轮

(这 300 轮里,62 轮的答案是 NEGATIVE141 轮有走不到的点, 4618 条边里 1294 条是负的。三个数字都钉在 check:viz 里。)

★ 生成器要的四样东西,互相打架
要什么为了打假谁
负权边没有它,这一章讲的东西一件都验不到(退化成第 32 章)
从 s 走不到的点「不判可达就松弛」(第 30、32 章那条第三次)
从 s 走不到的负环「判负环不看可达」—— 本章最刁的一条
长链「Floyd 的 k 放错层」和「少跑一轮」:随机图上最短路两三条边就走完了

⚠ 而它们互相打架:

  • 负权边一随机,就很容易撞出负环,那一组的答案就成了 NEGATIVE,长链白造了;
  • 负环一多,一大半数据的答案都是 NEGATIVE,别的 bug 全被挤没 (第 31、32 章「某一支占得太多」的第三次,档位 4 那一行就是现场);
  • 图一碎(为了造走不到的点),长链就不容易连起来。

★ 解法是把它们拆开分别控制,两招都是从前面章节搬来的:

招一:势函数(第 32 章那个)。 造边时先随机一个非负的 w0,再令

w(u → v) = w0 + h[u] − h[v]

任何环上 h 首尾相消 → 环长 = Σw0 ≥ 0绝不可能有负环,但单条边可以是负的。 于是负环只在我故意注入的时候才出现(往回加一条特意配平过头的边)。 负权边的浓度归势函数管,负环的比例归注入管,两件事解耦了。

招二:把点分成两块。

R = id[0 .. L-1](起点 s 就在里面)      U = id[L .. n-1]

边只允许 R→RU→UU→R永远不连 R→U —— 于是 U 天生从 s 走不到。 两块各串一条链保证内部连通,负环想注入哪一块都行。 把负环注入到 U 里,它就是「一个 s 永远走不到的负环」 —— 第 ③ 件事有了。

★ 第一张表:五处改动,每一处把一个 0 变成非 0

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

档位改了什么NEGATIVE有 xk 放错层少跑一轮不判可达不看可达inq 没清
0(最初)非负权 + 处处可达 + 纯随机边008320012
1用势函数 → 出现负权边008320012
2L 可以小于 n → 出现走不到的点02393109704
3R 那条链的 w0 全压成 0 → 逼出长最短路023973497069
4注入负环(只往 R 里注)30001000284
5负环也可能注入进 U241591105959233

档位 1 一个数字都没变。 光有负权边、没有别的配套,五个 bug 一个都没多抓到 —— 这条老实账要写在这儿:「加了个好东西」不等于「数据变好了」。

★ 档位 4 那一行是这一章最刺眼的地方:300 轮全是 NEGATIVE, 于是「k 放错层」从 73 掉到 1、「不判可达」从 97 掉到 0。 负环那一支占满了整张表,别的 bug 连出场的机会都没有。

★ 第二张表:四次调平衡,其中一次得把两件事绑在一起做
档位改了什么NEGATIVE有 xk 放错层少跑一轮不判可达不看可达inq 没清
5(上一张表的最后一行)241591105959233
6注入比例降到 2/313315233011377148
7最坏边序 + 链拉满全图1529848377157170
8注入比例再降到 1/37412984717335145
9(在用)注入尽量往 U 里放6214185718547135

★ 档位 7 那一行是这一章最费劲的一处,而且它教了一条新东西:

「少跑一轮」这个 bug,要现形得同时满足两件事 —— 最短路真的用满 n−1 条边(链得跨过所有点),而且边是按最坏顺序给的。

实测:只把边打乱 → 0 / 300;只把链拉满 → 4 / 300;两样一起 → 37 / 300

⚠ 为什么边序这么要命:Bellman-Ford 一轮里是按输入顺序挨个松弛边的。 链要是正好顺着排,一轮就能从头传到尾,n−2 轮绰绰有余。 随机顺序下一轮平均也能往前推两三格。 只有把链倒着放,才逼得它一轮只推进一格 —— ★ 「n−1 轮」这个下界,只在最坏的边顺序下才是紧的。

⚠ 由此得到一条以前没写过的规矩:边的顺序也是数据的一部分。 而且更一般地:要证明一个下界是紧的,就得自己造出那个最坏情况 —— 随机数据永远碰不到它。这是随机对拍的又一个盲区 (第 20 章「对拍只能证伪」、第 31 章「验证器的盲区」之后的第三个)。

档位 9 的选法照旧是第 32 章那条:让最弱的那一支尽量强 (档位 8 最弱的是 35,档位 9 是 47)。

gen.cpp(十个档位)九次改动全部可重跑

10 实测:三个算法到底差多少

本机实测./genBig <n>,稀疏图 m ≈ 4n,只能在终端里跑):

g++ -O2 -std=c++17 -o genBig genBig.cpp && g++ -O2 -std=c++17 -o brute brute.cpp
g++ -O2 -std=c++17 -o bell bell.cpp && g++ -O2 -std=c++17 -o spfa spfa.cpp
./genBig 2400 > big.txt
time ./brute < big.txt > /dev/null      # 5.77 秒
time ./bell  < big.txt > /dev/null      # 0.00 秒
点数 n边数Floyd O(n³)Bellman-Ford O(nm)SPFA
3001 1990.01 秒0.00 秒0.00 秒
6002 3990.09 秒0.00 秒0.00 秒
1 2004 7990.69 秒0.00 秒0.00 秒
2 4009 5995.77 秒0.00 秒0.00 秒
20 00079 999跑不动0.04 秒(11 轮)0.05 秒
80 000319 999跑不动0.19 秒(13 轮)0.23 秒
320 0001 279 999跑不动0.87 秒(15 轮)1.37 秒

★ Floyd 的 一点折扣都不打:n 翻一倍,它慢八倍(0.09 → 0.69 → 5.77)。 它算的是所有点对,这道题只要一行 —— 单源问题上别用它。

⚠ 两笔老实账:最后两行里 SPFA 比 Bellman-Ford 还慢

第 7 步刚量过:SPFA 的松弛尝试只有 Bellman-Ford 的 0.19 倍。可它跑得更慢(1.37 vs 0.87 秒)。

两个原因,都能查出来:

  • Bellman-Ford 根本没跑满 n−1 轮。 那句 if (!changed) break; 让它在随机图上 15 轮就收敛了(n = 320 000!)。所谓 O(nm) 的 n,实际只有 15。
  • 常数不一样。 Bellman-Ford 的内层是在一个连续数组上顺序扫边,缓存友好到极点; SPFA 要维护队列、还要顺着邻接表跳来跳去。 (第 29 章那条「链式前向星常数最小在稠密图上不成立,原因是缓存」的同款。)

「SPFA 比 Bellman-Ford 快」也是一句要复核的口诀。 少做的工作是真的,可它换来的收益被常数吃掉了。

第二笔账:我本来还想造一组「卡 SPFA」的数据(一般说的就是网格图),把它的最坏情况演出来。 没做成:2500 个点、9800 条边的随机权网格上,SPFA 只入队了 5188 次(约 2n), 一点都没退化。

「SPFA 已死」是真的 —— 它的最坏复杂度确实还是 O(nm)。 但光把图摆成网格不够,要卡住它得针对边权专门构造。这一章没做出来,如实写在这里。 (第 29、32 章那条「口诀要拿实测复核」的第三次 —— 而这一次连「口诀是对的,只是我没复现出来」都得说清楚。)

genBig.cpp两张耗时表的数据源(第二个参数是图的形状)

11 三种算法怎么选

★ 关键的一步
复杂度负权负环什么时候用它
Dijkstra(第 32 章)O(m log n)边权非负 —— 能用它就用它,快一个数量级
FloydO(n³)✓(全图)所有点对之间的距离,而且 n 很小(几百)
Bellman-FordO(nm)✓(s 可达)有负权 / 要判负环;代码最短,而且常数极小
SPFA最坏 O(nm)✓(s 可达)同上,通常更快 —— 但最坏情况会被卡

★ 一句话:先问边权有没有负数。 没有就 Dijkstra,有就 Bellman-Ford 那一族; 要所有点对、而且 n 小,才轮到 Floyd。

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

★ 关键的一步

【1】★ Floyd 的 k 是阶段,必须在最外层。 f[k][i][j] = min(f[k-1][i][j], f[k-1][i][k] + f[k-1][k][j]) —— k 是阶段,i、j 只是格子。这就是第 21 章「依赖谁就先填谁」的第七次登场, 也和第 23 章「转移右边的第一维必须是 i−1」是同一个形状。 ⚠ 放错层不崩溃、不报错,300 轮里只错 85 轮。

【2】★ Bellman-Ford 的 n−1 不是背的。 跑完第 i 轮,dist 就不差于「最多走 i 条边」的答案; 而没有负环时最短路是简单路径,最多 n−1 条边。判负环是白送的:多跑一轮还能松弛就有环。 ⚠ 少跑一轮不是答案错,是误报负环(没收敛而已)。

【3】★ SPFA 就是第 32 章那份「不小心答对了」的堆优化。 同一个算法,只差用什么容器。所以上一章那句结论要收尾成: 堆优化在负权图上答对了,不是因为 Dijkstra 对负权成立,而是因为它执行的是 SPFA。 ⚠ inq 记的是「在不在队里」,出队要清 —— 别和第 30 章 BFS 的 vis 记混。

【4】★ 同一句代码危不危险,取决于数据的取值范围。 「不判可达就松弛」在第 32 章(非负权)里完全没事,在这一章会一路传染、还连带误报负环。 同理,「走不到」这一章不能再用 -1 当记号 —— 因为距离本身可以是负的。 没有脱离数据的代码审查。

【5】★ 边的顺序也是数据的一部分。 「n−1 轮」这个下界只在最坏的边顺序下才紧:只打乱边 → 0 / 300;只把链拉满 → 4 / 300; 两样一起才 37 / 300。 更一般地:要证明一个下界是紧的,就得自己造出那个最坏情况 —— 随机数据永远碰不到它。这是随机对拍的第三个盲区 (前两个:第 20 章「只能证伪」、第 31 章「验证器证明不了没漏报」)。

下一章预告

第 34 章:最小生成树 —— Kruskal 与 Prim。

从「两点之间最短」换成「把所有点连起来,总代价最小」。 ★ 关键一步是切割性质:横跨任意一个切割的最小边,一定在某棵最小生成树里 —— Kruskal 和 Prim 都是它的推论,只是「怎么选那个切割」不一样。

顺带一个这一章埋下的坑:两种算法给出的树可能长得不一样,但权值和必须相同 —— 那么对拍该比什么?(第 31 章「答案不唯一」的第二次登场。)

13 自测

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