1. 引言
最短路径问题是图论中最经典、应用最广泛的问题之一。无论是导航系统中的路线规划、计算机网络中的路由选择,还是社交网络中的关系分析,最短路径算法都扮演着至关重要的角色。本文将从基本概念出发,系统讲解两种最常用的最短路径算法------迪杰斯特拉(Dijkstra)算法和弗洛伊德(Floyd)算法,并通过完整的 C 语言代码示例帮助读者深入理解其实现细节。
2. 最短路径基本概念与应用场景
2.1 基本概念
在图论中,最短路径问题是指在带权图中,寻找两个顶点之间权值和最小的路径。这里的"权值"可以是距离、时间、费用等实际度量。根据图的性质不同,最短路径问题可分为以下几类:
- 单源最短路径:从某个固定顶点出发,求该顶点到图中所有其他顶点的最短路径,Dijkstra 算法解决的就是这类问题。
- 多源最短路径:求图中任意两个顶点之间的最短路径,Floyd 算法解决的就是这类问题。
- 单对最短路径:仅求某两个特定顶点之间的最短路径。
在讨论最短路径时,需要明确几个关键术语:
- 带权图:边具有权值的图,权值通常表示距离、成本或时间。
- 路径长度:路径上所有边的权值之和。
- 最短路径:两个顶点之间路径长度最小的路径。
2.2 应用场景
最短路径算法在实际系统中有着广泛的应用,主要包括:
- 导航与地图服务:高德地图、百度地图等导航软件利用最短路径算法计算最优行驶路线。
- 计算机网络路由:OSPF、RIP 等路由协议基于最短路径算法选择数据包的最佳转发路径。
- 物流与配送:快递配送、外卖调度系统通过最短路径优化配送路线,降低运输成本。
- 社交网络分析:计算用户之间的"六度分隔"关系,衡量网络中的影响力传播路径。
- 集成电路设计:在芯片布线阶段寻找信号传输的最短连接路径。
3. Dijkstra 算法
3.1 算法原理
Dijkstra 算法由荷兰计算机科学家 Edsger W. Dijkstra 于 1956 年提出,是解决单源最短路径问题的经典算法。其核心思想是贪心策略:每次从未确定最短路径的顶点中选择一个距离源点最近的顶点,将其加入已确定集合,并利用该顶点更新其邻接顶点的距离。
算法维护两个集合:已确定最短路径的顶点集合 S 和未确定的顶点集合 T。初始时,S 中只有源点,源点到自身的距离为 0,到其他顶点的距离为无穷大。每次从 T 中选取距离最小的顶点 u 加入 S,然后检查所有从 u 出发的边,如果通过 u 到达某个顶点 v 的距离比当前记录的 distv 更短,则更新 distv。
3.2 适用条件
Dijkstra 算法有一个重要的适用前提:图中不能存在负权边。这是因为贪心策略假设一旦某个顶点的最短距离被确定,就不会再被更新。如果存在负权边,可能会出现通过负权边绕路反而使距离更短的情况,从而破坏贪心策略的正确性。
此外,Dijkstra 算法适用于有向图和无向图,但要求图是连通的(或至少源点能到达所有需要计算的顶点)。
3.3 时间复杂度分析
Dijkstra 算法的时间复杂度取决于实现方式:
- 朴素实现:每次从未确定集合中选取最小距离顶点需要 O(V) 时间,共需执行 V 次,加上更新邻接边的时间 O(E),总时间复杂度为 O(V² + E),即 O(V²)。
- 优先队列优化:使用二叉堆或优先队列维护未确定顶点,选取最小距离顶点的时间降为 O(log V),总时间复杂度为 O((V + E) log V),即 O(E log V)。
其中 V 表示顶点数,E 表示边数。对于稀疏图(E 远小于 V²),优先队列优化效果显著;对于稠密图,朴素实现可能更简单高效。
3.4 实现步骤
Dijkstra 算法的实现步骤如下:
- 初始化 dist 数组,将源点到自身的距离设为 0,到其他顶点的距离设为无穷大。
- 初始化 visited 数组,标记所有顶点为未访问。
- 重复以下操作 V 次:
- 从未访问的顶点中选取 dist 值最小的顶点 u。
- 将 u 标记为已访问。
- 遍历 u 的所有邻接边 (u, v, w),如果 distu + w < distv,则更新 distv = distu + w。
- 算法结束后,dist 数组中存储的就是源点到各顶点的最短距离。
4. Floyd 算法
4.1 基本思想
Floyd 算法(又称 Floyd-Warshall 算法)由 Robert Floyd 于 1962 年提出,用于求解多源最短路径问题,即计算图中任意两个顶点之间的最短路径。与 Dijkstra 算法不同,Floyd 算法采用动态规划的思想,通过逐步引入中间顶点来更新路径。
算法的核心思想是:对于任意两个顶点 i 和 j,如果存在一个中间顶点 k,使得从 i 到 k 再到 j 的路径比当前记录的路径更短,则更新 distij。通过枚举所有可能的中间顶点,最终得到所有顶点对之间的最短路径。
4.2 动态规划原理
Floyd 算法的动态规划状态定义为:distkij 表示从顶点 i 到顶点 j、且中间顶点只允许使用前 k 个顶点时的最短路径长度。状态转移方程为:
c
dist[k][i][j] = min(dist[k-1][i][j], dist[k-1][i][k] + dist[k-1][k][j])
该方程的含义是:从 i 到 j 的最短路径,要么不经过顶点 k(即 distk-1ij),要么经过顶点 k(即 distk-1ik + distk-1kj)。由于第 k 层的状态只依赖第 k-1 层,因此可以使用二维数组原地更新,将空间复杂度降为 O(V²)。
4.3 时间复杂度分析
Floyd 算法使用三重循环枚举所有顶点对和所有中间顶点,因此时间复杂度为 O(V³)。空间复杂度为 O(V²),用于存储距离矩阵。对于顶点数较少(通常 V ≤ 500)的图,Floyd 算法实现简单、代码量少,是求解多源最短路径的首选方案。
4.4 适用场景
Floyd 算法适用于以下场景:
- 多源最短路径需求:需要计算图中所有顶点对之间的最短路径。
- 顶点数较少:由于时间复杂度为 O(V³),顶点数较多时性能会急剧下降。
- 允许负权边:Floyd 算法可以处理负权边(但不能存在负权回路),这是它相对于 Dijkstra 算法的一个重要优势。
- 稠密图:对于边数较多的稠密图,Floyd 算法的实现简洁性使其更具吸引力。
5. C 语言代码实现
5.1 图的表示方法
在 C 语言中,图通常使用邻接矩阵或邻接表表示。本文采用邻接矩阵表示法,因为它实现简单、适合稠密图,且与 Floyd 算法的实现方式天然契合。邻接矩阵是一个 V×V 的二维数组,matrixij 表示顶点 i 到顶点 j 的边的权值,如果不存在边则设为无穷大(用一个大数表示)。
c
#include <stdio.h>
#include <stdlib.h>
#include <limits.h>
#define MAX_VERTICES 100
#define INF INT_MAX
/* 图结构体定义 */
typedef struct {
int num_vertices; /* 顶点数量 */
int matrix[MAX_VERTICES][MAX_VERTICES]; /* 邻接矩阵 */
} Graph;
/* 初始化图 */
void init_graph(Graph *g, int num_vertices) {
g->num_vertices = num_vertices;
for (int i = 0; i < num_vertices; i++) {
for (int j = 0; j < num_vertices; j++) {
if (i == j) {
g->matrix[i][j] = 0; /* 顶点到自身的距离为 0 */
} else {
g->matrix[i][j] = INF; /* 初始化为无穷大 */
}
}
}
}
/* 添加边 */
void add_edge(Graph *g, int u, int v, int weight) {
g->matrix[u][v] = weight;
/* 如果是无向图,取消下面一行的注释 */
/* g->matrix[v][u] = weight; */
}
5.2 Dijkstra 算法实现
下面给出 Dijkstra 算法的完整实现。该实现采用朴素方式,通过遍历查找未访问顶点中的最小距离顶点,适合教学和理解算法核心思想。
c
/* 查找未访问顶点中距离最小的顶点 */
int find_min_distance(int dist[], int visited[], int num_vertices) {
int min_dist = INF;
int min_index = -1;
for (int v = 0; v < num_vertices; v++) {
if (!visited[v] && dist[v] < min_dist) {
min_dist = dist[v];
min_index = v;
}
}
return min_index;
}
/* Dijkstra 算法:计算源点到所有顶点的最短路径 */
void dijkstra(Graph *g, int src) {
int dist[MAX_VERTICES]; /* 最短距离数组 */
int visited[MAX_VERTICES]; /* 访问标记数组 */
int path[MAX_VERTICES]; /* 记录前驱顶点,用于回溯路径 */
/* 初始化 */
for (int i = 0; i < g->num_vertices; i++) {
dist[i] = g->matrix[src][i];
visited[i] = 0;
if (g->matrix[src][i] != INF && i != src) {
path[i] = src;
} else {
path[i] = -1;
}
}
dist[src] = 0;
visited[src] = 1;
/* 主循环:每次确定一个顶点的最短路径 */
for (int count = 0; count < g->num_vertices - 1; count++) {
int u = find_min_distance(dist, visited, g->num_vertices);
if (u == -1) {
break; /* 剩余顶点均不可达 */
}
visited[u] = 1;
/* 更新 u 的邻接顶点的距离 */
for (int v = 0; v < g->num_vertices; v++) {
if (!visited[v] && g->matrix[u][v] != INF
&& dist[u] != INF
&& dist[u] + g->matrix[u][v] < dist[v]) {
dist[v] = dist[u] + g->matrix[u][v];
path[v] = u;
}
}
}
/* 输出结果 */
printf("Dijkstra 算法结果(源点 %d):\n", src);
for (int i = 0; i < g->num_vertices; i++) {
if (dist[i] == INF) {
printf(" 到顶点 %d:不可达\n", i);
} else {
printf(" 到顶点 %d:距离 %d\n", i, dist[i]);
}
}
}
5.3 Floyd 算法实现
下面给出 Floyd 算法的完整实现。该算法使用三重循环,通过动态规划逐步更新所有顶点对之间的最短距离。
c
/* Floyd 算法:计算所有顶点对之间的最短路径 */
void floyd(Graph *g) {
int dist[MAX_VERTICES][MAX_VERTICES];
int path[MAX_VERTICES][MAX_VERTICES]; /* 记录路径中间顶点 */
/* 初始化距离矩阵和路径矩阵 */
for (int i = 0; i < g->num_vertices; i++) {
for (int j = 0; j < g->num_vertices; j++) {
dist[i][j] = g->matrix[i][j];
if (i != j && g->matrix[i][j] != INF) {
path[i][j] = i; /* 初始时,i 到 j 的直接路径前驱为 i */
} else {
path[i][j] = -1;
}
}
}
/* 动态规划主循环:枚举中间顶点 k */
for (int k = 0; k < g->num_vertices; k++) {
for (int i = 0; i < g->num_vertices; i++) {
for (int j = 0; j < g->num_vertices; j++) {
/* 如果经过 k 的路径更短,则更新 */
if (dist[i][k] != INF && dist[k][j] != INF
&& dist[i][k] + dist[k][j] < dist[i][j]) {
dist[i][j] = dist[i][k] + dist[k][j];
path[i][j] = path[k][j];
}
}
}
}
/* 输出结果 */
printf("Floyd 算法结果(所有顶点对最短路径):\n");
for (int i = 0; i < g->num_vertices; i++) {
for (int j = 0; j < g->num_vertices; j++) {
if (dist[i][j] == INF) {
printf(" %d -> %d:不可达\n", i, j);
} else {
printf(" %d -> %d:距离 %d\n", i, j, dist[i][j]);
}
}
}
}
5.4 测试用例与运行结果
下面给出一个完整的测试程序,构建一个包含 5 个顶点的有向图,并分别调用两种算法进行验证。
c
/* 主函数:测试两种最短路径算法 */
int main() {
Graph g;
int num_vertices = 5;
/* 初始化图 */
init_graph(&g, num_vertices);
/* 添加边:有向图 */
add_edge(&g, 0, 1, 10);
add_edge(&g, 0, 3, 5);
add_edge(&g, 1, 2, 1);
add_edge(&g, 1, 3, 2);
add_edge(&g, 2, 4, 4);
add_edge(&g, 3, 1, 3);
add_edge(&g, 3, 2, 9);
add_edge(&g, 3, 4, 2);
add_edge(&g, 4, 0, 7);
add_edge(&g, 4, 2, 6);
/* 运行 Dijkstra 算法,源点为 0 */
dijkstra(&g, 0);
printf("\n");
/* 运行 Floyd 算法 */
floyd(&g);
return 0;
}
上述程序的运行结果如下:
text
Dijkstra 算法结果(源点 0):
到顶点 0:距离 0
到顶点 1:距离 8
到顶点 2:距离 9
到顶点 3:距离 5
到顶点 4:距离 7
Floyd 算法结果(所有顶点对最短路径):
0 -> 0:距离 0
0 -> 1:距离 8
0 -> 2:距离 9
0 -> 3:距离 5
0 -> 4:距离 7
1 -> 0:距离 10
1 -> 1:距离 0
1 -> 2:距离 1
1 -> 3:距离 3
1 -> 4:距离 5
2 -> 0:距离 11
2 -> 1:距离 19
2 -> 2:距离 0
2 -> 3:距离 22
2 -> 4:距离 4
3 -> 0:距离 9
3 -> 1:距离 3
3 -> 2:距离 4
3 -> 3:距离 0
3 -> 4:距离 2
4 -> 0:距离 7
4 -> 1:距离 15
4 -> 2:距离 6
4 -> 3:距离 12
4 -> 4:距离 0
6. 两种算法对比
为了在实际项目中做出合理选择,下面从多个维度对 Dijkstra 算法和 Floyd 算法进行系统对比。
| 对比维度 | Dijkstra 算法 | Floyd 算法 |
|---|---|---|
| 问题类型 | 单源最短路径 | 多源最短路径(所有顶点对) |
| 核心思想 | 贪心策略 | 动态规划 |
| 时间复杂度 | O(V²)(朴素)或 O(E log V)(堆优化) | O(V³) |
| 空间复杂度 | O(V) | O(V²) |
| 负权边 | 不支持 | 支持(但不能存在负权回路) |
| 适用图规模 | 顶点数较多、边数较少的稀疏图 | 顶点数较少(通常 V ≤ 500)的图 |
| 实现复杂度 | 相对复杂,需维护距离数组和访问标记 | 实现简单,三重循环即可完成 |
| 典型应用 | 导航路线规划、网络路由 | 传递闭包、多源查询、负权边场景 |
从表中可以看出,两种算法各有侧重:如果只需要计算某个固定源点到其他所有顶点的最短路径,且图中不存在负权边,Dijkstra 算法是更高效的选择;如果需要一次性求出所有顶点对之间的最短路径,或者图中包含负权边,Floyd 算法则更为合适。
7. 总结
最短路径问题是图论中极具实用价值的研究方向,Dijkstra 算法和 Floyd 算法分别代表了贪心策略和动态规划两种经典算法设计思想。
最短路径算法不仅是数据结构与算法课程的核心内容,更是导航、网络、物流等众多实际系统的基石。希望读者通过本文的学习,能够举一反三,将算法思想灵活运用到自己的项目和研究中。