当前位置: 首页 > news >正文

《算法与数据结构》第七章[算法4]:最短路径

  前两天,正好是我们的国庆节长假,本人也是趁着这个机会,出去游玩了一圈。在决定去哪里的时候,发现可以选择的地方实在太多了,最后,本人发现一个有趣的玩法:在我国的地图上,随机选择一个城市去玩,可是,若是随机到一个偏远的城市,可能会面临交通不便的问题,于是,我决定在我国的铁路网上,选择一个城市去玩。于是乎,我找到了下面这张图(图片来源“高铁网”[https://www.gaotie.cn/CRHMAP/]):

图1:我国铁路网“八纵八横”示意图

图1:我国铁路网“八纵八横”示意图

  可以看到,我国的铁路网是一个非常复杂的网络,图中每一个节点代表一个城市,每一条边代表两座城市之间有铁路相连。笔者人是在兰州的,选择了郑州作为目的地,于是开始在铁路12306网站开始查询购票,能买到直达车票自然是最好的,然而,现实却是残酷的,由于国庆假期出游的人实在太多,车票早早被一抢而空了,此时,我点开了“中转”选项,发现可以通过中转去郑州,可是中转的城市有许多,比如西安、广元、甚至重庆,行程耗时也不一样,票价也不一样,实际上,从上图中不难看出,从西安中转去郑州是最合适的,因为它们三座城市几乎在一条线上,路径也是最短的。此时,便出现一个问题,那些订票、导航软件是如何找出我们最需要的那条耗时最少的路径的呢?而这个问题,便是我们今天要讨论的“最短路径”问题。

1、最短路径相关定义

  我们知道,在上图中,每一个城市间都是有代价的,这个代价可以是距离、时间、费用等,这样我们在选择最短路径时的标准则是这些代价之和最低,这并没有什么问题;若是每条路径都没有代价,或者说代价都相同(一个单位代价),那么我们选择的标准就成了路径上边的数量最少。

  所以说,对于网图来说,最短路径是指两顶点之间经过的边上权值之和最少的路径,并且,我们称这条路径上的第一个顶点为源点(Source),最后一个顶点为终点(Destination)。

  同样,我们还是先给出部分约定:

  • 只考虑连通图:当一个图不是连通图时,图中有些顶点是无法到达的,它们之间连路径都没有,更谈不上最短路径了。
  • 边的权值不同:若存在几条边的权值相同,此时找出的最短路径可能不唯一。
  • 边的权值非负:若存在边的权值为负数,此时可能会出现环路,使得路径权值无限减小,无法找到最短路径。
  • 最短路径都是简单路径:即我们不考虑存在自环的情况。

  首先我们先来认识一个算法——Dijkstra算法,它是由荷兰计算机科学家Edsger W. Dijkstra在1956年提出的,请记住这个人,他多半还会再次出现在你的计算机学习过程中。

2、迪杰斯特拉(Dijkstra)算法

  首先我们来想一个问题,若我们类似上一节中所讲的Prim算法一样,每次都选择当前路径权值最小的边加入到路径中,最终是不是就会得到一个最短路径呢?答案是否定的,我们先来看下面这个例子:

图2:一个图

图2:一个图

  假设我们要从顶点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中,表示当前已经找到从源点出发的最短路径的顶点集合;同时,初始化一个数组distdist[i]表示从源点vsv_svs到顶点viv_ivi的当前已知最短路径长度,初始时,dist[s]设为0,目前无法到达的顶点设为无穷大。
  • 在集合V−SV-SVS中选择一个顶点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]

3:Dijkstra策略初始化

图3:Dijkstra策略初始化

  然后,我们在V−SV-SVS中选择dist值最小的顶点v5v_5v5,将其加入到集合SSS中,并更新dist数组:

我们看到从v5v_5v5出发,我们可以到达v0v_0v0v4v_4v4v6v_6v6,我们发现v0v_0v0已经在集合SSS中,不需要更新;而v4v_4v4v6v_6v6dist值分别为无穷大和9,而通过v5v_5v5到达它们的路径长度分别为3+17=203+17=203+17=203+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]

图4:Dijkstra策略第一次更新

