news 2026/10/12 19:11:37

Floyd算法全解:全源最短路径、负权边处理与三重循环原理

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Floyd算法全解:全源最短路径、负权边处理与三重循环原理

如果你已经能把Dijkstra堆优化、SPFA的SLF优化写到肌肉记忆,那你大概率也被一个问题卡过:题目要求求所有点对之间的最短距离,怎么搞最稳?更麻烦的是,图里还有负权边,又不能跑Dijkstra,那怎么办?我见过不少人的第一反应是“对每个起点跑一遍单源最短路”,听着合理,但对稠密图或者带负权的图来说,这个方案很容易变得又慢又不正确。这个系列写到第11篇,我想把Floyd算法真正讲透——不是丢一段模板代码让你背,而是把它背后的状态设计、降维原理、路径回溯和边界坑全部拆开。哪怕你之前只是听说过这个名字,看完也能自己推出来,并且知道什么时候该用它、什么时候果断换别的算法。

1. 全源最短路径到底是什么问题:单源算法为什么顶不上

1.1 单源与全源的本质差别

单源最短路径,指的是固定一个起点,比如节点1,求它到图中所有其他节点的最短距离。这类问题最常用的武器是Dijkstra、Bellman-Ford、SPFA。它们的共同点是:一次计算只服务一个起点。

全源最短路径则完全不同:它要求任意两个节点之间的最短距离。这个问题至少有两种“笨办法”:

  • 对每个节点都跑一次单源最短路,加起来是n次。
  • 把图里所有路径枚举一遍,显然不可行。

第一种办法看起来简单,但你得先选对单源算法。假设图里有负权边,Dijkstra直接废掉,因为你不能保证“当前选出的最近节点”以后不会被一条负权边更新。你只能退回到Bellman-Ford或者SPFA,跑n次,复杂度变成O(n * n * m)级别,可以理解为三到四次层嵌套。如果图再稠密一点,m接近n^2,那总复杂度直接往O(n^4)方向走,稍微大一点的图就彻底跑不动。

所以全源最短路径不是单源最短路径的简单重复,它是一个需要重新审视的问题。Floyd算法恰好用一个极短的O(n^3)三重循环,一次性把所有点对的距离都算出来。

1.2 Floyd算法在这种问题里的位置

Floyd算法的思维出发点是“路径拼接”。设想i到j之间有一条最短路径,这条路径上可能经过一堆中间节点。如果能逐步放宽“允许经过的中间节点集合”,并不断修正任何两点之间的最短路,那么最终收敛时,所有点对距离就是全源最短路径。

这也是Floyd算法最吸引人的地方:所有操作都在一张邻接矩阵上进行。你不需要建邻接表,不需要优先队列,不需要处理复杂的出队入队。只需要一张二维表,然后三层循环把表刷到稳定。

此外,Floyd对负权边天然免疫。只要图里没有负环,Floyd就能给出正确结果,这是Dijkstra做不到的。负权边在实际建模中并不少见,比如某些目标函数是“收益费收益”的转换,两个状态之间的转移代价可能是负值。

我自己的使用经验里,Floyd还有一个隐藏优势:跑完之后,任意两点之间的最短距离是O(1)查询。后续不管做动态规划、聚类、还是多组询问,直接查表,不需要再碰图结构。这比“现场临时跑一次Dijkstra”要好用很多,尤其是查询次数非常多的时候。

2. 三层循环背后的状态推导:从三维DP到二维原地更新

2.1 经典状态设计:允许经过哪些中间节点

很多人背Floyd的时候只知道三重循环长这样:

for k in 1..n for i in 1..n for j in 1..n dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j])

但完全不知道这个k到底是什么。我从动态规划的角度给你拆开。

定义一个三维状态:

  • dp[k][i][j] 表示从i到j,只允许经过编号为1到k的节点作为中间节点时,最短路径长度。

注意dp[0][i][j]是什么?中间节点一个都不允许经过,那只能从i直接走到j。如果存在边,就是边权;不存在边,就是正无穷。所以初始化时,把邻接矩阵直接当作dp[0]。

