Dijkstra

1. 引言:最短路问题的魅力
想象一下,你身处一个错综复杂的城市交通网络,或者一个庞大的计算机网络,你想从一个起点出发,找到到达其他所有(或某个特定)地点耗时最短、成本最低或距离最近的路径。这就是最短路问题的核心。在算法竞赛中,最短路问题无处不在,它们可能直接出现,也可能隐藏在其他问题的子步骤中。掌握高效的最短路算法,是每一位竞赛选手的基本功。
Dijkstra 算法,正是解决 单源非负权最短路 问题最经典、最高效的算法之一。它的优雅在于其简洁的贪心思想,以及通过数据结构优化后达到的卓越性能。理解它,不仅能让你解决一大类问题,更能培养你分析问题、选择策略、优化实现的综合能力。
2. 初识 Dijkstra:核心思想与贪心本质
问题定义:单源最短路径
给定一个 带权有向图
关键约束: Dijkstra 算法最核心的要求是,所有边的权重 必须是非负的,即
我们将使用一个数组 dis
来存储从源点 dis[s] = 0
,而对于所有其他顶点
贪心的直觉:每次选择“最近”的
Dijkstra 算法的灵魂在于它的 贪心策略。它维护一个已经确定了最短路径的顶点集合(我们称之为 dis
值最小的顶点,记为
这个选择是贪心的:我们相信,当前距离源点 dis[u]
值就是它真正的最短路径长度。一旦选定了
松弛操作: 对于从
可以想象成,源点
算法流程概览
初始化:
- 创建一个距离数组
dis
,dis[s] = 0
,dis[v] = \infty
对所有。 - 创建一个集合
(或使用一个 vis
数组标记),初始为空(或vis
全为 false)。
- 创建一个距离数组
迭代
次(或直到所有可达点都被处理): - 选择: 从
中选择一个顶点 ,使得 最小。 - 加入: 将
加入 (或标记 vis[u] = true
)。 - 松弛: 对于从
出发的每条边 : - 如果
,则更新 。
- 如果
- 选择: 从
结束: 算法结束后,
dis[v]
就存储了从到 的最短路径长度(如果 不可达,则 dis[v]
仍为)。
3. Dijkstra 的正确性:为何贪心可行?
Dijkstra 算法的贪心选择如此直观,但为什么它是正确的?关键在于 非负边权 这个前提。
关键性质:非负边权
如果允许负权边,那么我们当前找到的“最短”路径,可能会因为后面经过一条负权边而变得更短,从而推翻之前的贪心选择。而非负边权保证了路径长度只会增加或不变,不会减少。
证明思路:反证法
证明 Dijkstra 算法正确性的经典方法是使用 反证法,结合 数学归纳法 的思想。我们要证明的核心是:当一个顶点
假设这个结论不成立。令
由于
既然
考虑这条更短的路径
示意图:
路径
因为
当
现在考虑路径
又因为
因此,
再考虑路径
所以,
结合起来:
我们已知
所以,
但是,在算法选择
因此,最初的假设(存在一个
数学归纳:不变性维持
我们也可以用数学归纳法来理解。
归纳基础:
归纳假设: 假设当
归纳步骤: 当算法选择第
通过这种方式,我们可以确信 Dijkstra 算法在非负权图上找到的最短路径是正确的。
4. 朴素实现:邻接矩阵/邻接表 + 暴力查找
我们先来看一种最直观的实现方式,虽然效率不高,但有助于理解算法流程。
数据结构选择
- 图的表示:
- 邻接矩阵 (
g[N][N]
): 适用于 稠密图(边数接近 )。 g[u][v]
存储边的权重,若无边则存 。空间复杂度 。 - 邻接表 (
vector<pair<int, int>> adj[N]
): 适用于 稀疏图(边数远小于 ,竞赛中更常见)。 adj[u]
存储一个列表,每个元素是{v, w}
表示从到 有一条权重为 的边。空间复杂度 。
- 邻接矩阵 (
- 距离数组:
ll dis[N]
。 - 标记数组:
bool vis[N]
,标记顶点是否已加入集合。
代码实现与分析
这里我们使用邻接表实现朴素 Dijkstra。
题意概括: 给定一个
1 |
|
复杂度分析:
- 时间复杂度:
- 外层循环最多执行
次。 - 内部的选择步骤(步骤 1)需要遍历所有
个顶点来找到 dis
最小的未访问顶点,复杂度为。 - 内部的松弛步骤(步骤 3)对于一个顶点
,需要遍历其所有出边。在整个算法过程中,每条边最多被松弛一次。总的松弛操作复杂度,如果用邻接表是 ,如果用邻接矩阵是 。 - 主导复杂度来自选择步骤:总时间复杂度为
。对于邻接矩阵实现,松弛也是 (遍历一行),总复杂度为 。通常我们记为 ** **。
- 外层循环最多执行
- 空间复杂度:
- 邻接表:
。 - 邻接矩阵:
。 - 辅助数组
dis
和vis
:。
- 邻接表:
复杂度瓶颈
dis
值最小的未访问顶点。如何加速这个查找过程?这自然引出了优先队列优化。
5. 效率飞跃:优先队列优化
为了解决朴素实现中 dis
值的瓶颈,我们可以使用一种更高效的数据结构来维护未访问顶点的距离信息——优先队列(Priority Queue),通常用 二叉堆(Binary Heap) 实现。
瓶颈突破:快速找到最小距离点
优先队列(最小堆)允许我们高效地执行以下操作:
- 插入(Insert): 将一个带优先级的元素加入队列。复杂度
,其中 是队列大小。 - 查询最小(Find-Min): 查看优先级最高的元素(即
dis
最小的顶点)。复杂度。 - 删除最小(Extract-Min): 移除并返回优先级最高的元素。复杂度
。 - 减小优先级(Decrease-Key): 降低某个元素的优先级(即松弛操作后更新
dis
值)。标准库的priority_queue
不直接支持对任意元素进行 Decrease-Key,但我们可以通过插入新值和“懒惰删除”策略来模拟。
优先队列(堆)的应用
我们将优先队列用于存储 pair<ll, int>
,即 {当前距离, 顶点编号}
。优先队列会根据距离(第一个元素)自动维护最小值在队首。
算法流程(优先队列优化版):
初始化:
dis
数组初始化同前(dis[s]=0
, 其他为)。 vis
数组初始化为false
(或者可以省略vis
,见后文)。- 创建一个 最小优先队列
pq
。 - 将源点信息
{0, s}
插入pq
。
循环直到队列为空:
- 提取最小: 从
pq
中提取距离最小的顶点信息{d, u}
(即pq.top()
和pq.pop()
)。 - 有效性检查(关键):
- 如果
已经被访问过( vis[u] == true
),说明这个{d, u}
是旧的、冗余的信息(因为我们可能因为松弛多次将一个顶点以不同距离加入队列),直接continue
跳过。 - 或者,如果提取出的距离
大于当前记录的 dis[u]
(这意味着在入队后,又通过其他路径找到了一个更短的路径到达 ,并更新了 dis[u]$),这个
{d, u}也是过时的信息,
continue跳过。**这个检查可以替代
vis` 数组的作用,是实现“懒惰删除”的核心。** 通常推荐使用这个检查,因为它更简洁。
- 如果
- 标记访问(如果使用
vis
数组): 标记vis[u] = true
。 - 松弛: 对于从
出发的每条边 ,权重为 : - 如果
: - 更新
。 - 将新的、更优的顶点信息
{dis[v], v}
插入pq
。
- 更新
- 如果
- 提取最小: 从
结束: 算法结束后,
dis
数组即为所求。
代码实现与分析(竞赛推荐)
题意概括: 同上,给定一个
1 |
|
复杂度分析:
- 时间复杂度:
- 每个顶点最多被成功提取(即
d <= dis[u]
)并用于松弛一次。 - 每次松弛一条边
,如果更新了 dis[v]
,会执行一次pq.push()
操作,复杂度(优先队列的大小最多为 ,但在稀疏图中 可能远大于 ,而堆的大小与图中节点有关,更精确地说是 或 ,通常认为 ,所以 和 是同阶的,记为 )。 - 每条边最多导致一次成功的松弛和一次入队操作。总共最多有
次 push
。 - 每次从优先队列提取最小元素
pq.pop()
的复杂度是。最坏情况下,每次松弛都入队,队列中可能有 个元素(包含冗余状态)。因此, while
循环和pop
操作的总次数可能达到。 - 综合起来,总时间复杂度是
或 。在 和 关系不定的情况下,有时也写成 ,因为至少有 次成功的 pop
和次 push
(如果图连通)。对于连通图,所以 是一个很好的近似。这是竞赛中最常用的 Dijkstra 实现及其复杂度。
- 每个顶点最多被成功提取(即
- 空间复杂度:
- 邻接表:
。 dis
数组:。 - 优先队列:最坏情况下可能存储
个元素(包含冗余),所以是 。 - 总空间复杂度主要是
。
- 邻接表:
“懒惰删除”策略解析
注意到在优先队列实现中,当松弛操作 {dis[v], v}
插入队列。
这样做是因为 std::priority_queue
不支持直接修改队列内部元素的优先级。那么旧的信息怎么办?
这就是“懒惰删除”的核心:我们不主动删除旧信息,而是在从队列顶端 取出 元素 {d, u}
时进行检查。
检查 if (d > dis[u])
的含义是:当前取出的这个状态 {d, u}
,其距离 dis[u]
还要大?
如果 {d, u}
加入队列之后,又通过其他的路径找到了一个到达 dis[u]$ 并将那个更优的状态也加入了队列。因此,当前取出的这个
{d, u} 是一个 **过时的、无效的状态**,我们直接忽略它(
continue`),相当于把它“懒惰地删除”了。
只有当 dis
减小时才入队,且 dis
值来的,所以实际只会是
这种策略避免了复杂的堆修改操作,代码简洁,效率也足够高,是竞赛中的标准实践。它也解释了为什么优先队列的大小最坏可能是
6. 实战细节与常见陷阱
魔鬼在细节中。即使理解了算法原理和核心代码,实战中也可能因为一些细节问题翻车。
初始化:无穷大与源点距离
- 无穷大的选择:
INF
必须足够大,要大于任何可能的最短路径长度。如果路径长度可能很大(例如),需要确保 INF
不会溢出long long
(如果使用了long long
),并且INF + w
也不会轻易溢出。通常1e18
对于long long
是安全的。 - 源点距离: 必须将
dis[s]
初始化为 0,其他所有点初始化为INF
。这是算法的起点。 - 优先队列: 初始时只将
{0, s}
放入优先队列。
整数溢出:long long
的必要性
竞赛题目中,边权 int
)的范围(约
务必使用 long long
(即 ll
) 存储距离 dis
数组和优先队列中的距离 d
。 否则,即使是很简单的 Dijkstra 题目也可能因为溢出而得到错误答案。
检查 dis[u] + w < dis[v]
时,也要注意 dis[u] + w
本身是否可能溢出。如果 dis[u]
已经是 INF
,则 dis[u] + w
可能会溢出。通常,如果 dis[u]
是 INF
,这个判断 dis[u] + w < dis[v]
不会成立(除非 dis[v]
也是 INF
,但即使更新了也没意义),所以一般还好。但如果 dis[u]
不是 INF
但已经很大,dis[u] + w
仍需小心。不过在非负权图中,这个问题相对少见,因为最短路通常不会比 INF
大。
重边与自环
- 重边(Multiple Edges): 即两个顶点之间有多条边。Dijkstra 算法(无论是朴素还是优先队列版本)自然地处理了重边。松弛操作
dis[u] + w < dis[v]
会自动选择权重最小的那条边来更新dis[v]
。所以在建图时,即使有多条边,直接加入邻接表即可。 - 自环(Self-loops): 即形如
的边。如果自环权重 ,它不会影响最短路径结果,因为通过自环不会让路径变短。松弛 dis[u] + w(u, u) < dis[u]
永远不会成立。如果自环权重,它也没有影响。如果允许负权自环 ,Dijkstra 本身就不适用。因此,通常可以忽略自环或者照常加入图中。
无法到达的节点
如果从源点 dis[v]
将保持其初始值 INF
。在输出结果时,需要判断 dis[v]
是否等于 INF
,并按题目要求输出(可能是 “INF”, -1, 或一个特定的大数)。
路径重构:记录前驱节点
Dijkstra 算法计算了最短路径的长度,但有时题目还需要输出具体的路径。为此,我们需要在松弛操作时记录下每个顶点的前驱(predecessor)。
增加一个
pre
数组:int pre[N]
。初始化:可以将
pre
都初始化为 0 或 -1。修改松弛操作:
1
2
3
4
5if (dis[u] + w < dis[v]) {
dis[v] = dis[u] + w;
pre[v] = u; // 记录 v 的前驱是 u
pq.push({dis[v], v});
}重构路径: 要找到
到 的最短路径,可以从 开始,不断通过 pre
数组回溯,直到到达。 1
2
3
4
5
6
7
8
9
10vector<int> getPath(int t) {
vector<int> path;
if (dis[t] == INF) return path; // 不可达
for (int cur = t; cur != 0; cur = pre[cur]) { // 假设 pre[s] = 0
path.push_back(cur);
if (cur == s) break; // 防止源点未设置pre导致死循环
}
reverse(path.begin(), path.end()); // 回溯得到的是反向路径,需要翻转
return path;
}注意
pre[s]
的处理,可以约定pre[s] = 0
或s
本身,并在回溯时正确终止。
7. Dijkstra 的局限:负权边的挑战
我们反复强调 Dijkstra 的前提是 非负边权。如果图中存在负权边,Dijkstra 的贪心策略就可能失效。
反例:贪心失效
考虑下图:
dis[s]=0
,dis[A]=INF
,dis[B]=INF
. 优先队列{ (0, s) }
。- 取出
s
。松弛: dis[A]=3
,pq.push({3, A})
。松弛: dis[B]=5
,pq.push({5, B})
。队列{ (3, A), (5, B) }
。 - 取出
A
(因为 3 < 5)。dis[A]=3
被确认为最短路。松弛: dis[A] + w(A, B) = 3 + (-4) = -1
。因为,更新 dis[B]=-1
,pq.push({-1, B})
。队列{ (-1, B), (5, B) }
。 - 取出
{ -1, B }
。dis[B]=-1
被确认为最短路。 - 取出
{ 5, B }
。发现5 > dis[B]=-1
,忽略。 - 队列为空,结束。
最终结果 dis[A]=3
, dis[B]=-1
。
问题: 当
核心原因: 负权边破坏了“一旦一个节点被确定最短路,其距离就不会再变小”的性质。
替代方案:Bellman-Ford 与 SPFA
当图中存在负权边时,需要使用其他算法:
- Bellman-Ford 算法: 可以处理带负权边的单源最短路问题,并且可以检测 负权环(Negative Cycle)。时间复杂度
。如果图中没有负权环,它能正确计算最短路;如果存在负权环,它能报告出来。 - SPFA (Shortest Path Faster Algorithm): 是 Bellman-Ford 的队列优化版本。在随机数据和稀疏图上通常表现比 Bellman-Ford 好,期望复杂度
( 是个小常数),但最坏情况下仍然是 ,并且可能被特殊构造的数据卡到这个最坏情况。SPFA 也能检测负权环。
选择:
- 如果保证 边权非负,优先使用 Dijkstra(优先队列版),因为
通常远优于 。 - 如果 可能存在负权边,但 保证没有负权环,可以使用 Bellman-Ford 或 SPFA。SPFA 在很多情况下更快,但在可能被卡时 Bellman-Ford 更稳定。
- 如果 可能存在负权环,必须使用 Bellman-Ford 或 SPFA 来检测,并按需处理(通常是报告存在负环,因为负环意味着最短路可能无限小)。
8. Dijkstra 的延伸与应用
Dijkstra 算法的思想和框架可以应用到更广泛的问题中。
网格图上的 Dijkstra
很多问题发生在二维网格上,例如迷宫寻路。可以将网格抽象成图:
- 顶点: 每个格子
是一个顶点。 - 边: 如果可以从格子
移动到相邻格子 (例如上下左右移动),则在顶点 和 之间连边。 - 权重: 边的权重通常是 1(表示移动一步),或者是移动到目标格子的代价(例如,地形的耗时)。
然后就可以在转换后的图上跑 Dijkstra。顶点可以用 pair<int, int>
表示,或者将其映射到一个整数编号 id = r * num_cols + c
。
注意: 如果所有边的权重都是 1(或 0),那么使用 广度优先搜索 (BFS) 会更高效,时间复杂度为
状态压缩 + Dijkstra
有时,图的“顶点”不仅仅是物理位置,还包含了一些 状态 信息。例如:
- 带有钥匙的迷宫: 状态可能是
(当前位置, 持有的钥匙集合)
。 - 限制步数/油量的移动: 状态可能是
(当前位置, 剩余步数/油量)
。
我们可以将每个 (位置, 状态) 组合视为一个 超级节点。图的边表示从一个状态到另一个状态的转移,边的权重是这次转移的代价。
例子:收集钥匙
假设迷宫中有
- 状态:
(u, mask)
,其中是当前所在格子, mask
是一个位的二进制数,表示当前持有的钥匙集合(第 位为 1 表示持有第 种钥匙)。 - 顶点: 共有
个状态顶点( 是格子数)。 - 边:
- 从
(u, mask)
移动到相邻格子(v, mask)
,如果移动合法,则有一条边,权重为移动代价(通常是 1)。 - 如果格子
处有第 种钥匙,并且 mask
的第位是 0,则可以从 (u, mask)
转移到(u, mask | (1 << j))
,这条边的权重是 0(拾取钥匙不耗时)。 - 如果格子
是一个需要第 种钥匙才能打开的门,并且 mask
的第位是 1,则可以从 (u, mask)
移动到门后的格子(v, mask)
,权重为移动代价。
- 从
在这个 状态空间图 上运行 Dijkstra,起点是 (起始位置, 初始钥匙状态)
,终点是所有 (终点位置 T, 最终钥匙状态 mask)
中距离最短的那个(mask
必须包含所有需要的钥匙)。
复杂度: 状态数乘以边的对数,即
第 K 短路问题(简介)
寻找从
对偶图与最短路(进阶)
在平面图(可以画在平面上且边不交叉的图)中,最短路问题有时可以转化为其 对偶图(Dual Graph)上的最小割问题,或者反之。这涉及到更深的图论知识,如最大流最小割定理,通常在较高难度的题目中出现。
9. 解题思维:如何识别 Dijkstra 模型
面对一个算法题,如何判断它是否可以用 Dijkstra 解决?
核心特征:单源、非负权、最短路
最直接的线索:
- 单源(Single Source): 问题要求从一个明确的起点出发,计算到其他点(或特定终点)的某种累计量的最小值。
- 非负权(Non-negative Weights): 状态转移的代价(边的权重)必须是非负的。这是使用 Dijkstra 的硬性前提。如果出现负权,立刻考虑 Bellman-Ford/SPFA。
- 最短路(Shortest Path): 问题的目标是找到一个最小值,这个值通常是路径上各段代价的累加。
隐式图:状态转移的抽象
很多题目不会直接给你一个图。你需要自己 构建图模型。
- 识别节点: 问题的“状态”是什么?它们能否作为图的节点?(例如:位置、(位置, 状态)、数字、字符串等)
- 识别边: 状态之间如何转移?一次合法的操作是什么?这对应图的边。
- 识别权重: 每次转移的代价是多少?这对应边的权重。
如果构建出的图满足单源、非负权、求最短(最小代价)这三个条件,那么 Dijkstra 就是一个有力的候选算法。
模型转换:将问题转化为图论模型
例子:
- 最少操作次数: 如果每次操作代价为 1,这就是一个边权为 1 的最短路问题(可以用 BFS 或 Dijkstra)。
- 最小花费/时间: 直接对应边权。
- 数字变换: 数字
可以通过某种操作变成数字 ,代价为 。求 到 的最小总代价。节点是数字,边是操作,权重是代价。 - 同余最短路: 问题涉及模意义下的可达性和最小代价,例如用给定面额的硬币凑出某个金额(或模
的余数),要求使用硬币数量最少。可以构建一个以余数为节点,边表示加一个硬币面额的图。
关键在于 抽象 和 建模 的能力。看到问题,要思考它内在的联系和转移关系,能否映射到一个图上。
10. 写在最后
Dijkstra 算法简洁、高效,应用广泛。通过本文,我们一起:
- 理解了 Dijkstra 的 核心贪心思想 及其基于 非负权 的 正确性证明。
- 掌握了 朴素实现(
)和 优先队列优化( )的代码,特别是竞赛中常用的后者。 - 熟悉了 “懒惰删除” 等实现细节。
- 探讨了 整数溢出、路径重构、负权边处理 等实战要点和陷阱。
- 了解了 Dijkstra 在 网格图、状态压缩 等场景下的延伸应用。
- 培养了 识别 Dijkstra 模型 的解题思维。
精通 Dijkstra 算法,不仅仅是记住代码模板。更重要的是理解其原理,知道它的能力边界(非负权),能够在各种问题背景下灵活地构建图模型并套用它,甚至在需要时进行适当的修改和扩展。
- Title: Dijkstra
- Author: YZY
- Created at : 2025-06-17 22:00:04
- Updated at : 2025-06-17 22:00:04
- Link: https://blog.dtoj.team/2025/06/17/迪杰斯特拉/
- License: 三金家的Y同学