Dijkstra:每次确定最近的点
代码模板从非负边权理解 Dijkstra 的贪心选择,掌握堆优化、过期记录、路径还原与常见建模。
一句话算法
每次确定还没定下来的点中离起点最近的一个,再用它更新邻居;边权非负,绕到后面再回来不会更近。
问题模型
给定有
允许零权边、重边和自环;不可达的点单独标记。无向图的每条边存成两个方向即可。
这里比较的是整条路径的边权之和,不是经过的边数,也不是最后一条边的长度。边权全为
核心直觉:先发现,不等于已确定
设 dis[v] 为目前找到的从
沿边
这一步叫作松弛,意思是“经过
例如,以
flowchart LR
a((1)) -->|8| b((2))
a -->|2| c((3))
c -->|1| b
b -->|2| d((4))
c -->|7| d
从
因此要区分两个动作:
- 发现路线:得到一个候选距离,还可以改小。
- 确定答案:已经能证明没有更短路线,以后不必再改。
现在最有希望确定的是
还有一个疑问:某条尚未发现的绕路,会不会比当前最小值还短? 证明中会沿着那条绕路,找到第一个尚未确定的点,说明它一定早就提供了一个更小的候选值。
算法步骤
先看不依赖堆的版本。用集合
- 初始化
dis[s] = 0,其他距离为无穷大,为空。 - 在
外选择 dis最小的点。若没有这样的点,或最小距离仍为无穷大,结束。 - 确定
的距离,把它加入 。 - 枚举
的所有出边,用 dis[u] + w松弛终点。 - 回到第 2 步。
上图的过程如下,表中距离是本轮松弛结束后的值:
| 本轮确定的点 | dis[1] |
dis[2] |
dis[3] |
dis[4] |
|---|---|---|---|---|
| 初始化 | 0 | ∞ | ∞ | ∞ |
| 1 | 0 | 8 | 2 | ∞ |
| 3 | 0 | 3 | 2 | 9 |
| 2 | 0 | 3 | 2 | 5 |
| 4 | 0 | 3 | 2 | 5 |
确定顺序按距离排列,不按点编号排列。 距离相同时,任取一个都可以。
算法证明
不变量:已经确定的点,其 dis 就是真正的最短距离。 每个有限的 dis 都来自实际路线,所以不可能小于真正的最短距离。
源点首先被选中。所有边权非负,从
假设前面确定的点都正确,现在选中
在一条从
后半段中可以再次经过已确定的点;我们只用到
已确定并松弛过 。到 的最短距离不会超过这条路径的前缀长度,因此 dis[y]不会超过这条路径到的前缀长度。 - 剩余边权非负,所以该前缀长度不超过整条路径长度
。 是尚未确定的点中 dis最小的,所以。
记到
两端相同,所有不等号只能取等号,得到
若仍有可达点尚未确定,在它的一条路径上取第一个尚未确定的点,同样会发现它已经被前驱更新成有限距离。因此算法结束后,剩下的无穷大距离恰好对应不可达点。
为什么不能有负权边
考虑
负权边破坏了“剩余路程不会减少费用”这一步,即使图中没有负环也一样。零权边只会让不等式取等号,不影响证明。
本文堆版本允许距离改小后再次入堆,在某些负权图上也可能算出正确结果,但此时“每个点只扩展一次”和复杂度保证已经失效,遇到可达负环还可能不断更新。不能用偶然算对的例子放宽非负边权的条件。 负权图应另看 Bellman-Ford。
堆优化:保存候选距离,跳过旧记录
朴素版本每轮扫描全部点找最小值,需要
堆中存 (距离, 点编号),距离在前。每次成功松弛,就把新记录放入堆。C++ 的 priority_queue 不方便修改堆内已有记录,所以保留旧记录,出堆时再判断它是否过期。
前面的例子中,到 (8, 2) 和 (3, 2)。先弹出 (3, 2),以后弹出 (8, 2) 时,发现它与 dis[2] = 3 不一致,直接跳过:
1
2
3
auto [du, u] = q.top();
q.pop();
if (du != dis[u]) continue;
堆里始终保留着每个有限、尚未确定的距离的最新记录。跳过旧记录后,取出的有效记录就是算法要求的最小值。
本文只在距离严格变小时入堆,所以同一点不会产生两条相同距离的新记录;在非负权条件下,有效出堆后也不会再被改小。因此不必额外维护 vis。也可以使用“出堆时检查并设置 vis”的写法,但不能在入堆时就标记完成。
三个容易写错的地方
- 使用小根堆:
priority_queue默认是大根堆,需要指定greater。 - 松弛条件用
<,不要用<=;零权环上的相等距离反复入堆会让程序无法结束。 - 跳过过期记录后再扫描出边,否则大量旧记录会反复扫描同一点的邻接表。
若只查询终点
复杂度分析
这里
| 实现 | 找最小值的方法 | 时间复杂度 | 总空间复杂度 |
|---|---|---|---|
| 朴素版,邻接表存图 | 每轮扫描全部点 | O(n²+m) | O(n+m) |
| 本文堆版本 | 小根堆,允许旧记录 | O(n+m log(m+2)) | O(n+m) |
堆版本的计数依据是:初始化
简单图有
稀疏图通常选择堆版本。若
代码模板
两个版本都按 INF 表示不可达、用 pre 记录前驱,接口一一对应:C++ 版把图和状态收进 Dijkstra 结构体,Python 版换成两个纯函数 build_graph 与 dijkstra。
C++17 版里 add_edge 添加一条有向边,无向边调用两次;run(s) 会重置距离与前驱,可以在同一张图上更换源点重复运行。
距离和边权使用 long long;要求所有需要表示的有限最短距离严格小于 INF = 2^62。代码在相加前跳过结果不小于 INF 的候选,避免大边权导致整数溢出。该判断依赖边权非负,不负责检测负权输入。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#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;
}
};
Python 版用 heapq 代替 priority_queue。存放的是 (距离, 点编号) 元组,元组按字典序比较,距离在前才能按距离出堆;这正对应 C++ 里 pair<long long, int> 加 greater 的写法。
int 是任意精度,不必担心溢出,INF = 1 << 62 只是与 C++ 版对齐的哨兵。build_graph(n, edges, directed=False) 一次把无向图的双向边展开,省去手写两次 add_edge。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# Dijkstra 非负权单源最短路(堆优化),点编号 1..n(dist[0] 不用)。
# dijkstra 返回 (dist, pre):dist 用 INF 表示不可达,pre[source] = -1,其余前驱在松弛成功时记录。
# 边权必须非负;出现负权边时本文的距离不再可信,应改用 Bellman-Ford 或 SPFA。
# Python 的 int 是任意精度,不必像 C++ 那样用 long long 防溢出。
import heapq
INF = 1 << 62 # 哨兵,比任何合法最短距离都大
type WeightedAdj = list[list[tuple[int, int]]] # graph[u]:若干 (终点 v, 边权 w)
def build_graph(
n: int, edges: list[tuple[int, int, int]], directed: bool = True
) -> WeightedAdj:
"""把 (u, v, w) 边表转成 1..n 的邻接表;无向图把每条边存成两个方向。"""
graph: WeightedAdj = [[] for _ in range(n + 1)]
for u, v, w in edges:
graph[u].append((v, w))
if not directed:
graph[v].append((u, w))
return graph
def dijkstra(graph: WeightedAdj, source: int) -> tuple[list[int], list[int]]:
"""返回 (dist, pre);同一张图换源点可重复调用。"""
n = len(graph) - 1
dist = [INF] * (n + 1)
pre = [-1] * (n + 1)
dist[source] = 0
heap: list[tuple[int, int]] = [(0, source)] # (距离, 点编号),距离在前才能按它比大小
while heap:
du, u = heapq.heappop(heap)
if du != dist[u]:
continue # 旧记录:这个点已有更短距离,不要再扫它的出边
for v, w in graph[u]:
nd = du + w
if nd < dist[v]: # 只接受严格改善,零权环上相等距离不再入堆
dist[v] = nd
pre[v] = u # 前驱指向更早确定的点,不会形成环
heapq.heappush(heap, (nd, v))
return dist, pre
def path_to(dist: list[int], pre: list[int], target: int) -> list[int]:
"""返回 source 到 target 的一条最短路径(含两端);不可达返回空列表。"""
if dist[target] == INF:
return []
path: list[int] = []
u = target
while u != -1:
path.append(u)
u = pre[u]
return path[::-1]
还原一条最短路径
成功松弛 pre[v] = u。运行结束后调用 path_to(t),从终点顺着前驱回走,再翻转即可(Python 版用 path[::-1] 完成翻转)。
不可达时返回空数组;源点的路径只有源点自身。若存在多条最短路径,模板返回其中一条,不保证字典序最小。零权边下仍只在严格改善时修改前驱,前驱指向更早确定的点,因此不会形成前驱环。
代码实现
下面是可单独编译运行的完整程序。
输入: 第一行 n m s,接下来 u v w,表示有向边
输出: 按点编号输出从 -1。具体 OJ 的不可达标记可能不同,提交时按题面调整。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
#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;
}
};
#include <iostream>
int main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int n, m, s;
if (!(std::cin >> n >> m >> s)) return 0;
Dijkstra solver(n);
for (int i = 0; i < m; ++i) {
int u, v;
long long w;
std::cin >> u >> v >> w;
solver.add_edge(u, v, w);
}
solver.run(s);
for (int v = 1; v <= n; ++v) {
if (v > 1) std::cout << ' ';
std::cout << (solver.dis[v] == Dijkstra::INF ? -1 : solver.dis[v]);
}
std::cout << '\n';
}
测试用例
普通情况:后来找到更短路线
输入对应前面的示意图:
4 5 1
1 2 8
1 3 2
3 2 1
2 4 2
3 4 7
输出:
0 3 2 5
到 (8, 2)、(9, 4) 最后都会作为旧记录跳过。
边界情况:重边、零权环与不可达
5 6 1
1 2 9
1 2 4
2 3 0
3 2 0
3 3 0
4 5 1
输出:
0 4 4 -1 -1
重边自然取更优的距离;零权环与零权自环不会触发严格改善;
距离超出 32 位整数
3 2 1
1 2 2000000000
2 3 2000000000
输出:
0 2000000000 4000000000
只有一个点且没有边时,输入 1 0 1,输出应为 0。
应用分类详解
Dijkstra 维护的是“从起点到各个状态的最小累计代价”。识别问题时,先确定状态是什么,再检查每次转移是否增加非负代价。
一、单源最小费用
识别信号: 一个起点,要求到多个位置的最短距离、最少时间或最低费用;费用可以累加,而且非负。
把位置当点,允许的移动当边,费用当边权。一次运行求出所有终点的答案,不需要对每个终点重新跑。若要求输出路线,再维护前驱。
二、所有点到同一个终点
识别信号: 多个出发点去同一个地点,或要求往返距离。
将每条
所以在反图从
三、多个起点,求最近的一个
识别信号: 多个仓库、医院或出口,求每个点与其中最近一个设施之间的距离。
如果求的是“任一源点到
若有向图问的是“从
四、带使用次数的状态最短路
识别信号: 最多使用
只记 dis[u] 不够。改用状态
- 正常走:
,代价 。 - 免费走:若
,则 ,代价 。
所有转移仍非负,便能在新状态图上运行 Dijkstra。若允许至多
经典例题
1. P4779 【模板】单源最短路径(标准版)
非负权有向图的直接应用。重点练习小根堆、严格松弛、跳过过期记录,以及使用足够宽的整数类型。先把这道题写熟,再考虑改变建图方式。
2. P1629 邮递员送信
每次从
这道题练习“多起点到同一终点”转换为反图单源最短路。题目保证任意两点互相可达。
3. P4568 [JLOI2011] 飞行路线
把“用了几次免费机会”加入状态,按上一节的两类转移运行 Dijkstra。原图是无向图,两个方向都要考虑;题目按
关键练习是分清“到达同一城市”与“到达同一状态”,并对终点的所有允许层数取最小值。