然后考虑dp[k][i][j]。当你把第k个节点加入“允许的中间节点集合”后,从i到j的最短路径只可能分成两种:

  1. 完全不用第k个节点作为中间节点,此时路径长度仍然是dp[k-1][i][j]。
  2. 用第k个节点作为中间节点,路径分为两段:i先到k,再从k到j,长度是dp[k-1][i][k] + dp[k-1][k][j]。

所以状态转移方程是:

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

这个方程是整个Floyd算法的灵魂。所有的三重循环,不过是在按k从小到大填这个三维表。

2.2 为什么原地更新是安全的

三维表二维化,这是初学者最容易困惑的地方。按理说,dp[k]的计算用到了dp[k-1]的值,如果我用一个二维数组原地更新,会不会把还没用上的旧值覆盖掉,导致结果出错?

答案是不会,但需要证明。核心关键是看dp[k][i][k]和dp[k][k][j]这两个值。

先从定义出发:dp[k][i][k]表示从i到k,只允许经过前k个节点作为中间节点的最短距离。这里的端点也是k。如果这条路径中间又经过了k,就会形成“走一圈回来”的效果,比如i到某个节点,再到k,再回到k。最短路径里,这种重复经过同一个中间节点的环一定可以去掉,去掉后距离不会变大。所以真正的最短路径并不会把k当作自己的中间节点再用一次。

因此:

  • dp[k][i][k]实际上等于dp[k-1][i][k]
  • dp[k][k][j]实际上等于dp[k-1][k][j]

现在再回到更新dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j])。表面上我们读的dist[i][k]和dist[k][j]可能是已经被第k轮更新过的值,但刚才证明了它们更新后的值跟更新前完全一样,所以用二维数组原地更新是严格等价的。

这也是为什么Floyd的循环顺序必须是k在最外层,i和j在内层随意。如果反过来,把i或者j放最外层,k放内层,这个等价性就不成立,结果会错得莫名其妙。

2.3 一个更容易记住的直观解释

如果你觉得上面的形式化推导太绕,可以换一个生活化的比喻。把节点想象成一群联系人,消息只能通过联系人之间互传。一开始,每个人只知道和直接认识的人怎么联系。现在开始“开会”,第1个人被拉进核心群,全网更新的规则是:任何人的消息如果经过1号中转会更短,就改成这条新路线。然后是2号加入,全网再更新一遍。每加入一个人,消息中转的可能性就更丰富。

Floyd里的k,本质就是“当前核心群最后一个加入的人”。每加入一个人,就要重新审视所有点和所有点之间的路径:要不要通过这个新加入的家伙绕一下?这个过程重复n次,所有可能的中间节点组合都考虑完了,距离自然收敛到全局最短路。

这样想,三重循环就不再是无脑嵌套,而是一个清晰的“逐人加入、全网更新”过程。

3. 手写一版能跑的Floyd:初始化、三重循环、路径回溯

3.1 邻接矩阵初始化:重边、自环和无向图对称性

写Floyd之前,邻接矩阵的初始化直接决定你会不会在细节上翻车。

  • 自己到自己的距离初始化为0,即dist[i][i] = 0。
  • 没有边的位置初始化为一个很大的数,一般叫INF。
  • 如果输入存在重边,保留最小的那条。不能直接赋值,要用min。
  • 如果是无向图,加边时双向都要写;有向图则只写方向。
  • 如果出现自环且权值为正,dist[i][i]应该还是0,因为最短路径没必要绕自己一圈。如果自环权值是负的,dist[i][i]会被更新成负数,这就是负环的信号,后面专门说。

路径矩阵的初始化和dist同步。我用path[i][j]表示从i到j这条路径上的“第一跳节点”,初始时如果i可以直接到j,第一跳就是j。这样将来还原路径时,从i出发查第一跳,再查第一跳的下一跳,一直查到j为止。

3.2 核心代码:C++和Python两种版本

C++版本可以直接存邻接矩阵,缺点是最多容纳几百到几千个节点,再大内存就吃紧。代码写法如下。

