【图论】Dijkstra 算法

Dijkstra 算法(迪杰斯特拉算法)是图论中最经典、最基础的单源最短路径算法之一。它由荷兰计算机科学家艾兹赫尔·戴克斯特拉(Edsger W. Dijkstra)于 1956 年提出,用于解决带权有向图或无向图中,从一个固定源点(Source Vertex)到图中其他所有顶点的最短路径问题。

一、 算法目标

给定一个带权图 G=(V,E)G = (V, E)G=(V,E) 和一个源点 s∈Vs \in VsV,Dijkstra 算法的目标是计算出从 sss 到图中每一个其他顶点 vvv 的最短路径长度(距离),并可选择性地记录下路径本身。

二、 核心思想:贪心策略 + 优先队列

Dijkstra 算法的核心思想是贪心策略(Greedy Strategy)。它维护一个集合 SSS,表示已确定最短路径的顶点集合。算法在每一步都选择一个距离源点最近的、尚未加入 SSS 的顶点 uuu,将其加入 SSS,然后松弛(Relax)所有从 uuu 出发的边,更新这些边所指向顶点的最短距离估计值。

关键概念

  • 松弛 (Relaxation):这是最短路径算法中的核心操作。对于一条从顶点 uuu 到顶点 vvv 的边,权重为 w(u,v)w(u,v)w(u,v),如果通过 uuu 到达 vvv 的路径比目前已知的从源点 sssvvv 的最短距离更短,则更新这个距离。
    • 松弛操作
      if (dist[u] + w(u, v) < dist[v]) {
          dist[v] = dist[u] + w(u, v);
          predecessor[v] = u; // 更新前驱,用于重建路径
      }
      
  • 贪心选择:每次选择当前 dist 值最小的未确定顶点。这个选择之所以是“贪心”的,是因为它在当前看来是局部最优的。Dijkstra 算法的正确性依赖于一个关键前提:图中不能有负权边。因为如果有负权边,即使当前 dist[u] 最小,未来也可能通过一个负权边找到更短的路径,从而推翻之前的“最优”选择。

三、 算法步骤

1. 初始化

  • 创建一个距离数组 dist[],大小为 ∣V∣|V|V
    • 对于所有顶点 vvv
      • dist[v] = \infty(表示初始时不可达)。
    • dist[s] = 0(源点到自身的距离为 0)。
    • 创建一个集合 SSS(或布尔数组 visited[]),初始为空,用于记录已确定最短路径的顶点。
    • (可选)创建一个前驱数组 predecessor[],用于路径重建,初始为 -1nullptr

2. 主循环

  • 当集合 SSS 的大小小于 ∣V∣|V|V 时(即还有顶点未确定最短路径):
    a. 选择:从未加入 SSS 的顶点中,选择 dist[u] 值最小的顶点 uuu
    b. 加入集合:将顶点 uuu 加入集合 SSS(标记为已确定)。
    c. 松弛:遍历所有从 uuu 出发的边 (u,v)(u, v)(u,v)
    • 如果 vvv 不在 SSS 中,执行松弛操作:
      if (dist[u] + weight(u, v) < dist[v])
      dist[v] = dist[u] + weight(u, v)
      predecessor[v] = u

3. 路径重建

  • 使用 predecessor[] 数组,从目标顶点 ttt 开始,不断回溯其前驱,直到回到源点 sss,即可得到从 sssttt 的最短路径。

4. 数据结构的选择与复杂度

Dijkstra 算法的效率高度依赖于如何实现“选择 dist 值最小的未确定顶点”这一步骤

  • 方法一:朴素实现(数组/线性扫描)

    • 数据结构:使用一个数组存储 dist 值,每次扫描整个数组找到最小值。
    • 时间复杂度
      • 选择最小值:O(V)O(V)O(V),共 VVV 次,总计 O(V2)O(V^2)O(V2)
      • 松弛操作:每条边最多被松弛一次,O(E)O(E)O(E)
      • 总时间复杂度O(V2+E)O(V^2 + E)O(V2+E)。对于稠密图 (E≈V2E \approx V^2EV2),约为 O(V2)O(V^2)O(V2)
    • 空间复杂度O(V)O(V)O(V)
  • 方法二:优先队列优化(最小堆)

    • 数据结构:使用一个最小堆(优先队列),存储 (距离, 顶点) 对。堆顶总是距离最小的顶点。
    • 操作
      • 初始化:将 (0, s) 加入堆。
      • 选择:从堆顶取出元素(O(log V))。
      • 松弛:当更新 dist[v] 时,将新的 (dist[v], v) 加入堆。注意:同一个顶点 vvv 可能有多个不同的距离值在堆中。当从堆中取出一个顶点 uuu 时,需要检查其距离值是否与当前 dist[u] 一致(即是否是过时的值),如果一致才处理,否则忽略。
    • 时间复杂度
      • 堆操作(插入和删除):O(log⁡V)O(\log V)O(logV)
      • 每条边可能触发一次堆插入(松弛成功时),最多 EEE 次插入。
      • 每个顶点最多被删除一次,VVV 次删除。
      • 总时间复杂度O((V+E)log⁡V)O((V + E) \log V)O((V+E)logV)。对于稀疏图 (E≪V2E \ll V^2EV2),这比 O(V2)O(V^2)O(V2) 更优。
    • 空间复杂度O(V+E)O(V + E)O(V+E)(堆中最多可能有 EEE 个元素)。