图4:Dijkstra策略第一次更新

  接下来,我们在V−SV-SVS中选择dist值最小的顶点v6v_6v6,将其加入到集合SSS中,并更新dist数组:

v6v_6v6出发,我们可以到达图中其余所有顶点(v0v_0v0~v5v_5v5),权值分别为9、4、1、36、25、16,而v0v_0v0v5v_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]

图5:Dijkstra策略第二次更新

图5:Dijkstra策略第二次更新

  后续的过程就不再赘述了,与上面操作一致,就是不断更新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 vsvavbvmvk

  而且这条路径上,至少有一个顶点不在集合SSS(即还没被处理过)。

为什么一定有这样的顶点?
因为如果这条路径上的所有顶点都已经在SSS中去了,那说明我们早就通过它们更新过 (v_k) 的 dist 值了,那 dist[k] 就应该是这条更短路径的长度,而不是现在的值 —— 这就和我们最初的假设矛盾了。
所以,这条更短的路径上,至少有一个顶点还没被处理(不在SSS中)。

  我们设这条路径上,第一个不在SSS中的顶点是viv_ivi。也就是说,从起点vsv_svsviv_ivi之前的所有顶点(比如va∼vi−1v_a \sim v_{i-1}vavi1)都已经在SSS中。

  根据Dijkstra算法的做法,当我们把这些顶点加入SSS时,已经用它们来更新过它们邻接点的dist值了。所以,dist[i] 的值一定已经通过vi−1v_{i-1}vi1更新过了,也就是说,我们已经找到了从起点到viv_ivi的一条最短路径(至少是目前已知的最短路径)。

  而且,因为viv_ivi是在vkv_kvk之前被访问的(它是路径上第一个不在SSS中的顶点),所以dist[i] 的值一定小于或等于 dist[k]

换句话说,从起点到viv_ivi的路径长度,已经比到vkv_kvk的路径更短或相等。

  那既然我们能通过viv_ivi走到vkv_kvk,而且viv_ividist 值更小,那我们应该先处理viv_ivi,而不是vkv_kvk。这就说明:我们不可能先选到vkv_kvk,再去发现一条更短的路径通过viv_ivi —— 因为viv_ividist 值更小,它应该更早被选中。所以假设不成立!

  我们一开始假设 “dist[k] 不是最短路径长度”,但推着推着就发现:如果真有更短的路径,那它上面的第一个未处理顶点viv_ivi应该比vkv_kvk更早被选中。这就和“我们选了vkv_kvk”矛盾了。

  总归,看文字不如看图来的直观,我们给出这样一张图

图6:反证法示例图

图6:反证法示例图

  我们可以看到,从图中v0v_0v0v3v_3v3有三条路径可以走,可以直观看出路径v0→v3v_0 \rightarrow v_3v0v3是最短路径,选择过程中也是v3v_3v3先被选择进SSS,我们能想办法让v0→v2→v3v_0 \rightarrow v_2 \rightarrow v_3v0v2v3这条路径更短吗?我们可以让(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_3v0v2v3这条路径更短,就要使得

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_ivivjv_jvj0≤i<j≤k0 \leq i < j \leq k0i<jk),路径(vi,vi+1,⋯ ,vj)(v_i,v_{i+1},\cdots,v_j)(vi,vi+1,,vj)也是从viv_ivivjv_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中,然后更新distpath数组。

  第一次循环时,我们先初始化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行,我们遍历所有顶点,更新distpath数组。我们发现,顶点v5v_5v5可以到达v0v_0v0v4v_4v4v6v_6v6,其中v0v_0v0已经在集合SSS中,不需要更新;而v4v_4v4v6v_6v6dist值分别为无穷大和9,而通过v5v_5v5到达它们的路径(即找到的路径为v0→v5→v4v_0 \rightarrow v_5 \rightarrow v_4v0v5v4v0→v5→v6v_0 \rightarrow v_5 \rightarrow v_6v0v5v6)长度分别为3+17=203+17=203+17=203+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行,我们遍历所有顶点,更新distpath数组。顶点v6v_6v6可以到达图中其余所有顶点(v0v_0v0~v5v_5v5),权值分别为9、4、1、36、25、16,而v0v_0v0v5v_5v5已经在集合SSS中,不需要更新;通过v6v_6v6到达v1v_1v1的路径(即找到的路径为v0→v6→v1v_0 \rightarrow v_6 \rightarrow v_1v0v6v1)长度为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_2v0v6v2)长度为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_3v0v6v3)长度为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_4v0v6v4)长度为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_0v0v5v_5v5v6v_6v6的最短路径,分别是v0→v5v_0 \rightarrow v_5v0v5v0→v6v_0 \rightarrow v_6v0v6。在第三次外层循环时,我们会选择dist值最小的顶点v2v_2v2,并将其加入集合SSS中,然后更新distpath数组,最终我们会找到从v0v_0v0v2v_2v2的最短路径为v0→v6→v2v_0 \rightarrow v_6 \rightarrow v_2v0v6v2。其余步骤不再赘述,与上述过程一致。我们可以绘制出一张求解路径过程的表格:

