④ 数据结构与算法 — 第 12 课
dis 最小的未确定点,就地「盖章」确定,靠一条前提保证盖下去不反悔:边权非负。n-1 轮。dis 初始 ①0 ②∞ ③∞ ④∞,然后:dis=2 最小。要从别处绕到 ③,必须先经过某个未定点 x,而 dis[x] ≥ 2(③ 是最小的那个),再加上边权 ≥ 0,绕过去只会更贵 —— 所以 2 就是最终答案。这句推理里「边权 ≥ 0」正是今天要拆掉的砖。
四个点,一条负权边:
1 --2--> 2 --1--> 4
1 --3--> 3
3 --(-2)--> 2
真值:d2 = 3 + (-2) = 1,d4 = d2 + 1 = 2
把上节课那份朴素 Dijkstra 原样跑一遍(下面这段是程序实际打印出来的过程):
finalize 1 (d=0) : relax 1->2 => d2=2 relax 1->3 => d3=3
finalize 2 (d=2) : relax 2->4 => d4=3
finalize 3 (d=3) : relax 3->2 => d2=1 <-- 2 号已经盖过章了
finalize 4 (d=3) :
d1=0 d2=1 d3=3 d4=3 <-- d4 错,真值 2
错在哪一步?4 号的距离 3 是拿「2 号 = 2」这个旧值算出来的。后来 3 号把 2 号压到了 1,可 2 号已经盖过章 —— 它的出边不会再被走一遍,4 号就永远停在 3。
更阴的是:d2 最后显示 1,是对的。因为数组里的值确实被改小了,只是「改了也不会重新往外传播」。所以负权图上的错误常常不是一眼可见的崩,而是有的点对、有的点错。
既然「盖章」是问题,那就干脆不盖。规则只有一句:
n-1 轮。每轮从头到尾扫一遍边数组,只要 dis[u] + w < dis[v] 就更新 dis[v]。一个点被改小多少次都行,从不「定死」。n-1:没有负环时,最短路一定是简单路径(绕圈只会变贵或白走),最多 n-1 条边;每松弛一轮,至少能把「路径上再多走一条边」的信息往外传一层。
代码(原样用 g++ -O2 -std=c++17 -Wall -Wextra 编译过,零警告):
const int INF = 0x3f3f3f3f;
struct Edge { int u, v, w; }; // 有向边 u -> v,权 w
vector<Edge> edges;
// 返回 true = 没有负环;顺便把 dis 算好
bool bellman_ford(int s, vector<int>& dis) {
dis.assign(n + 1, INF);
dis[s] = 0;
for (int round = 1; round <= n - 1; round++) {
bool changed = false;
for (auto& e : edges) {
if (dis[e.u] == INF) continue; // 不可达的点别拿去加
if (dis[e.u] + e.w < dis[e.v]) {
dis[e.v] = dis[e.u] + e.w;
changed = true;
}
}
if (!changed) break; // 整轮没变化 → 提前收工
}
for (auto& e : edges) // 第 n 轮还能松弛 → 有负环
if (dis[e.u] != INF && dis[e.u] + e.w < dis[e.v]) return false;
return true;
}
拿第 1 节那张图实测(同一个程序里跑的):
bellman_ford ok=1 : d1=0 d2=1 d3=3 d4=2 <-- d4 对了
spfa ok=1 : d1=0 d2=1 d3=3 d4=2
floyd : d(1,2)=1 d(1,3)=3 d(1,4)=2
Bellman-Ford 在第 1 轮就把 d4 修正成了 2 —— 因为它不认「盖子」,每轮都重新看所有边。
「最多 n-1 轮」是最坏情况,实际要几轮和边的排列顺序关系很大。实测一张链 1→2→3→4→5→6(每条边权 1,真值 d6 = 5):
| 边数组的给出顺序 | 实际用了多少轮 | d6 |
|---|---|---|
1→2, 2→3, 3→4, 4→5, 5→6(顺着路径) | 1 轮 | 5 |
5→6, 4→5, 3→4, 2→3, 1→2(逆着路径) | 5 轮(= n-1) | 5 |
顺着扫时,一轮之内信息就能一路传到链尾;逆着扫时每轮只往前挪一条边。所以:最坏是 n-1 轮,别指望它总是快;而 if (!changed) break; 这个提前退出,在多数图上能省掉大量无用轮次。
复杂度 O(n·m)。用 300 组随机小图(点数 4-7、边权 -5…7,含负权、剔除含负环的)与 Floyd 交叉验证:可比 73 组全部一致,0 组不一致(另外 227 组因为含负环被跳过)。
沿着上面的思路再推一步。既然 n-1 轮足以求出所有最短路,那么第 n 轮还能把某个点改小,只能意味着一件事:存在一条「边权和为负的回路」,可以无限绕圈、永远更便宜 —— 这就是负环。
检测代码就是上面那份的第 n 轮那三行:
for (auto& e : edges)
if (dis[e.u] != INF && dis[e.u] + e.w < dis[e.v]) return false; // 有负环
dis[e.u] != INF(continue 那一行)不能省:不可达的点本来就等于没被松弛过,不筛掉就会把「另一个连通块里的负环」也算进来,答案直接错。
实测两种判据在两张图上的表现 —— 完全一致:
| 图 | Bellman-Ford 第 n 轮 | SPFA 计数法 |
|---|---|---|
A:环 2→3→2,权 1 + (-3) = -2,从 1 可达 | YES | YES |
| B:同一个环搬到 5、6,从 1 不可达 | NO | NO |
回头看 Bellman-Ford:每一轮它把所有边都扫一遍,可一轮里真正能引起变化的,只有「刚刚被改小」的那些点的出边。其余的点全是白扫。
那就用队列记下「哪些点刚变过,值得去松弛它的邻居」:
struct Arc { int to, w; };
vector<vector<Arc>> adj; // 邻接表
// 返回 true = 没有负环
bool spfa(int s, vector<int>& dis) {
dis.assign(n + 1, INF);
vector<int> cnt(n + 1, 0); // cnt[v]:当前这条最短路用了多少条边
vector<bool> inq(n + 1, false); // 是否已在队列里(避免重复入队)
deque<int> q;
dis[s] = 0; q.push_back(s); inq[s] = true;
while (!q.empty()) {
int u = q.front(); q.pop_front(); inq[u] = false;
for (auto& a : adj[u]) {
if (dis[u] + a.w < dis[a.to]) {
dis[a.to] = dis[u] + a.w;
cnt[a.to] = cnt[u] + 1;
if (cnt[a.to] >= n) return false; // 走了 n 条边还在变短 → 负环
if (!inq[a.to]) { q.push_back(a.to); inq[a.to] = true; }
}
}
}
return true;
}
负环判据换了个说法:一条最短路如果用了 ≥ n 条边,就必然重复经过了某个点 —— 也就是绕了一个负环。(另一种等价写法是统计「入队次数 ≥ n」,效果一样。)
| 图 | 堆优化 Dijkstra | SPFA | 结论 |
|---|---|---|---|
| 随机稀疏:n = 105、m = 3×105 | 15.3 ms | 6.2 ms | SPFA 快 2.5×(两边答案完全一致) |
| 网格 400×400(16 万点 / 63.84 万条有向边) | 19.9 ms | 52.0 ms | SPFA 慢 2.6×(就是它被卡的那种图) |
| P3385 规模:T = 10、n = 2000、m = 3000 | —— | 60.9 ms | 同规模下 Bellman-Ford 48.6 ms,反而更快 |
三行数据连起来就是一句话:SPFA 是「平均很快」,不是「很快」。它的最坏复杂度仍是 O(n·m),出题人只要造一张网格图或特殊构造的图,就能让它退化。P4779 的题目背景讲的就是这件事(那位同学用 SPFA 交了 60 分)。
O(m log m))。SPFA 的价值在负权 + 单源,以及作为负环检测的顺手工具。
前面三个都是「单源」:一个起点,算出它到所有点的距离。如果题目要问任意两点之间的距离,而且 n 不大,那就用它 —— 代码五行:
for (int k = 1; k <= n; k++) // 中转点,必须最外层
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
if (f[i][k] + f[k][j] < f[i][j])
f[i][j] = f[i][k] + f[k][j];
它其实是一个三维 DP:f[k][i][j] = 「只允许把 1…k 这些点当中转点,i 到 j 的最短路」。k 是 DP 的阶段,所以滚动成二维数组之后,k 那一层必须留在最外面。
图:1→3 (1)、3→2 (1)、2→4 (1)、1→4 (10),真值 d(1,4) = 1+1+1 = 3。三种循环顺序各跑一遍:
| 循环顺序 | 算出的 d(1,4) | 结果 |
|---|---|---|
(k, i, j):k 最外层(教科书写法) | 3 | 正确 |
(i, k, j):k 挪到中间 | 10 | 错 |
(i, j, k):k 放最内层 | 3 | 正确 |
在这张图上,中间那种写法把答案算成了 10 —— 因为轮到它处理 (i=1, j=4) 时,f[1][3] 还没被算出来,等它算出来时那个 k 已经过去了。
1→2→3→4 那条链上三种顺序都对)。所以不要靠「我这样写也过了」来判断 —— 背下 k 在最外层。
INT_MAX 会溢出(实测)如果图里有不可达的点对,Floyd 也要跟 INF 打交道。同一张图(只有 2→3 一条边、1 号孤立)三种写法实测:
| 写法 | f[1][2] | f[1][3] | 该有的值 |
|---|---|---|---|
INF = INT_MAX,不判断 | 2147483637 | 2147483631 | 2147483647(不可达) |
INF = 0x3f3f3f3f,不判断 | 1061109567 | 1061109567 | |
INF = INT_MAX + >= INF 判断 | 2147483647 | 2147483647 |
第一行就是经典溢出:INF + INF 溢出成负数,比 f[i][j] 还小,于是「不可达」被写成了一个奇怪的巨大值。第二行侥幸没事,是因为 2 × 0x3f3f3f3f 还没溢出 int —— 所以用 0x3f3f3f3f 当 INF,或者老老实实加上判断,两者选一个。
| n | 运算次数 | 实测耗时 | 二维数组内存 |
|---|---|---|---|
| 300 | 2.7×107 | 17.9 ms | ≈ 0.36 MB |
| 1000 | 109 | 462.8 ms | ≈ 4 MB |
| 2000 | 8×109 | 约 3.7 秒(按上面的比例外推) | ≈ 16 MB |
结论:n 在 500 以内、又需要多源或稠密图时用 Floyd;n 上千就别想了。
负权它也吃得下(这正是它的长处)。判负环也顺手:跑完之后只要有任何 f[i][i] < 0,就存在负环 —— 注意 Floyd 判的是「全图」,不区分是否从某个起点可达。
| 情形 | 用什么 | 复杂度 |
|---|---|---|
| 单源、边权全部非负 | 堆优化 Dijkstra(0411) | O(m log m) |
| 单源、有负权、n·m 不大 | Bellman-Ford | O(n·m) |
| 单源、有负权、想赌它快 | SPFA(会被卡) | 平均快,最坏 O(n·m) |
| 要判负环 | Bellman-Ford 第 n 轮 / SPFA 计数 | 同上 |
| 多源(任意两点),n ≤ 500 | Floyd | O(n3) |
u v w:若 w ≥ 0,表示 u→v 和 v→u 两条边都存在、权都是 w;若 w < 0,只有 u→v 一条边。2m。
完整解法(原样编译运行过;另外用 300 组随机 P3385 格式数据与一份独立的 Floyd 判环脚本对拍,0 组不一致):
#include <bits/stdc++.h>
using namespace std;
int main() {
int T; scanf("%d", &T);
while (T--) {
int n, m; scanf("%d %d", &n, &m);
vector<vector<pair<int,int>>> adj(n + 1); // 每组重新建,天然清空
for (int i = 0; i < m; i++) {
int u, v, w; scanf("%d %d %d", &u, &v, &w);
if (w >= 0) { adj[u].push_back({v, w}); adj[v].push_back({u, w}); }
else { adj[u].push_back({v, w}); } // 负权只有单向
}
const int INF = 1e9;
vector<int> dis(n + 1, INF), cnt(n + 1, 0);
vector<bool> inq(n + 1, false);
deque<int> q;
dis[1] = 0; q.push_back(1); inq[1] = true;
bool has = false;
while (!q.empty() && !has) {
int u = q.front(); q.pop_front(); inq[u] = false;
for (auto& e : adj[u]) {
if (dis[u] + e.second < dis[e.first]) {
dis[e.first] = dis[u] + e.second;
cnt[e.first] = cnt[u] + 1;
if (cnt[e.first] >= n) { has = true; break; }
if (!inq[e.first]) { q.push_back(e.first); inq[e.first] = true; }
}
}
}
puts(has ? "YES" : "NO");
}
return 0;
}
用这组输入实测(两组数据,按题面规则手推的结果是 NO 然后 YES,程序输出完全一致):
2
3 4
1 2 2
1 3 4
2 3 1
3 1 -3
3 3
1 2 3
2 3 4
3 1 -8
NO
YES
第一组三个非负权各自是双向边,环 1→3→1 是 4 + (-3) = 1,1→2→3→1 是 0,都不是负的 → NO。第二组 1→2→3→1 = 3 + 4 + (-8) = -1 < 0 → YES。
w ≥ 0 的行是不是只 push 了一次?(要 push 两次)dis / cnt / inq / 邻接表是不是每组都重建?cnt[v] >= n 还是不小心的 > n?inq 没判会变慢,但不会错)d4 给 3,真值 2)if (!changed) break; 别省if (dis[u] == INF) continue; —— P3385 只问「从 1 可达的」负环inq 数组:不判就重复入队,慢;判错了(出队没置回 false)会漏松弛cnt 用 cnt[u] + 1:它是「路径边数」,不是「入队次数」,两种判据别混着写一半k 必须最外层:实测挪到中间那层会给出 10(真值 3)INT_MAX 就必须加 >= INF 判断,否则相加溢出污染答案f[i][i] < 0 不区分可达性,和 P3385 的要求不是一回事n-1 轮就够了?n 轮还能松弛某个点的距离,说明什么?k 为什么必须在最外层?w ≥ 0 的一行代表什么?w ≥ 0 要加双向边(这是题面最大的坑);② 多测,每组都要重建 dis / cnt / inq / 邻接表;③ 只判「从 1 可达」的负环。SPFA 或 Bellman-Ford 都能过(实测两者都在 60 ms 内)。w ≥ 0 有没有加双向边、多测有没有重建、cnt 判据是不是 >= n、负环有没有限定「从 1 可达」。d4 给 3,真值 2)n-1 轮,O(n·m);轮数受边的排列顺序影响(实测 1 轮 vs 5 轮)n 轮还能松弛 → 有负环;必须限定「从起点可达」cnt[v] = cnt[u] + 1 ≥ n 判负环;平均快、最坏 O(n·m),网格图上实测慢 2.6×O(n³)、多源、能吃负权;k 必须在最外层(实测换顺序给出 10,真值 3)INT_MAX 相加会溢出污染答案,用 0x3f3f3f3f 或加 >= INF 判断deque 和 queue 有什么区别、cnt 为什么能判负环、Floyd 的 DP 阶段是什么 —— 直接问我,我从零再讲一遍。