5. 算法前提与限制

  • 前提图中所有边的权重必须为非负数(即 ≥0\geq 00)。这是 Dijkstra 算法正确性的基石。如果存在负权边,贪心策略失效,算法可能得到错误结果。
  • 优点
    • 算法思想直观,易于理解。
    • 优先队列优化后,在稀疏图上效率较高。
    • 一旦某个顶点被加入 SSS,其最短距离就确定了,后续不会改变(这是非负权边保证的)。
  • 缺点
    • 不能处理负权边。
    • 朴素实现对于稠密图效率尚可,但对于稀疏图不如优先队列优化版本。
    • 优先队列优化版本实现稍复杂,且堆中可能有重复顶点。

6. 与其他算法的比较

  • vs Bellman-Ford:Dijkstra 更快(O((V+E)log⁡V)O((V+E)\log V)O((V+E)logV) vs O(VE)O(VE)O(VE)),但 Bellman-Ford 可以处理负权边并检测负权环。
  • vs Floyd-Warshall:Dijkstra 是单源算法,Floyd 是全源算法。如果只需要单源最短路径,Dijkstra 通常比 Floyd (O(V3)O(V^3)O(V3)) 更高效。

四、 C++ 实现

我们将实现两种版本:优先队列优化版朴素数组版

版本一:优先队列优化版

#include <iostream>
#include <vector>
#include <queue>
#include <climits>
#include <algorithm>
#include <stack>

using namespace std;

// 常量:表示无穷大(不可达)
const int INF = INT_MAX;

// 定义图的邻接表表示
// adj[u] 是一个 vector<pair<int, int>>,存储 (邻居顶点v, 边权w(u,v))
using Graph = vector<vector<pair<int, int>>>;

class Dijkstra {
private:
    int n; // 顶点数量
    Graph adj; // 邻接表
    vector<int> dist; // 距离数组
    vector<int> predecessor; // 前驱数组,用于路径重建
    bool computed; // 标记是否已计算过

public:
    // 构造函数
    Dijkstra(int vertices) : n(vertices), adj(vertices), computed(false) {}

    // 添加有向边
    void addEdge(int from, int to, int weight) {
        adj[from].push_back({to, weight});
        // 对于无向图,还需添加反向边
        // adj[to].push_back({from, weight});
    }

    // 执行 Dijkstra 算法,从源点 source 开始
    void computeShortestPaths(int source) {
        // 初始化
        dist.assign(n, INF);
        predecessor.assign(n, -1); // -1 表示无前驱
        dist[source] = 0;
        computed = true;

        // 最小堆:存储 (距离, 顶点)
        // 注意:priority_queue 默认是最大堆,所以用 greater 使其变为最小堆
        priority_queue<pair<int, int>, vector<pair<int, int>>, greater<pair<int, int>>> pq;
        pq.push({0, source});

        while (!pq.empty()) {
            auto [d, u] = pq.top(); // C++17 结构化绑定
            pq.pop();

            // 跳过过时的条目(懒惰删除)
            if (d != dist[u]) {
                continue;
            }

            // 遍历所有从 u 出发的边
            for (auto &[v, w] : adj[u]) { // C++17 结构化绑定
                // 尝试通过 u 松弛到 v 的路径
                if (dist[u] + w < dist[v]) {
                    dist[v] = dist[u] + w;
                    predecessor[v] = u;
                    pq.push({dist[v], v}); // 将新距离加入堆
                }
            }
        }
    }

    // 查询从源点到顶点 v 的最短距离
    int getDistance(int v) const {
        if (!computed) {
            cout << "请先调用 computeShortestPaths()!" << endl;
            return INF;
        }
        return dist[v];
    }

    // 获取从源点到顶点 v 的最短路径(顶点序列)
    vector<int> getPath(int v) const {
        if (!computed) {
            cout << "请先调用 computeShortestPaths()!" << endl;
            return {};
        }
        if (dist[v] == INF) {
            cout << "从源点到 " << v << " 不可达。" << endl;
            return {};
        }

        vector<int> path;
        // 从 v 开始,通过前驱数组回溯到源点
        for (int current = v; current != -1; current = predecessor[current]) {
            path.push_back(current);
        }
        // 回溯得到的路径是逆序的,需要反转
        reverse(path.begin(), path.end());
        return path;
    }

    // 打印从源点到所有顶点的最短距离和路径
    void printAllResults(int source) const {
        if (!computed) {
            cout << "请先调用 computeShortestPaths()!" << endl;
            return;
        }

        cout << "从源点 " << source << " 出发的最短路径结果:" << endl;
        for (int v = 0; v < n; ++v) {
            cout << "到顶点 " << v << ": ";
            if (dist[v] == INF) {
                cout << "不可达" << endl;
            } else {
                cout << "距离 = " << dist[v] << ", 路径: ";
                auto path = getPath(v);
                for (size_t i = 0; i < path.size(); ++i) {
                    cout << path[i];
                    if (i < path.size() - 1) cout << " -> ";
                }
                cout << endl;
            }
        }
    }
};

