```mermaid graph LR A[Mermaid Keeper] --> B[保持激活] ```

前言

上一篇的 Dijkstra 有一个硬性前提:所有边权非负。但现实中有”收益”——走某条路不是付出代价,而是获得奖励(负权边)。比如从城市 A 到城市 B 的运输,走某条路反而能赚 100 元,这相当于权值 100-100

Dijkstra 在这种图上会出错。回忆它的贪心证明:dist[v]dist[u]dist[v] \ge dist[u],加上非负的边权 w0w \ge 0,所以经过 vv 的路径不可能更短。但如果 ww 可以是 100-100,这个论证就崩溃了——经过 vv 的路径确实可能更短。

Bellman-Ford 不依赖”非负”假设。它的方法暴力但可靠:对所有边做 N1N-1 轮松弛。时间 O(NM)O(NM),比 Dijkstra 慢,但能处理负权边。

Floyd-Warshall 解决另一个问题:不求”从一个起点出发”,而是求所有节点对之间的最短路。三重循环,O(N3)O(N^3),代码极短。

问题的本质

Dijkstra 的证明在负权时怎么崩溃的?

再看 Dijkstra 的核心论证:取出 uu(距离最小的未确定节点),声称 dist[u]dist[u] 已经是最短路。因为任何经过其他未确定节点 vv 的路径,长度 dist[v]dist[u]\ge dist[v] \ge dist[u]

但如果存在负权边 vuv \to u(权 100-100),经过 vv 的路径长度 =dist[v]+(100)= dist[v] + (-100),可能 <dist[u]< dist[u]。Dijkstra 的贪心选择不再安全——uu 的距离还可能被后续发现的负权边更新。

Bellman-Ford:为什么 N-1 轮就够?

Bellman-Ford 的思路非常直接:对所有边执行松弛操作,重复 N1N-1 轮。

为什么 N1N-1 轮就够? 因为最短路径最多经过 N1N-1 条边(经过 NN 条边必然重复某个节点,形成环。如果是负环,最短路不存在;如果不是负环,去掉环路径更短)。第一轮松弛确定了”经过 1 条边”的最短路,第二轮确定了”经过 2 条边”的,……,第 N1N-1 轮确定了”经过 N1N-1 条边”的最短路——这就覆盖了所有可能的最短路。

怎么检测负环? 再做一轮(第 NN 轮)。如果还能松弛,说明存在经过 NN 条边的更短路径——这只有在存在负环时才可能。

Floyd-Warshall:三重循环的智慧

Floyd 解决的问题是:给定 NN 个节点的加权图(可以有负权边),求每对节点之间的最短路。结果是一个 N×NN \times N 的矩阵,dist[i][j]dist[i][j] 是从 iijj 的最短距离。

核心思想:逐步允许经过更多的中转节点dp[k][i][j]dp[k][i][j] 表示”只允许经过节点 1,2,,k1, 2, \ldots, k 作为中转”时 iijj 的最短路。转移方程:

dp[k][i][j]=min(dp[k1][i][j], dp[k1][i][k]+dp[k1][k][j])dp[k][i][j] = \min(dp[k-1][i][j],\ dp[k-1][i][k] + dp[k-1][k][j])

为什么 kk 必须是最外层循环? 因为 dp[k]dp[k] 只依赖 dp[k1]dp[k-1]。如果把 kk 放在内层,可能用已经更新过的 dp[k]dp[k] 值去更新其他节点,导致错误。这和背包问题中”01 背包内层逆序”是同一个道理——依赖关系决定了循环顺序。

用滚动数组优化后,只需要一个二维数组:

dist[i][j]=min(dist[i][j], dist[i][k]+dist[k][j])dist[i][j] = \min(dist[i][j],\ dist[i][k] + dist[k][j])

代码只有三行循环,但背后的 DP 思想非常深刻。

理论 + 代码

Bellman-Ford

#include <cstdio>
#include <cstring>
using namespace std;

typedef long long ll;
const ll INF = 1e18;
const int MAXN = 1006, MAXM = 2006;

struct Edge { int u, v, w; };
Edge edges[MAXM];
ll dist[MAXN];
int N, M;

