Dijkstra 最短路径算法
Dijkstra 最短路径算法
核心结论
Dijkstra 算法用于求解边权非负的加权图中的单源最短路径。它采用贪心策略:每次从尚未确定最短距离的顶点中,选择当前距离估计最小的顶点,并将该距离永久确定。
目录
- 1. 问题定义
- 2. 基本思想
- 3. 需要问题具有的性质
- 4. 核心操作:松弛
- 5. 具体过程
- 6. 伪代码
- 7. 正确性要点
- 8. 时空间复杂度
- 9. 典型例子
- 10. 路径恢复
- 11. 常见错误
- 12. 与其他最短路径算法的比较
1. 问题定义
给定一个加权图:
其中:
-
是顶点集合;
-
是边集合;
-
是边
(u,v)的权重; -
是指定源点。
Dijkstra 算法需要求出源点 到每个顶点 v 的最短路径距离:
其中,路径 p 的权重等于路径上所有边权之和。
单源与所有点对
Dijkstra 本身解决的是单源最短路径问题。若要求所有点对最短路径,可以依次将每个顶点作为源点运行一次 Dijkstra。
2. 基本思想
Dijkstra 算法维护两个核心对象:
-
dist[v]:当前已知的从源点source到顶点v的最短距离估计; -
已确定集合 S:其中顶点的最短距离已经永久确定。
算法重复执行以下贪心步骤:
-
在所有尚未确定的顶点中,选择
dist最小的顶点 u; -
将 u 加入已确定集合 S;
-
使用 u 更新其所有邻接顶点的距离估计;
-
直到所有可达顶点均被确定。
直观理解
可以把
dist[v]理解为源点向外扩张时,到达顶点 v 的当前最低成本。由于边权非负,当前成本最小的未确定顶点不可能再通过其他尚未处理的顶点获得更短路径,因此可以立即确定。
3. 需要问题具有的性质
3.1 边权必须非负
Dijkstra 正确运行的关键条件是:
允许存在权重为 0 的边,但不能存在负权边。
负权边会破坏贪心选择
Dijkstra 一旦确定某个顶点的距离,之后不会重新修改该顶点。若图中存在负权边,一个已经确定的顶点可能通过后续路径得到更短距离,从而使算法产生错误结果。
3.2 问题具有最优子结构
最短路径具有如下性质:
一条最短路径的任意子路径,也必须是对应两个端点之间的最短路径。
否则,可以用更短的子路径替换原子路径,从而得到一条更短的完整路径,与原路径最短矛盾。
3.3 图可以是有向图或无向图
-
有向图:按边的方向进行松弛;
-
无向图:通常将每条无向边
(u,v,w)存储为两条有向边:
3.4 图不要求连通
若某个顶点无法从源点到达,则最终:
4. 核心操作:松弛
对于边 (u,v),若经过 u 到达 v 比当前记录的路径更短,则更新 dist[v]:
对应判断为:
if dist[u] + weight < dist[v]:
dist[v] = dist[u] + weight
parent[v] = u
其中:
-
dist[v]记录当前最短距离估计; -
parent[v]记录当前最短路径中顶点 v 的前驱; -
parent数组可用于恢复具体路径。
松弛的含义
松弛并不保证一次就得到最终最短距离。它只是利用一条边检查能否改进当前答案。Dijkstra 通过按距离从小到大的顺序处理顶点,使这些局部改进最终收敛到全局最短路径。
5. 具体过程
设图有 n 个顶点,程序内部使用 0-based 下标:
5.1 初始化
对所有顶点设置:
源点距离设为:
前驱数组初始化为:
将 (0, source) 加入最小优先队列。
5.2 选择当前最近顶点
从最小优先队列中取出距离最小的记录:
(current_dist, u) = extract_min(priority_queue)
此时,若该记录不是顶点 u 的最新距离,则跳过它:
if current_dist != dist[u]:
continue
这是使用“重复入堆”代替 DECREASE-KEY 时的过期记录检查。
5.3 松弛邻接边
遍历 u 的每个邻接顶点 v:
new_dist = dist[u] + weight
若:
则更新 dist[v]、parent[v],并把新的 (dist[v], v) 加入优先队列。
5.4 终止
当最小优先队列为空时:
-
所有从源点可达的顶点均已得到最短距离;
-
不可达顶点的距离仍为 。
不变量
每次从优先队列取出的、未过期的最小距离顶点 u,其
dist[u]已经等于真正的最短路径距离 。
6. 伪代码
6.1 邻接表 + 最小堆
DIJKSTRA(adj, n, source):
dist <- array of size n, initialized to infinity
parent <- array of size n, initialized to -1
dist[source] <- 0
pq <- empty min-priority-queue
INSERT(pq, (0, source))
while pq is not empty:
(current_dist, u) <- EXTRACT-MIN(pq)
if current_dist != dist[u]:
continue
for each (v, weight) in adj[u]:
new_dist <- dist[u] + weight
if new_dist < dist[v]:
dist[v] <- new_dist
parent[v] <- u
INSERT(pq, (dist[v], v))
return dist, parent
6.2 路径恢复
RESTORE_PATH(parent, source, target):
if source != target and parent[target] = -1:
return empty path
path <- empty list
current <- target
while current != -1:
APPEND(path, current)
if current = source:
break
current <- parent[current]
REVERSE(path)
return path
7. 正确性要点
Dijkstra 正确性的关键是证明:
每次选择当前
dist最小的未确定顶点 u 时,dist[u]已经是源点到 u 的真实最短距离。
设 u 是当前被选中的顶点。反设还存在一条更短路径从源点到 u。
沿这条更短路径,从源点出发,设 y 是第一个尚未确定的顶点,x 是其前一个已经确定的顶点。由于 x 已被处理,边 (x,y) 已经进行过松弛,因此:
又因为后续边权均非负:
所以:
这与 u 是当前 dist 最小的未确定顶点矛盾。因此:
非负边权的作用
上述证明依赖“从 y 继续走到 u 不会使路径长度减小”。这正是边权非负条件的核心作用。
8. 时空间复杂度
设:
-
为顶点数;
-
为边数。
8.1 邻接矩阵 + 顺序查找最小值
每轮通过线性扫描选择最小距离顶点,共进行 轮:
空间复杂度:
该实现适合稠密图或教学演示。
8.2 邻接表 + 二叉最小堆
-
每个顶点和边会被处理;
-
入堆、出堆操作的代价为 。
常写为:
对于连通图,|E|$$\ge -1,可以简写为:
空间复杂度:
若采用允许重复入堆的实现,堆中可能存在过期记录,最坏空间可写得更精确为:
8.3 Fibonacci 堆
理论上可以达到:
但实现复杂,工程中通常使用二叉堆。
8.4 复杂度总结
| 图表示与优先队列 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 邻接矩阵 + 顺序查找 | 稠密图、教学实现 | ||
| 邻接表 + 二叉堆 | 稀疏图、常用实现 | ||
| 邻接表 + Fibonacci 堆 | 理论分析 |
9. 典型例子
9.1 图结构
以下为一个无向加权图,源点为顶点 0:
对应邻接关系来自下列无向边:
(0, 1, 10)
(0, 3, 30)
(0, 4, 45)
(1, 2, 50)
(1, 4, 40)
(1, 5, 25)
(2, 4, 35)
(2, 5, 15)
(3, 5, 20)
(4, 5, 55)
9.2 初始化
源点为 0:
9.3 执行过程
| 步骤 | 当前确定顶点 | 松弛后的 dist[0..5] | 主要更新 |
|---|---|---|---|
| 0 | — | [0,,,,,] | 初始化 |
| 1 | 0 | [0,10,,30,45,] | 更新 1、3、4 |
| 2 | 1 | [0,10,60,30,45,35] | 更新 2、5 |
| 3 | 3 | [0,10,60,30,45,35] | 经 3 到 5 为 50,不更新 |
| 4 | 5 | [0,10,50,30,45,35] | 经 5 到 2:35+15=50 |
| 5 | 4 | [0,10,50,30,45,35] | 无更优路径 |
| 6 | 2 | [0,10,50,30,45,35] | 算法结束 |
最终最短距离为:
9.4 最短路径结果
| 目标顶点 | 最短距离 | 一条最短路径 |
|---|---|---|
| 0 | 0 | 0 |
| 1 | 10 | |
| 2 | 50 | |
| 3 | 30 | |
| 4 | 45 | |
| 5 | 35 |
容易忽略的路径
从
0到2的直接候选路径0 → 1 → 2长度为 10+50=60,但经过顶点5的路径0 → 1 → 5 → 2长度为 10+25+15=50,因此后者更短。
10. 路径恢复
仅有 dist 数组只能得到最短距离,不能直接知道完整路径。因此在每次成功松弛时记录:
parent[v] = u
例如,源点为 0 时可能得到:
parent = [-1, 0, 5, 0, 0, 1]
恢复从 0 到 2 的路径:
2 <- 5 <- 1 <- 0
逆序后得到:
多条最短路径
若存在多条长度相同的最短路径,使用严格条件
<时通常只保留最先发现的一条。若需要记录所有最短路径前驱,需要将parent[v]改为前驱集合。
11. 常见错误
11.1 在含负权边的图上使用 Dijkstra
错误原因:已经确定的顶点可能被负权边再次改进。
解决方法:改用 Bellman-Ford Algorithm。
11.2 把无向边只加入一个方向
无向边 (u,v,w) 必须同时加入:
adj[u].append((v, w))
adj[v].append((u, w))
11.3 忘记跳过堆中过期记录
使用重复入堆实现时,应检查:
if current_dist != dist[u]:
continue
否则虽然很多情况下仍能得到正确答案,但会重复处理顶点,降低效率。
11.4 使用 visited 后仍错误更新已确定顶点
一种规范做法是:
-
使用
visited[u],取出后若已访问则跳过;或 -
不使用
visited,仅通过current_dist != dist[u]跳过过期记录。
两种方式均可,但不要混用出逻辑矛盾。
11.5 无穷大与溢出问题
伪代码中使用 。程序实现时:
-
Python 可使用
float("inf"); -
固定宽度整数语言应避免
INF + weight发生溢出; -
松弛前先检查
dist[u] != INF。
11.6 只输出距离,没有维护前驱
若题目要求具体路径,必须维护 parent 数组。
12. 与其他最短路径算法的比较
| 算法 | 解决问题 | 是否允许负权边 | 是否检测负权环 | 典型复杂度 |
|---|---|---|---|---|
| BFS | 无权图或等权图单源最短路 | 不涉及 | 否 | |
| Dijkstra | 非负权图单源最短路 | 否 | 否 | |
| Bellman–Ford | 一般加权图单源最短路 | 是 | 是 | |
| Floyd–Warshall | 所有点对最短路 | 是 | 可辅助判断 |
选择规则
无权图:优先使用 BFS;
边权非负的单源最短路:优先使用 Dijkstra;
存在负权边:使用 Bellman–Ford;
顶点较少且要求所有点对距离:可考虑 Floyd–Warshall。
参考资料
-
Dijkstra, E. W. (1959). A Note on Two Problems in Connexion with Graphs. Numerische Mathematik, 1, 269–271.
-
Cormen, T. H., Leiserson, C. E., Rivest, R. L., & Stein, C. (2009). Introduction to Algorithms (3rd ed.), Chapter 24. MIT Press.
-
Demaine, E. D., & Leiserson, C. E. (2005). Introduction to Algorithms, Lecture 15: Shortest Paths I. MIT.
复习检查:
- 能否解释为什么负权边会破坏贪心选择?
- 能否独立写出松弛操作?
- 能否区分邻接矩阵与邻接表实现的复杂度?
- 能否使用 parent 数组恢复最短路径?