算法背景与核心直觉
前置算法: [[bellman-ford]],以及数据结构:小根堆(优先队列)。
在单源最短路问题中,若图中所有边权非负(
回顾松弛操作:
1:if then
2:
3:end if
与 SPFA 任意时刻允许多次反复入队不同,Dijkstra 算法基于一个关键洞察: “先发现路线,不等于确定最终答案;但当前未确定的点中距离最近的那一个,它的答案一定已经确定了。”
核心贪心定理
将图中的点集划分为两个集合:
- 已确定最短路的大集合
。 - 尚未最终确定最短路的点集
。
定理1:在所有边权非负的前提下,设当前
证明:
设真正的最短路径长度为
假设存在一条更短的走法从
- 因为
,点 之前已经被选出并对它的所有出边进行了松弛,因此当前必有 。 - 因为图上所有边权非负(
),从 继续走到 的后续路程权值和必然 。因此: - 这得出
,与“点 是当前 中 最小的点”假设矛盾!
因此不存在比
图解 Dijkstra 执行过程
我们以包含非负边权的图(源点为
交互动画加载中...
图中边权:
权值 权值 权值 权值 权值 - 点
为孤立点(不可达)
过程演进流程图
详细步骤追踪表
| 轮次 | 堆顶弹出 (du, u) |
状态判断 | 加入集合 |
松弛出边及更新 | 堆内元素(小根堆) | [1..5] |
|---|---|---|---|---|---|---|
| 初始 | - | - | - | 初始化 (0, 1) |
[(0, 1)] |
[0, ∞, ∞, ∞, ∞] |
| 第 1 轮 | (0, 1) | 有效记录 | 松弛 松弛 |
[(2, 3), (8, 2)] |
[0, 8, 2, ∞, ∞] |
|
| 第 2 轮 | (2, 3) | 最小点为 3! | 松弛 松弛 |
[(3, 2), (8, 2)*, (9, 4)] |
[0, 3, 2, 9, ∞] |
|
| 第 3 轮 | (3, 2) | 最小点为 2! | 松弛 |
[(5, 4), (8, 2)*, (9, 4)*] |
[0, 3, 2, 5, ∞] |
|
| 第 4 轮 | (5, 4) | 最小点为 4! | 点 4 无出边 | [(8, 2)*, (9, 4)*] |
[0, 3, 2, 5, ∞] |
|
| 后续 | (8, 2), (9, 4) | 过期跳过 | 直接 continue,不重复扫描出边 |
空 [] |
[0, 3, 2, 5, ∞] |
为什么 Dijkstra 不能处理负权边?
思考:如果图中含有负权边,上面的定理证明哪一步会被破坏?
在上面的反例中:
- 从起点
出发,有边 权值为 , 权值为 。 - Dijkstra 优先挑出暂定距离最小的点
( ),并立即认定 的最短路已经确定为 。 - 但是,后面通过
的总长度是 !显然 。
本质原因:非负边权是贪心成立的基石——它保证了“沿着其他未选的点继续往前走,路径长度只增不减”。如果边权允许为负数,那么“绕到后面的点再绕回来”费用完全可能反而减小,从而推翻前面已经固化的判断。
堆优化与过期记录处理
朴素的 Dijkstra 每一轮在
使用小根堆(C++ std::priority_queue<State, vector<State>, greater<State>>)后,每次可以在
过期记录(Lazy Deletion)机制
在标准库的优先队列中,修改堆内部某个已存在元素的代价极高。因此标准做法是:
- 每次成功松弛出更优的距离时,直接将新的
(新距离, 目标点)压入堆中。 - 堆顶弹出的元素
(du, u)时,先做过期检查:auto [du, u] = q.top(); q.pop(); if (du != dis[u]) continue; // 之前更优的记录已经处理过点 u,当前记录已过期失效 - 只要
du == dis[u],说明这是点最优距离的第一次有效出堆,此时确定点 并扫描它的所有出边。
算法核心步骤
- 初始化:
,其余所有点 。 - 将
(0, s)压入小根堆。 - 循环直到堆为空:
- 弹出堆顶
(du, u)。 - 若
du != dis[u],说明该记录已过期,直接跳过。 - 遍历点
的所有出边 : - 若
: - 更新
。 - 记录前驱:
(用于还原路径)。 - 将
(dis[v], v)压入堆中。
- 更新
- 若
- 弹出堆顶
模板代码
#include <algorithm>
#include <functional>
#include <queue>
#include <utility>
#include <vector>
struct Dijkstra {
using ll = long long;
static constexpr ll INF = (1LL << 62);
struct Edge {
int to;
ll w;
};
std::vector<std::vector<Edge>> g;
std::vector<ll> dis;
std::vector<int> pre;
// 点编号 1..n;所有边权非负,有限最短距离必须小于 INF。
explicit Dijkstra(int n) : g(n + 1), dis(n + 1, INF), pre(n + 1, -1) {}
void add_edge(int u, int v, ll w) {
g[u].push_back({v, w}); // 无向边需要再添加 v -> u
}
void run(int s) {
std::fill(dis.begin(), dis.end(), INF);
std::fill(pre.begin(), pre.end(), -1);
using State = std::pair<ll, int>; // 距离在前,点编号在后
std::priority_queue<State, std::vector<State>, std::greater<State>> q;
dis[s] = 0;
q.push({0, s});
while (!q.empty()) {
auto [du, u] = q.top();
q.pop();
if (du != dis[u]) continue; // 旧记录不再扫描出边
for (const auto& e : g[u]) {
// du < INF,且 w >= 0;先判断再相加,避免溢出。
if (e.w >= INF - du) continue;
ll nd = du + e.w;
if (nd >= dis[e.to]) continue; // 只接受严格改善
dis[e.to] = nd;
pre[e.to] = u;
q.push({nd, e.to});
}
}
}
// run 后使用;不可达返回空数组,否则返回一条最短路径。
std::vector<int> path_to(int t) const {
if (dis[t] == INF) return {};
std::vector<int> path;
for (int u = t; u != -1; u = pre[u]) path.push_back(u);
std::reverse(path.begin(), path.end());
return path;
}
};
复杂度与算法对比
| 算法 | 适用图类型 | 负权边 | 判负环 | 堆优化时间复杂度 |
|---|---|---|---|---|
| Dijkstra | 有向/无向非负权图 | 不支持 | 不支持 | |
[[spfa]] |
任意权值图 | 支持 | 支持 | 随机图极快,最坏 |
[[bellman-ford]] |
任意权值图 | 支持 | 支持 | 稳定 |