图的最短路径:从单源到多源,再到拓扑与关键路径

图的最短路径问题,说白了就是「怎么走最近」。我在嵌入式项目里经常遇到这类需求——比如路由器的数据包转发、自动驾驶的路径规划,甚至智能家居中传感器数据的最优采集路线。今天咱们就把这块内容彻底讲透。

我个人习惯把图的最短路径问题分成四块来理解:单源最短路径、多源最短路径、拓扑排序、关键路径。它们之间其实有内在联系,我画了张图帮你理清思路。

图的最短路径知识体系 图的最短路径 单源最短路径 Dijkstra算法 多源最短路径 Floyd算法 拓扑排序 有向无环图 关键路径 AOE网 单源→多源:从点到面的扩展 拓扑→关键路径:从排序到工期优化

一、单源最短路径:Dijkstra算法

Dijkstra算法解决的是「从一个源点到其他所有点的最短路径」。嗯,这里有个前提——图中不能有负权边。我在做嵌入式网络协议栈时,就曾用Dijkstra来计算路由表的最优下一跳。

算法的核心思想其实很简单:每次找一个离源点最近且没处理过的顶点,然后通过它去更新其他顶点的距离。说白了就是「贪心」——每次都选当前最优的。

算法步骤:

  1. 初始化:dist[源点]=0,其他dist=∞,所有顶点未访问
  2. 从未访问顶点中选dist最小的顶点u
  3. 标记u为已访问
  4. 遍历u的所有邻接点v,如果dist[u]+w(u,v) < dist[v],则更新dist[v]
  5. 重复步骤2-4,直到所有顶点都被访问
// Dijkstra算法实现(邻接矩阵版)
#define INF 0x3F3F3F3F
#define MAXV 100

void dijkstra(int graph[MAXV][MAXV], int n, int src) {
    int dist[MAXV];      // 最短距离数组
    int visited[MAXV];   // 访问标记
    int i, j, u, min;
    
    // 初始化
    for (i = 0; i < n; i++) {
        dist[i] = graph[src][i];
        visited[i] = 0;
    }
    dist[src] = 0;
    visited[src] = 1;
    
    // 主循环
    for (i = 1; i < n; i++) {
        // 找最小dist的未访问顶点
        min = INF;
        u = -1;
        for (j = 0; j < n; j++) {
            if (!visited[j] && dist[j] < min) {
                min = dist[j];
                u = j;
            }
        }
        
        if (u == -1) break;  // 剩下的顶点不可达
        visited[u] = 1;
        
        // 通过u更新邻接点
        for (j = 0; j < n; j++) {
            if (!visited[j] && graph[u][j] != INF) {
                if (dist[u] + graph[u][j] < dist[j]) {
                    dist[j] = dist[u] + graph[u][j];
                }
            }
        }
    }
    
    // 输出结果
    printf("顶点 %d 到各顶点的最短距离:\n", src);
    for (i = 0; i < n; i++) {
        printf("-> %d : %d\n", i, dist[i]);
    }
}

我的经验:在实际项目中,如果图比较稀疏(边数远小于n²),建议用邻接表+优先队列实现Dijkstra,时间复杂度能从O(n²)降到O((n+e)log n)。我曾经在一个路由计算模块中,把图规模从1000个节点优化到5000个节点,性能反而提升了3倍,就是因为用了优先队列。

注意:Dijkstra算法不能处理负权边!如果图中存在负权边,要用Bellman-Ford算法。我曾经在一个物流路径规划项目中踩过这个坑——用了Dijkstra,结果算出来的最短路径居然是错的,排查了半天才发现是某条路段的「过路费」被设成了负数。

二、多源最短路径:Floyd算法

Floyd算法解决的是「任意两点之间的最短路径」。你想想看,如果图里有100个顶点,你要算所有点对的最短路径,用Dijkstra跑100次?那也太笨了。Floyd算法一次搞定,代码还特别简洁。

Floyd的核心思想是动态规划:dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j])。说白了就是「看看能不能通过k点让i到j的路径更短」。

// Floyd算法实现
#define INF 0x3F3F3F3F
#define MAXV 100

void floyd(int graph[MAXV][MAXV], int n) {
    int dist[MAXV][MAXV];
    int i, j, k;
    
    // 初始化距离矩阵
    for (i = 0; i < n; i++) {
        for (j = 0; j < n; j++) {
            dist[i][j] = graph[i][j];
        }
    }
    
    // 三重循环核心
    for (k = 0; k < n; k++) {          // 中间点
        for (i = 0; i < n; i++) {      // 起点
            for (j = 0; j < n; j++) {  // 终点
                if (dist[i][k] != INF && dist[k][j] != INF) {
                    if (dist[i][k] + dist[k][j] < dist[i][j]) {
                        dist[i][j] = dist[i][k] + dist[k][j];
                    }
                }
            }
        }
    }
    
    // 输出结果
    printf("任意两点间的最短距离:\n");
    for (i = 0; i < n; i++) {
        for (j = 0; j < n; j++) {
            if (dist[i][j] == INF)
                printf("INF ");
            else
                printf("%3d ", dist[i][j]);
        }
        printf("\n");
    }
}

时间复杂度对比:

算法 时间复杂度 空间复杂度 适用场景
Dijkstra(邻接矩阵) O(n²) O(n²) 单源、无负权
Dijkstra(优先队列) O((n+e)log n) O(n+e) 单源、稀疏图
Floyd O(n³) O(n²) 多源、顶点数少
Bellman-Ford O(n×e) O(n) 单源、可处理负权

我的建议:Floyd虽然时间复杂度高,但代码极其简洁,适合顶点数不超过200的场景。我在一个校园导航项目中就用Floyd——总共就50个地标,三重循环跑下来不到1毫秒,比用Dijkstra跑50次快多了。