#include <bits/stdc++.h> using namespace std; const int MAXN = 505; const int INF = 0x3f3f3f3f; int dist[MAXN][MAXN]; int nxt[MAXN][MAXN]; // nxt[i][j] 表示从i出发到j的第一跳节点 void init(int n) { for (int i = 1; i <= n; i++) { for (int j = 1; j <= n; j++) { if (i == j) dist[i][j] = 0; else dist[i][j] = INF; nxt[i][j] = j; } } } void add_edge(int u, int v, int w) { dist[u][v] = min(dist[u][v], w); // 重边取最小 } void floyd(int n) { for (int k = 1; k <= n; k++) { for (int i = 1; i <= n; i++) { if (dist[i][k] == INF) continue; // 小优化:跳掉不可能的路径 for (int j = 1; j <= n; j++) { 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]; } } } } } void print_path(int u, int v) { if (dist[u][v] == INF) { cout << "no path" << endl; return; } int cur = u; while (cur != v) { cout << cur << " -> "; cur = nxt[cur][v]; } cout << v << endl; }

Python版本更简洁,适合快速验证思路:

import sys def floyd(n, dist): nxt = [[j if i != j else None for j in range(n)] for i in range(n)] for k in range(n): for i in range(n): if dist[i][k] == float('inf'): continue for j in range(n): new_dist = dist[i][k] + dist[k][j] if new_dist < dist[i][j]: dist[i][j] = new_dist nxt[i][j] = nxt[i][k] return dist, nxt

注意C++版里的INF用了0x3f3f3f3f,这是一个经典取值,约等于10.6亿。两个这样的INF相加大约是21.2亿,还在int的表示范围内,不会立即溢出成负数。这一点比直接用int的最大值要安全很多,后面我还会细讲。

3.3 用4节点的图手工推一遍矩阵变化

光看代码还是不够直观,我拿一个非常小的例子走一遍。

图里有4个节点,编号从1到4。边如下:

  • 1到2权重3
  • 2到3权重2
  • 1到3权重8
  • 3到4权重1
  • 2到4权重7

初始dist矩阵(行是起点,列是终点):

起点/终点1234
1038INF
23027
38201
4INF710

一开始只有直达边。

k=1,也就是允许节点1作为中间节点。走一遍i和j,发现没有哪个路径能通过节点1获得明显优势。比如2到3,原来直接是2,如果绕1,3+8=11,反而更差。矩阵不变。

k=2,允许节点2作为中间节点。这时1到3有两条路:直达8,或者1到2再到3,代价3+2=5,所以dist[1][3]更新为5。同样,1到4原本不可达,现在1到2再到4,3+7=10,所以dist[1][4]从INF变成10。3到1也对称更新为5,4到1更新为10。

k=3,允许节点3作为中间节点。此时1到4的路可以走1到3再到4,5+1=6,比原来的10更短,于是dist[1][4]更新为6。2到4也可以走2到3再到4,2+1=3,比原来的7更短,dist[2][4]更新为3。同理4到1、4到2也分别被刷新。

k=4,允许节点4作为中间节点。因为4只连了3,几乎所有路径都没变短,最终矩阵稳定。

最终结果是:

起点/终点1234
10356
23023
35201
46310

1到4的最短路径是1 -> 2 -> 3 -> 4,总距离6。这就是Floyd一步一步“松弛”出来的结果。

3.4 路径还原:维护next数组的三个关键点

距离算完之后,如果题目只是求长度,那到此为止。但很多场景要求输出具体路径,这时候next数组就必须维护好。

第一个关键点:初始化时,nxt[i][j] = j,意思是默认从i直接跳到j。如果i和j之间根本没有边,这个默认值也没关系,因为dist是INF,路径还原时会先判断不可达。

第二个关键点:更新dist[i][j]的时候,必须同步更新nxt[i][j] = nxt[i][k]。为什么是这个值?因为新路径是i先走到“到k路径上的第一跳”,然后再从k走到j。所以从i出发后第一步要走的节点,就是nxt[i][k]。

