本文所贴示的伪代码均来源《算法导论》,本文只是对其中《单源最短路径》章节的简单总结,许多数学证明过程已忽略。
前言
最短路径的定义:给定一个图G=(V,E),希望找到从给定源节点s∈V 到每个结点v∈V 的最短路径。
单源最短路径可以用来解决许多其他问题,包括:
1、单目的地最短路径问题:找到从每个结点v到给定目的结点t的最短路径,如果将图的每条边的方向翻转过来,就可以将这个问题转换为单源最短路径问题。
2、单结点对最短路径问题:找到从给定结点u到给定结点v的最短路径,如果解决了针对单个结点u的单源最短路径问题,也就解决了这个问题。
3、所有结点对的最短路径问题:对于每个结点对u和v,找到从u到v的最短路径,虽然可以针对每一个结点运行一次单源最短路径算法,但是通常可以更快地解决这个问题。
注:最短路径算法通常依赖最短路径的一个重要性质---两个结点之间的一条最短路径包含着其他最短路径
一、一些定义
为了讨论单源最短路径问题,这里需要作一些必要的定义
最短路径的表示:通常情况下,我们不仅希望计算出最短路径的权重,还希望计算出最短路径上的结点。给定图G=(V,E),对于每个结点v,我们维持一个前驱结点v.p,该前驱结点可以是另一个结点或者NIL。这里的最短路径算法对每个结点的前驱进行设置,这样,将从结点v开始的前驱结点反过来,也就是从s到v的最短路径了。
最短路径树:最短路径树是一棵有根节点的树,该树包含了从源结点s到每个可以从s到达的结点的一条最短路径。当然,最短路径不是唯一的,最短路径树自然也不是唯一的。
松弛操作:本章算法需要使用松弛(relaxation)技术。对于每个结点v来说,我们维持一个属性v.d。用来记录从源结点s到结点v的最短路径权重的上界,我们称v.d为s到v的最短路径估计。这里使用下面的伪代码来对最短路径的估计和前驱结点进行初始化:
对一条边(u,v)的松弛过程为:首先测试一下是否可以对从s到v的最短路径进行改善。测试方法是,将从结点s到u之间的最短路径距离加上与v之间的边权重,并与当前的从s到v的最短路径估计进行比较,如果前者更小,则对v.d和进行更新,松弛步骤可以降低最短路径的估计值v.d并更新v的前驱属性
;伪代码如下
最短路径和松弛操作的性质:
1、三角不等式性质:对于任何边(u,v)∈E,我们有δ(s,v)<=δ(s,u)+w(u,v);
2、上界性质:对于所有结点v∈V,总是有v.d>=δ(s,v)
3、非路径性质:如果从结点s到v之间不存在路径,则v.d=δ(s,v) =∞
4、路径松弛性质:如果p<v0,v1,v2…vk>是从源结点v0到结点vk的一条最短路径,并且我们对p中所有边的松弛次序为(v0,v1)(v1,v2)…则vk.d=δ(s,vk).(大概意思是,在执行了一系列的松弛操作后,所有结点都取得了最后的最短路径权重,那么前驱子图将也会是一棵最短路径树)
5、收敛性质:对于某些结点u,v∈V,如果s~u->v是图G中的一条最短路径,并且在对边(u,v)进行松弛前的任意时间有u.d=δ(s,u),则在此之后所有时间有v.d=δ(s,v);
二、Bellman-Ford算法
Bellman-Ford算法解决的是一般情况下的单源最短路径问题。这里,边的权重可以为负值。Bellman-Ford算法通过对边进行松弛操作来渐进地降低从源结点到每个结点v的最短路径估计值v.d,直到该估计值与实际的最短路径权重δ(s,v)相同为止。其伪代码如下:
此算法对每条边进行|V|-1次松弛操作(因为图G中,对于任意源结点,其到达其他任意结点的无环路径最多只有|V|-1条边,所以对于任意一条边也需要经过|V|-1次松弛以求出最小的估计值)
该算法返回一个布尔值,以表明是否存在一个从源结点可以到达的权重为负值的环路。如果存在这样一个环路,算法将告诉我们不存在一个解决方案。
三、有向无环图中的单源最短路径问题
根据结点的拓扑排序次序来对带权重的有向无环图G=(V,E)进行边的松弛操作,我们便可以在O(V+E)的时间内计算出单个源结点到所有结点之间的最短路径。在有向无环图中,即使存在权重为负值的边,但因为没有权重为负值的环路,最段路径都是存在的。
四、Dijkstra算法
Dijkstra算法解决的是带权重的有向图上单源最短路径问题,该算法要求所有边的权重都为非负。如果采用合理的实现方式,Dijkstra算法的运行时间要低于bellman-Ford算法的运行时间。
Dijkstra算法在运行过程中维持的关键信息是一组结点集合S.从源结点s到该集合中的每一个结点的最短路径已被找到。算法重复从集合V-S中选择最短路径估计最小的点u,将u加入到集合S。然后对所有从u发出的边进行松弛。在下面的实现方式中,使用一个最小优先队列Q来保存结点集合,每个结点的关键字即为d值。(注:下图左边是Dijkstra算法。右边的Prim是用来对比的,因为两者真的很相似!)
Dijkstra算法对边的松弛如下所示(图源《算法导论》)
因为Dijkstra算法总是选择集合V-S中“最轻”或者“最近”的结点来加入到S中,该算法使用的是贪心策略。虽然贪心策略并不总是能得到最优结果,但是使用贪心策略的Dijkstra算法确实能计算出最短路径。这里的关键证明是一个事实:该算法每次选择结点u来加入到S时,有u.d=δ(s,u);
Dijkstra算法的时间复杂度取决于最小优先级队列的实现。如果利用结点号1~|V|来维持最小优先队列,时间复杂度为O(V^2+E)=O(V^2);
如果使用的是斐波那契数列,可以将该算法的时间改善到O(VlogV+E).
Dijkstra算法既类似于广度优先搜索,也有点类似于计算最小生成树的Prim算法。其与Prim相似之处在于,二者都是用最小优先队列来寻找给定集合之外“最轻”的结点,将该结点加入到集合里,并对位于集合外面的结点权重进行相应调整。区别在于,Prim算法并不进行松弛操作,其每一步都只寻找轻量级边,而Dijkstra算法选出优先级队列中权重最小的点后要进行松弛操作。
附:Dijkstra算法和Prim算法的区别
看到这里,可能混淆了Dijkstra和Prim算法的区别,这里做个简单的示例。比如下面这个三角形:
很显然,假设源结点是s(图上没有画出来,但是可以确定s通过某条路径到达了A),目前把A加入到了集合内:
step1、这时候,Prim和Dijkstra都对A的邻接边进行处理;其中,Prim算法让B和C的父亲结点都先变为A,然后让B和C的key都变为“父结点到本结点的权重”,分别为3和5;而Dijkstra算法则是对边A-B,A-C都进行了松弛,分别更新了δ(s,B)=δ(s,A)+3和δ(s,C)=δ(s,A)+5,并让B、C的父亲结点都变成A.
step2、接下来,无论是Dijkstra算法还是Prim算法都会先选择B加入集合,因为很显然在两个算法中B此时都处于优先级队列的最前端。
step3、待B加入集合中后,分歧点就开始了:此时在Prim算法中,因为B-C的权重小于先前C中记录的键值(先前记录的是A-C的权重),所以这时候B就会成为C的父亲结点了。而对于Dijkstra算法,C中记录的是“最短路径估计值”,也就是说,此时C中的key是δ(s,A)+5,而在松弛时,比较一下δ(s,B)+w(B,C)和δ(s,A)+5,显然后者更小,所以C的父亲结点与键值均不变!
所以区分两者的关键在于,将某点加入到集合中后的后续操作:Prim只是单纯比较路径权重,Dijkstra却是“最短路径估计值”(松弛操作)!
也就是说,Dijkstra记录了累计的“消耗”,是“长视”的;而Prim只关注当下横跨切割的边谁是最短的,而不关注从源结点过来的累加路径是不是最短的,是“短视”的。