Dijkstra(题外话:正确读音为/ˈdaɪkstrə/,音译应该是戴克斯彻,而不是广为人知的迪杰斯特拉)是荷兰著名的计算机科学家,他本人也因诸多计算机领域的突破性贡献而获得1972年的图灵奖。今天要回顾的Dijkstra(单源最短路径)算法发明于1959年,被大家评为“统治世界的十大算法”之一。
〇、一种有趣的解法
对于任意的图 G = ( V , E ) G = (V,E) G=(V,E), 源点为 s s s, 我们可以对应地制作一张网:顶点用串珠代替,顶点之间的边则用线连接(线的长度由权重决定)。那么,任意点 u u u 到源点 s s s 的距离怎么算呢?很简单,一只手抓住 s s s, 另一只手抓住 u u u, 将两点拉直,则 s , u s,u s,u 之间绷直的线段的长度之和就是他们的最短距离。如图2(图片来自于清华大学邓俊辉老师的课件)所示。
思路看上去很简单,但却不可实现,我们难以做出非常复杂的网,也没有那么长的手可以抓住任意两个点并拉直。那么,Dijkstra如何解决这个问题呢?
一、最短路径树
为了描述Dijkstra算法,我们先介绍最短路径树(Shortest Path Tree,SPT)的概念。在一个连通图中,给定的源点 s s s 到每个点至少存在一条最短路径(可能存在多条长度相等的最短路径),且所有点的最短路径的并不包含回路,也就是一棵树,一般称为最短路径树。
图3给出了一个例子,左侧为给定的图(左上角为源点
s
s
s), 右侧为其对应的最短路径树。
这里有人可能会质疑,如果源点到某个点的最短路径有多条,则所有最短路径的并可能包含回路,从而无法构成最短路径树。如下图所示,我们若设定 w ( A . B ) = 23 w(A.B)=23 w(A.B)=23,则 s s s 到 B B B的最短路径存在两条: s → A → B s \rightarrow A \rightarrow B s→A→B 和 s → C → B s \rightarrow C \rightarrow B s→C→B。 对于这种情况,我们只需删除 A → B A \rightarrow B A→B 或 C → B C \rightarrow B C→B 中的任意一条边即可,并不影响最终的结论,从而仍然可以构造一棵最短路径树。
有了最短路径树后,我们就可以通过深度遍历的方式轻松获取每个顶点到源点的最短路径及其长度。
二、Dijkstra算法的思想
注:此部分内容主要参考清华大学邓俊辉老师的课件,再次表示感谢!
给定图
G
=
(
V
,
E
)
G=(V,E)
G=(V,E), 源点为
s
s
s ,令:
d
(
s
,
u
)
d(s,u)
d(s,u):表示源点到
u
u
u 的最短距离的估计(估计值一般大于最短距离,通常会逐步逼近最短距离)。
δ
(
s
,
u
)
\delta(s,u)
δ(s,u):表示源点到
u
u
u 的最短距离。
w
(
u
,
v
)
w(u,v)
w(u,v) :表示边
(
u
,
v
)
(u,v)
(u,v) 的权重 。
我们期望由近及远的方式确定所有点到源点的最短距离。也就是说,越早确定的点离源点越近,越晚确定的点离源点越远。我们将所有点按照到源点的距离排序如下:
δ
(
s
,
u
1
)
≤
δ
(
s
,
u
2
)
⋯
≤
δ
(
s
,
u
∣
V
∣
−
1
)
,
u
1...
∣
V
∣
−
1
∈
V
−
s
\delta(s,u_1) \leq \delta(s,u_2) \cdots \leq \delta(s,u_{|V|-1}) , u_{1...|V|-1} \in V-s
δ(s,u1)≤δ(s,u2)⋯≤δ(s,u∣V∣−1),u1...∣V∣−1∈V−s
注意:这里使用
u
i
u_i
ui 主要是跟
v
i
v_i
vi 区分开,
u
i
u_i
ui 不一定等于
v
i
v_i
vi。
我们将图3再次搬下来,可以发现:从源点开始,沿任意最短路径,各顶点到源点的最短距离单调递增。 下面给出了几条路径(顶点后面的数值表示到源点的最短距离):
1)
S
(
0
)
→
A
(
9
)
S(0) \rightarrow A(9)
S(0)→A(9)
2)
S
(
0
)
→
F
(
15
)
S(0) \rightarrow F(15)
S(0)→F(15)
3)
S
(
0
)
→
C
(
14
)
→
B
(
32
)
→
D
(
34
)
→
E
(
45
)
S(0) \rightarrow C(14) \rightarrow B(32) \rightarrow D(34) \rightarrow E(45)
S(0)→C(14)→B(32)→D(34)→E(45)
4)
S
(
0
)
→
C
(
14
)
→
B
(
32
)
→
D
(
34
)
→
G
(
50
)
S(0) \rightarrow C(14) \rightarrow B(32) \rightarrow D(34) \rightarrow G(50)
S(0)→C(14)→B(32)→D(34)→G(50)
-
那么, u 1 = ? u_1 = ? u1=?
根据以上观察,我们说 u 1 u_1 u1 必定与 s s s 直接相连。
为此,只需要找到 s s s 的邻接点中与 s s s 距离最近的点即可。
下图中, w ( s , A ) = 9 < w ( s , C ) = 14 < w ( s , F ) = 15 w(s,A) = 9 < w(s,C)=14 < w(s,F)=15 w(s,A)=9<w(s,C)=14<w(s,F)=15, 因此 u 1 = A u_1 = A u1=A, δ ( s , u 1 ) = 9 \delta(s,u_1) = 9 δ(s,u1)=9。 -
接下来, u 2 = ? u_2 = ? u2=?
不难发现, u 2 u_2 u2可能的情况:1)接在 u 1 = A u_1=A u1=A 之后,2)与 s s s直接相连(开辟一条新的路径)。
下图中, d ( s , C ) = 14 < d ( s , F ) = 15 < δ ( s , A ) + w ( s , F ) = 34 d(s,C) = 14 < d(s,F)=15 < \delta(s,A) + w(s,F)=34 d(s,C)=14<d(s,F)=15<δ(s,A)+w(s,F)=34, 因此 u 2 = C u_2 = C u2=C, δ ( s , u 2 ) = 14 \delta(s,u_2) = 14 δ(s,u2)=14。 -
接下来, u 3 = ? u_3 = ? u3=?
不难发现, u 3 u_3 u3可能的情况:1)接在 u 1 = A u_1 = A u1=A 之后,2)接在 u 2 = C u_2 = C u2=C 之后,3)与 s s s直接相连(开辟一条新的路径)。
下图中, w ( s , F ) = 15 < δ ( s , C ) + w ( C , F ) = 19 < δ ( s , A ) + w ( A , B ) = 34 ⋯ w(s,F) = 15 < \delta(s,C) + w(C,F)=19 < \delta(s,A)+w(A,B)=34 \cdots w(s,F)=15<δ(s,C)+w(C,F)=19<δ(s,A)+w(A,B)=34⋯, 因此 u 3 = F u_3 = F u3=F, δ ( s , u 3 ) = 15 \delta(s,u_3) = 15 δ(s,u3)=15。
- 最后,一般意义下,
u
k
=
?
u_k = ?
uk=?
我们发现待确定的下一个顶点总是在已确定顶点的基础上扩展得到的。确定所有点的最短路径的过程,其实就是从无到有生成最短路径树的过程。
我们令 T n T_n Tn 表示图 G G G 的最短路径树, T i T_i Ti 表示包含 i i i 个顶点的最短路径树 T n T_n Tn 的子树。如上所述,一旦构造出最短路径树,那么所有顶点的最短路径也就显而易见了。接下来,我们就来渐进地构造这样一棵最短路径树。
我们从只包含源点的树 T 1 = ( u 0 , ∅ ) T_1 = ({u_0}, \varnothing) T1=(u0,∅) 开始逐步构造 T 2 , T 3 , ⋯ , T n T_2,T_3,\cdots,T_n T2,T3,⋯,Tn 。
假设 k k k 步之后, T k = ( V k , E k ) T_k = (V_k, E_k) Tk=(Vk,Ek), 其中, ∣ V k ∣ = k , ∣ E k ∣ = k − 1 |V_k| = k, |E_k| = k-1 ∣Vk∣=k,∣Ek∣=k−1。
为了从
T
k
T_k
Tk 构造
T
k
+
1
T_{k+1}
Tk+1, 我们只需要将
V
k
V_k
Vk 和
V
−
V
k
V-V_k
V−Vk 视为原图的一个割,并在割的所有跨边中找出最小者:
e
k
=
(
v
k
,
u
k
)
e_k = (v_k,u_k)
ek=(vk,uk)(
u
k
u_k
uk到源点距离最近),然后将
u
k
u_k
uk 和
e
k
e_k
ek 接入
T
k
T_k
Tk 即可:
T
k
+
1
=
(
V
k
+
1
,
E
k
+
1
)
=
(
V
k
∪
u
k
,
E
k
∪
e
k
)
T_{k+1}= (V_{k+1}, E_{k+1}) = (V_k\cup u_k, E_k \cup e_k)
Tk+1=(Vk+1,Ek+1)=(Vk∪uk,Ek∪ek)
同时需要注意的是,当扩充
u
k
u_k
uk, 我们需要更新它的邻接点到源点的最短距离:
d
(
s
,
x
)
=
m
i
n
(
d
(
s
,
x
)
,
δ
(
s
,
u
k
)
+
w
(
u
k
,
x
)
)
d(s,x) = min(d(s,x), \delta(s,u_k) + w(u_k,x))
d(s,x)=min(d(s,x),δ(s,uk)+w(uk,x))
其中,
x
x
x 表示集合
V
−
V
k
+
1
V-V_{k+1}
V−Vk+1 中
u
k
u_k
uk 的邻接点 (已经确定最短路径的邻接点不必更新)。
图5给出了该算法的一个示例。
三、另一种理解方式 (悬挂法)
我认为另一种非常好理解的方式是悬挂法。这种方法是这样做的,我们还是将图制作成一张网,将其置于桌面上,然后用右手抓住代表源点( u 0 = s u_0 = s u0=s)的珠子,缓缓将其从桌面提起来。第0棵离开桌面的珠子是源点本身,第1棵拉起来的珠子是 u 1 u_1 u1, 接下来是 u 2 , u 3 . . . u ∣ V − 1 ∣ u_2, u_3...u_{|V-1|} u2,u3...u∣V−1∣。
之前邓俊辉老师讲过这种方法,我从斯坦福大学找到了类似的课件,分享如下:
可以直观地发现,我们将源点( u 0 = G a t e s u_0 = Gates u0=Gates)慢慢从桌面拉起来时,与源点直接相连的点将首先被提起来( u 1 = P a c k a r d u_1 = Packard u1=Packard)。接下来 u 2 u_2 u2 要么跟Gates相连,要么跟Packard相连,这里 CS161跟源点最近,因此 u 2 = C S 161 u_2 = CS161 u2=CS161 被提起来。重复此操作,直到 u 4 = D i s h u_4 = Dish u4=Dish 被拉起来,最终最短路径树被构造出来。
四、算法代码 (非队列优化)
五、Dijkstra算法的问题
Dijkstra算法的主要问题是不能处理负权边,图8给出了两个例子。
- 在上方的例子中,Dijkstra算法第一次确定的顶点 是 v 2 v_2 v2 ,其到源点的最短距离为 2,但是真正的最短路径为 v 0 → v 1 → v 2 v_0 \rightarrow v_1 \rightarrow v_2 v0→v1→v2, 距离为 1。Dijkstra算法中,已经确定的点的最短距离将不再改变。另一方面,参考悬挂法,珠子之间的线不可能是负数。
- 在下方的例子中,存在负权回路 v 1 → v 2 → v 3 → v 1 v_1 \rightarrow v_2 \rightarrow v_3 \rightarrow v_1 v1→v2→v3→v1, 绕的次数越多最短距离越小,本质上没有最短只有更短。
六、结语
算法回顾到此结束,这里主要分享了Dijkstra算法的思想以及简单的理解方式,并没有过多的分析时间复杂度以及优化算法。我认为理解原始算法的思想更加重要。鉴于作者水平,如有不正确之处,还请读者批评指正!
参考资料
[1] 数据结构与算法,清华大学邓俊辉。
[2] 悬挂法课件:https://web.stanford.edu/class/archive/cs/cs161/cs161.1204/Lectures/Lecture11/Lecture11-compressed.pdf