摘要:本文以“最短路径”问题为引导,介绍一种贪心算法:迪杰斯特拉(Dijkstra),在不同的“图”的条件下细致地探讨它的工作原理。
阅读本文需要的先导知识:完全不需要任何先导知识呢!不过算法描述中会用到简易的数据结构的语言。
part1:问题导出
问题1:为相应“要致富,先修路”的号召,书记老王准备给自己辖区内几个贫困村修建公路。在地图上标注出所有可能修公路的部分如下:
由于经费有限,要用最少的成本修建最短的公路把各个村庄都联通。请问王书记应该选择图中哪几条线修建成公路?
问题2:八里台学院接到一个紧急通知,领导要把这个消息快速发给各个学生辅导员。可是领导只有个别几个辅导员的联系方式,每个辅导员也只有个别几个其他导员的电话号。要怎么做才能以最快速度联系到每个辅导员?
上面两个问题既有联系也有区别。相同之处在于两个问题都在寻求某种意义上的“最短路径”(用最短的公路把所有村子联通、打最少次数电话联系到每个导员)。不同之处在于:
第一:在第一个问题中,不是每个村子之间的公路长度都相等的,但是在第二个问题中每次电话耗费的时间可以认为是一样的。
第二:第一个问题里,两个村子之间的公路是“没有确定方向”的。但是在第二个问题里,导员1有导员2的电话,不代表导员2有导员1的电话。
归纳以上两个问题,我们给出一种普遍适用的“数据结构”来描述“最短路径问题”:
定义1:(有向图)设
定义2:(最短路径问题)对于非负有向图
注意,定义中我们明确地说明了“非负”条件。这个条件是不可或缺的:Dijkstra算法在图上特定位置有负数权值的情形下会出现问题。这个问题放在后面讨论。
part 2 算法描述
我们先给出算法的伪代码,再进行解释。
在计算机中图的储存结构一般是邻接链表(Adjacency list),也就是一组线性链表,元素构成为节点名、权值以及指针。比如以下图为例,第一行第一个元素代表图中的节点A,之后的元素代表A指向的元素。
为执行这个算法,我们需要对图
def INITIALIZE(G,s):
for each vertex v in G.V:
v.d = inf
v.p = null
s.d = 0
也就是说将每个d属性都赋值为无穷,把每个p属性都赋值为空指针,再把起始节点的d属性赋值为0。
再定义一个在迭代过程中的更新函数
def relax((u,v,w))
if u.d + w(u,v) > v.d:
v.d = u.d + w(u,v)
v.p = u
下面是伪代码的主体:
def Dijkstra(G, s):
INITIALIZE(G,s)
S = empty
Q = G.V
while Q is not empty:
u = extractmin(Q)
S = S.add(u)
for each vertex v in G.Adj(u):
relax(G.(u,v,w))
这个过程的实质是什么呢?粗略地想象,找出图上的最短路径,当然是要从初始节点开始,以某种方式尝试“探测”整个地图。随着探测的范围越来越大,我们可以发现越来越多的“捷径”。迪杰斯特拉算法的想法也不过如此,只是该算法的“探测方式”非常程式化、非常有效罢了。
我们可以想象Q集合代表那些“没被探测到”的元素。随着Q中元素越来越多地进入S集合,图上的节点被探测到的也越来越多。首先观察relax函数:它负责从一个“根节点”出发,试着降低一个“目标节点”的d值。由此看来,一个节点的d值可以解释为”按照目前探测到的部分得出的最短路径长度“,因为它随着探测过程不断地降低。那么p值的意义是什么呢?显然,最短路径总长变短,是因为我们选取了另一条捷径。p值便是在新的捷径上当前节点的前节点(predecessor),它包含的是具体路径的信息。
所以算法过程可以描述为:
- 初始化图,创建未探测列表(堆)Q和探测集S
- 进行循环:找出当前d值最小的节点,加入探测集S,更新所有被当前节点指向的节点d值
- 直到所有节点均加入S,结束循环
一个示例如下:
每个节点的最短路径,就是从该节点出发,按照p值回溯过去最终到达初始节点的路径。
part 3 算法的思想和证明
这个部分是本文的中心所在。我们从一些引理(其实这些引理就是最短路径算法的本质思想)开始,证明算法的同时讨论“为什么会想到这样的办法”。
先宏观地考虑一个简单的状态(其实这个状态可能对于计算机专业的童鞋已经够用了):所有的权值都是正的。
从初始节点s开始,如何找到一条“局部的”(即只考虑初始结点和各个邻接节点的)最短路径呢?显然,选取权值最小的那一条便是,记为
当我们在第m步确定了m条最短路径后,第m+1条的除去末尾的所有节点都应该在S中。这样一来,我们只需要找离S集合最“近”的那个节点作为下一条最短路径的末尾就可以了。而离S最近的点,恰好就指的是Q中d值最小的点!这样我们就得出迪杰斯特拉算法的雏形了。
这个猜想是否正确呢?我们可以基于“非负假设”证明。反设在m+1步找到的最短路径
下面我们讨论迪杰斯特拉算法的一般情形。实际上,该算法在有0权值的边时仍然成立,在某些情形下,甚至可以应用于负权值的图!如果这样就算证明结束,就太委屈它了。为了探索这些情形,我们有必要对于一般的最短路径算法进行彻底的理解。
首先给出一个引理:
lemma 1 (局部化) 对于有向权图上一条最短路径
proof:设最短路径为
与
这个性质看似简单,实际上它是迪杰斯特拉算法(贪心算法)的核心思想:从局部推断整体。当每一步都做到“最短”时,连接起来的整体也一定是最短。这个性质还有一个特点:在权值非正时仍然成立。
下面规定一个记号:记
lemma 2 (三角不等式)
proof:这由最短路径定义即可得出。#
上面这个引理保证了Relax函数的合理性,即Relax函数不会把d值降得比真实的最短路径权值还低:
lemma 3 (确界引理)在任何时候,
proof:初始状态时上式显然。每一次用Relax函数更新d值时,都有:
下面又是一个本质性的引理,它证明了我们寻找最短路径的过程是“逐渐收敛的”,即如果真的找到一个节点的最短路径后(当然我们不知道是不是真的找到了,除非遍历完整个图),之后的行动不会破坏该节点的最短路径。
lemma 4(收敛性) 对于最短路径
proof:由Relax定义有
下面给出非负图形式的算法证明:(此时不再有路径长度严格递增的条件了)
proof:不妨设图只有一个连通分支(所有点都联通)。如果我们能证明对于
反设不成立,不妨假设u是S中第一个违背的元素。而u不能是初始节点s(为什么?),所以在u放入S之前,S集合非空。由于u即将被放入S,沿着u.p回溯回去的路径不是最短路径,那么肯定有一条从s到u的最短路径
可以直观感受到y似乎“就是”u。但是如果这样,我们无疑陷入了正权重图的证明无法自拔。所以我们的目标改为证明
由于权重非负,且
然而在此时我们选择要加入S集合的元素是u而不是y,所以有
所以
对于负权重情形,首先我们需要排除掉“负圈“,即对于某个节点,在图上绕一圈回来d值变小。这样的节点的最短路径长度为负无穷。如图所示:
对于一般的没有负圈的图,我们也不能轻易应用迪杰斯特拉算法。考虑下图,
可以看出,之所以不能应用迪杰斯特拉算法,还是因为该算法的“局部性”:它会被眼前的状况“欺骗”。贪心算法之所以“贪心”,是因为在某种意义下,后边的情况不可能比前边的“好”,所以只需要做到当下最好就可以了。
那么在什么情况下可以使用迪杰斯特拉算法呢?有一个充分条件:图上所有权重为负值的边都从初始节点发出。为什么这样呢?作为一个思考题目留给读者。
提示:回顾之前非负图的证明,看哪一步需要权重非负的条件!
参考文献:
[1] Thomas H. Cormen: "introduction to algorithms, third edition", the mit press.
[2] 严蔚敏,吴伟民:数据结构(C语言版),清华大学出版社
[3] stackoverflow上的各种回答,以及各种学校的pdf讲义(图片来源,侵删)
[4] 题图:轻音少女封面图片
悄咪咪:平泽唯天下第一!