编码 · 算法
Floyd-Warshall 算法详解:原理、代码与复杂度
简短回答
Floyd-Warshall 算法用于计算带权图中每一对节点之间的最短路径。它维护一个 V × V 的距离矩阵,依次让每个节点 k 充当中转点,检查经过 k 能否缩短 i 到 j 的路径:dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j])。它支持负权边,能检测负环,时间复杂度为 O(V³),空间复杂度为 O(V²)。
Floyd-Warshall 解决什么问题
有时你需要的是所有地点两两之间的距离,而不只是从某一个起点出发:所有城市之间的行程时间表、每对服务器之间的延迟,或依赖图的完整可达关系。Floyd-Warshall 用一个非常简短的动态规划算法解决了这个全源最短路径问题。
它由 Robert Floyd 于 1962 年发表(《Algorithm 97: Shortest path》),建立在 Stephen Warshall 同年提出的传递闭包算法之上。
用大白话讲思路
把节点编号为 0 到 V − 1。处理完节点 k 之后,dist[i][j] 就是只允许使用节点 0 到 k 作为中转点时,i 到 j 的最短路径。再允许多一个中转点 k,有两种选择:不经过 k 的原最优路径,或者由两段最优路径拼成的 i → k → j(这两段只使用 k 之前的节点)。
- 填充矩阵:对角线为 0,有边的位置填边权,其余位置填无穷大。
- 对 k = 0 到 V − 1,遍历每一对 (i, j):如果
dist[i][k] + dist[k][j] < dist[i][j],就更新它。 - 最后一个 k 处理完后,矩阵中就是所有最短距离。对角线上出现负数表示存在负环。
循环的顺序至关重要:k 必须是最外层循环。
逐步演示一个例子
有向图含 4 个节点,边为 0→1(3)、0→3(7)、1→0(8)、1→2(2)、2→0(5)、2→3(1)、3→0(2)。初始矩阵(行为起点,列为终点):
| 起点 \ 终点 | 0 | 1 | 2 | 3 |
|---|---|---|---|---|
| 0 | 0 | 3 | ∞ | 7 |
| 1 | 8 | 0 | 2 | ∞ |
| 2 | 5 | ∞ | 0 | 1 |
| 3 | 2 | ∞ | ∞ | 0 |
- k = 0:路径可以经过节点 0。1→3 变为 8 + 7 = 15,2→1 变为 5 + 3 = 8,3→1 变为 2 + 3 = 5。
- k = 1:经过节点 1。0→2 变为 3 + 2 = 5,3→2 变为 5 + 2 = 7。
- k = 2:经过节点 2。0→3 从 7 改进为 5 + 1 = 6,1→3 从 15 改进为 2 + 1 = 3,1→0 从 8 改进为 2 + 5 = 7。
- k = 3:经过节点 3。1→0 从 7 改进为 3 + 2 = 5,2→0 从 5 改进为 1 + 2 = 3,2→1 从 8 改进为 1 + 5 = 6。
最终矩阵各行:0 → [0, 3, 5, 6],1 → [5, 0, 2, 3],2 → [3, 6, 0, 1],3 → [2, 5, 7, 0]。
Python 实现
INF = float("inf")
def floyd_warshall(n, edges):
dist = [[0 if i == j else INF for j in range(n)] for i in range(n)]
nxt = [[j if i == j else None for j in range(n)] for i in range(n)]
for u, v, w in edges:
dist[u][v] = w
nxt[u][v] = v
for k in range(n):
for i in range(n):
for j in range(n):
if dist[i][k] + dist[k][j] < dist[i][j]:
dist[i][j] = dist[i][k] + dist[k][j]
nxt[i][j] = nxt[i][k]
return dist, nxt
edges = [(0, 1, 3), (0, 3, 7), (1, 0, 8), (1, 2, 2),
(2, 0, 5), (2, 3, 1), (3, 0, 2)]
dist, nxt = floyd_warshall(4, edges)
for row in dist:
print(row)
# [0, 3, 5, 6]
# [5, 0, 2, 3]
# [3, 6, 0, 1]
# [2, 5, 7, 0]nxt 矩阵保存 i 到 j 最优路径上的第一步,足以还原任意一条路径:
def path(nxt, i, j):
if nxt[i][j] is None:
return [] # no path
route = [i]
while i != j:
i = nxt[i][j]
route.append(i)
return route
print(path(nxt, 1, 0)) # [1, 2, 3, 0]如果节点很多又想在 Python 中提速,可以用 NumPy 把同样的三重循环写成每个 k 一次向量化更新:d = np.minimum(d, d[:, k, None] + d[None, k, :])。
时间与空间复杂度
| 指标 | 开销 | 原因 |
|---|---|---|
| 时间 | O(V³) | 对所有节点的三重嵌套循环,与边数无关。 |
| 空间 | O(V²) | 距离矩阵(外加可选的下一跳矩阵)。原地更新是安全的。 |
| 实际规模 | 几百到几千个节点 | V = 1,000 意味着 10⁹ 次内层操作,纯 Python 下很慢,但在 C 或 NumPy 中没问题。 |
对于边权非负的稀疏图,从每个节点各运行一次 Dijkstra 的开销是 O(V × (V + E) log V);当 E 远小于 V² 时,比 O(V³) 更快。
变体与常见错误
- 负环:循环结束后,如果某个 i 满足
dist[i][i] < 0,说明节点 i 位于负环上,经过它的最短路径没有定义。 - 传递闭包(Warshall):改用布尔值,把 min/加法换成 或/与:
reach[i][j] = reach[i][j] or (reach[i][k] and reach[k][j])。 - 极小化最大值与极大化最小值路径:把
+换成max(或min),例如求最大边权尽可能小的路径。 - 只有在确定两个“无穷大”相加不会溢出时,才用一个很大的有限数代替无穷大;在 Python 中,
float("inf")是安全的选择。 - 把对角线初始化为 0;遇到平行边时,保留权最小的那条。
何时使用及实际应用
| 需求 | 最佳选择 |
|---|---|
| 全源最短路径,稠密图或小图(V 至多几百) | Floyd-Warshall |
| 全源最短路径,大型稀疏图,权非负 | 从每个节点运行 Dijkstra |
| 全源最短路径,稀疏图含负权 | Johnson 算法 |
| 仅单源 | Dijkstra 或 Bellman-Ford |
- 预先计算所有车站、仓库或城市之间的距离表,供以后查询。
- 图分析,例如小型网络的接近中心性或直径。
- 面试题:找出在距离阈值内邻居数最少的城市、除法求值(比值即路径乘积)、把课程先修关系视为传递闭包。
常见问题
Floyd-Warshall 能处理负权边吗?
能,前提是没有负环。如果存在负环,某个对角线元素 dist[i][i] 会变为负数,这就是检测负环的方法。
为什么 k 必须是最外层循环?
算法分阶段构造解:第 k 阶段只允许节点 0 到 k 作为中转点。必须先为第 k 阶段更新完所有节点对,才能允许路径使用节点 k + 1。
Floyd-Warshall 属于动态规划吗?
是的。状态是只用到节点 k 为止的中转点时,i 到 j 的最短路径,每个阶段都由上一阶段算出。k 这一维可以省去,因为原地更新不会改变结果。
该选 Floyd-Warshall 还是 Dijkstra?
单源,或边权非负的大型稀疏图,用 Dijkstra。小型或稠密图的全源最短路径,或者存在负权时,用 Floyd-Warshall。