4. Dijkstra算法:从原理到实战

Dijkstra算法,说白了就是「找最短路径」的祖师爷。我当年刚入行时,第一个接触的路径规划算法就是它。你想想看,地图上两个点之间,哪条路最短?Dijkstra给出了一个非常优雅的答案。

4.1 算法核心思想

Dijkstra算法的本质,是一个「贪心+动态规划」的组合。它从起点开始,一层层向外扩散,每次选择当前已知最短的路径继续探索。嗯,这里要注意:它要求所有边的权重不能为负数。

我个人习惯把它的工作方式想象成「水波扩散」——你往平静的水面扔一颗石子,波纹会一圈圈向外扩散,最先到达岸边的那一圈波纹,就是最短路径。

核心三要素:

  • 距离数组:记录从起点到每个节点的当前最短距离
  • 访问标记:标记哪些节点已经找到了最短路径
  • 优先队列:每次取出距离最小的未访问节点

4.2 优先队列实现

为什么用优先队列?我刚开始学的时候也纳闷——用普通数组不行吗?行是行,但效率差太多了。普通数组每次找最小值要O(n),优先队列只要O(log n)。

我在项目中遇到过一个问题:地图上有几千个节点,用普通数组实现Dijkstra,跑一次要好几秒。换成优先队列后,瞬间降到几十毫秒。这就是数据结构的威力。

下面是我常用的C++实现模板:

#include <vector>
#include <queue>
#include <limits>

using namespace std;

// 图的邻接表表示
vector<vector<pair<int, int>>> graph;  // (邻居节点, 边权重)

vector<int> dijkstra(int start, int n) {
    // 距离数组,初始化为无穷大
    vector<int> dist(n, numeric_limits<int>::max());
    dist[start] = 0;
    
    // 优先队列:存储 (距离, 节点编号)
    // 默认是大顶堆,用greater变成小顶堆
    priority_queue<pair<int, int>, 
                   vector<pair<int, int>>, 
                   greater<pair<int, int>>> pq;
    pq.push({0, start});
    
    while (!pq.empty()) {
        int d = pq.top().first;
        int u = pq.top().second;
        pq.pop();
        
        // 如果当前距离大于已知最短距离,跳过
        if (d > dist[u]) continue;
        
        // 遍历邻居
        for (auto& edge : graph[u]) {
            int v = edge.first;
            int w = edge.second;
            
            // 松弛操作
            if (dist[u] + w < dist[v]) {
                dist[v] = dist[u] + w;
                pq.push({dist[v], v});
            }
        }
    }
    
    return dist;
}

避坑指南:我曾经在优先队列里忘记用greater,结果跑出来全是错误路径。默认的priority_queue是大顶堆,取出来的是最大距离,那算法就完全乱套了。记住:Dijkstra要用小顶堆!

4.3 最优性证明

为什么Dijkstra算法一定能找到最短路径?这个问题我面试时被问过好几次。其实证明思路很直观:

归纳法证明:

  1. 基础情况:起点到自身的距离为0,显然是最短的
  2. 归纳步骤:假设已经找到了k个节点的最短路径,那么第k+1个被选中的节点,它的距离一定是全局最短的

为什么会这样?因为所有边权非负,任何绕路的路径只会让距离变大。你想想看,如果有一条更短的路径能到当前节点,那它早就被优先队列弹出来了。

重要前提:边权必须非负!如果有负权边,Dijkstra会失效。我在一个项目中遇到过这种情况——地图上有「下坡路」导致负权值,结果Dijkstra跑出来的路径完全不对。后来换成了Bellman-Ford算法才解决。

4.4 栅格地图上的仿真

在实际的机器人项目中,我们经常把环境抽象成栅格地图。每个格子要么是空地(可通行),要么是障碍物(不可通行)。

下面是我在仿真中常用的8邻域搜索方式:

// 栅格地图上的Dijkstra
// 0表示空地,1表示障碍物
vector<vector<int>> grid;  // 地图数据
int rows, cols;             // 地图尺寸

// 8个方向的移动代价
int dx[] = {-1, -1, -1, 0, 0, 1, 1, 1};
int dy[] = {-1, 0, 1, -1, 1, -1, 0, 1};
int cost[] = {14, 10, 14, 10, 10, 14, 10, 14};  // 对角移动代价为14,直线为10

vector<vector<int>> dijkstraGrid(int startX, int startY) {
    // 距离矩阵
    vector<vector<int>> dist(rows, vector<int>(cols, INT_MAX));
    dist[startX][startY] = 0;
    
    // 优先队列
    priority_queue<tuple<int, int, int>, 
                   vector<tuple<int, int, int>>, 
                   greater<tuple<int, int, int>>> pq;
    pq.push({0, startX, startY});
    
    while (!pq.empty()) {
        auto [d, x, y] = pq.top();
        pq.pop();
        
        if (d > dist[x][y]) continue;
        
        // 搜索8个方向
        for (int i = 0; i < 8; i++) {
            int nx = x + dx[i];
            int ny = y + dy[i];
            
            // 边界检查
            if (nx < 0 || nx >= rows || ny < 0 || ny >= cols) continue;
            // 障碍物检查
            if (grid[nx][ny] == 1) continue;
            
            int newDist = d + cost[i];
            if (newDist < dist[nx][ny]) {
                dist[nx][ny] = newDist;
                pq.push({newDist, nx, ny});
            }
        }
    }
    
    return dist;
}

仿真结果解读:

  • 距离矩阵中每个格子的值,代表从起点到该格子的最短路径长度
  • 障碍物格子的距离保持为无穷大
  • 通过回溯距离矩阵,可以重建完整的路径

我在做AGV(自动导引车)项目时,就是用这个算法做全局路径规划。不过要注意,栅格地图的分辨率很关键——格子太大,路径不够精细;格子太小,计算量暴增。我一般取机器人直径的1/3作为格子大小,效果还不错。

4.5 算法复杂度分析

实现方式 时间复杂度 空间复杂度 适用场景
普通数组 O(V²) O(V) 稠密图,节点少
二叉堆优先队列 O((V+E)logV) O(V+E) 稀疏图,通用场景
斐波那契堆 O(VlogV+E) O(V+E) 大规模图,理论最优

我的建议:实际工程中,二叉堆实现就够用了。斐波那契堆虽然理论更快,但常数大,实现复杂,性价比不高。我曾经在项目中试过斐波那契堆,结果因为实现bug调试了两天,最后还是换回了二叉堆。

4.6 知识体系总览

下面这张图总结了Dijkstra算法的核心知识结构:

Dijkstra算法 算法原理 贪心 + 动态规划 水波扩散思想 优先队列实现 小顶堆 O(log n) 松弛操作 最优性证明 归纳法证明 边权非负前提 栅格地图仿真 8邻域搜索 距离矩阵回溯 核心:每次选择当前最短路径,逐步扩展到全局最优 时间复杂度 O((V+E)logV) | 空间复杂度 O(V+E)

Dijkstra算法虽然经典,但也有局限性。比如它不知道目标点在哪,会盲目地朝所有方向扩散。下一章我们要讲的A*算法,就是在这个基础上加了启发式信息,让搜索更有方向感。

可可精品资料库