bool bellman_ford(int start) {
    for (int i = 1; i <= N; i++) dist[i] = INF;
    dist[start] = 0;
    for (int i = 0; i < N - 1; i++) {       // ① N-1 轮
        bool updated = false;
        for (int j = 0; j < M; j++) {
            int u = edges[j].u, v = edges[j].v, w = edges[j].w;
            if (dist[u] < INF && dist[v] > dist[u] + w) { // ② 松弛
                dist[v] = dist[u] + w;
                updated = true;
            }
        }
        if (!updated) break;                // ③ 提前终止
    }
    // 检测负环
    for (int j = 0; j < M; j++) {
        int u = edges[j].u, v = edges[j].v, w = edges[j].w;
        if (dist[u] < INF && dist[v] > dist[u] + w) return false; // ④ 第 N 轮还能松弛 → 负环
    }
    return true;
}

逐行解析

  • N1N-1 轮松弛。为什么是 N1N-1 不是 NN?最短路最多经过 N1N-1 条边(NN 个节点不重复的路径)。每轮松弛至少让”经过 kk 条边的最短路”被确定一轮。
  • ② 松弛条件 dist[u] < INF:如果 uu 本身不可达,不能用它去松弛别人。dist[u] + w 可能溢出。
  • ③ 如果一轮下来没有任何更新,说明所有最短路已经确定了,提前退出。这是常见优化——实际中很多图不需要跑满 N1N-1 轮。
  • ④ 第 NN 轮还能松弛 → 存在负环。负环意味着可以无限绕圈让距离越来越小,最短路不存在。

Floyd-Warshall

#include <cstdio>
#include <algorithm>
using namespace std;

const int MAXN = 406;
int dist[MAXN][MAXN];
int N, M;

void floyd() {
    for (int k = 1; k <= N; k++)          // ① 中转点(最外层!)
        for (int i = 1; i <= N; i++)
            for (int j = 1; j <= N; j++)
                dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j]); // ② 松弛
}

逐行解析

  • kk 必须是最外层循环。为什么?因为 DP 的定义是”只允许经过 1..k1..k 作为中转”。如果 kk 在内层,更新 dist[i][j]dist[i][j] 时可能用到了已经考虑过 kkdist[i][k]dist[i][k],打破了”只经过 1..k11..k-1“的假设。
  • ② 核心转移:要么不经过 kk(直接 iji \to j),要么经过 kkikji \to k \to j),取较短的。

初始化:dist[i][i] = 0dist[i][j] = w(如果有边),其余 INF

三种最短路算法对比

DijkstraBellman-FordFloyd-Warshall
适用图非负权任意权(含负)任意权(含负)
求解目标单源单源全源
时间复杂度O((N+M)logN)O((N+M)\log N)O(NM)O(NM)O(N3)O(N^3)
负环检测需要额外判断
竞赛使用★★★★★★★★★★

选择原则:非负权单源 → Dijkstra,有负权单源 → Bellman-Ford(或 SPFA),全源 → Floyd(N400N \le 400 时)。

例题

例题 1:Bellman-Ford 基础——有负权边的最短路

场景NN 个城市、MM 条单向道路,每条路有一个可能为负的耗时。求从城市 1 到城市 NN 的最短时间。如果不可达输出 “unreachable”。

数据范围2N10002 \le N \le 10001M20001 \le M \le 2000109Ci109-10^9 \le C_i \le 10^9

分析:标准 Bellman-Ford。边权可能为负,所以 Dijkstra 不适用。

代码

#include <cstdio>
using namespace std;

typedef long long ll;
const ll INF = 1e18;
const int MAXN = 1006, MAXM = 2006;

struct Edge { int u, v; ll c; } e[MAXM];
ll dist[MAXN];

int main() {
    int N, M;
    scanf("%d%d", &N, &M);
    for (int i = 0; i < M; i++)
        scanf("%d%d%lld", &e[i].u, &e[i].v, &e[i].c);
    for (int i = 1; i <= N; i++) dist[i] = INF;
    dist[1] = 0;
    for (int i = 1; i < N; i++)           // ① N-1 轮
        for (int j = 0; j < M; j++)
            if (dist[e[j].u] < INF)
                if (dist[e[j].v] > dist[e[j].u] + e[j].c)
                    dist[e[j].v] = dist[e[j].u] + e[j].c;
    if (dist[N] == INF) printf("unreachable\n");
    else printf("%lld\n", dist[N]);
    return 0;
}

逐行解析

  • N1N-1 轮松弛保证:如果无负环,最短路最多经过 N1N-1 条边,N1N-1 轮后一定收敛。

例题 2:负环检测

场景NN 个城市,MM 条单向道路(边权可正可负)。判断从城市 1 出发,是否存在可以被利用的负环(即从 1 能到达的负环)。