第三个关键点:访问nxt[cur][v]的时候,cur会不断变化,但v始终保持终点不变。这样做是为了沿着第一跳链走到终点。如果dist是INF,就不要进入这个循环,否则可能死循环。

很多人在更新距离时忘了维护路径矩阵,等要输出路径才发现根本没法还原,只能重新跑一遍BFS。别问我是怎么知道的。

4. 负权边、负环与INF:Floyd最容易被坑的三个细节

4.1 能处理负权不能处理负环:差在哪

Dijkstra为什么一碰到负权边就失效?因为它的贪心策略是“当前距离最近的节点一旦确定,就不再更新”,但负权边可能让一个节点在已经确定后又被一条新路径变得更短。Floyd没有这个贪心假设,它枚举了所有可能的中间节点组合,所以负权边对它来说只是普通的边权。

但是,负环是另一回事。如果图中存在一个环,环上总权重为负,那么沿着这个环反复转圈就能让路径代价无限减小。这种情况下,最短路径没有意义,不存在所谓的收敛结果。

Floyd可以检测负环。原理很简单:跑完正常的Floyd后,如果存在某个i满足dist[i][i] < 0,说明从i出发绕一圈回来,距离变成负的,那就说明存在负环。

为什么会这样?正常情况下dist[i][i]初始是0,任何正权环都会让dist[i][i]保持0或者变成更大的正数,只有负环能让它变成负数。所以只要在Floyd结束之后扫描一遍对角线,就能知道图里有没有负环。

如果你想在计算过程中就发现负环,可以加一个判断:当dist[i][k] + dist[k][j] < dist[i][j]更新之后,顺便检查dist[i][i]是否小于0。一旦发现,直接停止计算,因为后面所有结果都没有意义。

4.2 无穷大的选取与溢出陷阱

一开始学Floyd的人经常直接写INF = 1e9,或者干脆用int最大值。这两种都有隐患。

先说int最大值2147483647。如果dist[i][k]和dist[k][j]都是这个值,那dist[i][k] + dist[k][j]会直接溢出成负数。一旦出现负数,它必然小雨当前的dist[i][j](除非dist[i][j]也是负数),结果就是因为你没判断INF,错误地把一个“不可达”的路径更新成了负数。这个问题极其隐蔽,跑小样例可能没事,随机大图一来就全错。

再说1e9。两个1e9相加等于2e9,在int范围内但已经非常接近上限。虽然大部分时候不会溢出,但如果你在不该加的时候加了,判断不出来,还是会污染矩阵。

正规做法有两个:

  1. 在加法之前先判断dist[i][k]和dist[k][j]是否为INF,是就跳过。
  2. 把INF设为0x3f3f3f3f,这样两个INF相加仍然是int范围内的正数,不会溢出不致于污染,然后靠“小于dist[i][j]”这个判断来过滤,因为正常真实路径很难超过这个值。

我自己的模板里会两种都做:既判断,又用0x3f3f3f3f。多写一行判断,能省掉一晚上的排查时间。

4.3 规模红线:什么时候果断放弃Floyd

Floyd的复杂度O(n^3),这是它最大的天花板。给你一个粗略的体感估算:

  • 当n = 300时,2700万次更新,C++轻松跑完,Python也还算能接受。
  • 当n = 500时,1.25亿次更新,C++大概1秒上下,Python会明显变慢。
  • 当n = 1000时,10亿次更新,C++也要好几秒甚至更多,Python基本不可行。

所以在竞赛或者面试题里,看到n <= 300到500这个量级,Floyd是正常选项;看到n超过1000,就要立刻转头想别的办法。内存也别忘了:两张n*n矩阵各占4字节的话,n=1000时是8MB,n=3000时是72MB,n=5000时直接200MB,很容易超出内存限制。

很多初学者养成了“看到邻接矩阵就无脑Floyd”的习惯,这是不对的。Floyd适合小规模全源最短路,不适合大图。这一点必须时刻记住。

5. 选型对比与变体扩展:Floyd、Dijkstra、Bellman-Ford与Johnson怎么选

5.1 四个算法的横向对比