步骤选中dist数组path数组final数组说明
--[0, 15, ∞, ∞, ∞, 3, 9][0, 0, -1, -1, -1, 0, 0][1, 0, 0, 0, 0, 0, 0]初始化
i=1v5v_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=2v6v_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_1v1v2v_2v2v3v_3v3
i=3v2v_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=4v1v_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=5v4v_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=6v3v_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_0v0v2v_2v2的路径,我们发现path[2]为6,说明v2v_2v2的前驱节点是v6v_6v6,然后我们再看path[6],它为0,说明v6v_6v6的前驱节点是v0v_0v0,而path[0]为0,说明v0v_0v0是源点,所以我们最终得到从v0v_0v0v2v_2v2的路径为v0→v6→v2v_0 \rightarrow v_6 \rightarrow v_2v0v6v2。我们便可以通过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_0v0v2v_2v2的路径,我们调用PrintPath(path, 2),它会递归调用PrintPath(path, 6),然后再递归调用PrintPath(path, 0),此时path[0]为0,说明v0v_0v0是源点,打印0,然后返回上一层,打印6,再返回上一层,打印2,最终我们得到从v0v_0v0v2v_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_ivivjv_jvj的最短路径的问题,拆分成求viv_ivivkv_kvk的最短路径和vkv_kvkvjv_jvj的最短路径的问题。

  这个性质告诉我们:大问题的最优解可以由小问题的最优解组合而成,自然,我们就会思考,我们可不可以“从小到大”一步一步拼出最短路径。

  一开始,我们只知道图中直接相连的边的长度,也就是我们的邻接矩阵,我们将其改名为distdistdist,其中dist[i][j]dist[i][j]dist[i][j]表示从顶点viv_ivi到顶点vjv_jvj的路径长度,最后用它来存储从顶点viv_ivi到顶点vjv_jvj的最短路径长度。

图7:一个图及邻接矩阵

