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

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

上一章那个贪心断在了负权边上。这一章还三笔账:Floyd 为什么对、负权该怎么办、负环怎么判 —— 顺带发现上一章那份堆优化其实就是 SPFA。

需要先学:第 32 章 最短路一:Dijkstra例题:带负权的单源最短路 + 判负环建议用时: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]=0,d[6]=4,d[7]=4-2=2,d[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
// 标准答案 —— Floyd:把「允许拿哪些点当中转站」当成 DP 的阶段
//
// 输入:第一行 n m s
// 接下来 m 行 `u v w`,表示一条 u → v 的**有向**边,长 w(⚠ **w 可以是负数**,|w| ≤ 100)
// ⚠ 可能有重边、自环,也可能有从 s 走不到的点
// 输出:如果从 s 出发**能走到**某个负环,输出一行 `NEGATIVE`(最短路不存在);
// 否则输出一行 n 个数:第 i 个是 s 到 i 的最短距离,**走不到的输出 `x`**。
//
// ⚠ 为什么走不到用 `x` 而不是第 32 章那个 `-1`:这一章的距离**可以是负数**,
// -1 是一个完全合法的距离。**答案的记号和数据的取值范围是一对,改了一边就得对一遍另一边。**
//
// ★ 这一章的关键一步,就在下面那三重循环的**顺序**上:
//
// for (k) for (i) for (j) d[i][j] = min(d[i][j], d[i][k] + d[k][j]);
// ↑ k 必须在最外层
//
// 原因不是「大家都这么写」,而是它本来有三维:
//
// 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 的距离」毫无帮助(绕经自己只会更远,除非有负环)。
// 把 k 挪到里层就全乱了 —— 那就是 wrongK.cpp,正文第 5 步专门讲。
//
// ★ 负环怎么判:跑完之后 `d[k][k] < 0` ⇔ k 在某个负环上。
// ⚠ 但题目问的是「**从 s 能走到的**负环」,所以还要加一句 `d[s][k] < INF`。
// Floyd 看的是全图,它天生不知道 s 是谁 —— 这个过滤是**必须自己补上**的。
// (对照 bell.cpp / spfa.cpp:那两份从 s 出发算,这个过滤是白送的。
// 正文第 8 步那个 wrongGlobalNeg.cpp 就是漏了这一句。)
//
// 复杂度 O(n³)。这一章 n 很小,它当标准答案正合适:
// 它和 Bellman-Ford「一轮一轮松弛所有边」的想法完全不同,一个是 DP、一个是迭代逼近。
#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;
// ⚠ 自环这一次**不能扔**:负的自环本身就是一个最短的负环
d[u][v] = min(d[u][v], w); // 重边取 min(第 32 章那条)
}
// ★ k 在最外层。⚠ 两头都走得到才松弛 —— 有负权时 `INF + (-7) < INF` 是成立的
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]);
}
// 从 s 能走到的负环
for (int k = 1; k <= n; k++)
if (d[k][k] < 0 && d[s][k] < INF) { cout << "NEGATIVE\n"; return 0; }
for (int i = 1; i <= n; i++) {
if (d[s][i] >= INF) cout << "x";
else cout << d[s][i];
cout << " \n"[i == n];
}
return 0;
}
点一下即可编辑
输入(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
// 标准答案 —— Floyd:把「允许拿哪些点当中转站」当成 DP 的阶段
//
// 输入:第一行 n m s
// 接下来 m 行 `u v w`,表示一条 u → v 的**有向**边,长 w(⚠ **w 可以是负数**,|w| ≤ 100)
// ⚠ 可能有重边、自环,也可能有从 s 走不到的点
// 输出:如果从 s 出发**能走到**某个负环,输出一行 `NEGATIVE`(最短路不存在);
// 否则输出一行 n 个数:第 i 个是 s 到 i 的最短距离,**走不到的输出 `x`**。
//
// ⚠ 为什么走不到用 `x` 而不是第 32 章那个 `-1`:这一章的距离**可以是负数**,
// -1 是一个完全合法的距离。**答案的记号和数据的取值范围是一对,改了一边就得对一遍另一边。**
//
// ★ 这一章的关键一步,就在下面那三重循环的**顺序**上:
//
// for (k) for (i) for (j) d[i][j] = min(d[i][j], d[i][k] + d[k][j]);
// ↑ k 必须在最外层
//
// 原因不是「大家都这么写」,而是它本来有三维:
//
// 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 的距离」毫无帮助(绕经自己只会更远,除非有负环)。
// 把 k 挪到里层就全乱了 —— 那就是 wrongK.cpp,正文第 5 步专门讲。
//
// ★ 负环怎么判:跑完之后 `d[k][k] < 0` ⇔ k 在某个负环上。
// ⚠ 但题目问的是「**从 s 能走到的**负环」,所以还要加一句 `d[s][k] < INF`。
// Floyd 看的是全图,它天生不知道 s 是谁 —— 这个过滤是**必须自己补上**的。
// (对照 bell.cpp / spfa.cpp:那两份从 s 出发算,这个过滤是白送的。
// 正文第 8 步那个 wrongGlobalNeg.cpp 就是漏了这一句。)
//
// 复杂度 O(n³)。这一章 n 很小,它当标准答案正合适:
// 它和 Bellman-Ford「一轮一轮松弛所有边」的想法完全不同,一个是 DP、一个是迭代逼近。
#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;
// ⚠ 自环这一次**不能扔**:负的自环本身就是一个最短的负环
d[u][v] = min(d[u][v], w); // 重边取 min(第 32 章那条)
}
// ★ k 在最外层。⚠ 两头都走得到才松弛 —— 有负权时 `INF + (-7) < INF` 是成立的
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]);
}
// 从 s 能走到的负环
for (int k = 1; k <= n; k++)
if (d[k][k] < 0 && d[s][k] < INF) { cout << "NEGATIVE\n"; return 0; }
for (int i = 1; i <= n; i++) {
if (d[s][i] >= INF) cout << "x";
else cout << d[s][i];
cout << " \n"[i == n];
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

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

k 一层一层加上去 —— 阶段必须在最外层
x x x 5 0 4 2
第 1 / 9 步
距离表 d[i][j]
i\j1234567
10-5∞∞∞∞∞
2∞02∞7∞∞
31∞0∞∞∞∞
4∞∞∞0∞∞∞
5∞∞∞904∞
6∞∞∞8∞0-2
7∞∞∞3∞∞0
绿色 = 这一层刚被改小的格子。第 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)。接下来允许中转的点一个一个多起来。

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

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