算法典型复杂度适用场景负权边负环检测备注
FloydO(n^3)小规模全源最短路支持能实现极短,查询O(1)
n次Dijkstra(堆优化)O(n * m log n)稀疏图全源最短路不支持不能图中不能有负权
单次Bellman-FordO(n * m)单源最短路、负环检测支持能全源需要跑n次
JohnsonO(n * m log n)稀疏图全源最短路支持能通过重赋权消掉负权再跑n次Dijkstra

这张表里最容易被忽略的是Johnson算法。它的思路是先跑一次Bellman-Ford给每个点算势能,然后把所有边重赋权成非负值,最后做n次Dijkstra。在稀疏图里,Johnson通常比Floyd划算得多。

5.2 我做选型的判断流程

现在拿到一道最短路题目,我不会急着写Floyd,而是按下面这套顺序判断:

  1. 如果是单源问题,直接进单源算法支线:无负权用Dijkstra,有负权用Bellman-Ford或SPFA,要判负环就用Bellman-Ford。
  2. 如果是全源问题,先看n的大小。n小到500以内,再问自己:图里有没有负权?有负权且稠密,优先Floyd。有负权但很稀疏,可以考虑Johnson。无负权且稀疏,n次Dijkstra更稳。
  3. 如果后续有大量“任意两点距离”的查询需求,这时候就算n稍微大一点,只要内存放得下,我也会倾向用Floyd先预处理,因为查询O(1)太省事了。

举个例子,状态压缩DP里经常要预先算好所有城市之间的最短路,然后每步都查两个城市之间的代价。这种情况下,Floyd的预计算结果就是整个DP的地基,极其合适。

5.3 传递闭包与bitset优化:Floyd思想的变形

Floyd的思想不只用于求最短路,还能用于求有向图的传递闭包,也就是判断任意两点之间是否存在可达路径。此时把边权换成布尔值“0/1”,转移方程变成:

reach[i][j] = reach[i][j] || (reach[i][k] && reach[k][j])

如果要追求极致优化,可以使用bitset,把一行压成一个整数。转移变成:

if (reach[i][k]) reach[i] |= reach[k];

这样一次可以处理一个整数位,复杂度近似O(n^3 / 64)。n达到2000甚至3000都有机会跑过去。这种变形在求祖先关系、依赖关系、闭包判断的题目里非常好用。

6. 实战感悟与测试方法:从会背代码到真正用对

6.1 看到什么题干特征,第一反应该用Floyd

刷题多了之后,你会发现Floyd出场的信号非常明显:

  • 题干明确说“任意两点之间的最短路径”。
  • 输入直接给了邻接矩阵。
  • n在500以下,甚至只有300以内。
  • 图里可能有负权边,但保证无负环。
  • 后面还有大量询问,需要快速查询点对距离。
  • 题目是图论与其他专题的组合,比如状态压缩DP、最小生成树变体等。

只要同时满足其中几项,Floyd基本就是最佳选择。

6.2 对拍测试与易错点核对表

写算法最忌讳“我觉得没问题”。我自己的习惯是写一个暴力版本和Floyd对拍。

具体做法是随机生成一个小图,节点数10以内,边随机加,权重随机取正负。然后写一个全排列暴力或者每次BFS/枚举所有路径的版本,跟Floyd的结果对比。跑上千组随机样例,距离相等,路径也还原正确,才算这个实现真的靠谱。

下面这张表是我每次写完Floyd都要过一遍的检查清单:

易错点典型症状解决办法
INF设置过大或过小出现负数或错误更新用0x3f3f3f3f,并在加算前判断INF
重边只赋最后一条距离偏小或偏大加边用min
自环处理错误正自环导致奇怪结果初始化dist[i][i]=0
无向图只加了单向边结果不对称加边时双向写入
更新距离不更新next矩阵路径还原错误同步维护nxt[i][j]=nxt[i][k]
路径不可达时强行还原死循环先判断dist[u][v]是否为INF
负环未检测距离不断变小无意义扫描dist[i][i] < 0