图7:一个图及邻接矩阵

  我们能不能通过引入一些“中间点”,来一步步缩短这个矩阵中的距离呢?

  即我们的思路如下:

  • 一开始,不允许经过任何中间点,我们能走的就只有直接相连的边。
  • 然后,我们允许经过v0v_0v0,看看能不能缩短部分路径。
  • 接着,我们允许经过v0v_0v0v1v_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_3v3v1v_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=010301382061040

  然后,我们再引入中间点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=010301352061040

  接下来,我们将其推广,让每一个顶点都尝试作为中间点,并使用一个三维矩阵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[k1][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[k1][i][j]
  • 尝试经过vkv_kvk,那么此时路径就是(vi,⋯ ,vk,⋯ ,vj)(v_i, \cdots, v_k, \cdots, v_j)(vi,,vk,,vj),而根据我们知道的“最短路径的子路径也是最短路径”定理,这两段也肯定是在前k−1k-1k1个顶点中选出的最短路径,所以从viv_ivivkv_kvk的最短路径长度是dist[k−1][i][k]dist[k-1][i][k]dist[k1][i][k],从vkv_kvkvjv_jvj的最短路径长度是dist[k−1][k][j]dist[k-1][k][j]dist[k1][k][j],那么当前最短路径长度就是dist[k−1][i][k]+dist[k−1][k][j]dist[k-1][i][k] + dist[k-1][k][j]dist[k1][i][k]+dist[k1][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[k1][i][j],dist[k1][i][k]+dist[k1][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[k1][i][j]dist[k−1][i][k]dist[k-1][i][k]dist[k1][i][k]dist[k−1][k][j]dist[k-1][k][j]dist[k1][k][j]有关,也就是说,我们要计算第kkk层的值,只需要知道第k−1k-1k1层的值就可以了,所以我们其实并不需要一个三维矩阵来存储这些值,我们只需要一个二维矩阵来存储当前的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_ivivjv_jvj的最短路径长度。我们还需要知道具体的路径,所以我们同样需要维护一个path数组,这里的path数组也得是二维的,path[i][j]表示从顶点viv_ivi到顶点vjv_jvj的路径上vjv_jvj的前一个顶点是谁,初始时,如果viv_ivivjv_jvj之间有边相连,那么path[i][j]就设为viv_ivi的下标iii,表示vjv_jvj此时的前驱是viv_ivi,否则设为−1-11,表示路径不存在。当我们更新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_kvkvjv_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_ivivjv_jvj之间有边相连,那么path[i][j]就设为iii,否则设为−1-11

  初始化后,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=0103082061040 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=1113011101130121

  • 然后,代码18 ~ 31行是Floyd算法的主循环,循环G.numVertexes次,每次循环中,我们枚举一个中间点kkk,然后枚举所有起点iii和终点jjj,尝试通过中间点kkk来缩短从iiijjj的路径长度。

  • 我们首先从k=0k=0k=0开始,表示我们允许经过v0v_0v0作为中间点。然后,我们枚举所有起点iii和终点jjj,尝试通过中间点v0v_0v0来缩短从viv_ivivjv_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_0v0v3v_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=010301382061040 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=1113011001130121

  • 接下来,我们将kkk更新为1,表示我们允许经过v1v_1v1作为中间点。然后,我们枚举所有起点iii和终点jjj,尝试通过中间点v1v_1v1来缩短从viv_ivivjv_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)=010301352061040 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)=1113011011130121


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)=010301352069640 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)=1113011011132221


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)=1333010011132221


  同样地,我们还要想办法通过path数组来反向推导出从viv_ivivjv_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_0v0v3v_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_0v0v3v_3v3的路径为0 1 2 3

  Floyd算法真是十分简洁,关键代码只有那个三重循环,但是也正因为这个三重循环,该算法的时间复杂度为O(V3)O(V^3)O(V3)。代码虽简洁,但要理解还是较为困难的,尤其是理解为什么要这样更新dist数组和path数组。希望通过上面的分析,能帮助大家更好地理解Floyd算法。

http://www.dtcms.com/a/491535.html

相关文章:

  • 做网站字号多大网络营销推广方案pdf
  • 网站开发需求分析内容淄博网站制作设计高端
  • DOM CDATA
  • 动态规划----过河卒
  • 2025-10-15 ZROJ25普及联考day12 赛后总结
  • 工控人如何做自己的网站网页版微信文件传输助手
  • 南京网站建设网站高端网站建设 案例
  • Qt程序高分辨界面显示不正常解决办法
  • 如何下载Windows 11安装文件
  • Java 大视界 -- 基于 Java 的大数据隐私计算在医疗影像数据共享中的实践探索
  • 版本管理:Git Large File,二进制文件追踪?
  • 网站开发课程报告心得中国十大装修公司品牌排行榜
  • 广告设计培训机构哪家好南京谷歌seo
  • 企业活动网站创意案例铜陵市建设局网站
  • 计算机操作系统——文件系统的全局结构
  • 万万州州微微网站网站建建设设网页设计实训报告主要内容
  • pwn.college之Cryptography模块
  • wordpress 点评类网站找人做网站注意什么
  • 桥接模式详解
  • 【入门级-算法-3、基础算法:二分法】
  • 配置串口与应用
  • python中的浮点数运算
  • 如何解决Redis缓存“数据一致性“问题?
  • 一般的域名可以做彩票网站吗高校网站网页设计
  • 第一家中文商务网站服装设计网页制作素材
  • IDEA从jdk8换成jdk17后又还原的那些事
  • 网站建设解决方案有哪些wordpress如何定义锚
  • 机器人逆动力学及其应用
  • 微服务之SpringCloud Alibaba(注册中心Nacos)
  • NewStarCTF2025-Week2-Misc