《算法与数据结构》第七章[算法4]:最短路径
前两天,正好是我们的国庆节长假,本人也是趁着这个机会,出去游玩了一圈。在决定去哪里的时候,发现可以选择的地方实在太多了,最后,本人发现一个有趣的玩法:在我国的地图上,随机选择一个城市去玩,可是,若是随机到一个偏远的城市,可能会面临交通不便的问题,于是,我决定在我国的铁路网上,选择一个城市去玩。于是乎,我找到了下面这张图(图片来源“高铁网”[https://www.gaotie.cn/CRHMAP/]):
可以看到,我国的铁路网是一个非常复杂的网络,图中每一个节点代表一个城市,每一条边代表两座城市之间有铁路相连。笔者人是在兰州的,选择了郑州作为目的地,于是开始在铁路12306网站开始查询购票,能买到直达车票自然是最好的,然而,现实却是残酷的,由于国庆假期出游的人实在太多,车票早早被一抢而空了,此时,我点开了“中转”选项,发现可以通过中转去郑州,可是中转的城市有许多,比如西安、广元、甚至重庆,行程耗时也不一样,票价也不一样,实际上,从上图中不难看出,从西安中转去郑州是最合适的,因为它们三座城市几乎在一条线上,路径也是最短的。此时,便出现一个问题,那些订票、导航软件是如何找出我们最需要的那条耗时最少的路径的呢?而这个问题,便是我们今天要讨论的“最短路径”问题。
1、最短路径相关定义
我们知道,在上图中,每一个城市间都是有代价的,这个代价可以是距离、时间、费用等,这样我们在选择最短路径时的标准则是这些代价之和最低,这并没有什么问题;若是每条路径都没有代价,或者说代价都相同(一个单位代价),那么我们选择的标准就成了路径上边的数量最少。
所以说,对于网图来说,最短路径是指两顶点之间经过的边上权值之和最少的路径,并且,我们称这条路径上的第一个顶点为源点(Source),最后一个顶点为终点(Destination)。
同样,我们还是先给出部分约定:
- 只考虑连通图:当一个图不是连通图时,图中有些顶点是无法到达的,它们之间连路径都没有,更谈不上最短路径了。
- 边的权值不同:若存在几条边的权值相同,此时找出的最短路径可能不唯一。
- 边的权值非负:若存在边的权值为负数,此时可能会出现环路,使得路径权值无限减小,无法找到最短路径。
- 最短路径都是简单路径:即我们不考虑存在自环的情况。
首先我们先来认识一个算法——Dijkstra算法,它是由荷兰计算机科学家Edsger W. Dijkstra在1956年提出的,请记住这个人,他多半还会再次出现在你的计算机学习过程中。
2、迪杰斯特拉(Dijkstra)算法
首先我们来想一个问题,若我们类似上一节中所讲的Prim算法一样,每次都选择当前路径权值最小的边加入到路径中,最终是不是就会得到一个最短路径呢?答案是否定的,我们先来看下面这个例子:
假设我们要从顶点v0v_0v0出发,去顶点v2v_2v2,我们按上述策略就会找出(v0,v5)(v_0,v_5)(v0,v5)、(v5,v6)(v_5,v_6)(v5,v6)和(v6,v2)(v_6,v_2)(v6,v2)这三条边,路径权值为3+16+1=203+16+1=203+16+1=20;然而,显然(v0,v6)(v_0,v_6)(v0,v6)和(v6,v2)(v_6,v_2)(v6,v2)这两条边的路径权值为9+1=109+1=109+1=10,更短。所以说,这种策略并不适用于最短路径问题。
那么,我们该如何解决这个问题呢?实际上,Dijkstra算法的思路与我们Prim算法的代码实现出的策略十分相似,我们在代码过程中会维护一个数组lowcost
,它所记录的是当前生成树到各顶点的最小代价,只是我们现在要做的不是将其更新成0(表示它在最小生成树中),而是将其更新成当前路径的最小代价。具体来说,Dijkstra算法的策略如下:
- 选择一个源点vsv_svs,将其加入到集合SSS中,表示当前已经找到从源点出发的最短路径的顶点集合;同时,初始化一个数组
dist
,dist[i]
表示从源点vsv_svs到顶点viv_ivi的当前已知最短路径长度,初始时,dist[s]
设为0,目前无法到达的顶点设为无穷大。 - 在集合V−SV-SV−S中选择一个顶点vkv_kvk,满足
dist[k]
是dist
数组中当前最小的,将其加入集合SSS中。 - 对于每个不在集合SSS中的顶点vjv_jvj,如果通过vkv_kvk到达vjv_jvj的路径长度小于当前已知的
dist[j]
,则更新dist[j]
为通过vkv_kvk到达vjv_jvj的路径长度。 - 重复步骤2和3,直到所有顶点都被加入集合SSS中,或者
dist
数组中的所有值都不再更新。
实际上,我们维护的dist
数组是一个“备忘录”,它记录了从源点到各个顶点的当前已知最短路径长度,而等我们的更新操作全部结束后,它所存储的值就是从源点到各个顶点的最短路径长度。
我们可以先来看一下我们这个策略的过程,还是按照上图来进行说明,假设我们要从顶点v0v_0v0出发,去顶点v2v_2v2,我们先初始化:
S={v0},dist=[0,15,∞,∞,∞,3,9]S=\{v_0\},dist=[0,15,\infty,\infty,\infty,3,9]S={v0},dist=[0,15,∞,∞,∞,3,9]
然后,我们在V−SV-SV−S中选择dist
值最小的顶点v5v_5v5,将其加入到集合SSS中,并更新dist
数组:
我们看到从v5v_5v5出发,我们可以到达v0v_0v0、v4v_4v4和v6v_6v6,我们发现v0v_0v0已经在集合SSS中,不需要更新;而v4v_4v4和v6v_6v6的dist
值分别为无穷大和9,而通过v5v_5v5到达它们的路径长度分别为3+17=203+17=203+17=20和3+16=193+16=193+16=19,显然,20小于无穷大,所以我们更新dist[4]
为20,而19大于9,所以不更新dist[6]
,此时,我们得到:
S={v0,v5},dist=[0,15,∞,20,∞,3,9]S=\{v_0,v_5\},dist=[0,15,\infty,20,\infty,3,9]S={v0,v5},dist=[0,15,∞,20,∞,3,9]
接下来,我们在V−SV-SV−S中选择dist
值最小的顶点v6v_6v6,将其加入到集合SSS中,并更新dist
数组:
从v6v_6v6出发,我们可以到达图中其余所有顶点(v0v_0v0~v5v_5v5),权值分别为9、4、1、36、25、16,而v0v_0v0、v5v_5v5已经在集合SSS中,不需要更新;通过v6v_6v6到达v1v_1v1的路径长度为9+4=139+4=139+4=13,小于当前dist[1]
的15,所以更新dist[1]
为13;通过v6v_6v6到达v2v_2v2的路径长度为9+1=109+1=109+1=10,小于当前dist[2]
的无穷大,所以更新dist[2]
为10;通过v6v_6v6到达v3v_3v3的路径长度为9+36=459+36=459+36=45,大于当前dist[3]
的20,不更新;通过v6v_6v6到达v4v_4v4的路径长度为9+25=349+25=349+25=34,大于当前dist[4]
的20,不更新。此时,我们得到:
S={v0,v5,v6},dist=[0,13,10,20,∞,3,9]S=\{v_0,v_5,v_6\},dist=[0,13,10,20,\infty,3,9]S={v0,v5,v6},dist=[0,13,10,20,∞,3,9]
后续的过程就不再赘述了,与上面操作一致,就是不断更新dist
数组。最终,我们可以得到从源点到各个顶点的最短路径长度。
但是有同学会产生疑问,为什么我们每次选择dist
值最小的顶点加入集合SSS中,就能保证这个顶点的dist
值是最终的最短路径长度呢?万一存在一条更短的路径通过其他顶点到达这个顶点呢?
实际上,这个问题同样可以通过反证法来证明,若是接下来的证明过程看不懂,可以先跳过,先将其当作一个结论来使用。
首先明确我们要证明的是:
当一个顶点vkv_kvk被选中加入集合SSS(即已确定最短路径的顶点集合)时,它的dist[k]
值一定是从起点vsv_svs到它的真正最短路径长度,不可能还有更短的路径。
假设我们选了一个当前 dist
值最小的顶点vkv_kvk,但它的 dist[k]
并不是最终的真正最短路径长度。也就是说,存在一条更短的路径可以到达vkv_kvk,只是我们还没发现。这条更短的路径可能是这样的:
vs→va→vb→⋯→vm→vk
v_s \rightarrow v_a \rightarrow v_b \rightarrow \cdots \rightarrow v_m \rightarrow v_k
vs→va→vb→⋯→vm→vk
而且这条路径上,至少有一个顶点不在集合SSS中(即还没被处理过)。
为什么一定有这样的顶点?
因为如果这条路径上的所有顶点都已经在SSS中去了,那说明我们早就通过它们更新过 (v_k) 的dist
值了,那dist[k]
就应该是这条更短路径的长度,而不是现在的值 —— 这就和我们最初的假设矛盾了。
所以,这条更短的路径上,至少有一个顶点还没被处理(不在SSS中)。
我们设这条路径上,第一个不在SSS中的顶点是viv_ivi。也就是说,从起点vsv_svs到viv_ivi之前的所有顶点(比如va∼vi−1v_a \sim v_{i-1}va∼vi−1)都已经在SSS中。
根据Dijkstra算法的做法,当我们把这些顶点加入SSS时,已经用它们来更新过它们邻接点的dist
值了。所以,dist[i]
的值一定已经通过vi−1v_{i-1}vi−1更新过了,也就是说,我们已经找到了从起点到viv_ivi的一条最短路径(至少是目前已知的最短路径)。
而且,因为viv_ivi是在vkv_kvk之前被访问的(它是路径上第一个不在SSS中的顶点),所以dist[i]
的值一定小于或等于 dist[k]
。
换句话说,从起点到viv_ivi的路径长度,已经比到vkv_kvk的路径更短或相等。
那既然我们能通过viv_ivi走到vkv_kvk,而且viv_ivi的 dist
值更小,那我们应该先处理viv_ivi,而不是vkv_kvk。这就说明:我们不可能先选到vkv_kvk,再去发现一条更短的路径通过viv_ivi —— 因为viv_ivi的 dist
值更小,它应该更早被选中。所以假设不成立!
我们一开始假设 “dist[k]
不是最短路径长度”,但推着推着就发现:如果真有更短的路径,那它上面的第一个未处理顶点viv_ivi应该比vkv_kvk更早被选中。这就和“我们选了vkv_kvk”矛盾了。
总归,看文字不如看图来的直观,我们给出这样一张图
我们可以看到,从图中v0v_0v0到v3v_3v3有三条路径可以走,可以直观看出路径v0→v3v_0 \rightarrow v_3v0→v3是最短路径,选择过程中也是v3v_3v3先被选择进SSS,我们能想办法让v0→v2→v3v_0 \rightarrow v_2 \rightarrow v_3v0→v2→v3这条路径更短吗?我们可以让(v0,v2)(v_0,v_2)(v0,v2)的权值为0.5,(v2,v3)(v_2,v_3)(v2,v3)的权值为1,这样一来,这条路径的权值为1.5,小于2;但是,我们发现,(v0,v2)(v_0,v_2)(v0,v2)的权值为0.5时,我们就会先选择v2v_2v2,而不是v3v_3v3,因为此时dist[2]
为0.5,dist[3]
为2,所以v2v_2v2会先被选择进SSS,然后再通过v2v_2v2更新dist[3]
,最终我们还是能找到最短路径。
所以说我们若想让v0→v2→v3v_0 \rightarrow v_2\rightarrow v_3v0→v2→v3这条路径更短,就要使得
w(v0,v2)+w(v2,v3)<w(v0,v3) w(v_0,v_2)+w(v_2,v_3)<w(v_0,v_3) w(v0,v2)+w(v2,v3)<w(v0,v3)
但是,只要满足上式,必有w(v0,v2)<w(v0,v3)w(v_0,v_2)<w(v_0,v_3)w(v0,v2)<w(v0,v3),所以此时v2v_2v2会先被选择进SSS,自然就不会出现v3v_3v3先被选择进SSS的情况。
由此,我们知道,Dijkstra算法中,每次选择dist
值最小的顶点加入集合SSS中,是合理且正确的。同时,以上证明过程还隐含着这样一个引理:
最短路径的子路径也是最短路径:给定一个图G=(V,E)G=(V,E)G=(V,E),设p=(v0,v1,⋯ ,vk)p=(v_0,v_1,\cdots,v_k)p=(v0,v1,⋯,vk)是从顶点v0v_0v0到顶点vkv_kvk的最短路径,那么对于路径ppp上的任意两个顶点viv_ivi和vjv_jvj(0≤i<j≤k0 \leq i < j \leq k0≤i<j≤k),路径(vi,vi+1,⋯ ,vj)(v_i,v_{i+1},\cdots,v_j)(vi,vi+1,⋯,vj)也是从viv_ivi到vjv_jvj的最短路径。
同时,我们也能理解为什么要求边的权值非负了,若是存在负权边,比如上图中,我们若将(v2,v3)(v_2,v_3)(v2,v3)的权值设为-7,那么我们就会发现,在一开始将v3v_3v3加入SSS后(dist[3]
=3),等我们探索到v2v_2v2时,发现通过v2v_2v2到达v3v_3v3的路径长度为9+(−7)=29+(-7)=29+(−7)=2,这样就会出现我们刚才提出上述问题中所担心的情况,即当前根据dist
值选择的顶点走向并不是最终的最短路径的走向。
接下来,我们来看一下Dijkstra算法的邻接矩阵代码实现:
void Dijkstra(GraphAdjMatrix G, int s, int dist[], int path[])
{int i, j, k, min;int final[MAXVEX]; // final[i]为1表示顶点vi已加入集合S中,0表示未加入// 初始化for (i = 0; i < G.numVertexes; i++){final[i] = 0; // 全部顶点初始化为未加入Sdist[i] = G.arc[s][i]; // dist数组初始化为源点到各顶点的权值if (dist[i] < INFINITY)path[i] = s; // 初始化路径数组elsepath[i] = -1; // -1表示路径不存在}dist[s] = 0; // 源点到自身距离为0final[s] = 1; // 源点加入集合S中// 主循环,寻找最短路径for (i = 1; i < G.numVertexes; i++){ min = INFINITY; // 初始化min为无穷大,表示当前最小距离for (j = 0; j < G.numVertexes; j++){ // 在V-S中选择dist值最小的顶点if (!final[j] && dist[j] < min){min = dist[j]; // 更新当前最小距离k = j; // 记录当前最小距离的顶点}}final[k] = 1; // 将选中的顶点k加入集合S中// 更新dist和path数组for (j = 0; j < G.numVertexes; j++){if (!final[j] && (min + G.arc[k][j] < dist[j])){ // 若顶点j不在S中,且经过k顶点到达j的距离更小dist[j] = min + G.arc[k][j]; // 更新dist值path[j] = k; // 更新路径}}}
}
按照惯例,参考上述给出的图,我们来分析一下调用Dijkstra(G, 0, dist, path)
时的过程:
- 首先,代码3 ~ 16行都是初始化操作,我们初始化
final
数组为全0,表示所有顶点都未加入集合SSS中;初始化dist
数组,表示从源点v0v_0v0到各顶点的当前已知最短路径长度;初始化path
数组,表示从源点v0v_0v0到各顶点的路径前驱节点。同时,我们将dist[0]
设为0,表示源点到自身的距离为0,并将final[0]
设为1,表示源点v0v_0v0已经加入集合SSS中。
初始化后,dist
数组和path
数组的值分别为:
dist=[0,15,∞,∞,∞,3,9] dist=[0,15,\infty,\infty,\infty,3,9] dist=[0,15,∞,∞,∞,3,9] path=[0,0,−1,−1,−1,0,0] path=[0,0,-1,-1,-1,0,0] path=[0,0,−1,−1,−1,0,0] final=[1,0,0,0,0,0,0] final=[1,0,0,0,0,0,0] final=[1,0,0,0,0,0,0]
- 然后,代码18 ~ 41行是Dijkstra算法的主循环,循环
G.numVertexes-1
次,每次循环中,我们先在final
数组中选择一个dist
值最小的顶点vkv_kvk,将其加入集合SSS中,然后更新dist
和path
数组。
第一次循环时,我们先初始化min
为无穷大,它表示当前最小距离,然后我们遍历dist
数组,找到值最小且未被加入集合SSS(即final[j] == 0
)的顶点,这里是v5v_5v5,它的dist
值为3,所以我们将min
更新为3,并将k
更新为5,表示当前最小距离的顶点是v5v_5v5。然后,我们将final[5]
设为1,表示顶点v5v_5v5已经加入集合SSS中。此时:
final=[1,0,0,0,0,1,0]final=[1,0,0,0,0,1,0]final=[1,0,0,0,0,1,0]
接下来第33 ~ 40行,我们遍历所有顶点,更新dist
和path
数组。我们发现,顶点v5v_5v5可以到达v0v_0v0、v4v_4v4和v6v_6v6,其中v0v_0v0已经在集合SSS中,不需要更新;而v4v_4v4和v6v_6v6的dist
值分别为无穷大和9,而通过v5v_5v5到达它们的路径(即找到的路径为v0→v5→v4v_0 \rightarrow v_5 \rightarrow v_4v0→v5→v4和v0→v5→v6v_0 \rightarrow v_5 \rightarrow v_6v0→v5→v6)长度分别为3+17=203+17=203+17=20和3+16=193+16=193+16=19,显然,20小于无穷大,所以我们更新dist[4]
为20,并将path[4]
设为5,表示到v4v_4v4的路径前驱节点是v5v_5v5;而19大于9,所以不更新dist[6]
。此时:
dist=[0,15,∞,∞,20,3,9]dist=[0,15,\infty,\infty,20,3,9]dist=[0,15,∞,∞,20,3,9] path=[0,0,−1,−1,5,0,0]path=[0,0,-1,-1,5,0,0]path=[0,0,−1,−1,5,0,0] final=[1,0,0,0,0,1,0]final=[1,0,0,0,0,1,0]final=[1,0,0,0,0,1,0]
结束后,我们进入第二次外层循环,再次初始化min
为无穷大,然后遍历dist
数组,找到值最小且未被加入集合SSS的顶点v6v_6v6,它的dist
值为9,所以我们将min
更新为9,并将k
更新为6,表示当前最小距离的顶点是v6v_6v6。然后,我们将final[6]
设为1,表示顶点v6v_6v6已经加入集合SSS中。此时:
final=[1,0,0,0,0,1,1]final=[1,0,0,0,0,1,1]final=[1,0,0,0,0,1,1]
接下来再到第33 ~ 40行,我们遍历所有顶点,更新dist
和path
数组。顶点v6v_6v6可以到达图中其余所有顶点(v0v_0v0~v5v_5v5),权值分别为9、4、1、36、25、16,而v0v_0v0、v5v_5v5已经在集合SSS中,不需要更新;通过v6v_6v6到达v1v_1v1的路径(即找到的路径为v0→v6→v1v_0 \rightarrow v_6 \rightarrow v_1v0→v6→v1)长度为9+4=139+4=139+4=13,小于当前dist[1]
的15,所以更新dist[1]
为13,并将path[1]
设为6,表示到v1v_1v1的路径前驱节点是v6v_6v6;通过v6v_6v6到达v2v_2v2的路径(即找到的路径为v0→v6→v2v_0 \rightarrow v_6 \rightarrow v_2v0→v6→v2)长度为9+1=109+1=109+1=10,小于当前dist[2]
的无穷大,所以更新dist[2]
为10,并将path[2]
设为6,表示从v2v_2v2的路径前驱节点是v6v_6v6;通过v6v_6v6到达v3v_3v3的路径(即找到的路径为v0→v6→v3v_0 \rightarrow v_6 \rightarrow v_3v0→v6→v3)长度为9+36=459+36=459+36=45,小于当前dist[3]
的无穷大,更新dist[3]
为45,并将path[3]
设为6,表示到v3v_3v3的路径前驱节点是v6v_6v6;通过v6v_6v6到达v4v_4v4的路径(即找到的路径为v0→v6→v4v_0 \rightarrow v_6 \rightarrow v_4v0→v6→v4)长度为9+25=349+25=349+25=34,大于当前dist[4]
的20,不更新。此时:
dist=[0,13,10,45,20,3,9]dist=[0,13,10,45,20,3,9]dist=[0,13,10,45,20,3,9] path=[0,6,6,5,5,0,0]path=[0,6,6,5,5,0,0]path=[0,6,6,5,5,0,0]
此时,我们找到了从v0v_0v0到v5v_5v5和v6v_6v6的最短路径,分别是v0→v5v_0 \rightarrow v_5v0→v5和v0→v6v_0 \rightarrow v_6v0→v6。在第三次外层循环时,我们会选择dist
值最小的顶点v2v_2v2,并将其加入集合SSS中,然后更新dist
和path
数组,最终我们会找到从v0v_0v0到v2v_2v2的最短路径为v0→v6→v2v_0 \rightarrow v_6 \rightarrow v_2v0→v6→v2。其余步骤不再赘述,与上述过程一致。我们可以绘制出一张求解路径过程的表格:
步骤 | 选中 | dist数组 | path数组 | final数组 | 说明 |
---|---|---|---|---|---|
- | - | [0, 15, ∞, ∞, ∞, 3, 9] | [0, 0, -1, -1, -1, 0, 0] | [1, 0, 0, 0, 0, 0, 0] | 初始化 |
i=1 | v5v_5v5 | [0, 15, ∞, ∞, 20, 3, 9] | [0, 0, -1, -1, 5, 0, 0] | [1, 0, 0, 0, 0, 1, 0] | 选择v5v_5v5,更新v4v_4v4 |
i=2 | v6v_6v6 | [0, 13, 10, 45, 20, 3, 9] | [0, 6, 6, 6, 5, 0, 0] | [1, 0, 0, 0, 0, 1, 1] | 选择v6v_6v6,更新v1v_1v1、v2v_2v2和v3v_3v3 |
i=3 | v2v_2v2 | [0, 13, 10, 33, 20, 3, 9] | [0, 6, 6, 2, 5, 0, 0] | [1, 0, 1, 0, 0, 1, 1] | 选择v2v_2v2,无更新 |
i=4 | v1v_1v1 | [0, 13, 10, 33, 20, 3, 9] | [0, 6, 6, 2, 5, 0, 0] | [1, 1, 1, 0, 0, 1, 1] | 选择v1v_1v1,无更新 |
i=5 | v4v_4v4 | [0, 13, 10, 33, 20, 3, 9] | [0, 6, 6, 2, 5, 0, 0] | [1, 1, 1, 0, 1, 1, 1] | 选择v4v_4v4,无更新 |
i=6 | v3v_3v3 | [0, 13, 10, 33, 20, 3, 9] | [0, 6, 6, 2, 5, 0, 0] | [1, 1, 1, 1, 1, 1, 1] | 选择v3v_3v3,无更新 |
之前我们已经提到,我们最后的dist
数组中存储的值就是从源点到各个顶点的最短路径长度,也就是说,最后得到的其实不是一个点到一个点的最短路径,而是一个点到所有点的最短路径。因为我们并不知道我们想要的终点会在什么时候被选择进集合SSS中,可能是第一个,也可能是最后一个,所以我们最快也只能等到刚好我们想要的终点被选择进集合SSS中时,才能得到它的最短路径长度,可以在代码中添加一个判断语句,当我们选择的顶点是我们想要的终点时,直接跳出循环即可。但不可避免的是,我们还是需要等到它被选择进集合SSS中时,才能得到它的最短路径长度。
另外,我们在代码中还维护了一个path
数组,它记录了从源点到各个顶点的路径前驱节点,这样我们就能通过它来反向推导出从源点到某个顶点的具体路径,比如我们要找从v0v_0v0到v2v_2v2的路径,我们发现path[2]
为6,说明v2v_2v2的前驱节点是v6v_6v6,然后我们再看path[6]
,它为0,说明v6v_6v6的前驱节点是v0v_0v0,而path[0]
为0,说明v0v_0v0是源点,所以我们最终得到从v0v_0v0到v2v_2v2的路径为v0→v6→v2v_0 \rightarrow v_6 \rightarrow v_2v0→v6→v2。我们便可以通过path
数组来反向推导出从源点到各个顶点的具体路径,代码如下:
void PrintPath(int path[], int v)
{if (path[v] == -1){printf("No path from source to vertex %d\n", v);return;}if (path[v] == v){printf("%d ", v); // 到达源点,打印源点return;}PrintPath(path, path[v]); // 递归打印前驱节点printf("%d ", v); // 打印当前节点
}
假设我们要打印从v0v_0v0到v2v_2v2的路径,我们调用PrintPath(path, 2)
,它会递归调用PrintPath(path, 6)
,然后再递归调用PrintPath(path, 0)
,此时path[0]
为0,说明v0v_0v0是源点,打印0,然后返回上一层,打印6,再返回上一层,打印2,最终我们得到从v0v_0v0到v2v_2v2的路径为0 6 2
。
最后,我们来分析一下Dijkstra算法的时间复杂度,假设图中有VVV个顶点,EEE条边,那么Dijkstra算法的时间复杂度为O(V2)O(V^2)O(V2),这是因为我们在每次选择dist
值最小的顶点时,需要遍历整个dist
数组,时间复杂度为O(V)O(V)O(V),而我们需要进行VVV次这样的操作,所以总的时间复杂度为O(V2)O(V^2)O(V2)。
当我们想要得知各个顶点到其余各个顶点的最短路径时,我们可以对每个顶点进行一次Dijkstra算法,这样总的时间复杂度就变成了O(V3)O(V^3)O(V3)。我们也可以使用Floyd-Warshall(亦称Floyd)算法来解决这个问题,它的时间复杂度也是O(V3)O(V^3)O(V3),但是它的实现更为简单。
3、弗洛伊德(Floyd)算法
我们在上一节中得知了一个定理:最短路径的子路径也是最短路径。根据这个定理,我们可以得知,当我们找出一条最短路径(vi,⋯ ,vj)(v_i,\cdots,v_j)(vi,⋯,vj)时,它的子路径(vi,⋯ ,vk)(v_i,\cdots,v_k)(vi,⋯,vk)和(vk,⋯ ,vj)(v_k,\cdots,v_j)(vk,⋯,vj)也都是最短路径,同理,(vi,⋯ ,vk)(v_i, \cdots, v_k)(vi,⋯,vk)的子路径也是最短路径,(vk,⋯ ,vj)(v_k, \cdots, v_j)(vk,⋯,vj)的子路径也是最短路径。也就是说,一条最短路径可以拆分成若干条更小的最短路径。这样一来,我们就可以把求viv_ivi到vjv_jvj的最短路径的问题,拆分成求viv_ivi到vkv_kvk的最短路径和vkv_kvk到vjv_jvj的最短路径的问题。
这个性质告诉我们:大问题的最优解可以由小问题的最优解组合而成,自然,我们就会思考,我们可不可以“从小到大”一步一步拼出最短路径。
一开始,我们只知道图中直接相连的边的长度,也就是我们的邻接矩阵,我们将其改名为distdistdist,其中dist[i][j]dist[i][j]dist[i][j]表示从顶点viv_ivi到顶点vjv_jvj的路径长度,最后用它来存储从顶点viv_ivi到顶点vjv_jvj的最短路径长度。
我们能不能通过引入一些“中间点”,来一步步缩短这个矩阵中的距离呢?
即我们的思路如下:
- 一开始,不允许经过任何中间点,我们能走的就只有直接相连的边。
- 然后,我们允许经过v0v_0v0,看看能不能缩短部分路径。
- 接着,我们允许经过v0v_0v0和v1v_1v1,看看能不能缩短更多路径。
- 直到我们允许经过所有顶点,就得到了考虑过所有情况的最短路径。
就如图中我们认为dist[3][1]dist[3][1]dist[3][1]为∞\infty∞,即v3v_3v3无法直接到达v1v_1v1,然而,当我们试着引入中间点v0v_0v0时,我们发现通过v3v_3v3到达v1v_1v1的路径长度为dist[3][0]+dist[0][1]=10+3=13dist[3][0] + dist[0][1] = 10 + 3 = 13dist[3][0]+dist[0][1]=10+3=13,小于∞\infty∞,所以我们就将dist[3][1]dist[3][1]dist[3][1]更新为13,表示我们可以缩短v3v_3v3到v1v_1v1的路径;而dist[3][2]dist[3][2]dist[3][2]为6,表示v3v_3v3可以直接到达v2v_2v2,但是我们发现通过v0v_0v0到达v2v_2v2的路径长度为dist[3][0]+dist[0][2]=10+8=18dist[3][0] + dist[0][2] = 10 + 8 = 18dist[3][0]+dist[0][2]=10+8=18,大于6,并没有缩短,我们就不更新它。
此时我们的distdistdist矩阵变为:
dist=[03810∞02∞∞∞04101360] dist = \begin{bmatrix} 0 & 3 & 8 & 10 \\ \infty & 0 & 2 & \infty \\ \infty & \infty & 0 & 4 \\ 10 & 13 & 6 & 0 \\ \end{bmatrix} dist=0∞∞1030∞13820610∞40
然后,我们再引入中间点v1v_1v1,我们发现dist[0][2]dist[0][2]dist[0][2]为8,但是我们发现通过v1v_1v1到达v2v_2v2的路径长度为dist[0][1]+dist[1][2]=3+2=5dist[0][1] + dist[1][2] = 3 + 2 = 5dist[0][1]+dist[1][2]=3+2=5,小于8,所以我们就将dist[0][2]dist[0][2]dist[0][2]更新为5;而dist[0][3]dist[0][3]dist[0][3]为10,但是我们发现通过v1v_1v1到达v3v_3v3的路径长度为dist[0][1]+dist[1][3]=3+∞=∞dist[0][1] + dist[1][3] = 3 + \infty = \inftydist[0][1]+dist[1][3]=3+∞=∞,并没有缩短,我们就不更新它。
此时的distdistdist矩阵变为:
dist=[03510∞02∞∞∞04101360] dist = \begin{bmatrix} 0 & 3 & 5 & 10 \\ \infty & 0 & 2 & \infty \\ \infty & \infty & 0 & 4 \\ 10 & 13 & 6 & 0 \\ \end{bmatrix} dist=0∞∞1030∞13520610∞40
接下来,我们将其推广,让每一个顶点都尝试作为中间点,并使用一个三维矩阵dist[k][i][j]dist[k][i][j]dist[k][i][j]来表示只允许前kkk个顶点作为中间点时,从顶点viv_ivi到顶点vjv_jvj的最短路径长度。那么,我们最终的目标就变为了:求出dist[V][i][j]dist[V][i][j]dist[V][i][j],其中VVV为图中顶点的数量,也就是允许所有顶点作为中间点时,从顶点viv_ivi到顶点vjv_jvj的最短路径长度。
我们从上面的过程中发现从dist[k−1][i][j]dist[k-1][i][j]dist[k−1][i][j]到dist[k][i][j]dist[k][i][j]dist[k][i][j]的变化过程中对于每一对顶点(vi,vj)(v_i,v_j)(vi,vj),我们都有两种选择:
- 不经过vkv_kvk,那么当前最短路径长度就是dist[k−1][i][j]dist[k-1][i][j]dist[k−1][i][j];
- 尝试经过vkv_kvk,那么此时路径就是(vi,⋯ ,vk,⋯ ,vj)(v_i, \cdots, v_k, \cdots, v_j)(vi,⋯,vk,⋯,vj),而根据我们知道的“最短路径的子路径也是最短路径”定理,这两段也肯定是在前k−1k-1k−1个顶点中选出的最短路径,所以从viv_ivi到vkv_kvk的最短路径长度是dist[k−1][i][k]dist[k-1][i][k]dist[k−1][i][k],从vkv_kvk到vjv_jvj的最短路径长度是dist[k−1][k][j]dist[k-1][k][j]dist[k−1][k][j],那么当前最短路径长度就是dist[k−1][i][k]+dist[k−1][k][j]dist[k-1][i][k] + dist[k-1][k][j]dist[k−1][i][k]+dist[k−1][k][j]。
而我们做选择的策略也非常简单,谁更小选谁就完事了,于是我们得到一个等式:
dist[k][i][j]=min(dist[k−1][i][j],dist[k−1][i][k]+dist[k−1][k][j]) dist[k][i][j] = \min(dist[k-1][i][j], dist[k-1][i][k] + dist[k-1][k][j]) dist[k][i][j]=min(dist[k−1][i][j],dist[k−1][i][k]+dist[k−1][k][j])
我们可以看到,这个等式中,dist[k][i][j]dist[k][i][j]dist[k][i][j]只与dist[k−1][i][j]dist[k-1][i][j]dist[k−1][i][j]、dist[k−1][i][k]dist[k-1][i][k]dist[k−1][i][k]和dist[k−1][k][j]dist[k-1][k][j]dist[k−1][k][j]有关,也就是说,我们要计算第kkk层的值,只需要知道第k−1k-1k−1层的值就可以了,所以我们其实并不需要一个三维矩阵来存储这些值,我们只需要一个二维矩阵来存储当前的dist
值,然后每次更新时,直接原地更新即可。
于是,我们得到了Floyd算法的核心思想:
dist[i][j]=min(dist[i][j],dist[i][k]+dist[k][j]) dist[i][j] = \min(dist[i][j], dist[i][k] + dist[k][j]) dist[i][j]=min(dist[i][j],dist[i][k]+dist[k][j])
与Dijstra算法中的dist
数组一样,我们在Floyd算法中的dist
数组也只能告诉我viv_ivi到vjv_jvj的最短路径长度。我们还需要知道具体的路径,所以我们同样需要维护一个path
数组,这里的path
数组也得是二维的,path[i][j]
表示从顶点viv_ivi到顶点vjv_jvj的路径上vjv_jvj的前一个顶点是谁,初始时,如果viv_ivi和vjv_jvj之间有边相连,那么path[i][j]
就设为viv_ivi的下标iii,表示vjv_jvj此时的前驱是viv_ivi,否则设为−1-1−1,表示路径不存在。当我们更新dist[i][j]
时,我们就将path[i][j]
设为path[k][j]
,因为我们得到的新路径是(vi,⋯ ,vk,⋯ ,vj)(v_i, \cdots, v_k, \cdots, v_j)(vi,⋯,vk,⋯,vj),所以vjv_jvj的前驱肯定在路径(vk,⋯ ,vj)(v_k, \cdots, v_j)(vk,⋯,vj)上,而path[k][j]
正好记录了vkv_kvk到vjv_jvj的路径上vjv_jvj的前驱。
接下来,我们来看一下Floyd算法的邻接矩阵代码实现:
void Floyd(GraphAdjMatrix G, int dist[][MAXVEX], int path[][MAXVEX])
{int i, j, k;// 初始化dist和path数组for (i = 0; i < G.numVertexes; i++){for (j = 0; j < G.numVertexes; j++){dist[i][j] = G.arc[i][j]; // 初始化dist数组为邻接矩阵if (G.arc[i][j] < INFINITY && i != j)path[i][j] = i; // 初始化path数组elsepath[i][j] = -1; // -1表示路径不存在}}// 主循环,寻找最短路径for (k = 0; k < G.numVertexes; k++){ // 枚举中间点for (i = 0; i < G.numVertexes; i++){ // 枚举起点for (j = 0; j < G.numVertexes; j++){ // 枚举终点if (dist[i][k] + dist[k][j] < dist[i][j]){ // 如果经过k点路径更短dist[i][j] = dist[i][k] + dist[k][j]; // 更新dist值path[i][j] = path[k][j]; // 更新路径}}}}
}
按照惯例,参考上述给出的图,我们来分析一下调用Floyd(G, dist, path)
时的过程:
- 首先,代码5 ~ 15行都是初始化操作,我们初始化
dist
数组为邻接矩阵,表示从顶点viv_ivi到顶点vjv_jvj的路径长度;初始化path
数组,表示从顶点viv_ivi到顶点vjv_jvj的路径上vjv_jvj的前驱节点。如果viv_ivi和vjv_jvj之间有边相连,那么path[i][j]
就设为iii,否则设为−1-1−1。
初始化后,dist
数组和path
数组的值分别为:
dist=[03810∞02∞∞∞0410∞60] dist = \begin{bmatrix}
0 & 3 & 8 & 10 \\
\infty & 0 & 2 & \infty \\
\infty & \infty & 0 & 4 \\
10 & \infty & 6 & 0 \\
\end{bmatrix}
dist=0∞∞1030∞∞820610∞40 path=[−1000−1−11−1−1−1−123−13−1]
path = \begin{bmatrix}
-1 & 0 & 0 & 0 \\
-1 & -1 & 1 & -1 \\
-1 & -1 & -1 & 2 \\
3 & -1 & 3 & -1 \\
\end{bmatrix}
path=−1−1−130−1−1−101−130−12−1
-
然后,代码18 ~ 31行是Floyd算法的主循环,循环
G.numVertexes
次,每次循环中,我们枚举一个中间点kkk,然后枚举所有起点iii和终点jjj,尝试通过中间点kkk来缩短从iii到jjj的路径长度。 -
我们首先从k=0k=0k=0开始,表示我们允许经过v0v_0v0作为中间点。然后,我们枚举所有起点iii和终点jjj,尝试通过中间点v0v_0v0来缩短从viv_ivi到vjv_jvj的路径长度。
当i=0i=0i=0时,我们发现无论jjj为何值,dist[0][j]
都无法通过v0v_0v0来缩短,因为v0v_0v0此时是我们的起点,无法通过自己来缩短到达其他顶点的路径长度,所以不更新。
当i=1i=1i=1时,我们发现dist[1][0]
为∞\infty∞,表示v1v_1v1无法直接到达v0v_0v0,这时无论jjj为何值,dist[1][j]
都无法通过v0v_0v0来缩短,因为通过v0v_0v0到达它们的路径长度都会加上∞\infty∞,所以不可能缩短。但是算法并不会管这些,它只会照着代码走一遍,发现不满足条件就不更新。
注:代码实现中没有处理∞+x\infty + x∞+x的情况,实际应用中需要注意避免这种情况,以防止整数溢出或错误的路径计算。
当i=2i=2i=2时,我们发现dist[2][0]
为∞\infty∞,即v2v_2v2无法直接到达v0v_0v0,情况同上。
当i=3i=3i=3时,我们发现dist[3][0]
为101010,表示v3v_3v3可以到达v0v_0v0,v3v_3v3通过v0v_0v0到达v0v_0v0的情况就不用说,然后我们发现通过v0v_0v0到达v1v_1v1的路径长度为dist[3][0]+dist[0][1]=10+3=13dist[3][0] + dist[0][1] = 10 + 3 = 13dist[3][0]+dist[0][1]=10+3=13,小于现在的dist[3][1]=∞dist[3][1]=\inftydist[3][1]=∞,所以我们就将dist[3][1]
更新为13,并将path[3][1]
设为path[0][1]
,即0,表示v1v_1v1的前驱节点是v0v_0v0;而dist[3][2]
为6,表示v3v_3v3可以直接到达v2v_2v2,我们发现通过v0v_0v0到达v2v_2v2的路径长度为dist[3][0]+dist[0][2]=10+8=18dist[3][0] + dist[0][2] = 10 + 8 = 18dist[3][0]+dist[0][2]=10+8=18,大于6,并没有缩短,我们就不更新它。
此时我们的dist
矩阵和path
矩阵变为:
dist=[03810∞02∞∞∞04101360] dist = \begin{bmatrix} 0 & 3 & 8 & 10 \\ \infty & 0 & 2 & \infty \\ \infty & \infty & 0 & 4 \\ 10 & 13 & 6 & 0 \\ \end{bmatrix} dist=0∞∞1030∞13820610∞40 path=[−1000−1−11−1−1−1−12303−1] path = \begin{bmatrix} -1 & 0 & 0 & 0 \\ -1 & -1 & 1 & -1 \\ -1 & -1 & -1 & 2 \\ 3 & 0 & 3 & -1 \\ \end{bmatrix} path=−1−1−130−1−1001−130−12−1
- 接下来,我们将kkk更新为1,表示我们允许经过v1v_1v1作为中间点。然后,我们枚举所有起点iii和终点jjj,尝试通过中间点v1v_1v1来缩短从viv_ivi到vjv_jvj的路径长度。
当i=0i=0i=0时,j=0j=0j=0不用说;j=1j=1j=1也不用说;当j=2j=2j=2时,我们发现dist[0][2]
为8,但是我们发现通过v1v_1v1到达v2v_2v2的路径长度为dist[0][1]+dist[1][2]=3+2=5dist[0][1] + dist[1][2] = 3 + 2 = 5dist[0][1]+dist[1][2]=3+2=5,小于8,所以我们就将dist[0][2]
更新为5,并将path[0][2]
设为path[1][2]
,即1,表示v2v_2v2的前驱节点是v1v_1v1;当j=3j=3j=3时,我们发现dist[0][3]
为10,但是dist[0][1]+dist[1][3]=3+∞=∞dist[0][1] + dist[1][3] = 3 + \infty = \inftydist[0][1]+dist[1][3]=3+∞=∞,不更新。
后续步骤与上述过程一致,接下来给出每一步的dist
矩阵和path
矩阵,使用dist(k)dist^{(k)}dist(k)表示允许经过前kkk个顶点作为中间点时的dist
矩阵,path^{(k)}
表示对应的path
矩阵:
dist(2)=[03510∞02∞∞∞04101360] dist^{(2)} = \begin{bmatrix} 0 & 3 & 5 & 10 \\ \infty & 0 & 2 & \infty \\ \infty & \infty & 0 & 4 \\ 10 & 13 & 6 & 0 \\ \end{bmatrix} dist(2)=0∞∞1030∞13520610∞40 path(2)=[−1010−1−11−1−1−1−12303−1] path^{(2)} = \begin{bmatrix} -1 & 0 & 1 & 0 \\ -1 & -1 & 1 & -1 \\ -1 & -1 & -1 & 2 \\ 3 & 0 & 3 & -1 \\ \end{bmatrix} path(2)=−1−1−130−1−1011−130−12−1
dist(3)=[0359∞026∞∞04101360] dist^{(3)} = \begin{bmatrix} 0 & 3 & 5 & 9 \\ \infty & 0 & 2 & 6 \\ \infty & \infty & 0 & 4 \\ 10 & 13 & 6 & 0 \\ \end{bmatrix} dist(3)=0∞∞1030∞1352069640 path(3)=[−1012−1−112−1−1−12303−1] path^{(3)} = \begin{bmatrix} -1 & 0 & 1 & 2 \\ -1 & -1 & 1 & 2 \\ -1 & -1 & -1 & 2 \\ 3 & 0 & 3 & -1 \\ \end{bmatrix} path(3)=−1−1−130−1−1011−13222−1
dist(4)=[035916026141704101360] dist^{(4)} = \begin{bmatrix} 0 & 3 & 5 & 9 \\ 16 & 0 & 2 & 6 \\ 14 & 17 & 0 & 4 \\ 10 & 13 & 6 & 0 \\ \end{bmatrix} dist(4)=016141030171352069640 path(4)=[−10123−11230−12303−1] path^{(4)} = \begin{bmatrix} -1 & 0 & 1 & 2 \\ 3 & -1 & 1 & 2 \\ 3 & 0 & -1 & 2 \\ 3 & 0 & 3 & -1 \\ \end{bmatrix} path(4)=−13330−10011−13222−1
同样地,我们还要想办法通过path
数组来反向推导出从viv_ivi到vjv_jvj的具体路径,代码与Dijkstra算法中的类似:
void PrintPath(int path[][MAXVEX], int i, int j)
{if (path[i][j] == -1){printf("No path from vertex %d to vertex %d\n", i, j);return;}if (path[i][j] == i){printf("%d ", i); // 到达源点,打印源点return;}PrintPath(path, i, path[i][j]); // 递归打印前驱节点printf("%d ", j); // 打印当前节点
}
假设我们要打印从v0v_0v0到v3v_3v3的路径,我们调用PrintPath(path, 0, 3)
,它会递归调用PrintPath(path, 0, 2)
,然后再递归调用PrintPath(path, 0, 1)
,然后再递归调用PrintPath(path, 0, 0)
,此时path[0][0]
为-1,说明v0v_0v0是源点,打印0,然后返回上一层,打印1,再返回上一层,打印2,再返回上一层,打印3,最终我们得到从v0v_0v0到v3v_3v3的路径为0 1 2 3
。
Floyd算法真是十分简洁,关键代码只有那个三重循环,但是也正因为这个三重循环,该算法的时间复杂度为O(V3)O(V^3)O(V3)。代码虽简洁,但要理解还是较为困难的,尤其是理解为什么要这样更新dist
数组和path
数组。希望通过上面的分析,能帮助大家更好地理解Floyd算法。