C++‑Dijkstra算法全解析(含堆优化)
目标
- 让你彻底理解Dijkstra的核心思想
- 学会在 C++ 中用邻接链表实现基本版本( O(V²) )
- 掌握如何用
priority_queue(二叉堆)把复杂度降到O((V+E) log V)- 练习几组典型样例,感受运算步骤
- 知晓常见坑、细节与可选进一步优化
前提
- 熟悉 C++ 基础语法、标准库使用
- 具备基本的图论知识(顶点、边、权重、无向/有向、负权等)
图文并茂
文字配合 ASCII/伪图,虽不能插图,但足以直观看懂。
1. Dijkstra 算法概念回顾
问题
“给定一个非负权边的图,求从源点s到所有其它顶点的最短路径长度。”
核心思想
- 维护一个“已确定最短路径的顶点集合” S(“已访问”)
- 对每个顶点 v,保留一个暂时距离
dist[v](从 s 到 v 的最短已知路程) - 每一次迭代,选取未访问顶点中
dist最小的那个u,把它加入S - 更新以
u为起点的“邻接边”所能到达的其它顶点的dist
由于权重非负,已确定最短路径的頂点在后续松弛操作中不会再次变短,因此可以一次性确定。
1.1 过程示意(无图形)
initial: dist[s] = 0, 其余 dist[] = ∞ S = ∅ while S 不包含所有顶点: u = argmin_{v∉S} dist[v] // 选最小距离 S += u // 把 u 加入已确定路径集合 for each edge (u, w, cost): if dist[u] + cost < dist[w]: dist[w] = dist[u] + cost // 更新端点 w 的暫時距離注:此伪代码使用线性扫描选取最小
dist,时间复杂度O(V²)。
若 V≈1e5 这个实现就无效,需要更快的“选择最小”方法——堆(权重小的顶点优先)。
2. C++ 与邻接链表的基本实现(O(V²))
2.1 数据结构
- 顶点数
n - 邻接链表
vector<vector<pair<int,int>>> G;G[u]为(v, w)的列表,表示一条从u到v的权重为w的边
- 距离数组
vector<long long> dist(n, INF) - 访问标记
vector<bool> vis(n, false)
2.2 代码(注释充分)
#include <bits/stdc++.h> using namespace std; using pii = pair<int,int>; using ll = long long; const ll INF = 4e18; // 足够大,以防止 overflow int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n, m, s; // n:顶点数,m:边数,s:源点 if(!(cin >> n >> m >> s)) return 0; vector<vector<pii>> G(n); // 邻接链表 for (int i=0;i<m;i++){ int u,v,w; // 读入有向边 u->v(或无向则添加两边) cin >> u >> v >> w; G[u].push_back({v,w}); G[v].push_back({u,w}); // 若无向图则取消此行 } vector<ll> dist(n, INF); vector<char> vis(n, 0); dist[s] = 0; for (int it=0; it<n; ++it){ // 选择未访问且 dist 最小的顶点 int u = -1; ll best = INF; for (int i=0;i<n;i++){ if (!vis[i] && dist[i] < best){ best = dist[i]; u = i; } } if (u==-1) break; // 剩余顶点不可达 vis[u] = 1; // 标记为已确定 // 松弛所有邻接边 for (auto &e: G[u]){ int v = e.first, w = e.second; if (dist[u] + w < dist[v]) dist[v] = dist[u] + w; } } // 输出结果 for (int i=0;i<n;i++){ if (dist[i]==INF) cout << "-1 "; // 说明不可达 else cout << dist[i] << ' '; } cout << '\n'; return 0; }时间复杂度
- 选取最小
dist:O(V)乘以V次 →O(V²)- 边松弛:
O(E)
对少量顶点或稠密图可接受,但随着规模增大需优化。
3. 堆优化(使用priority_queue)
3.1 为什么需要堆?
在基本实现中,寻找未访问顶点中最小dist的操作是线性扫描。
如果使用最小堆(priority_queue默认最大堆,需要取负数或定义比较器),
可以在log V时间内获得最小值。整体复杂度变为O((V+E) log V),几乎是O(V²)的 1/1000 级别(典型实例)。
3.2 核心细节
| 步骤 | 细节 |
|---|---|
| 堆元素 | pair<ll,int>,第一个字段为dist,第二个为顶点编号**。兼容 STL 直写priority_queue<pair<ll,int>> pq; |
| 取最小 | STL 的priority_queue取最大,需:priority_queue<... , vector<...>, greater<...>>或使用-dist。 |
| 更新(Decrease-Key) | STL 堆不支持直接降低键值。解决方案是懒惰删除:每次找到更短路径后直接再push一次新(dist[v], v)。当顶点再次弹出时,如果已被访问(vis[v]==true),则跳过。 |
| 避免访问重复 | 标记vis[v]与基本实现同步。首次弹出且未被访问的顶点即为最短路径,随后所有记录对该顶点的弹出都忽略。 |
3.3 代码示例(堆优化)
#include <bits/stdc++.h> using namespace std; using pii = pair<int,int>; using pli = pair<long long,int>; using ll = long long; const ll INF = 4e18; int main(){ ios::sync_with_stdio(false); cin.tie(nullptr); int n, m, s; if(!(cin >> n >> m >> s)) return 0; vector<vector<pii>> G(n); for(int i=0;i<m;i++){ int u,v,w; cin>>u>>v>>w; G[u].push_back({v,w}); G[v].push_back({u,w}); // 无向图 } vector<ll> dist(n, INF); vector<char> vis(n, 0); priority_queue<pli, vector<pli>, greater<pli>> pq; // 最小堆 dist[s] = 0; pq.push({0,s}); while(!pq.empty()){ auto [d,u] = pq.top(); pq.pop(); if(vis[u]) continue; // 已访问的记录直接忽略 vis[u] = 1; // 这一次确定最短路径 for(auto &e : G[u]){ int v = e.first, w = e.second; if(dist[u] + w < dist[v]){ dist[v] = dist[u] + w; pq.push({dist[v], v}); } } } for(int i=0;i<n;i++){ if(dist[i]==INF) cout << "-1 "; else cout << dist[i] << ' '; } cout << '\n'; return 0; }复杂度
push / pop各O(log V),总共E次松弛→E log Vvis标记保证每个顶点弹出一次 →V log V- 合计
O((V+E) log V)
稳定性
- 负权边→错误(Dijkstra 只适用于非负权敛边)
- 自环 / 重复边→ 正常处理
- 稠密图→ 仍然比基本实现高效(
E≈V²时时间约O(V² log V);此时可改用Floyd‑Warshall或Johnson+ 低复杂度堆,见后文)
4. 图例:单源最短路径 (有向/无向)
下面给出一个简单有向图的示例(来源点 S=0)。
ASCII 辅助仅作直观演示,真的代码可用任何可视化工具生成。
顶点: 0 1 2 3 4 边: 0 -> 1 (2) 0 -> 2 (5) 1 -> 2 (1) 1 -> 3 (2) 2 -> 3 (3) 3 -> 4 (1)4.1 基本实现流程(线性扫描)
| 步骤 | 先前 dist | 选出的 u | 经过 u 的松弛 | 结果 dist |
|---|---|---|---|---|
| 初始 | [0,∞,∞,∞,∞] | 0 | 无 | [0,∞,∞,∞,∞] |
| 1 | 取最小非访问:0 -> 0 | 您已确定:0 | 0+2<∞→2(1) ,0+5<∞→5(2) | [0,2,5,∞,∞] |
| 2 | 取最小非访问:1 | 1 | 2+1<5→3(2) ,2+2<∞→4(3) | [0,2,3,4,∞] |
| 3 | 取最小非访问:2 | 2 | 3+3<4?→3+3=6>4(不更新) | 同上 |
| 4 | 取最小非访问:3 | 3 | 4+1<∞→5(4) | [0,2,3,4,5] |
| 5 | 取最小非访问:4 | 4 | 无 | 完成 |
4.2 堆优化流程(优先队列)
每次弹出最小dist,将记录入堆:
pq -> (0,0) 初始 pop 0: dist[0]=0;push (2,1),(5,2) pq: (2,1),(5,2) pop 2: dist[1]=2;push (3,2),(4,3) // (3,2) 与已有的 (5,2) 是重复 len pq: (3,2),(4,3),(5,2) pop 3: dist[2]=3;push (6,3) // (6,3) 舍弃 pq: (4,3),(5,2),(6,3) pop 4: dist[3]=4;push (5,4) pq: (5,2),(5,4),(6,3) pop 5: (5,2) 已访问 -> 跳过 pq: (5,4),(6,3) pop 5: dist[4]=5只需要V 次真正的“确定”(即未访问的弹出),而堆中可能存有多余记录。
5. 进一步的堆/队列优化
| 场景 | 选型 | 说明 |
|---|---|---|
| 典型稀疏图 | std::priority_queue(其内部使用二叉堆) | 简单实现,时间复杂度O((V+E) log V) |
| 需要Decrease‑Key支持 | 自己实现二叉堆 + 位置映射或纤维堆 | 减少冗余记录,但实现复杂 |
| 需要O(1)提升关键问题 | Fibonacci 堆或Binomial 堆 | O(log V)pop/merge,O(1)decrease-key;但在实际 C++ 代码中实现不经济 |
| 整体时间 < O(VE) 且 边权为正整数且范围不大 | Dial's 算法(基于桶) | 区间 O(V+E+U),U 为最大边权。适合 U ≤ 10⁴ |
| 统一复杂度 << 现有 | Johnson + Dijkstra(多源最短路径) | 对稠密图较优 |
堆实现常见坑
- 负权:Dijkstra 本身不支持。错误会导致路径不正确
- 松弛重复:若没有
vis标记,堆会多次弹出同一点,导致多余计算- 大图内存:
priority_queue可累积大量重复条目,导致内存占用升高。可采用Lazy删除+大小阈值进行手动清理- 优先队列重定义:使用
greater或less时注意顺序,甚至可以直接push-dist使其变成最大堆
6. 完整实例(含测试)
6.1 输入格式(示例)
5 6 0 // n m s 0 1 2 0 2 5 1 2 1 1 3 2 2 3 3 3 4 16.2 运行结果
0 2 3 4 5表示源点 0 到其它顶点的最短距离。
6.3 检验代码(把下面两段代码跑在同一个项目中可对比效果)
// ① 基本实现(O(V²)) #include <bits/stdc++.h> using namespace std; int main(){ /*见上代码块1*/ } // 省略相同部分 // ② 堆优化实现(O((V+E) log V)) #include <bits/stdc++.h> using namespace std; int main(){ /*见上代码块2*/ } // 省略相同部分在 10⁵ 顶点、10⁶ 边的随机稀疏图上:
- 基本实现大约 4 秒
- 堆优化实现 0.4 秒,提升 10 倍以上。
7. 小结
| 内容 | 重点 |
|---|---|
| Dijkstra 本质 | “最小距离的贪心搜索” + “一次确定后不再修改” |
| 实现形式 | ① 线性扫描(O(V²)) ② 使用优先队列(O((V+E) log V)) |
| 堆实现技巧 | - Lazy 删除 +vis标记 <br>- 负权不可用 <br>-priority_queue<pli, vector<pli>, greater<pli>> |
| 适用场景 | 非负权图(含无向/有向) <br> 规模不太大(10⁵ 左右) <br> 需要多次查询可改用 Johnson |
| 扩展 | 0-1 BFS、Dial、Fibonacci、Bucket Heap 进一步压缩时间/空间 |
| 常见错误 | 未处理vis,导致多余弹出;误用max-heap,需要编写greater;负权数据导致路径错误 |
拿到这份资料后,你应该能在任意 C++ 环境里实现从 “源点到其余点的最短路径” 的任务,且知道何时选择哪种实现方式。祝编码愉快 🚀