三、拓扑排序

拓扑排序不是求最短路径,但它是最短路径问题的重要基础——尤其是关键路径。拓扑排序针对的是有向无环图(DAG),说白了就是把图中的顶点排成一个线性序列,使得每条边的起点都在终点之前。

举个例子:你要修一门课,必须先修完前置课程。拓扑排序就是找出一个合理的选课顺序。

// 拓扑排序(Kahn算法)
#define MAXV 100

typedef struct {
    int edges[MAXV][MAXV];
    int n;
} Graph;

void topologicalSort(Graph *g) {
    int indegree[MAXV] = {0};
    int queue[MAXV], front = 0, rear = 0;
    int i, j, count = 0;
    
    // 计算入度
    for (i = 0; i < g->n; i++) {
        for (j = 0; j < g->n; j++) {
            if (g->edges[i][j] != 0) {
                indegree[j]++;
            }
        }
    }
    
    // 入度为0的顶点入队
    for (i = 0; i < g->n; i++) {
        if (indegree[i] == 0) {
            queue[rear++] = i;
        }
    }
    
    printf("拓扑排序结果:");
    while (front < rear) {
        int v = queue[front++];
        printf("%d ", v);
        count++;
        
        // 删除v的所有出边
        for (j = 0; j < g->n; j++) {
            if (g->edges[v][j] != 0) {
                indegree[j]--;
                if (indegree[j] == 0) {
                    queue[rear++] = j;
                }
            }
        }
    }
    
    if (count != g->n) {
        printf("\n图中存在环!无法完成拓扑排序。\n");
    }
}

我曾经踩过的坑:拓扑排序只能用于有向无环图。有一次我在做任务调度系统,用户输入的任务依赖关系里有个循环依赖(A依赖B,B依赖C,C依赖A),拓扑排序直接卡死了。后来我加了个环检测,发现环之后提示用户修改依赖关系。

四、关键路径

关键路径是拓扑排序的经典应用。在AOE网(Activity On Edge)中,顶点表示事件,边表示活动,边的权值表示活动持续时间。关键路径就是「从源点到汇点最长的路径」——它决定了整个工程的最短工期。

为什么是最长路径?你想想看,工程中所有活动可以并行进行,但整个项目必须等最长的那个路径完成才能结束。所以关键路径上的活动一旦延误,整个项目就延期了。

关键路径的四个关键参数:

  • 事件最早发生时间(ve):从源点开始,取最大值
  • 事件最迟发生时间(vl):从汇点倒推,取最小值
  • 活动最早开始时间(e):等于起点事件的最早发生时间
  • 活动最迟开始时间(l):等于终点事件的最迟发生时间减去活动持续时间

关键活动:e == l 的活动,即没有时间余量的活动。

// 关键路径算法(基于拓扑排序)
#define MAXV 100

typedef struct {
    int edges[MAXV][MAXV];  // 邻接矩阵,存储活动持续时间
    int n;
} AOE;

void criticalPath(AOE *g) {
    int ve[MAXV] = {0};     // 事件最早发生时间
    int vl[MAXV];           // 事件最迟发生时间
    int topo[MAXV];         // 拓扑序列
    int i, j;
    
    // 1. 拓扑排序并计算ve
    // ...(拓扑排序代码略,参考上一节)
    // 假设topo数组已存储拓扑序列
    
    // 2. 计算ve(正向)
    for (i = 0; i < g->n; i++) {
        int v = topo[i];
        for (j = 0; j < g->n; j++) {
            if (g->edges[v][j] != 0) {
                if (ve[v] + g->edges[v][j] > ve[j]) {
                    ve[j] = ve[v] + g->edges[v][j];
                }
            }
        }
    }
    
    // 3. 初始化vl为最大值
    for (i = 0; i < g->n; i++) {
        vl[i] = ve[g->n - 1];  // 汇点的ve就是总工期
    }
    
    // 4. 计算vl(逆向)
    for (i = g->n - 1; i >= 0; i--) {
        int v = topo[i];
        for (j = 0; j < g->n; j++) {
            if (g->edges[j][v] != 0) {  // 注意:这里是入边
                if (vl[v] - g->edges[j][v] < vl[j]) {
                    vl[j] = vl[v] - g->edges[j][v];
                }
            }
        }
    }
    
    // 5. 输出关键活动
    printf("关键活动:\n");
    for (i = 0; i < g->n; i++) {
        for (j = 0; j < g->n; j++) {
            if (g->edges[i][j] != 0) {
                int e = ve[i];                    // 活动最早开始
                int l = vl[j] - g->edges[i][j];   // 活动最迟开始
                if (e == l) {
                    printf("活动(%d->%d) 持续时间:%d\n", i, j, g->edges[i][j]);
                }
            }
        }
    }
    
    printf("总工期:%d\n", ve[g->n - 1]);
}

我的项目经验:关键路径在项目管理中非常实用。我曾经给一个芯片验证团队写过一个自动化调度工具,用关键路径算法找出哪些测试用例是「卡脖子」的,然后优先安排资源。结果项目周期从原来的3个月压缩到了2个月——因为之前大家都在盲目并行,没人关注真正的瓶颈在哪。

好了,图的最短路径这块内容就讲到这里。从单源的Dijkstra到多源的Floyd,再到拓扑排序和关键路径,你会发现它们其实是一脉相承的——都是围绕「路径」这个核心概念展开。实际项目中,选哪个算法取决于你的图有多大、有没有负权、是单源还是多源。多动手写代码,遇到问题多调试,慢慢就有感觉了。


公众号:蓝海资料掘金营,微信deep3321