版本二:朴素数组实现版

class DijkstraNaive {
private:
    int n;
    vector<vector<int>> adjMatrix; // 邻接矩阵,adjMatrix[u][v] = 边权,无边则为 INF
    vector<int> dist;
    vector<int> predecessor;
    vector<bool> visited; // visited[u] = true 表示 u 已确定最短路径
    bool computed;

public:
    DijkstraNaive(int vertices) : n(vertices), computed(false) {
        // 初始化邻接矩阵
        adjMatrix.assign(n, vector<int>(n, INF));
        for (int i = 0; i < n; ++i) {
            adjMatrix[i][i] = 0;
        }
    }

    void addEdge(int from, int to, int weight) {
        adjMatrix[from][to] = weight;
        // 无向图:adjMatrix[to][from] = weight;
    }

    void computeShortestPaths(int source) {
        dist.assign(n, INF);
        predecessor.assign(n, -1);
        visited.assign(n, false);
        dist[source] = 0;
        computed = true;

        for (int iter = 0; iter < n; ++iter) { // 进行 n 次迭代
            // 1. 找到未访问顶点中 dist 最小的
            int u = -1;
            int minDist = INF;
            for (int v = 0; v < n; ++v) {
                if (!visited[v] && dist[v] < minDist) {
                    minDist = dist[v];
                    u = v;
                }
            }

            // 如果找不到,说明剩余顶点均不可达,跳出
            if (u == -1) break;

            // 2. 将 u 标记为已访问(已确定)
            visited[u] = true;

            // 3. 松弛所有从 u 出发的边
            for (int v = 0; v < n; ++v) {
                if (!visited[v] && adjMatrix[u][v] != INF) { // v 未确定 且 存在边 u->v
                    if (dist[u] + adjMatrix[u][v] < dist[v]) {
                        dist[v] = dist[u] + adjMatrix[u][v];
                        predecessor[v] = u;
                    }
                }
            }
        }
    }

    // 其他查询函数与优先队列版本类似,此处省略...
    // (为节省篇幅,省略了 getDistance, getPath, printAllResults)
};

测试函数

// 测试函数
int main() {
    // 示例:一个简单的正权有向图
    cout << "=== Dijkstra 算法测试 ===" << endl;
    Dijkstra dijkstra(6); // 6 个顶点:0, 1, 2, 3, 4, 5

    // 添加边 (from, to, weight)
    dijkstra.addEdge(0, 1, 4);
    dijkstra.addEdge(0, 2, 2);
    dijkstra.addEdge(1, 2, 1);
    dijkstra.addEdge(1, 3, 5);
    dijkstra.addEdge(2, 3, 8);
    dijkstra.addEdge(2, 4, 10);
    dijkstra.addEdge(3, 4, 2);
    dijkstra.addEdge(3, 5, 6);
    dijkstra.addEdge(4, 5, 3);

    int source = 0;
    dijkstra.computeShortestPaths(source);
    dijkstra.printAllResults(source);

    // 测试特定路径
    int target = 5;
    auto path = dijkstra.getPath(target);
    cout << "\n从 " << source << " 到 " << target << " 的路径: ";
    for (size_t i = 0; i < path.size(); ++i) {
        cout << path[i];
        if (i < path.size() - 1) cout << " -> ";
    }
    cout << " (距离: " << dijkstra.getDistance(target) << ")" << endl;

    return 0;
}

代码说明

  1. Graph 类型:使用 vector<vector<pair<int, int>>> 表示邻接表,高效且节省空间。
  2. priority_queue:使用 greater<pair<int, int>> 使其成为最小堆。pair 的第一个元素是距离,用于堆的排序。
  3. 懒惰删除 (Lazy Deletion):这是关键技巧。当更新 dist[v] 时,我们不从堆中删除旧的 (old_dist, v),而是直接插入新的 (new_dist, v)。当从堆顶取出一个元素 (d, u) 时,检查 d 是否等于当前的 dist[u]。如果不等,说明这个条目已经过时(dist[u] 已被更新为更小的值),直接忽略它。
  4. 路径重建:使用 predecessor 数组回溯,并将结果反转得到正确顺序。
  5. C++17 特性:代码中使用了结构化绑定 (auto [d, u] = ...),使代码更简洁。如果编译器不支持,可以用 int d = pq.top().first; int u = pq.top().second; 替代。

五、总结

Dijkstra 算法是解决非负权图单源最短路径问题的基石。其贪心策略保证了在非负权边条件下,一旦顶点被确定,其最短距离就不再改变。优先队列优化使其在稀疏图上具有优秀的 O((V+E)log⁡V)O((V+E)\log V)O((V+E)logV) 时间复杂度。理解其松弛操作、贪心选择以及优先队列的“懒惰删除”技巧,对于掌握该算法至关重要。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值