wrongK.cpp✗ for (i) for (j) for (k) —— 三行一个字没改,只换了顺序
// ✗ 错误版本① —— ★ 本章的主角:Floyd 的 k 没放在最外层
//
// 和 brute.cpp 只差**三行的顺序**:
//
// brute.cpp: for (k) for (i) for (j) // ✓ 阶段在最外层
// 这里: for (i) for (j) for (k) // ✗ 阶段跑到最里层去了
//
// 这是整个图论里被写错次数最多的一处,而且**它经常给出正确答案** ——
// 这才是它可怕的地方(正文第 6 步那张表:300 轮里它只错 XX 轮,其余全对)。
//
// ★ 为什么错: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 是阶段**(第 21 章那句「依赖谁就先填谁」),i、j 只是表格里的格子。
// 把 k 放到最里层,等于在「还没算完 k−1 那一层」的时候就去用它 ——
// `d[i][j]` 拿到的是一堆半成品。
//
// 具体到这一份:外层固定 i、j 之后,内层把 k 从 1 扫到 n,
// 期间用到的 `d[i][k]` 和 `d[k][j]` 里,很多格子**这一轮压根还没被更新过**。
// 于是它只找得到「一次中转」附近的路,两次以上的中转经常漏掉。
//
// ⚠ 它错得非常「安静」:不崩溃、不报错、大部分数据上答案还是对的。
// 只有当最短路**必须拐好几个弯**、而且那些弯的编号顺序不巧时才会现形。
// ★ 所以这一份是给**生成器**出的一道题:数据里得有「绕好几个中转点才最短」的路。
// (正文第 9 步那张表里,让它现形的那一处改动是「掺一条长链进去」。)
#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;
d[u][v] = min(d[u][v], w);
}
// ✗ 阶段 k 被放到了最里层
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
for (int k = 1; k <= n; k++)
if (d[i][k] < INF && d[k][j] < INF)
d[i][j] = min(d[i][j], d[i][k] + d[k][j]);
for (int k = 1; k <= n; k++)
if (d[k][k] < 0 && d[s][k] < INF) { cout << "NEGATIVE\n"; return 0; }
for (int i = 1; i <= n; i++) {
if (d[s][i] >= INF) cout << "x";
else cout << d[s][i];
cout << " \n"[i == n];
}
return 0;
}
点一下即可编辑
输入(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:把所有边挨个松弛一遍,叫一轮
// ★ Bellman-Ford —— 负权的正经办法,也是这一章第二个关键一步
//
// 和 brute.cpp(Floyd)解同一道题,答案必须一模一样。
//
// ★ 一句话:**把所有边挨个松弛一遍,叫一轮;跑 n−1 轮就够了。**
//
// 为什么是 n−1,而且一轮不多一轮不少 —— 关键在这个不变量:
//
// 跑完第 i 轮之后,dist[v] ≤「从 s 出发、**最多走 i 条边**能到 v 的最短长度」
//
// (归纳一行就完了:第 i 轮松弛边 (u,v) 时,dist[u] 已经不差于「最多 i−1 条边」的答案。)
//
// 而**没有负环时,最短路一定是一条简单路径** —— 它最多经过 n 个点、也就是 n−1 条边。
// 所以 n−1 轮之后一定收敛。★ 「n−1」不是背下来的,是「简单路径最多 n−1 条边」这句话。
//
// ★ 判负环是白送的(第 31 章「判环白送」的第二次):
//
// 跑完 n−1 轮之后,**再跑一轮**。要是还有边能松弛成功 —— 有负环。
//
// 为什么:能松弛成功说明存在一条「用了 n 条边还更短」的走法,
// n 条边的走法必然重复经过某个点,也就是绕了一个环,而绕它让距离变小 → 那是负环。
//
// ⚠ 而且这份代码天生只报告「**从 s 能走到的**负环」:
// dist 是从 s 初始化的,走不到的点永远是 INF,`if (dist[u] == INF) continue;` 会把它挡住。
// ★ 对照 brute.cpp:Floyd 看的是全图,那个过滤必须自己补一句 `d[s][k] < INF`。
// **同一个题面,两种算法一个天生满足、一个必须手动补** —— 这种地方最容易写出不一致。
//
// ⚠ `if (dist[u] == INF) continue;` 这一句在**负权图上是必须的**,不是保险:
// `INF + (-7)` 比 INF 小,不挡住的话,走不到的点会被刷出一个「比 INF 小一点」的距离,
// 而且顺着边传染。第 32 章那个「以为会传染、实测不会」的坑,在这一章**真的会**发生 ——
// 因为那时边权非负。**同一句代码危不危险,取决于数据的取值范围。**
//
// 复杂度 O(nm):n 轮,每轮扫 m 条边。比 Floyd 的 O(n³) 好在稀疏图上。
#include <bits/stdc++.h>
using namespace std;
const int INF = 0x3f3f3f3f;
struct E { int u, v, w; };
int main() {
int n, m, s;
if (!(cin >> n >> m >> s)) return 0;
vector<E> es(m);
for (auto& e : es) cin >> e.u >> e.v >> e.w;
vector<int> dist(n + 1, INF);
dist[s] = 0;
// ★ n−1 轮就够了:没有负环时,最短路是简单路径,最多 n−1 条边
for (int round = 1; round <= n - 1; round++) {
bool changed = false;
for (auto [u, v, w] : es) {
if (dist[u] == INF) continue; // ⚠ 负权下这一句是必须的,见上面
if (dist[u] + w < dist[v]) { dist[v] = dist[u] + w; changed = true; }
}
if (!changed) break; // 提前收敛了就不用跑满(不影响正确性)
}
// ★ 第 n 轮:还能松弛成功 ⇔ 从 s 能走到一个负环
for (auto [u, v, w] : es) {
if (dist[u] == INF) continue;
if (dist[u] + w < dist[v]) { cout << "NEGATIVE\n"; return 0; }
}
for (int i = 1; i <= n; i++) {
if (dist[i] >= INF) cout << "x";
else cout << dist[i];
cout << " \n"[i == n];
}
return 0;
}
点一下即可编辑
输入(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起点02∞3∞4∞5∞
点上面的数字 = 目前的 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 轮
// ✗ 错误版本② —— Bellman-Ford 的轮数少了一轮
//
// 和 bell.cpp 只差一个数字:
//
// bell.cpp: for (int round = 1; round <= n - 1; round++)
// 这里: for (int round = 1; round <= n - 2; round++) // ✗ 少跑一轮
//
// ★ 它专门用来验「n−1 这个数字到底是不是随便写的」。
// n−1 的来历是:**没有负环时最短路一定是简单路径,最多经过 n 个点、n−1 条边**;
// 而「跑完第 i 轮之后 dist 不差于『最多 i 条边』的答案」。
// 少跑一轮,就是「最多 n−2 条边」—— 只要有一条最短路**正好用满 n−1 条边**,它就漏了。
//
// ⚠ 而这种路在随机图上很少见:点少边多的时候,最短路往往两三条边就走完了。
// ★ 所以它也是给**生成器**出的题,而且要的东西和 wrongK.cpp 一样:
// 数据里得有**长链** —— 一条必须老老实实一格一格走过去的路。
// (正文第 9 步:掺入长链之前它是 0 / 300。)
//
// ⚠ 另外那句提前收敛的 `if (!changed) break;` 也一起删了。
// 留着它的话,图小的时候往往早就收敛完了,这个 bug 会被那句 break 顺手补上 ——
// **一个「优化」把 bug 盖住了**,那就验不出「n−1 是不是必须的」。
// (这也是一条经验:**做错误版本时,别让无关的优化替它把窟窿补上。**)
#include <bits/stdc++.h>
using namespace std;
const int INF = 0x3f3f3f3f;
struct E { int u, v, w; };
int main() {
int n, m, s;
if (!(cin >> n >> m >> s)) return 0;
vector<E> es(m);
for (auto& e : es) cin >> e.u >> e.v >> e.w;
vector<int> dist(n + 1, INF);
dist[s] = 0;
for (int round = 1; round <= n - 2; round++) { // ✗ 应该是 n − 1
for (auto [u, v, w] : es) {
if (dist[u] == INF) continue;
if (dist[u] + w < dist[v]) dist[v] = dist[u] + w;
}
}
for (auto [u, v, w] : es) {
if (dist[u] == INF) continue;
if (dist[u] + w < dist[v]) { cout << "NEGATIVE\n"; return 0; }
}
for (int i = 1; i <= n; i++) {
if (dist[i] >= INF) cout << "x";
else cout << dist[i];
cout << " \n"[i == n];
}
return 0;
}
点一下即可编辑
输入(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把三种算法的工作量数出来
// 把三种算法的「工作量」数出来 —— 光看秒表说不清谁在做什么
//
// 用法:./count < 输入
//
// 数三件事:
// · Floyd :三重循环跑了多少次(那就是 n³)
// · Bellman-Ford :松弛**尝试**了多少次、其中**成功**了多少次
// · SPFA :入队多少次、松弛尝试 / 成功多少次
//
// ★ 第二行那两个数是这一章最值钱的一组对照:
//
// Bellman-Ford 每一轮都把 m 条边全扫一遍,可其中**绝大多数是白扫的** ——
// 一条边只有在 dist[u] 刚刚变小时才可能松弛成功。
// **SPFA 的全部内容就是「别扫那些白扫的」**:谁变小了就把谁排队。
//
// 正文第 7 步拿这两个数把「为什么会想到 SPFA」讲成了一个可以量的事实,
// 而不是「有个优化叫 SPFA」。
//
// ⚠ 有负环时 Bellman-Ford / SPFA 会提前报 NEGATIVE 退出,那时数出来的是「到发现为止」的工作量。
#include <bits/stdc++.h>
using namespace std;
const int INF = 0x3f3f3f3f;
struct E { int u, v, w; };
int main() {
int n, m, s;
if (scanf("%d %d %d", &n, &m, &s) != 3) return 0;
vector<E> es(m);
vector<vector<pair<int, int>>> g(n + 1);
for (auto& e : es) {
if (scanf("%d %d %d", &e.u, &e.v, &e.w) != 3) break;
g[e.u].push_back({e.v, e.w});
}
/* ---------- Floyd ---------- */
long long floydSteps = 0;
// ⚠ n 一大,Floyd 的 n³ 本身就跑不动了(n = 2000 是 80 亿次)——
// 而「跑不动」正是这一节要说的话,所以大图上直接不跑,只报公式。
bool floydSkipped = (n > 400);
if (!floydSkipped) {
vector<vector<int>> d(n + 1, vector<int>(n + 1, INF));
for (int i = 1; i <= n; i++) d[i][i] = 0;
for (auto& e : es) d[e.u][e.v] = min(d[e.u][e.v], e.w);
for (int k = 1; k <= n; k++)
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++) {
floydSteps++;
if (d[i][k] < INF && d[k][j] < INF)
d[i][j] = min(d[i][j], d[i][k] + d[k][j]);
}
}
/* ---------- Bellman-Ford ---------- */
long long bfTry = 0, bfOk = 0;
int bfRounds = 0;
bool bfNeg = false;
{
vector<int> dist(n + 1, INF);
dist[s] = 0;
for (int round = 1; round <= n - 1; round++) {
bool changed = false;
bfRounds++;
for (auto [u, v, w] : es) {
if (dist[u] == INF) continue;
bfTry++;
if (dist[u] + w < dist[v]) { dist[v] = dist[u] + w; bfOk++; changed = true; }
}
if (!changed) break;
}
for (auto [u, v, w] : es) {
if (dist[u] == INF) continue;
if (dist[u] + w < dist[v]) { bfNeg = true; break; }
}
}
/* ---------- SPFA ---------- */
long long spPush = 0, spTry = 0, spOk = 0;
bool spNeg = false;
{
vector<int> dist(n + 1, INF), cnt(n + 1, 0), inq(n + 1, 0);
queue<int> q;
dist[s] = 0; q.push(s); inq[s] = 1; spPush++;
while (!q.empty() && !spNeg) {
int u = q.front(); q.pop(); inq[u] = 0;
for (auto [v, w] : g[u]) {
spTry++;
if (dist[u] + w < dist[v]) {
dist[v] = dist[u] + w; spOk++;
cnt[v] = cnt[u] + 1;
if (cnt[v] >= n) { spNeg = true; break; }
if (!inq[v]) { q.push(v); inq[v] = 1; spPush++; }
}
}
}
}
printf("n = %d 个点,m = %d 条边,起点 s = %d\n\n", n, m, s);
long long n3 = (long long)n * n * n;
if (floydSkipped)
printf("Floyd :n = %d 时三重循环要跑 %lld 次 —— 这一档直接没跑它\n", n, n3);
else
printf("Floyd :三重循环 %lld 次(n^3 = %lld)\n", floydSteps, n3);
printf("Bellman-Ford :跑了 %d 轮,松弛尝试 %lld 次,其中成功 %lld 次\n", bfRounds, bfTry, bfOk);
printf("SPFA :入队 %lld 次,松弛尝试 %lld 次,其中成功 %lld 次\n", spPush, spTry, spOk);
printf("\n");
if (bfTry > 0)
printf("★ Bellman-Ford 的松弛里只有 %.1f%% 是有用的(%lld / %lld)——\n"
" 剩下那些全是白扫的边。SPFA 的全部内容就是「别扫它们」:\n"
" 它只尝试了 %lld 次,是 Bellman-Ford 的 %.2f 倍。\n",
100.0 * bfOk / bfTry, bfOk, bfTry, spTry, bfTry ? (double)spTry / bfTry : 0.0);
printf("\n判负环:Bellman-Ford 说 %s,SPFA 说 %s\n",
bfNeg ? "有" : "没有", spNeg ? "有" : "没有");
return 0;
}
点一下即可编辑
输入(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 的队列优化
// SPFA —— Bellman-Ford 的队列优化
//
// 和 brute.cpp / bell.cpp 解同一道题,答案必须一模一样。
//
// ★ 想法只有一句:**Bellman-Ford 每轮都把所有边扫一遍,可其中绝大多数是白扫的。**
//
// 一条边 (u, v) 只有在 dist[u] **刚刚变小**的时候才可能松弛成功。
// 所以:谁的 dist 变小了,就把谁排队;从队列里取一个点出来,只松弛它的出边。
// —— 这就是 SPFA,一个字都没多。
//
// ★★ 而这里有一件第 32 章埋好的伏笔,现在可以挑明了:
//
// **SPFA 和第 32 章那份「在负权图上不小心答对了」的堆优化 Dijkstra,是同一件事。**
//
// 把这份代码里的 `queue` 换成「按 dist 排序的小根堆」,它逐字逐句就是那份 fast.cpp
// (连那句 `if (d > dist[u]) continue;` 都对应这里的 inq 判重)。
// 第 32 章说「堆优化在负权图上 300 轮一次都没错」——
// 现在知道原因了:**它当时执行的根本不是 Dijkstra,而是这个算法。**
// ⚠ 所以那句话的正确说法始终是:**不成立的是「取出来就定死」那个贪心。**
//
// ★ 判负环:给每个点记一个 cnt[v] = 「当前这条到 v 的最短路用了几条边」。
// 松弛成功时 cnt[v] = cnt[u] + 1;一旦 cnt[v] ≥ n,就说明这条路用了 n 条边 ——
// 必然重复经过某个点、绕了个环,而绕它还更短 → 负环。
// (和 bell.cpp 那个「第 n 轮还能松弛」是同一个道理的两种记法。)
// ⚠ 和 bell.cpp 一样,它天生只报告「从 s 能走到的」负环。
//
// ⚠ `inq[]` 的含义是「**它现在是不是正在队列里**」,不是「它来过没有」:
// 入队时置 1,**出队时必须清 0**。清晚了 / 忘了清,这个点就再也进不来了 ——
// 那就是 wrongSpfaInq.cpp。第 30 章「vis 要在入队时打」讲的是另一件事,别记混。
//
// 复杂度:**最坏还是 O(nm)**(它没有改变 Bellman-Ford 的上界,只是通常快很多)。
// ⚠ 「SPFA 已死」这句话说的就是这件事:出题人可以专门造网格状的数据把它卡回最坏。
// 正文第 7 步用 count.cpp 把「白扫的边」这个数量出来了。
#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<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), cnt(n + 1, 0), inq(n + 1, 0);
queue<int> q;
dist[s] = 0;
q.push(s); inq[s] = 1;
while (!q.empty()) {
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;
cnt[v] = cnt[u] + 1; // ★ 这条路用了几条边
if (cnt[v] >= n) { cout << "NEGATIVE\n"; return 0; }
if (!inq[v]) { q.push(v); inq[v] = 1; }
}
}
}
for (int i = 1; i <= n; i++) {
if (dist[i] >= INF) cout << "x";
else cout << dist[i];
cout << " \n"[i == n];
}
return 0;
}
点一下即可编辑
输入(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
// ✗ 错误版本⑤ —— SPFA 的 inq 标记,出队时忘了清
//
// 和 spfa.cpp 只差一行:
//
// spfa.cpp: int u = q.front(); q.pop(); inq[u] = 0; // 出队就清
// 这里: int u = q.front(); q.pop(); // ✗ 没清
//
// ★ 于是 inq[] 的含义被悄悄改成了「**它来过没有**」——
// 一个点出队之后 inq 还挂着 1,从此再也进不了队。
// 可 SPFA 的整个道理就建立在「一个点的 dist 变小了就得**重新**入队、把新值传下去」上面。
// 进不去 = 传不下去 = 一大片点停在半路上的值。
//
// ⚠ 这里有个很容易记混的地方,正文里特意点破了:
//
// 第 30 章:BFS 的 vis 要在**入队时**打,而且**永不清除** —— 那是为了「别塞两次」;
// 这一章: SPFA 的 inq 要在**出队时**清 —— 它说的是「**现在在不在队里**」。
//
// 两句话不打架,因为**它们是两个不同的变量**。
// BFS 里一个点确实只需要进一次(边权都一样长,第一次碰到就是最优);
// SPFA 里一个点可能进很多次(每次有人把它刷得更小)。
// ★ **「这个标记到底在记什么」比「它叫 vis 还是 inq」重要一百倍。**
//
// ⚠ 它还有个讨厌的性质:错得**不彻底**。很多图上第一次传播就已经是最优了,
// 它照样给出正确答案。要抓它就得让「某个点被刷小两次以上」真的发生 ——
// 而这在**有负权边**的图上才常见。
#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<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), cnt(n + 1, 0), inq(n + 1, 0);
queue<int> q;
dist[s] = 0;
q.push(s); inq[s] = 1;
while (!q.empty()) {
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;
cnt[v] = cnt[u] + 1;
if (cnt[v] >= n) { cout << "NEGATIVE\n"; return 0; }
if (!inq[v]) { q.push(v); inq[v] = 1; }
}
}
}
for (int i = 1; i <= n; i++) {
if (dist[i] >= INF) cout << "x";
else cout << dist[i];
cout << " \n"[i == n];
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

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

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

wrongInf.cpp(喂给它图 A)✗ 不判「走不到」就去松弛
// ✗ 错误版本③ —— 不判「走不到」就去松弛。★ 这一次它真的会传染
//
// 和 bell.cpp 只差一句:
//
// bell.cpp: if (dist[u] == INF) continue; // 走不到的点不参与松弛
// 这里: (删掉) // ✗
//
// ★★ 这一份是第 32 章那笔账的**正面回应**,值得单独停一下。
//
// 第 32 章我写过一个几乎一模一样的错误版本,然后被实测打脸:
// 那一章边权非负,`INF + w` 比 INF **大**,松弛条件 `dist[u] + w < dist[v]` 根本不成立,
// 所以「不判可达」在那里**一点事都没有**。
//
// 这一章边权可以是负的。`INF + (-7)` 比 INF **小** —— 那一句当场成立:
//
// 走不到的点,会被刷出一个「比 INF 小一点」的距离,然后顺着边一路传染出去。
//
// ⚠ **同一句代码危不危险,取决于数据的取值范围。**
// 「这句判断是不是多余的」这种问题,没有脱离数据的答案。
//
// ⚠ 它还会顺带把负环判错:那些被污染的点让第 n 轮凭空多出可松弛的边,
// 于是它有时会对一张根本没有负环的图喊 NEGATIVE。**一个 bug 同时污染两种输出。**
//
// ★ 所以这一份是给**生成器**出的题:数据里必须有**从 s 走不到的点**
// (第 30、32 章那条第三次登场),而且那些点之间还得有边,污染才传得开。
#include <bits/stdc++.h>
using namespace std;
const int INF = 0x3f3f3f3f;
struct E { int u, v, w; };
int main() {
int n, m, s;
if (!(cin >> n >> m >> s)) return 0;
vector<E> es(m);
for (auto& e : es) cin >> e.u >> e.v >> e.w;
vector<int> dist(n + 1, INF);
dist[s] = 0;
for (int round = 1; round <= n - 1; round++) {
for (auto [u, v, w] : es)
if (dist[u] + w < dist[v]) dist[v] = dist[u] + w; // ✗ 没挡住走不到的点
}
for (auto [u, v, w] : es)
if (dist[u] + w < dist[v]) { cout << "NEGATIVE\n"; return 0; }
for (int i = 1; i <= n; i++) {
if (dist[i] >= INF) cout << "x";
else cout << dist[i];
cout << " \n"[i == n];
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

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

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

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

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

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

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

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

wrongGlobalNeg.cpp(喂给它图 A)✗ 判负环时不管 s 走不走得到
// ✗ 错误版本④ —— 判负环时不管「s 到底走不走得到它」
//
// 和 brute.cpp 只差半句:
//
// brute.cpp: if (d[k][k] < 0 && d[s][k] < INF) → NEGATIVE
// 这里: if (d[k][k] < 0) → NEGATIVE // ✗ 少了后半句
//
// ★ 它错的不是算法,是**题面**:题目问的是「从 s 出发能不能走到负环」,
// 而 `d[k][k] < 0` 回答的是「图里有没有负环」。图的角落里躺着一个负环,
// 可 s 根本走不过去 —— 那道题的答案照样是一串距离,不是 NEGATIVE。
//
// ★★ 这一份最值钱的地方在于对照:
//
// | | 「只算 s 能走到的」这件事 |
// |---|---|
// | Floyd(brute.cpp) | 要**手动补**一句 `d[s][k] < INF` —— 它算的是全图 |
// | Bellman-Ford / SPFA | **白送** —— dist 从 s 初始化,走不到的点永远是 INF |
//
// 同一个题面,一个天生满足、一个必须自己补 ——
// **这种地方最容易在两份代码之间写出不一致,而且两边各自看都「没毛病」。**
// (第 19 章那条「题目对边界的约定要抄进注释」,这是图论版。)
//
// ⚠ 这一份是给**生成器**出的最刁的一道题:数据里得有「**s 走不到的负环**」。
// 而这要求两件事同时成立:图不连通(第 30、32 章那条),并且**孤立的那一块里有负环**。
// 正文第 9 步那张表里,它是最后一个从 0 变成非 0 的。
#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;
d[u][v] = min(d[u][v], w);
}
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 k = 1; k <= n; k++)
if (d[k][k] < 0) { cout << "NEGATIVE\n"; return 0; } // ✗ 没问 s 走不走得到
for (int i = 1; i <= n; i++) {
if (d[s][i] >= INF) cout << "x";
else cout << d[s][i];
cout << " \n"[i == n];
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

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

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

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

对拍器
★ 这个生成器调了九次。它得同时造出:负权边、走不到的点、s 走不到的负环、以及「必须走满 n−1 条边」的长链 —— 而这四样互相打架。
// ★ Bellman-Ford —— 负权的正经办法,也是这一章第二个关键一步
//
// 和 brute.cpp(Floyd)解同一道题,答案必须一模一样。
//
// ★ 一句话:**把所有边挨个松弛一遍,叫一轮;跑 n−1 轮就够了。**
//
// 为什么是 n−1,而且一轮不多一轮不少 —— 关键在这个不变量:
//
// 跑完第 i 轮之后,dist[v] ≤「从 s 出发、**最多走 i 条边**能到 v 的最短长度」
//
// (归纳一行就完了:第 i 轮松弛边 (u,v) 时,dist[u] 已经不差于「最多 i−1 条边」的答案。)
//
// 而**没有负环时,最短路一定是一条简单路径** —— 它最多经过 n 个点、也就是 n−1 条边。
// 所以 n−1 轮之后一定收敛。★ 「n−1」不是背下来的,是「简单路径最多 n−1 条边」这句话。
//
// ★ 判负环是白送的(第 31 章「判环白送」的第二次):
//
// 跑完 n−1 轮之后,**再跑一轮**。要是还有边能松弛成功 —— 有负环。
//
// 为什么:能松弛成功说明存在一条「用了 n 条边还更短」的走法,
// n 条边的走法必然重复经过某个点,也就是绕了一个环,而绕它让距离变小 → 那是负环。
//
// ⚠ 而且这份代码天生只报告「**从 s 能走到的**负环」:
// dist 是从 s 初始化的,走不到的点永远是 INF,`if (dist[u] == INF) continue;` 会把它挡住。
// ★ 对照 brute.cpp:Floyd 看的是全图,那个过滤必须自己补一句 `d[s][k] < INF`。
// **同一个题面,两种算法一个天生满足、一个必须手动补** —— 这种地方最容易写出不一致。
//
// ⚠ `if (dist[u] == INF) continue;` 这一句在**负权图上是必须的**,不是保险:
// `INF + (-7)` 比 INF 小,不挡住的话,走不到的点会被刷出一个「比 INF 小一点」的距离,
// 而且顺着边传染。第 32 章那个「以为会传染、实测不会」的坑,在这一章**真的会**发生 ——
// 因为那时边权非负。**同一句代码危不危险,取决于数据的取值范围。**
//
// 复杂度 O(nm):n 轮,每轮扫 m 条边。比 Floyd 的 O(n³) 好在稀疏图上。
#include <bits/stdc++.h>
using namespace std;
const int INF = 0x3f3f3f3f;
struct E { int u, v, w; };
int main() {
int n, m, s;
if (!(cin >> n >> m >> s)) return 0;
vector<E> es(m);
for (auto& e : es) cin >> e.u >> e.v >> e.w;
vector<int> dist(n + 1, INF);
dist[s] = 0;
// ★ n−1 轮就够了:没有负环时,最短路是简单路径,最多 n−1 条边
for (int round = 1; round <= n - 1; round++) {
bool changed = false;
for (auto [u, v, w] : es) {
if (dist[u] == INF) continue; // ⚠ 负权下这一句是必须的,见上面
if (dist[u] + w < dist[v]) { dist[v] = dist[u] + w; changed = true; }
}
if (!changed) break; // 提前收敛了就不用跑满(不影响正确性)
}
// ★ 第 n 轮:还能松弛成功 ⇔ 从 s 能走到一个负环
for (auto [u, v, w] : es) {
if (dist[u] == INF) continue;
if (dist[u] + w < dist[v]) { cout << "NEGATIVE\n"; return 0; }
}
for (int i = 1; i <= n; i++) {
if (dist[i] >= INF) cout << "x";
else cout << dist[i];
cout << " \n"[i == n];
}
return 0;
}
点一下即可编辑

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

⚠ 这里的「轮」是对拍的第几组随机数据,和 Bellman-Ford 那个「跑了几轮松弛」没有半点关系 —— 这一章里「轮」这个字被两件事共用了,看表时先认一眼主语。

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

(这 300 轮里,62 轮的答案是 NEGATIVE、141 轮有走不到的点, 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→R、U→U、U→R,永远不连 R→U —— 于是 U 天生从 s 走不到。 两块各串一条链保证内部连通,负环想注入哪一块都行。 把负环注入到 U 里,它就是「一个 s 永远走不到的负环」 —— 第 ③ 件事有了。

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

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

档位 改了什么 NEGATIVE 有 x k 放错层 少跑一轮 不判可达 不看可达 inq 没清
0(最初) 非负权 + 处处可达 + 纯随机边 0 0 83 2 0 0 12
1 用势函数 → 出现负权边 0 0 83 2 0 0 12
2 L 可以小于 n → 出现走不到的点 0 239 31 0 97 0 4
3 R 那条链的 w0 全压成 0 → 逼出长最短路 0 239 73 4 97 0 69
4 注入负环(只往 R 里注) 300 0 1 0 0 0 284
5 负环也可能注入进 U 241 59 11 0 59 59 233

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

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

★ 第二张表:四次调平衡,其中一次得把两件事绑在一起做
档位 改了什么 NEGATIVE 有 x k 放错层 少跑一轮 不判可达 不看可达 inq 没清
5 (上一张表的最后一行) 241 59 11 0 59 59 233
6 注入比例降到 2/3 133 152 33 0 113 77 148
7 ★ 最坏边序 + 链拉满全图 152 98 48 37 71 57 170
8 注入比例再降到 1/3 74 129 84 71 73 35 145
9(在用) 注入尽量往 U 里放 62 141 85 71 85 47 135

★ 档位 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,只能在终端里跑)。 ⚠ 这张表的档位和第 7 步那次工作量统计不是同一组数据(那次固定在 ./genBig 2000, 这里最接近的一行是 2 400)—— 两张表要横着对的是形状,别拿某一行的秒数去除某一行的次数。

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
300 1 199 0.01 秒 0.00 秒 0.00 秒
600 2 399 0.09 秒 0.00 秒 0.00 秒
1 200 4 799 0.69 秒 0.00 秒 0.00 秒
2 400 9 599 5.77 秒 0.00 秒 0.00 秒
20 000 79 999 跑不动 0.04 秒(11 轮) 0.05 秒
80 000 319 999 跑不动 0.19 秒(13 轮) 0.23 秒
320 000 1 279 999 跑不动 0.87 秒(15 轮) 1.37 秒

★ Floyd 的 n³ 一点折扣都不打: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) ✗ ✗ 边权非负 —— 能用它就用它,快一个数量级
Floyd O(n³) ✓ ✓(全图) 要所有点对之间的距离,而且 n 很小(几百)
Bellman-Ford O(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 章「答案不唯一」的第二次登场。)

⚠ 去洛谷交题之前:本章那个 x 是我们自己定的

「走不到输出 x」是这一章为了把话说清楚才定的记号,真题不这么写:

  • 洛谷的单源最短路模板(P3371 / P4779)要求走不到的点输出 2147483647;
  • 判负环那一类(P3385、P2850)问的干脆是 YES / NO,一个距离都不用输出。

⇒ 算法一个字都不用改,改的是输出那几行。 交题前先把题面里 「走不到怎么输出」「多组数据要不要清空」这两句找出来 —— 这正是第 1 步那条「答案的记号和数据的取值范围是一对」的考场版本。

13自测

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