数据范围1N5001 \le N \le 5001M10001 \le M \le 1000

代码

#include <cstdio>
using namespace std;

typedef long long ll;
const ll INF = 1e18;
const int MAXN = 506, MAXM = 1006;

struct Edge { int u, v; ll c; } e[MAXM];
ll dist[MAXN];

int main() {
    int N, M;
    scanf("%d%d", &N, &M);
    for (int i = 0; i < M; i++)
        scanf("%d%d%lld", &e[i].u, &e[i].v, &e[i].c);
    for (int i = 1; i <= N; i++) dist[i] = INF;
    dist[1] = 0;
    // ① N-1 轮正常松弛
    for (int i = 1; i < N; i++)
        for (int j = 0; j < M; j++)
            if (dist[e[j].u] < INF)
                if (dist[e[j].v] > dist[e[j].u] + e[j].c)
                    dist[e[j].v] = dist[e[j].u] + e[j].c;
    // ② 第 N 轮:还能松弛说明有负环
    bool neg = false;
    for (int j = 0; j < M; j++)
        if (dist[e[j].u] < INF && dist[e[j].v] > dist[e[j].u] + e[j].c)
            neg = true;
    printf("%s\n", neg ? "NEGATIVE CYCLE" : "OK");
    return 0;
}

逐行解析

  • ② 第 NN 轮额外检查:如果还能松弛,说明存在可以无限降低距离的环。由于只有从起点可达的节点 dist 不是 INF,所以只会检测从起点可达的负环。

例题 3:Floyd-Warshall 基础

场景NN 个城市,MM 条双向道路。QQ 个查询:从城市 AiA_i 到城市 BiB_i 的最短距离。

数据范围2N4002 \le N \le 4001MN(N1)/21 \le M \le N(N-1)/21Q1051 \le Q \le 10^5

分析N400N \le 400,Floyd 的 O(N3)=6.4×107O(N^3) = 6.4 \times 10^7 刚好能过。QQ 次查询直接查 dist[A][B]

代码

#include <cstdio>
#include <algorithm>
using namespace std;

typedef long long ll;
const ll INF = 1e18;
const int MAXN = 406;

ll dist[MAXN][MAXN];

int main() {
    int N, M;
    scanf("%d%d", &N, &M);
    for (int i = 1; i <= N; i++)
        for (int j = 1; j <= N; j++)
            dist[i][j] = (i == j) ? 0 : INF; // ① 初始化
    for (int i = 0; i < M; i++) {
        int a, b; ll c;
        scanf("%d%d%lld", &a, &b, &c);
        dist[a][b] = min(dist[a][b], c);     // ② 处理重边
        dist[b][a] = min(dist[b][a], c);
    }
    // ③ Floyd
    for (int k = 1; k <= N; k++)
        for (int i = 1; i <= N; i++)
            for (int j = 1; j <= N; j++)
                if (dist[i][k] < INF && dist[k][j] < INF)
                    dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j]);
    int Q;
    scanf("%d", &Q);
    while (Q--) {
        int a, b;
        scanf("%d%d", &a, &b);
        printf("%lld\n", dist[a][b] == INF ? -1 : dist[a][b]);
    }
    return 0;
}

逐行解析

  • ① 自己到自己是 0,其余初始化为 \infty
  • min 处理重边。
  • ③ Floyd 三重循环。注意 dist[i][k] < INF 检查防止从不可达点中转。

例题 4:传递闭包

场景NN 个元素的偏序关系。MM 条有向边表示”aabb 之前”。QQ 个查询:aa 是否在 bb 之前(直接或间接)?

分析:Floyd 的变体——传递闭包。把 dist[i][j] 改成 bool 值(可达/不可达)。

bool reach[MAXN][MAXN];
// 初始化:reach[i][j] = (i==j) || 有边 i→j
for (int k = 1; k <= N; k++)
    for (int i = 1; i <= N; i++)
        for (int j = 1; j <= N; j++)
            reach[i][j] = reach[i][j] || (reach[i][k] && reach[k][j]);

这和 Floyd 的结构完全一样,只是把 min(+) 换成了 ||(&&)

参考文献

教材讲解 — 競技プログラミングの鉄則 第 9 章

系统练习 — 競技プログラミングの鉄則

实战练习 — 競プロ典型 90 問


系列索引

第零章 基础工具

第一章 搜索技术

第二章 数学基础

第三章 数据结构

第四章 图论

第五章 动态规划

第六章 贪心

第七章 字符串

第八章 进阶