第十五章:最短路径——Dijkstra、Bellman-Ford与Floyd-Warshall
各位同学,今天我们来聊聊图论里一个非常经典的问题——最短路径。说白了,就是在地图上找一条从A点到B点的最短路线。你想想看,导航软件、网络路由、甚至游戏里的寻路,背后都离不开这些算法。
我个人做嵌入式开发时,经常需要在资源受限的芯片上跑路径规划。那时候我才真正体会到,算法选对了,事半功倍;选错了,内存直接爆掉。嗯,今天我就把这三个最核心的算法——Dijkstra、Bellman-Ford、Floyd-Warshall——掰开揉碎了讲给你听。
15.1 Dijkstra算法:最经典的单源最短路径
Dijkstra算法,我愿称之为「贪心思想的典范」。它解决的是:给定一个起点,找到它到图中所有其他节点的最短路径。前提条件——图中不能有负权边。
为什么不能有负权?因为Dijkstra每次从「未处理」的节点中挑一个距离最近的,然后就不再回头更新它了。如果有负权边,后面可能出现一条更短的路径,但Dijkstra已经错过了。我在项目中就踩过这个坑——有一次用Dijkstra处理带负权的地图数据,结果路径完全不对,排查了半天才发现是数据里混了负值。
核心思路
- 维护一个距离数组dist[],dist[起点]=0,其余为无穷大。
- 每次从「未访问」的节点中,选dist最小的那个,标记为已访问。
- 用这个节点去松弛它的邻居:如果dist[u] + w(u,v) < dist[v],就更新dist[v]。
- 重复直到所有节点都被访问。
关键点:Dijkstra的贪心策略保证了每次选出的节点,其dist值已经是最终的最短距离。这个性质只在非负权图中成立。
代码实现(邻接表 + 优先队列)
#include <stdio.h>
#include <stdlib.h>
#include <limits.h>
#define MAXN 1000
#define INF INT_MAX
// 邻接表节点
typedef struct Edge {
int to, weight;
struct Edge *next;
} Edge;
Edge *graph[MAXN];
int dist[MAXN];
int visited[MAXN];
int n; // 节点数
// 优先队列(简易实现,实际可用堆)
void dijkstra(int start) {
for (int i = 0; i < n; i++) {
dist[i] = INF;
visited[i] = 0;
}
dist[start] = 0;
for (int i = 0; i < n; i++) {
// 找未访问中dist最小的
int u = -1, minDist = INF;
for (int j = 0; j < n; j++) {
if (!visited[j] && dist[j] < minDist) {
minDist = dist[j];
u = j;
}
}
if (u == -1) break; // 剩下的不可达
visited[u] = 1;
// 松弛邻居
Edge *e = graph[u];
while (e) {
int v = e->to;
int w = e->weight;
if (!visited[v] && dist[u] != INF && dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
}
e = e->next;
}
}
}
我的建议:实际工程中一定要用二叉堆或斐波那契堆来优化找最小值的操作。上面这个O(V²)的版本只适合小规模数据。我曾经在一个有5000个节点的图上跑O(V²)的Dijkstra,等了快10秒才出结果——换成堆优化后,0.1秒就搞定了。
15.2 Bellman-Ford算法:能处理负权,还能检测负环
Bellman-Ford算法,说白了就是「暴力松弛」。它不挑节点,而是对所有边进行V-1轮松弛。为什么是V-1轮?因为一条最短路径最多包含V-1条边,再多就有环了。
这个算法我最喜欢的一点是:它不仅能处理负权边,还能检测出图中是否存在负权环。如果第V轮松弛还能更新距离,那就说明有负环——路径可以无限短下去。
核心步骤
- 初始化dist数组,dist[起点]=0,其余为INF。
- 重复V-1次:遍历所有边(u, v, w),如果dist[u] + w < dist[v],就更新dist[v]。
- 再遍历一次所有边:如果还能更新,说明存在负权环。
注意:Bellman-Ford的时间复杂度是O(VE),在稀疏图上还行,稠密图上就有点慢了。我曾经在一个有1000个节点、10万条边的图上跑Bellman-Ford,跑了将近2秒——换成SPFA(队列优化的Bellman-Ford)后,快了很多。
代码实现
#include <stdio.h>
#include <limits.h>
#define MAXN 1000
#define MAXM 10000
#define INF INT_MAX
typedef struct {
int u, v, w;
} Edge;
Edge edges[MAXM];
int dist[MAXN];
int n, m; // 节点数、边数
int bellman_ford(int start) {
for (int i = 0; i < n; i++) dist[i] = INF;
dist[start] = 0;
// V-1轮松弛
for (int i = 0; i < n - 1; i++) {
int updated = 0;
for (int j = 0; j < m; j++) {
int u = edges[j].u;
int v = edges[j].v;
int w = edges[j].w;
if (dist[u] != INF && dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
updated = 1;
}
}
if (!updated) break; // 提前结束
}
// 检测负环
for (int j = 0; j < m; j++) {
int u = edges[j].u;
int v = edges[j].v;
int w = edges[j].w;
if (dist[u] != INF && dist[u] + w < dist[v]) {
return -1; // 存在负环
}
}
return 0; // 正常
}
一个小技巧:如果某一轮没有任何边被松弛,就可以提前退出循环。这个优化在大多数实际场景中都能省下不少时间。我习惯在每一轮加一个updated标志位,没更新就直接break。
15.3 Floyd-Warshall算法:全源最短路径的「暴力美学」
Floyd-Warshall算法,我愿称之为「最优雅的暴力」。它用三层循环,直接算出所有节点对之间的最短路径。代码短到令人发指,但时间复杂度是O(V³)。
它的思想是动态规划:假设我们只允许经过前k个节点作为中间点,那么dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j])。随着k从0增加到V-1,我们逐步放开限制,最终得到全局最优。
核心代码(就这几行)
#include <stdio.h>
#include <limits.h>
#define MAXN 500
#define INF INT_MAX
int dist[MAXN][MAXN];
int n;
void floyd_warshall() {
// k是中间节点
for (int k = 0; k < n; k++) {
for (int i = 0; i < n; i++) {
if (dist[i][k] == INF) continue; // 小优化
for (int j = 0; j < n; j++) {
if (dist[k][j] == INF) continue;
if (dist[i][k] + dist[k][j] < dist[i][j]) {
dist[i][j] = dist[i][k] + dist[k][j];
}
}
}
}
}
使用场景:Floyd最适合节点数不超过500的稠密图。如果节点数超过1000,O(V³)的复杂度就有点吃不消了。我在一个项目中需要计算200个城市之间的最短航线距离,用Floyd简直不要太爽——代码写起来快,调试也简单。
15.4 三种算法对比
| 特性 | Dijkstra | Bellman-Ford | Floyd-Warshall |
|---|---|---|---|
| 问题类型 | 单源 | 单源 | 全源 |
| 负权边 | 不支持 | 支持 | 支持 |
| 负环检测 | 不能 | 能 | 能(通过检查对角线) |
| 时间复杂度 | O((V+E)logV) 堆优化 | O(VE) | O(V³) |
| 空间复杂度 | O(V+E) | O(V+E) | O(V²) |
| 代码复杂度 | 中等(需优先队列) | 简单 | 极简 |
| 推荐场景 | 无负权、稀疏图 | 有负权、小规模 | 全源、节点数少 |
15.5 避坑指南与个人经验
讲到这里,我想分享几个我实际踩过的坑:
- 我曾经在Dijkstra里忘了标记已访问节点,结果死循环了。嗯,这种低级错误,犯过一次就再也不会忘了。
- 我曾经用Floyd处理1000个节点的图,程序跑了快一分钟还没出结果——后来才发现O(V³)在V=1000时是10亿次操作,果断换方案了。
- 我建议你在实现Bellman-Ford时,一定要加上提前退出的优化。很多教科书上的版本没有这个优化,但实际工程中它能省掉大量无意义的计算。
- 我记得有一次在嵌入式设备上跑Dijkstra,堆的malloc/free太频繁导致内存碎片。后来我改用静态数组实现了一个简易优先队列,问题就解决了。
重要提醒:如果你用邻接矩阵存图,Floyd的代码最方便。但如果图很稀疏(边数远小于V²),用邻接表配合Dijkstra或Bellman-Ford会更省内存。我见过有人用邻接矩阵存10000个节点的图——直接爆了400MB内存,太夸张了。
好了,关于最短路径的三个核心算法,我们就讲到这里。这三种算法各有各的脾气,选对场景才能发挥最大威力。你可以在自己的项目中多试试,感受一下它们的区别。
公众号:蓝海资料掘金营,微信deep3321