把这张表贴在代码模板旁边,每次写完都对照一遍,能省很多Debug时间。

6.3 个人体会:Floyd在工程里远比想象中好用

很多人觉得Floyd只是竞赛里的小玩具,实际工程项目里没人用。我自己的体会恰恰相反。有一次我做一个规模约300个节点的距离矩阵预处理任务,节点之间有的有直接关系,有的没有,关系权重里还存在负数。如果每次查询都去临时跑Dijkstra,响应时间根本没法看。但用Floyd把所有点对距离一次性算完,后续任何查询都是查表,代码量小,结果稳定,排查问题也方便。

还有一次是在一个图论模型里,需要判断两个节点之间能否通过中间节点串联,当时直接用了传递闭包的bitset优化版本,一次跑完所有可达性。Floyd思想的可迁移性真的被低估了。

最后分享一个小技巧:学Floyd的时候,别急着背代码,先自己在纸上写三维状态的转移方程,然后想一想“为什么二维原地更新没事”。等你想明白了,再去看代码,你就能清楚地知道k、i、j三层的顺序为什么必须是那样。到这个程度,Floyd才算真正长在你脑子里,而不是停留在收藏夹里的一串字符。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/12 19:09:37

曼哈顿距离中位数选址:从水题到通用解题框架

1. 从一道“水题”说起&#xff1a;为什么它值得认真拆解“Lunch in Grid City”这个标题&#xff0c;第一次看到的人大概率会愣一下——听起来像是个模拟经营游戏&#xff0c;或者某个城市午餐配送的调度问题。实际上&#xff0c;这是一道出现在算法竞赛训练题单里的经典题目&…

作者头像 李华
网站建设 2026/10/12 19:07:25

PDF解析与归档实践:从编号双语书到可检索文档库

简介&#xff1a;《我看见的世界》是李飞飞&#xff08;Fei-Fei Li&#xff09;亲笔撰写的AI主题著作&#xff0c;这份PDF电子书面向人工智能爱好者、科技从业者及关注技术社会影响的读者&#xff0c;以作者个人视角串联起机器学习、深度学习等核心概念。资源包共1个PDF文件&am…

作者头像 李华
网站建设 2026/10/12 19:05:09

Python机器学习人脸识别系统:从期末大作业到门禁机实战

简介&#xff1a;这份资源是面向高校学生与Python初学者的机器学习人脸识别系统完整项目包&#xff0c;可直接用于期末大作业、课程设计或自学练手。压缩包共4个文件&#xff0c;包含2个py源码文件、1份pdf报告文档和1份md说明文件&#xff0c;整体约631KB&#xff0c;体积轻便…

作者头像 李华
网站建设 2026/10/12 19:03:41

open-code-review:一种以可追溯性为核心的代码评审实践范式

1. “open-code-review”不是开源项目&#xff0c;而是一套可落地的代码评审实践范式“open-code-review”这个词乍看像某个GitHub仓库名&#xff0c;或是某款新出的开源工具——但实际翻遍主流代码托管平台、CI/CD生态和开发者工具市场&#xff0c;都找不到一个叫这个名字的成…

作者头像 李华
网站建设 2026/10/12 19:00:36

模板方法模式深度解析:从原理到实战的完整指南

1. 模板方法模式到底在解决什么问题第一次接触模板方法模式的人&#xff0c;多半会有一种“这也算设计模式”的疑惑。毕竟它看起来太朴素了&#xff1a;一个抽象类里定义好一套流程骨架&#xff0c;把其中几个步骤延迟到子类去实现。没有复杂的对象组合&#xff0c;没有动态代理…

作者头像 李华
网站建设 2026/10/12 18:57:56

Python报错No module named ‘random‘?标准库失踪的真相与修复指南

看到ModuleNotFoundError: No module named random这条报错&#xff0c;我的第一反应不是“环境坏了”&#xff0c;而是“有意思&#xff0c;终于让我碰上了”。random是 Python 解释器自带的几十个标准库之一&#xff0c;只要解释器本身能跑起来&#xff0c;它就应该躺在标准库…

作者头像 李华