1 从单源最短路到全源最短路
在 bellman-ford、dijkstra 中,我们固定起点
如果要回答许多次询问,每次起点、终点都可能不同,就可以预先求出任意两点之间的最短距离,存进矩阵
逐个选择起点,运行单源最短路算法,也能解决这个问题。Floyd(Floyd–Warshall)则提供了一种只用三重循环的做法,时间复杂度为
本文先讨论没有负环的有向图。边权可以为负数;无向图只需把每条边按两个方向存入。
逐步放开允许经过的中间点,把两段已经求好的最短路拼起来。
2 从一次绕路想到分阶段求解
从
不一定。例如直达要花
这里的
把所有
还不能这样下结论。更新
可以先解决一个受限制的问题:不允许经过任何中间点时,只能直接走边,或者留在原地。然后允许经过点
新允许一个点作为中间点,会多出哪些走法?
假设之前只允许用
把上面的两步思考放在一起看:图 (a) 比较一次绕路的费用,图 (b) 说明新开放
图 (b) 中的省略号表示每段都可以是一整条路径。我们复用的是两段已经求好的距离,再与不经过
3 动态规划的状态与转移
3.1 状态:限制中间点的编号
定义
中间点指起点与终点之间的点,起点
第
3.2 初始状态:没有中间点
时为 ,表示一条边也不走。 且有边 时,为这些重边中的最小权值。 - 其余情况为
。
实现时先把对角线设为 INF,然后读边并取最小值。这样也能保留负权自环;不过负权自环本身就是负环,不符合本文主模板的前提。
3.3 转移:是否经过新开放的点
因为没有负环,路径中的环可以删去而不增加长度,所以对可达点对,总能选出一条简单最短路。按它是否把
- 不经过
:所有中间点仍在 内,最优值为 。 - 经过
:简单路径只经过 一次,在这里切开后,两段的中间点都在 内,分别对应 与 。
因此:
这个式子没有遗漏最优路径:它必然属于上面两类之一。反过来,两段可达路径拼接后也是一条合法走法;即使拼接产生重复点,也可以删去非负的环,使长度不增加。因此两段最短距离的和可以作为候选值。
起点或终点恰好是
初始状态正确,每次转移又完整考虑了新增的走法,于是由归纳可知:处理完第
4 先把转移直接写成三维代码
4.1 三维数组与转移一一对应
先完整保留阶段这一维。用 d[k][i][j] 存储
第
for (int k = 1; k <= n; k++) {
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
// 不经过新开放的 k:继承上一层。
d[k][i][j] = d[k - 1][i][j];
// 经过 k:两段距离都从上一层读取。
if (d[k - 1][i][k] != INF && d[k - 1][k][j] != INF) {
d[k][i][j] = min(d[k][i][j],
d[k - 1][i][k] + d[k - 1][k][j]);
}
}
}
}
这里读的是第
按照这个转移计算时,先算完第
完整保存第
4.2 可以直接运行的三维版本
下面是包含初始化、读边、转移和输出的完整程序。它先打印 D[0],之后每完成一整轮 D[k],让我们看清每一层的结果。最终答案在 d[n][i][j] 中。
教学版的数组按
#include <bits/stdc++.h>
using namespace std;
// 三维教学版:用于 n <= 50 的有向图,要求无负环。
// d[k][i][j]:只允许 1..k 作为中间点时,i 到 j 的最短距离。
// 输出第 0 层及每次转移后的完整矩阵,方便逐层核对。
const int N = 55;
const long long INF = 1000000000000000000LL;
long long d[N][N][N];
int n, m;
void print_layer(int k) {
cout << "D[" << k << "]\n";
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
if (j > 1) cout << ' ';
if (d[k][i][j] == INF)
cout << "INF";
else
cout << d[k][i][j];
}
cout << '\n';
}
cout << '\n';
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
d[0][i][j] = (i == j ? 0 : INF);
for (int t = 0; t < m; t++) {
int u, v;
long long w;
cin >> u >> v >> w;
d[0][u][v] = min(d[0][u][v], w);
}
print_layer(0);
for (int k = 1; k <= n; k++) {
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
// 不经过新开放的 k:继承上一层。
d[k][i][j] = d[k - 1][i][j];
// 经过 k:两段距离都从上一层读取。
if (d[k - 1][i][k] != INF && d[k - 1][k][j] != INF) {
d[k][i][j] = min(d[k][i][j],
d[k - 1][i][k] + d[k - 1][k][j]);
}
}
}
print_layer(k);
}
return 0;
}
5 运行三维版本,观察每一层
取
| 边 | 权值 |
|---|---|
查看 DOT 源码
// 样例图:点 5 孤立,其余为六条边;边旁数字为权值
digraph G {
rankdir=LR;
nodesep=0.6;
ranksep=0.9;
node [shape=circle, fixedsize=true, width=0.55, fontname="Noto Serif SC", fontsize=15];
edge [fontname="Noto Serif SC", fontsize=13];
1 -> 2 [label="8"];
1 -> 3 [label="2"];
1 -> 4 [label="20"];
3 -> 2 [label="1"];
2 -> 4 [label="2"];
3 -> 4 [label="7"];
5 [style=dashed];
}
5.1 输入、编译与完整输出
把上面的程序保存为 floyd_3d.cpp,把下面的输入保存为 sample.in:
5 6
1 2 8
1 3 2
1 4 20
3 2 1
2 4 2
3 4 7
在这两个文件所在目录运行:
g++ -std=c++17 -O2 floyd_3d.cpp -o floyd_3d
./floyd_3d < sample.in
矩阵的行是起点,列是终点。程序的完整输出如下,D[k] 表示只允许
D[0]
0 8 2 20 INF
INF 0 INF 2 INF
INF 1 0 7 INF
INF INF INF 0 INF
INF INF INF INF 0
D[1]
0 8 2 20 INF
INF 0 INF 2 INF
INF 1 0 7 INF
INF INF INF 0 INF
INF INF INF INF 0
D[2]
0 8 2 10 INF
INF 0 INF 2 INF
INF 1 0 3 INF
INF INF INF 0 INF
INF INF INF INF 0
D[3]
0 3 2 5 INF
INF 0 INF 2 INF
INF 1 0 3 INF
INF INF INF 0 INF
INF INF INF INF 0
D[4]
0 3 2 5 INF
INF 0 INF 2 INF
INF 1 0 3 INF
INF INF INF 0 INF
INF INF INF INF 0
D[5]
0 3 2 5 INF
INF 0 INF 2 INF
INF 1 0 3 INF
INF INF INF 0 INF
INF INF INF INF 0
可以先在纸上写出第 D[1] 没有变化,D[2] 和 D[3] 有变化,后面几层又保持不变;没有变化的层同样是合法的阶段结果。
5.2 手动核对关键更新
下面只追踪会变化的三个位置,其余位置始终不变。表中的每一行来自相应的第
| 完成的阶段 | 本轮新增的更短走法 | |||
|---|---|---|---|---|
| 初始化 | 只走直接边 | |||
| 无更新 | ||||
| 无更新 | ||||
| 无更新 |
先核对 d[2][1][4] 读取的三个数都来自第
同一轮还得到 d[3][1][4] 时,使用第
其中
可以先遮住输出,手动完成下面两次检查:
- 求
d[3][1][2]:应从哪一层取数,为什么会从变成 ? - 把输入中
的边权从 改成 ,重新手推 d[3][1][4],再运行程序检查它是否变成。
点 INF 是否被正确保留。
下面的动画也可以配合手推使用。先关注三维分层状态,观察每次更新从上一层取了哪两段距离;二维原地更新的理由放在下一节推导。
Floyd 全源最短路dsa3对照三维分层 DP 与二维原地更新,观察距离表和最短路径的变化。
6 从三维状态推导空间压缩
6.1 更早的层还有用吗?
观察三维转移:计算第
因此,如果只需要最终最短距离,就不必保留全部历史。先做一步容易确认正确的压缩:用 prev 保存上一层,用 cur 保存当前层,算完一层后交换它们。
假设已经把初始矩阵 int g[2][maxn][maxn] 的第 g[0]),转移核心可以写成:
const int maxn = 410; // 点数上限,按题目数据范围调整
const int INF = 0x3f3f3f3f; // 两个 INF 相加仍在 int 范围内
int g[2][maxn][maxn]; // g[0] 存 D_0,两层轮流使用
int prev = 0, cur = 1; // 这两个是“层号”,不是矩阵
for (int k = 1; k <= n; k++) {
memcpy(g[cur], g[prev], sizeof(g[cur])); // 当前层先完整继承上一层
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
if (g[prev][i][k] != INF && g[prev][k][j] != INF) {
g[cur][i][j] = min(g[cur][i][j], g[prev][i][k] + g[prev][k][j]);
}
}
}
swap(prev, cur); // 本轮结果成为下一轮的“上一层”
}
// 循环结束,最终答案在 g[prev]
这时仍然严格区分读、写两个阶段,只是把
换成分层数组之后有三处值得留意:
-
prev/cur从矩阵变成了层号。 它们只是g第一维的下标,swap(prev, cur)交换两个int,;真正 的搬动是那句 memcpy。读的时候写g[prev][i][j],写的时候写g[cur][i][j],读写分离靠名字表达,不再靠两张独立的变量。 -
memcpy抄的是整块maxn × maxn。 当明显小于 maxn时,按行只抄列更划算: for (int i = 1; i <= n; i++) { memcpy(g[cur][i], g[prev][i], sizeof(int) * (n + 1)); } -
为什么可以放心用
int。INF = 0x3f3f3f3f两倍仍小于INT_MAX,而转移里已经先用!= INF挡住了“不可达 + 任何数”的情形,所以不会溢出。代价是真实的最短距离必须小于INF:如果边权可能到、路径又要拼很多段,就换回 long long版本,并把INF改成1e18。
三维教学版为了观察过程而输出每一层;如果题目本身要求保留历史状态,就要按需求保存。下面讨论只需要最终矩阵的情形。
6.2 能不能连两张矩阵也合成一张?
如果直接在一张矩阵里修改,开始第
固定
d[i][j]:这个位置在本轮尚未更新,仍是上一层的旧答案。d[i][k]:第列的值。 d[k][j]:第行的值。
每个
无负环时,留在原地的最短距离为
不可达的位置也保持不可达。于是,第
这就证明了单张矩阵原地更新的安全性。不是看到转移只依赖上一层,就能直接删掉一个下标;还需要确认覆盖旧值不会破坏后续读取。
6.3 最后才得到二维核心
有了上面的证明,才可以把三维代码中的 d[k][i][j] 和 d[k-1][i][j] 合并成同一个 d[i][j]:
for (int k = 1; k <= n; k++) {
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
if (d[i][k] != INF && d[k][j] != INF) {
d[i][j] = min(d[i][j], d[i][k] + d[k][j]);
}
}
}
}
第
| 实现 | 保存的内容 | 空间复杂度 |
|---|---|---|
| 三维数组 | 第 |
|
| 两层滚动 | 上一层与当前层 | |
| 二维原地更新 | 一张逐轮覆盖的矩阵 |
两层滚动与原地更新的渐进空间复杂度相同;后者进一步减少了矩阵数量,也省去了每轮复制整张矩阵的操作。它们的核心计算时间仍然都是
7 压缩后,为什么 k 仍必须在最外层?
按阶段推进,才能保证开始第
7.1 把 k 放在内层的反例
考虑只有三条边的有向图,边权都为
查看 DOT 源码
digraph G {
rankdir=LR;
node [shape=circle, width=0.5, fontsize=13];
edge [arrowsize=0.7, fontsize=11];
1 -> 3 [label="1"];
3 -> 4 [label="1"];
4 -> 2 [label="1"];
}
正确的 i → j → k 的顺序、每个循环都从
- 枚举到
, 还是 INF,无法更新。 - 枚举到
, 还是 INF(要经过 ,而 还没被算过),也无法更新。
稍后处理
所以“每个三元组都算过一次”并不足够,依赖的距离必须在使用之前准备好。
8 二维压缩版完整模板
现在再来看竞赛中常用的二维实现。输入与三维版本相同,输出时只保留最终矩阵,不再打印各层。本模板同样要求图中没有负环。
#include <bits/stdc++.h>
using namespace std;
// Floyd 全源最短路竞赛精简版:有向图,要求无负环。
// 输入 n m 与 m 条边 u v w,输出最短路矩阵,不可达输出 INF。
// 点编号 1..n,要求 n < N;有限距离的绝对值小于 INF,距离相加不溢出。
// 口诀:中转点 k 最外层,两段都可达才相加。
const int N = 410;
const long long INF = 1e18;
// const long long INF = LLONG_MAX;
long long d[N][N];
int n, m;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
d[i][j] = (i == j ? 0 : INF);
for (int i = 0; i < m; i++) {
int u, v;
long long w;
cin >> u >> v >> w;
d[u][v] = min(d[u][v], w); // 重边取最小;无向图还需更新 d[v][u]
}
for (int k = 1; k <= n; k++) // 中转点必须在最外层,内层 i、j 可以交换
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) continue;
d[i][j] = min(d[i][j], d[i][k] + d[k][j]);
}
}
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
if (d[i][j] == INF)
cout << "INF";
else
cout << d[i][j];
cout << " \n"[j == n];
}
}
return 0;
}
使用前面的 sample.in 运行,输出为:
0 3 2 5 INF
INF 0 INF 2 INF
INF 1 0 3 INF
INF INF INF 0 INF
INF INF INF INF 0
它应当与三维版本的 D[5] 完全一致。读懂三维状态、手推过层间转移之后,这段精简代码里的每一次读写就都有了依据。
9 实现时容易出错的地方
9.1 初始化、重边与无向边
全局数组默认全是零,不能直接拿来表示距离,否则会把所有点对都当作零距离可达。必须先设置对角线为零、其余为 INF,再读入边。
重边必须取最小值,不能让最后读到的一条较长边覆盖较短边。无向边需要同时更新两个方向:
d[u][v] = min(d[u][v], w);
d[v][u] = min(d[v][u], w);
9.2 两段都可达,才能相加
数学中的 INF 只是一个有限整数。若直接计算 INF + (-5),结果小于 INF,就可能把不可达误判为可达。
所以模板先判断 d[i][k],不可达就跳过这一行;再判断 d[k][j],不可达就跳过当前点对。两段都可达才执行加法。
此外还要按题目范围选择数值类型与 INF:所有存储的有限距离都应与 INF 区分开,相加的中间结果也必须落在 long long 范围内。对于模板中的 long long 范围内。
在无负环的前提下,若边权绝对值至多为 INF 不是越接近整数上限越好。
9.3 不要因为某一轮没有更新就提前退出
第
10 负权边与负环
Floyd 的转移不依赖边权非负,因此可以处理负权边。但如果能够从
在数值运算不溢出的前提下,Floyd 也可以用于检测负环:运行后,如果存在点
就说明图中存在负环。因为这代表从
但“检测出负环”不等于“已经给每个点对算出答案”。若存在这样的
主模板按无负环的输入使用;处理负环检测时,还需要单独保证运算范围。更多负环的背景见 负环。
11 复杂度与算法选择
- 二维模板初始化与输出需要
时间,读边需要 时间;核心计算为 ,总时间为 。三维教学版打印全部 层需要 输出时间,总时间仍为 。 - 三维教学版保存全部阶段,空间为
;两层滚动和二维原地更新均为 ,不需要另外保存所有边。 - 预处理结束后,每次点对距离查询为
。
| 需求 | 可以考虑的算法 |
|---|---|
| 单源、所有边权非负 | dijkstra |
| 单源、有负权边,或需要检测从源点可达的负环 | bellman-ford |
| 点数较少,需要任意两点的距离 | Floyd |
| 点数较多的非负权稀疏图,需要多个起点的距离 | 按需从各起点运行 Dijkstra |
三维教学版只用于 N = 410 只是数组容量,不代表任意时限下都能通过。以
12 两个延伸方向
如果需要输出具体路径,可以额外维护 nxt[i][j],表示从 j;严格变短且改走 nxt[i][j] = nxt[i][k]。查询时不断沿第一步前进,直到终点。不可达以及受负环影响的点对要先排除。
如果只关心能否到达,把“取最小值”换成逻辑或,把“两段长度相加”换成逻辑与,就得到传递闭包:
// reach[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]);
它沿用同一条推理:新允许经过
13 练习题目
下面按训练顺序编排,覆盖洛谷、Codeforces 和 AtCoder。先写熟全源最短路,再练习“每一轮允许哪些点作为中间点”,最后尝试改造转移。这里的分组表示训练阶段,不是各平台的官方难度。
13.1 基础巩固:求出点对距离,再使用答案
给定无向连通图,输出所有点对的最短距离。适合检查初始化、重边取最小值和三重循环顺序。注意本文模板读的是有向边,本题需要同时存入两个方向。
2. luogu P2910: [USACO08OPEN] Clear And Present Danger S
要求按给定顺序经过若干岛屿,相邻的指定岛屿之间允许经过其他岛屿。先用 Floyd 求出任意两岛之间的最小危险指数,再累加指定序列中相邻两项的最短距离。注意危险指数有方向,不能擅自把矩阵改成对称的。
3. atcoder abc079_d: [ABC079D] Wall
把墙上的数字全部变成
13.2 性质与建模:理解 Floyd 的每个阶段
4. atcoder abc208_d: [ABC208D] Shortest Path Queries 2
题目要求把所有起终点、所有中间点编号上限对应的最短距离求和,恰好对应正文的 INF 加进答案。总和需要 long long。
村庄按重建时间逐步开放,询问某一天两点之间的最短路。把“允许经过的中间点”从编号前缀换成“已经重建的村庄”,按时间推进 Floyd。题面保证重建时间和询问时间都不下降,还要额外检查询问的起点、终点是否已经开放;村庄编号从
6. codeforces 295B: Greg and Graph
按给定顺序删除顶点,每次删除前统计剩余顶点之间的最短距离之和。正向删点不方便维护,可以倒过来逐个加点,每次加入一个新的中间点。更新距离时仍要枚举所有起终点,为之后的加点准备距离;统计答案时才只累加当前已加入的顶点对,最后把答案顺序还原。
13.3 综合进阶:重新建图或改变更新方式
7. atcoder abc143_e: [ABC143E] Travel by Car
汽车油箱容量有限,询问两城之间最少需要中途加油多少次。先用一次 Floyd 求路程,再把“一箱油能够到达”的不同城市连成边权为
8. codeforces 25C: Roads in Berland
给定的矩阵已经是旧路网的全源最短距离,每增加一条双向道路,就要重新统计距离之和。新增边
这是需要额外推导的扩展题:求至少包含三个顶点的最小简单环。枚举环上编号最大的点
如果时间有限,可以先做 B3647 → ABC079D → ABC208D → P1119;这条路线依次检查模板、建模、阶段状态和按时间开放中间点。
14 相关题解
| 题目 | OJ | 难度 |
|---|---|---|
| 【模板】传递闭包 | luogu | 普及 |