29、最短路径:迪杰斯特拉(Dijkstra)算法、弗洛伊德(Floyd)算法

说到图论里的最短路径,我脑子里第一个蹦出来的场景,就是当年做导航模块时,被地图数据虐得死去活来的日子。你想想看,从一个路口到另一个路口,怎么走最近?这问题听着简单,但放到代码里,就是两种经典算法的较量——迪杰斯特拉和弗洛伊德。

今天咱们就把这两个算法掰开揉碎,看看它们到底怎么用C语言实现。嗯,先别急着看代码,咱们先把逻辑理清楚。

迪杰斯特拉算法:单源最短路径的利器

迪杰斯特拉算法,说白了就是从一个起点出发,一步步找到到所有其他点的最短路径。它有个前提——图中不能有负权边。我在项目中遇到过有人拿它去算带负权的地图,结果跑出来的路径全是错的,排查了半天才发现是算法选型出了问题。

核心思想:贪心策略。每次从未访问的节点中,选一个距离起点最近的点,然后通过它去更新其他点的距离。

咱们直接上代码,我习惯用邻接矩阵来存图,这样逻辑最清晰:

#include <stdio.h>
#include <limits.h>

#define V 6  // 顶点数

// 找到距离起点最近且未访问的顶点
int minDistance(int dist[], int visited[]) {
    int min = INT_MAX, min_index;
    for (int v = 0; v < V; v++) {
        if (!visited[v] && dist[v] <= min) {
            min = dist[v];
            min_index = v;
        }
    }
    return min_index;
}

void dijkstra(int graph[V][V], int src) {
    int dist[V];      // 存储最短距离
    int visited[V];   // 标记已访问

    // 初始化
    for (int i = 0; i < V; i++) {
        dist[i] = INT_MAX;
        visited[i] = 0;
    }
    dist[src] = 0;

    // 找最短路径
    for (int count = 0; count < V - 1; count++) {
        int u = minDistance(dist, visited);
        visited[u] = 1;

        for (int v = 0; v < V; v++) {
            if (!visited[v] && graph[u][v] && 
                dist[u] != INT_MAX && 
                dist[u] + graph[u][v] < dist[v]) {
                dist[v] = dist[u] + graph[u][v];
            }
        }
    }

    // 打印结果
    printf("顶点\t距离起点\n");
    for (int i = 0; i < V; i++) {
        printf("%d\t%d\n", i, dist[i]);
    }
}

int main() {
    int graph[V][V] = {
        {0, 2, 4, 0, 0, 0},
        {2, 0, 1, 7, 0, 0},
        {4, 1, 0, 3, 5, 0},
        {0, 7, 3, 0, 2, 6},
        {0, 0, 5, 2, 0, 1},
        {0, 0, 0, 6, 1, 0}
    };
    dijkstra(graph, 0);
    return 0;
}

避坑指南:我曾经在嵌入式平台上用这个算法,忘了考虑INT_MAX加一个正数会溢出。结果dist[u] + graph[u][v]直接变成了负数,比任何距离都小。嗯,从那以后我每次做距离更新前,都会先判断dist[u]是不是INT_MAX。

弗洛伊德算法:全源最短路径的王者

迪杰斯特拉只能算一个点到其他所有点。那如果我想知道任意两个点之间的最短路径呢?这时候就该弗洛伊德算法登场了。

弗洛伊德算法的思路其实很暴力——它尝试把每个顶点都当作「中间点」,看看经过这个点能不能让路径更短。说白了就是三层循环,把所有可能性都试一遍。

核心公式:dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j])

代码实现比迪杰斯特拉还简洁:

#include <stdio.h>
#include <limits.h>

#define V 4
#define INF 99999

void floydWarshall(int graph[V][V]) {
    int dist[V][V];

    // 初始化距离矩阵
    for (int i = 0; i < V; i++) {
        for (int j = 0; j < V; j++) {
            dist[i][j] = graph[i][j];
        }
    }

    // 核心三重循环
    for (int k = 0; k < V; k++) {          // 中间点
        for (int i = 0; i < V; i++) {      // 起点
            for (int j = 0; j < V; j++) {  // 终点
                if (dist[i][k] + dist[k][j] < dist[i][j]) {
                    dist[i][j] = dist[i][k] + dist[k][j];
                }
            }
        }
    }

    // 打印结果
    printf("最短距离矩阵:\n");
    for (int i = 0; i < V; i++) {
        for (int j = 0; j < V; j++) {
            if (dist[i][j] == INF)
                printf("%7s", "INF");
            else
                printf("%7d", dist[i][j]);
        }
        printf("\n");
    }
}

int main() {
    int graph[V][V] = {
        {0, 5, INF, 10},
        {INF, 0, 3, INF},
        {INF, INF, 0, 1},
        {INF, INF, INF, 0}
    };
    floydWarshall(graph);
    return 0;
}

注意:弗洛伊德算法的时间复杂度是O(V³)。如果顶点数超过500,在嵌入式设备上跑起来会非常吃力。我建议在顶点数少于200时使用,否则还是考虑迪杰斯特拉加循环吧。

两种算法的对比

咱们用一张表把它们的区别说清楚:

特性 迪杰斯特拉 弗洛伊德
适用场景 单源最短路径 全源最短路径
时间复杂度 O(V²) 或 O(E log V) O(V³)
空间复杂度 O(V) O(V²)
负权边 不支持 支持(但不能有负权环)
实现难度 中等 简单

我个人习惯是:如果只需要知道一个点到其他点的距离,用迪杰斯特拉。如果要做路径分析、网络拓扑计算这种需要全局信息的,直接上弗洛伊德。代码量少,不容易出错。

知识体系总览

下面这张图把本章的核心逻辑串起来了,你看一眼就能明白两个算法的定位:

最短路径算法知识体系 最短路径算法 迪杰斯特拉算法 弗洛伊德算法 单源路径 贪心策略 无负权边 全源路径 动态规划 支持负权 选型建议:单源用迪杰斯特拉,全源用弗洛伊德 顶点数少(<200)时弗洛伊德更省心

实际项目中的选型建议

我在做嵌入式路由协议时,经常需要计算网络拓扑中所有节点之间的最短路径。那时候节点数也就几十个,我直接上了弗洛伊德。为什么?因为代码简单,调试方便,出bug的概率低。

但如果你是在做导航系统,起点固定,终点变化,那迪杰斯特拉更合适。我记得有一次优化车载导航模块,把弗洛伊德换成迪杰斯特拉,内存占用直接降了60%。

小技巧:如果图比较稀疏(边很少),迪杰斯特拉可以用优先队列优化,时间复杂度能从O(V²)降到O(E log V)。我习惯用C语言里的最小堆来实现,效果很不错。

好了,这两个算法就聊到这儿。你想想看,一个贪心一个动态规划,一个单源一个全源,搭配起来几乎能覆盖所有最短路径的需求。下次写代码时,先问问自己:我要算几个点?图里有负权吗?选对了算法,事半功倍。


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