《大话数据结构》12 图的相关算法

news2024/12/27 10:17:41

我有天早晨准备出门,发现钥匙不见了。昨晚还看到它,所以确定钥匙在家里。一定是我那三岁不到的儿子拿着玩,不知道丢到哪个犄角旮旯去了,问他也说不清楚。我现在必须得找到它,你们说,我应该如何找?介绍我们家的结构,如下图所示,是最典型的两室两厅一厨一卫一阳台。

有人说,往小孩子经常玩的地方找找看。OK,我照做了,可惜没找到。然后怎么办?有人说一间一间找,可怎么个找法?是把一间房间翻个底朝天再找下一间好呢,还是先每个房间的最常去的位置找一找,然后再一步一步细化到每个房间的角落?这是一个大家都可能会面临的问题,不找的东西时常见,需要的东西寻不着。找东西的策略也因人而异。有些人因为找东西没有规划,当一样东西找不到时,往往会反复地找,甚至某些抽屉找个四五遍,另一些地方却一次也没找过。找东西是没有什么标准方法的,不过今天我们学过了图的遍历以后,你至少应该在找东西时,更加科学地规划寻找方案,而不至于手忙脚乱。

图的遍历是和树的遍历类似,我们希望从图中某一顶点出发访遍图中其余顶点,且使每一个顶点仅被访问一次,这一过程就叫做图的遍历(Traversing Graph)。

树的遍历我们谈到了四种方案,应该说都还好,毕竟根结点只有一个,遍历都是从它发起,其余所有结点都只有一个双亲。可图就复杂多了,因为它的任一顶点都可能和其余的所有顶点相邻接,极有可能存在沿着某条路径搜索后,又回到原顶点,而有些顶点却还没有遍历到的情况。因此我们需要在遍历过程中把访问过的顶点打上标记,以避免访问多次而不自知。具体办法是设置一个访问数组visited[n],n是图中顶点的个数,初值为0,访问过后设置为1。这其实在小说中常常见到,一行人在迷宫中迷了路,为了避免找寻出路时屡次重复,所以会在路口用小刀刻上标记。

对于图的遍历来说,如何避免因回路陷入死循环,就需要科学地设计遍历方案,通常有两种遍历次序方案:它们是深度优先遍历和广度优先遍历。

1. 图的遍历

1.1 深度优先遍历 

深度优先遍历(Depth_First_Search),也有称为深度优先搜索,简称为DFS。它的具体思想就如同我刚才提到的找钥匙方案,无论从哪一间房间开始都可以,比如主卧室,然后从房间的一个角开始,将房间内的墙角、床头柜、床上、床下、衣柜里、衣柜上、前面的电视柜等挨个寻找,做到不放过任何一个死角,所有的抽屉、储藏柜中全部都找遍,形象比喻就是翻个底朝天,然后再寻找下一间,直到找到为止。

为了更好的理解深度优先遍历,我们来做一个游戏。假设你需要完成一个任务,要求你在如下图左图这样的一个迷宫中,从顶点A开始要走遍所有的图顶点并作上标记,注意不是简单地看着这样的平面图走哦,而是如同现实般地在只有高墙和通道的迷宫中去完成任务。

很显然我们是需要策略的,否则在这四通八达的通道中乱窜,要想完成任务那就只能是碰运气。如果你学过深度优先遍历,这个任务就不难完成了。

首先我们从顶点A开始,做上表示走过的记号后,面前有两条路,通向B和F,我们给自己定一个原则,在没有碰到重复顶点的情况下,始终是向右手边走,于是走到了B顶点。整个行路过程,可参看图7-5-2的右图。此时发现有三条分支,分别通向顶点C、I、G,右手通行原则,使得我们走到了C顶点。就这样,我们一直顺着右手通道走,一直走到F顶点。当我们依然选择右手通道走过去后,发现走回到顶点A了,因为在这里做了记号表示已经走过。此时我们退回到顶点F,走向从右数的第二条通道,到了G顶点,它有三条通道,发现B和D都已经是走过的,于是走到H,当我们面对通向H的两条通道D和E时,会发现都已经走过了。此时我们是否已经遍历了所有顶点呢?没有。可能还有很多分支的顶点我们没有走到,所以我们按原路返回。在顶点H处,再无通道没走过,返回到G,也无未走过通道,返回到F,没有通道,返回到E,有一条通道通往H的通道,验证后也是走过的,再返回到顶点D,此时还有三条道未走过,一条条来,H走过了,G走过了,I,哦,这是一个新顶点,没有标记,赶快记下来。继续返回,直到返回顶点A,确认你已经完成遍历任务,找到了所有的9个顶点。

反应快的同学一定会感觉到,深度优先遍历其实就是一个递归的过程,如果再敏感一些,会发现其实转换成如上图的右图后,就像是一棵树的前序遍历,没错,它就是。它从图中某个顶点v出发,访问此顶点,然后从v的未被访问的邻接点出发深度优先遍历图,直至图中所有和v有路径相通的顶点都被访问到。事实上,我们这里讲到的是连通图,对于非连通图,只需要对它的连通分量分别进行深度优先遍历,即在先前一个顶点进行一次深度优先遍历后,若图中尚有顶点未被访问,则另选图中一个未曾被访问的顶点作起始点,重复上述过程,直至图中所有顶点都被访问到为止。 

如果我们用的是邻接矩阵的方式,则代码如下:

 代码的执行过程,其实就是我们刚才迷宫找寻所有顶点的过程。

如果图结构是邻接表结构,其DFSTraverse函数的代码是几乎相同的,只是在递归函数中因为将数组换成了链表而有不同,代码如下。

对比两个不同存储结构的深度优先遍历算法,对于n个顶点e条边的图来说,邻接矩阵由于是二维数组,要查找每个顶点的邻接点需要访问矩阵中的所有元素,因此都需要O(n2)的时间。而邻接表做存储结构时,找邻接点所需的时间取决于顶点和边的数量,所以是O(n+e)。显然对于点多边少的稀疏图来说,邻接表结构使得算法在时间效率上大大提高。对于有向图而言,由于它只是对通道存在可行或不可行,算法上没有变化,是完全可以通用的。这里就不再详述了。

1.2 广度优先遍历

广度优先遍历(Breadth_First_Search),又称为广度优先搜索,简称BFS。还是以找钥匙的例子为例。小孩子不太可能把钥匙丢到大衣柜顶上或厨房的油烟机里去,深度优先遍历意味着要彻底查找完一个房间才查找下一个房间,这未必是最佳方案。所以不妨先把家里的所有房间简单看一遍,看看钥匙是不是就放在很显眼的位置,如果全走一遍没有,再把小孩在每个房间玩得最多的地方或各个家俱的下面找一找,如果还是没有,那看一下每个房间的抽屉,这样一步步扩大查找的范围,直到找到为止。事实上,我在全屋查找的第二遍时就在抽水马桶后面的地板上找到了。

如果说图的深度优先遍历类似树的前序遍历,那么图的广度优先遍历就类似于树的层序遍历了。我们将下图的第一幅图稍微变形,变形原则是顶点A放置在最上第一层,让与它有边的顶点B、F为第二层,再让与B和F有边的顶点C、I、G、E为第三层,再将这四个顶点有边的D、H放在第四层,如下图的第二幅图所示。此时在视觉上感觉图的形状发生了变化,其实顶点和边的关系还是完全相同的。

有了这个讲解,我们来看代码就非常容易了。以下是邻接矩阵结构的广度优先遍历算法。

对于邻接表的广度优先遍历,代码与邻接矩阵差异不大,代码如下。

对比图的深度优先遍历与广度优先遍历算法,你会发现,它们在时间复杂度上是一样的,不同之处仅仅在于对顶点访问的顺序不同。可见两者在全图遍历上是没有优劣之分的,只是视不同的情况选择不同的算法。

不过如果图顶点和边非常多,不能在短时间内遍历完成,遍历的目的是为了寻找合适的顶点,那么选择哪种遍历就要仔细斟酌了。深度优先更适合目标比较明确,以找到目标为主要目的的情况,而广度优先更适合在不断扩大遍历范围时找到相对最优解的情况。 

2. 最小生成树

假设你是电信的实施工程师,需要为一个镇的九个村庄架设通信网络做设计,村庄位置大致如下图,其中v0~v8是村庄,之间连线的数字表示村与村间的可通达的直线距离,比如v0至v1就是10公里(个别如v0与v6,v6与v8,v5与v7未测算距离是因为有高山或湖泊,不予考虑)。你们领导要求你必须用最小的成本完成这次任务。你说怎么办?

显然这是一个带权值的图,即网结构。所谓的最小成本,就是n个顶点,用n-1条边把一个连通图连接起来,并且使得权值的和最小。在这个例子里,每多一公里就多一份成本,所以只要让线路连线的公里数最少,就是最少成本了。

如果你加班加点,没日没夜设计出的结果是如下图的方案一(粗线为要架设线路),我想你离被炒鱿鱼应该是不远了(同学微笑)。因为这个方案比后两个方案多出60%的成本会让老板气晕过去的。

方案三设计得非常巧妙,但也只以极其微弱的优势对方案二胜出,应该说很是侥幸。我们有没有办法可以精确计算出这种网图的最佳方案呢?答案当然是Yes。

我们在讲图的定义和术语时,曾经提到过,一个连通图的生成树是一个极小的连通子图,它含有图中全部的顶点,但只有足以构成一棵树的n-1条边。显然上图的三个方案都是由网图生成的生成树。那么我们把构造连通网的最小代价生成树称为最小生成树(Minimum Cost Spanning Tree)。 

找连通网的最小生成树,经典的有两种算法,普里姆算法和克鲁斯卡尔算法。我们就分别来介绍一下。

2.1 普里姆(Prim)算法

我们先有一个例子展开讲解。

现在我们已经有了一个存储结构为MGragh的G。G有9个顶点,它的arc二维数组如上图的右图所示。数组中的我们用65535来代表∞。

于是普里姆(Prim)算法代码如下,左侧数字为行号。其中INFINITY为权值极大值,不妨是65535,MAXVEX为顶点个数最大值,此处大于等于9即可。现在假设我们自己就是计算机,在调用MiniSpanTree_Prim函数,输入上述的邻接矩阵后,看看它是如何运行并打印出最小生成树的。

1.程序开始运行,我们由第4~5行,创建了两个一维数组lowcost和adjvex,长度都为顶点个数9。它们的作用我们慢慢细说。

2.第6~7行我们分别给这两个数组的第一个下标位赋值为0,arjvex[0]=0其实意思就是我们现在从顶点v0开始(事实上,最小生成树从哪个顶点开始计算都无所谓,我们假定从v0开始),lowcost[0]=0就表示v0已经被纳入到最小生成树中,之后凡是lowcost数组中的值被设置为0就是表示此下标的顶点被纳入最小生成树。 

3.第8~12行表示我们读取图7-6-3的右图邻接矩阵的第一行数据。将数值赋值给lowcost数组,所以此时lowcost数组值为{0,10,65535,65535,65535,11,65535,65535, 65535},而arjvex则全部为0。此时,我们已经完成了整个初始化的工作,准备开始生成。

4.第13~36行,整个循环过程就是构造最小生成树的过程。

5.第15~16行,将min设置为了一个极大值65535,它的目的是为了之后找到一定范围内的最小权值。j是用来做顶点下标循环的变量,k是用来存储最小权值的顶点下标。

6.第17~25行,循环中不断修改min为当前lowcost数组中最小值,并用k保留此最小值的顶点下标。经过循环后,min=10,k=1。注意19行if判断的lowcost[j]!=0表示已经是生成树的顶点不参与最小权值的查找。

7.第26行,因k=1,adjvex[1]=0,所以打印结果为(0,1),表示v0至v1边为最小生成树的第一条边。如下图所示。

8.第27行,此时因k=1我们将lowcost[k]=0就是说顶点v1纳入到最小生成树中。此时lowcost数组值为{0,0,65535,65535,65535,11,65535,65535,65535}。

9.第28~35行,j循环由1至8,因k=1,查找邻接矩阵的第v1行的各个权值,与lowcost的对应值比较,若更小则修改lowcost值,并将k值存入adjvex数组中。因第v1行有18、16、12均比65535小,所以最终lowcost数组的值为:{0,0,18,65535,65535,11,16,65535,12}。adjvex数组的值为:{0,0,1,0,0,0,1,0,1}。这里第30行if判断的lowcost[j]!=0也说明v0和v1已经是生成树的顶点不参与最小权值的比对了。 

10.再次循环,由第15行到第26行,此时min=11,k=5,adjvex[5]=0。因此打印结构为(0,5)。表示v0至v5边为最小生成树的第二条边,如下图所示。

 

 

12.之后,相信大家也都会自己去模拟了。通过不断的转换,构造的过程如下图中图1~图6所示。 

有了这样的讲解,再来介绍普里姆(Prim)算法的实现定义可能就容易理解一些。

假设N=(P,{E})是连通网,TE是N上最小生成树中边的集合。算法从U={u0}(u0∈V),TE={}开始。重复执行下述操作:在所有u∈U,v∈V-U的边(u,v)∈E中找一条代价最小的边(u0,v0)并入集合TE,同时v0并入U,直至U=V为止。此时TE中必有n-1条边,则T=(V,{TE})为N的最小生成树。

由算法代码中的循环嵌套可得知此算法的时间复杂度为O(n2)。

2.2 克鲁斯卡尔(Kruskal)算法

现在我们来换一种思考方式,普里姆(Prim)算法是以某顶点为起点,逐步找各顶点上最小权值的边来构建最小生成树的。这就像是我们如果去参观某个展会,例如世博会,你从一个入口进去,然后找你所在位置周边的场馆中你最感兴趣的场馆观光,看完后再用同样的办法看下一个。可我们为什么不事先计划好,进园后直接到你最想去的场馆观看呢?事实上,去世博园的观众,绝大多数都是这样做的。

同样的思路,我们也可以直接就以边为目标去构建,因为权值是在边上,直接去找最小权值的边来构建生成树也是很自然的想法,只不过构建时要考虑是否会形成环路而已。此时我们就用到了图的存储结构中的边集数组结构。以下是edge边集数组结构的定义代码:

我们将之前的邻接矩阵通过程序转化为下图的右图的边集数组,并且对它们按权值从小到大排序。

于是克鲁斯卡尔(Kruskal)算法代码如下,左侧数字为行号。其中MAXEDGE为边数量的极大值,此处大于等于15即可,MAXVEX为顶点个数最大值,此处大于等于9即可。现在假设我们自己就是计算机,在调用MiniSpanTree_Kruskal函数,输入图7-6-3右图的邻接矩阵后,看看它是如何运行并打印出最小生成树的。

 

1.程序开始运行,第5行之后,我们省略掉颇占篇幅但却很容易实现的将邻接矩阵转换为边集数组,并按权值从小到大排序的代码。,也就是说,在第5行开始,我们已经有了边集数组。

2.第5~7行,我们声明一个数组parent,并将它的值都初始化为0,它的作用我们后面慢慢说。

3.第8~17行,我们开始对边集数组做循环遍历,开始时,i=0。

4.第10行,我们调用了第19~25行的函数Find,传入的参数是数组parent和当前权值最小边(v4,v7)的begin:

4。因为parent中全都是0所以传出值使得n=4。

5.第11行,同样作法,传入(v4,v7)的end:7。传出值使得m=7。

6.第12~16行,很显然n与m不相等,因此parent[4]=7。此时parent数组值为{0,0,0,0,7,0,0,0,0},并且打印得到“(4,7)7”。此时我们已经将边(v4,v7)纳入到最小生成树中,如下图所示。

7.循环返回,执行10~16行,此时i=1,edge[1]得到边(v2,v8),n=2,m=8,parent[2]=8,打印结果为“(2,8)8”,此时parent数组值为{0,0,8,0,7,0,0,0,0},这也就表示边(v4,v7)和边(v2,v8)已经纳入到最小生成树,如下图所示。 

8.再次执行10~16行,此时i=2,edge[2]得到边(v0,v1),n=0,m=1,parent[0]=1,打印结果为“(0,1)10”,此时parent数组值为{1,0,8,0,7,0,0,0,0},此时边(v4,v7)、(v2,v8)和(v0,v1)已经纳入到最小生成树,如下图所示。 

9.当i=3、4、5、6时,分别将边(v0,v5)、(v1,v8)、(v3,v7)、(v1,v6)纳入到最小生成树中,如下图所示。此时parent数组值为{1,5,8,7,7,8,0,0,6},怎么去解读这个数组现在这些数字的意义呢? 

从上图的最右图i=6的粗线连线可以得到,我们其实是有两个连通的边集合A与B中纳入到最小生成树中的,如图7-6-12所示。当parent[0]=1,表示v0和v1已经在生成树的边集合A中。此时将parent[0]=1的1改为下标,由parent[1]=5,表示v1和v5在边集合A中,parent[5]=8表示v5与v8在边集合A中,parent[8]=6表示v8与v6在边集合A中,parent[6]=0表示集合A暂时到头,此时边集合A有v0、v1、v5、v8、v6。我们查看parent中没有查看的值,parent[2]=8表示v2与v8在一个集合中,因此v2也在边集合A中。再由parent[3]=7、parent[4]=7和parent[7]=0可知v3、v4、v7在另一个边集合B中。 

10.当i=7时,第10行,调用Find函数,会传入参数edges[7].begin=5。此时第21行,parent[5]=8>0,所以f=8,再循环得parent[8]=6。因parent[6]=0所以Find返回后第10行得到n=6。而此时第11行,传入参数edges[7].end=6得到m=6。此时n=m,不再打印,继续下一循环。这就告诉我们,因为边(v5,v6)使得边集合A形成了环路。因此不能将它纳入到最小生成树中,如上图所示。 

11.当i=8时,与上面相同,由于边(v1,v2)使得边集合A形成了环路。因此不能将它纳入到最小生成树中,如上图所示。

12.当i=9时,边(v6,v7),第10行得到n=6,第11行得到m=7,因此parent[6]=7,打印“(6,7)19”。此时parent数组值为{1,5,8,7,7,8,7,0,6},如下图所示。

我们来把克鲁斯卡尔(Kruskal)算法的实现定义归纳一下。

假设N=(V,{E})是连通网,则令最小生成树的初始状态为只有n个顶点而无边的非连通图T={V,{}},图中每个顶点自成一个连通分量。在E中选择代价最小的边,若该边依附的顶点落在T中不同的连通分量上,则将此边加入到T中,否则舍去此边而选择下一条代价最小的边。依次类推,直至T中所有顶点都在同一连通分量上为止。

此算法的Find函数由边数e决定,时间复杂度为O(loge),而外面有一个for循环e次。所以克鲁斯卡尔算法的时间复杂度为O(eloge)。

对比两个算法,克鲁斯卡尔算法主要是针对边来展开,边数少时效率会非常高,所以对于稀疏图有很大的优势;而普里姆算法对于稠密图,即边数非常多的情况会更好一些。

3. 最短路径

我们时常会面临着对路径选择的决策问题。例如在北京、上海、广州等城市,因其城市面积较大,乘地铁或公交都要考虑从A点到B点,如何换乘到达?比如下图这样的地铁网图,如果不是专门去做研究,对于刚接触的人来说,都会犯迷糊。

现实中,每个人需求不同,选择方案就不尽相同。有人为了省钱,它需要的是路程最短(定价以路程长短为标准),但可能由于线路班次少,换乘站间距离长等原因并不省时间;而另一些人,为了要赶飞机火车或者早晨上班不迟到,他最大的需求是总时间要短;还有一类人,如老人行动不便,或者上班族下班,忙碌一天累得要死,他们都不想多走路,哪怕车子绕远路耗时长也无所谓,关键是换乘要少,这样可以在车上好好休息一下(有些线路方案换乘两次比换乘三四次耗时还长)。这些都是老百姓的需求,简单的图形可以靠人的经验和感觉,但复杂的道路或地铁网就需要计算机通过算法计算来提供最佳的方案。我们今天就要来研究关于图的最短路径的问题。

在网图和非网图中,最短路径的含义是不同的。由于非网图它没有边上的权值,所谓的最短路径,其实就是指两顶点之间经过的边数最少的路径;而对于网图来说,最短路径,是指两顶点之间经过的边上权值之和最少的路径,并且我们称路径上的第一个顶点是源点,最后一个顶点是终点。显然,我们研究网图更有实际意义,就地图来说,距离就是两顶点间的权值之和。而非网图完全可以理解为所有的边的权值都为1的网。

我们要讲解两种求最短路径的算法。先来讲第一种,从某个源点到其余各顶点的最短路径问题。

你能很快计算出图7-7-2中由源点v0到终点v8的最短路径吗?如果不能,没关系,我们一同来研究看如何让计算机计算出来。如果能,哼哼,那仅代表你智商还不错,你还是要来好好学习,毕竟真实世界的图可没这么简单,人脑是用来创造而不是做枯燥复杂的计算的。好了,我们开始吧。

3.1 迪杰斯特拉(Dijkstra)算法

这是一个按路径长度递增的次序产生最短路径的算法。它的思路大体是这样的。

比如说要求下图中顶点v0到顶点v1的最短距离,没有比这更简单的了,答案就是1,路径就是直接v0连线到v1。

 

由于顶点v1还与v2、v3、v4连线,所以此时我们同时求得了v0→v1→v2=1+3=4,v0→v1→v3=1+7=8,v0→v1→v4=1+5=6。

现在,我问v0到v2的最短距离,如果你不假思索地说是5,那就犯错了。因为边上都有权值,刚才已经有v0→v1→v2的结果是4,比5还要小1个单位,它才是最短距离,如下图所示。 

由于顶点v2还与v4、v5连线,所以此时我们同时求得了v0→v2→v4其实就是v0→v1→v2→v4=4+1=5,v0→v2→v5=4+7=11。这里v0→v2我们用的是刚才计算出来的较小的4。此时我们也发现v0→v1→v2→v4=5要比v0→v1→v4=6还要小。所以v0到v4目前的最小距离是5,如下图所示。

 

当我们要求v0到v3的最短距离时,通向v3的三条边,除了v6没有研究过外,v0→v1→v3的结果是8,而v0→v4→v3=5+2=7。因此,v0到v3的最短距离是7,如下图所示。 

好了,我想你大致明白,这个迪杰斯特拉(Dijkstra)算法是如何干活的了。它并不是一下子就求出了v0到v8的最短路径,而是一步步求出它们之间顶点的最短路径,过程中都是基于已经求出的最短路径的基础上,求得更远顶点的最短路径,最终得到你要的结果。

如果还是不太明白,不要紧,现在我们来看代码,从代码的模拟运行中,再次去理解它的思想。

 

 调用此函数前,其实我们需要为图7-7-7的左图准备邻接矩阵MGraph的G,如下图的右图,并且定义参数v0为0。

1.程序开始运行,第4行final数组是为了v0到某顶点是否已经求得最短路径的标记,如果v0到vw已经有结果,则final[w]=1。

2.第5~10行,是在对数据进行初始化的工作。此时final数组值均为0,表示所有的点都未求得最短路径。D数组为{65535,1,5,65535,65535,65535,65535,65535,65535}。因为v0与v1和v2的边权值为1和5。P数组全为0,表示目前没有路径。 

3.第11行,表示v0到v0自身,权值和结果为0。D数组为{0,1,5,65535,65535,65535,65535,65535,65535}。第12行,表示v0点算是已经求得最短路径,因此final[0]=1。此时final数组为{1,0,0,0,0,0,0,0,0}。此时整个初始化工作完成。

4.第13~33行,为主循环,每次循环求得v0与一个顶点的最短路径。因此v从1而不是0开始。

5.第15~23行,先令min为65535的极大值,通过w循环,与D[w]比较找到最小值min=1,k=1。

6.第24行,由k=1,表示与v0最近的顶点是v1,并且由D[1]=1,知道此时v0到v1的最短距离是1。因此将v1对应的final[1]设置为1。此时final数组为{1,1,0,0,0,0,0,0,0}。

7.第25~32行是一循环,此循环甚为关键。它的目的是在刚才已经找到v0与v1的最短路径的基础上,对v1与其他顶点的边进行计算,得到v0与它们的当前最短距离。如下图所示。因为min=1,所以本来D[2]=5,现在v0→v1→v2=D[2]=min+3=4,v0→v1→v3=D[3]=min+7=8,v0→v1→v4=D[4]=min+5=6,因此,D数组当前值为{0,1,4,8,6,65535,65535,65535,65535}。而P[2]=1,P[3]=1,P[4]=1,它表示的意思是v0到v2、v3、v4点的最短路径它们的前驱均是v1。此时P数组值为:{0,0,1,1,1,0,0,0,0}。

8.重新开始循环,此时i=2。第15~23行,对w循环,注意因为final[0]=1和final[1]=1,由第18行的!final[w]可知,v0与v1并不参与最小值的获取。通过循环比较,找到最小值min=4,k=2。

9.第24行,由k=2,表示已经求出v0到v2的最短路径,并且由D[2]=4,知道最短距离是4。因此将v2对应的final[2]设置为1,此时final数组为:{1,1,1,0,0,0,0,0,0}。

10.第25~32行。在刚才已经找到v0与v2的最短路径的基础上,对v2与其他顶点的边,进行计算,得到v0与它们的当前最短距离,如下图所示。因为min=4,所以本来D[4]=6,现在v0→v2→v4=D[4]=min+1=5,v0→v2→v5=D[5]=min+7=11,因此,D数组当前值为:{0,1,4,8,5,11,65535,65535, 65535}。而原本P[4]=1,此时P[4]=2,P[5]=2,它表示v0到v4、v5点的最短路径它们的前驱均是v2。此时P数组值为:{0,0,1,1,2,2,0,0,0}。 

11.重新开始循环,此时i=3。第15~23行,通过对w循环比较找到最小值min=5,k=4。

12.第24行,由k=4,表示已经求出v0到v4的最短路径,并且由D[4]=5,知道最短距离是5。因此将v4对应的final[4]设置为1。此时final数组为:{1,1,1,0,1,0,0,0,0}。

13.第25~32行。对v4与其他顶点的边进行计算,得到v0与它们的当前最短距离,如下图所示。因为min=5,所以本来D[3]=8,现在v0→v4→v3=D[3]=min+2=7,本来D[5]=11,现在v0→v4→v5=D[5]=min+3=8,另外v0 →v4→v6=D[6]=min+6=11,v0→v4→v7=D[7]=min+9=14,因此,D数组当前值为:{0,1,4,7,5,8,11,14,65535}。而原本P[3]=1,此时P[3]=4,原本P[5]=2,此时P[5]=4,另外P[6]=4,P[7]=4,它表示v0到v3、v5、v6、v7点的最短路径它们的前驱均是v4。此时P数组值为:{0,0,1,4,2,4,4,4,0}。 

14.之后的循环就完全类似了。得到最终的结果,如下图所示。此时final数组为:{1,1,1,1,1,1,1,1,1},它表示所有的顶点均完成了最短路径的查找工作。此时D数组为:{0,1,4,7,5,8,10,12,16},它表示v0到各个顶点的最短路径数,比如D[8]=1+3+1+2+3+2+4=16。此时的P数组为:{0,0,1,4,2,4,3,6,7},这串数字可能略为难理解一些。比如P[8]=7,它的意思是v0到v8的最短路径,顶点v8的前驱顶点是v7,再由P[7]=6表示v7的前驱是v6,P[6]=3,表示v6的前驱是v3。这样就可以得到,v0到v8的最短路径为v8←v7←v6←v3←v4←v2←v1←v0,即v0→v1→v2→v4→v3→v6→v7→v8。 

其实最终返回的数组D和数组P,是可以得到v0到任意一个顶点的最短路径和路径长度的。例如v0到v8的最短路径并没有经过v5,但我们已经知道v0到v5的最短路径了。由D[5]=8可知它的路径长度为8,由P[5]=4可知v5的前驱顶点是v4,所以v0到v5的最短路径是v0→v1→v2→v4→v5。

也就是说,我们通过迪杰斯特拉(Dijkstra)算法解决了从某个源点到其余各顶点的最短路径问题。从循环嵌套可以很容易得到此算法的时间复杂度为O(n2),尽管有同学觉得,可不可以只找到从源点到某一个特定终点的最短路径,其实这个问题和求源点到其他所有顶点的最短路径一样复杂,时间复杂度依然是O(n2)。

这就好比,你吃了七个包子终于算是吃饱了,就感觉很不划算,前六个包子白吃了,应该直接吃第七个包子,于是你就去寻找可以吃一个就能饱肚子的包子,能够满足你的要求最终结果只能有一个,那就是用七个包子的面粉和馅做的一个大包子。这种只关注结果而忽略过程的思想是非常不可取的。

可如果我们还需要知道如v3到v5、v1到v7这样的任一顶点到其余所有顶点的最短路径怎么办呢?此时简单的办法就是对每个顶点当作源点运行一次迪杰斯特拉(Dijkstra)算法,等于在原有算法的基础上,再来一次循环,此时整个算法的时间复杂度就成了O(n3)。

对此,我们现在再来介绍另一个求最短路径的算法——弗洛伊德(Floyd),它求所有顶点到所有顶点的时间复杂度也是O(n3),但其算法非常简洁优雅,能让人感觉到智慧的无限魅力。好了,让我们就一同来欣赏和学习它吧。

3.2 弗洛伊德(Floyd)算法 

为了能讲明白弗洛伊德(Floyd)算法的精妙所在,我们先来看最简单的案例。下图的左图是一个最简单的3个顶点连通网图。

我们先定义两个二维数组D[3][3]和P[3][3],D代表顶点到顶点的最短路径权值和的矩阵。P代表对应顶点的最小路径的前驱矩阵。在未分析任何顶点之前,我们将D命名为D-1,其实它就是初始的图的邻接矩阵。将P命名为P-1,初始化为图中所示的矩阵。

首先我们来分析,所有的顶点经过v0后到达另一顶点的最短路径。因为只有三个顶点,因此需要查看v1→v0→v2,得到D-1 [1][0]+D-1 [0][2]=2+1=3。D-1 [1][2]表示的是v1→v2的权值为5,我们发现D-1 [1][2]> D-1 [1][0]+D-1 [0][2],通俗的话讲就是v1 →v0→v2比直接v1→v2距离还要近。所以我们就让D-1 [1][2]=D-1[1][0]+D-1 [0][2]=3,同样的D-1 [2][1]=3,于是就有了D0的矩阵。因为有变化,所以P矩阵对应的P-1[1][2]和P-1[2][1]也修改为当前中转的顶点v0的下标0,于是就有了P0。也就是说 

接下来,其实也就是在D0和P0的基础上继续处理所有顶点经过v1和v2后到达另一顶点的最短路径,得到D1和P1、D2和P2完成所有顶点到所有顶点的最短路径计算工作。

如果我就用这么简单的图形来讲解代码,大家一定会觉得不能说明什么问题。所以我们还是以前面的复杂网图为例,来讲解弗洛伊德(Floyd)算法。

首先我们针对下图的左网图准备两个矩阵D-1和P-1,D-1就是网图的邻接矩阵,P-1初设为P[i][j]=j这样的矩阵,它主要用来存储路径。 

代码如下,注意因为是求所有顶点到所有顶点的最短路径,因此Pathmatirx和ShortPathTable都是二维数组。

 

1.程序开始运行,第4~11行就是初始化了D和P。从矩阵也得到,v0→v1路径权值是1,v0→v2路径权值是5,v0→v3无边连线,所以路径权值为极大值65535。 

2.第12~25行,是算法的主循环,一共三层嵌套,k代表的就是中转顶点的下标。v代表起始顶点,w代表结束顶点。

3.当K=0时,也就是所有的顶点都经过v0中转,计算是否有最短路径的变化。可惜结果是,没有任何变化,如下图所示。

4.当K=1时,也就是所有的顶点都经过v1中转。此时,当v=0时,原本D[0][2]=5,现在由于D[0][1]+D[1][2]=4。因此由代码的第20行,二者取其最小值,得到D[0][2]=4,同理可得D[0][3]=8、D[0][4]=6,当v=2、3、4时,也修改了一些数据,请参考如下图左图中虚线框数据。由于这些最小权值的修正,所以在路径矩阵P上,也要作处理,将它们都改为当前的P[v][k]值,见代码第21行。 

5.接下来就是k=2一直到8结束,表示针对每个顶点做中转得到的计算结果,当然,我们也要清楚,D0是以D-1为基础,D1是以D0为基础,……,D8是以D7为基础,就像我们曾经说过的七个包子的故事,它们是有联系的,路径矩阵P也是如此。最终当k=8时,两矩阵数据如下图所示。 

至此,我们的最短路径就算是完成了,你可以看到矩阵第v0行的数值与迪杰斯特拉(Dijkstra)算法求得的D数组的数值是完全相同,都是{0,1,4,7,5,8,10,12,16}。而且这里是所有顶点到所有顶点的最短路径权值和都可以计算出。

那么如何由P这个路径数组得出具体的最短路径呢?以v0到v8为例,从上图的右图第v8列,P[0][8]=1,得到要经过顶点v1,然后将1取代0得到P[1][8]=2,说明要经过v2,然后将2取代1得到P[2][8]=4,说明要经过v4,然后将4取代2得到P[4][8]=3,说明要经过v3,……,这样很容易就推导出最终的最短路径值为v0→v1→ v2→v4→v3→v6→v7→v8。 

求最短路径的显示代码可以这样写。

再次回过头来看看弗洛伊德(Floyd)算法,它的代码简洁到就是一个二重循环初始化加一个三重循环权值修正,就完成了所有顶点到所有顶点的最短路径计算。几乎就如同是我们在学习C语言循环嵌套的样例代码而已。如此简单的实现,真是巧妙之极,在我看来,这是非常漂亮的算法,不知道你们是否喜欢?很可惜由于它的三重循环,因此也是O(n3)时间复杂度。如果你面临需要求所有顶点至所有顶点的最短路径问题时,弗洛伊德(Floyd)算法应该是不错的选择。

另外,我们虽然对求最短路径的两个算法举例都是无向图,但它们对有向图依然有效,因为二者的差异仅仅是邻接矩阵是否对称而已。

 

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1622072.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

华为OD机试 - 智能驾驶 - 广度优先搜索(Java 2024 C卷 200分)

华为OD机试 2024C卷题库疯狂收录中,刷题点这里 专栏导读 本专栏收录于《华为OD机试(JAVA)真题(A卷B卷C卷)》。 刷的越多,抽中的概率越大,每一题都有详细的答题思路、详细的代码注释、样例测试…

「 网络安全常用术语解读 」什么是0day、1day、nday漏洞

1. 引言 漏洞攻击的时间窗口被称为漏洞窗口(window of vulnerability)。一般来说,漏洞窗口持续的时间越长,攻击者可以利用漏洞进行攻击的可能性就越大。 2. 0day 漏洞 0day 漏洞,又被称为"零日漏洞"&…

YUNBEE云贝-Oracle 19c OCM 5月19日

Oracle 19c OCM认证大师培训 - 课程体系 - 云贝教育 (yunbee.net) 19c OCM考试类别? Oracle 19c OCM认证大师直考(2天考试,4个模块,每个模块3小时) Oracle 19c OCM认证大师升级考(1天考试,2个模块,每个模块3小时) 在…

Day1--什么是网络安全?网络安全常用术语

目录 1. 什么是网络安全? 信息系统(Information System) 信息系统安全三要素(CIA) 网络空间安全管理流程 网络安全管理 2. 网络安全的常用术语 3. 网络安全形势 4. 中国网络安全产业现状 1. 什么是网络安全&am…

pnpm install报错 Value of “this“ must be of type URLSearchParams

执行pnpm install的时候就报错Value of “this” must be of type URLSearchParams 由于之前执行没有出现过这个问题,最近在使用vue3所以使用了高版本的node,怀疑是node版本的问题。 解决: 检查node版本 node -v当前使用的是20.11.0的 修改…

婚恋app系统开发相亲交友小程序源码单身交友app软件下载客户管理系统婚恋交友平台管理系统源码h5聊天室源码

在数字化时代,婚恋相亲交友小程序以其便捷、高效、智能的特点,受到了越来越多人的青睐。红娘婚恋相亲交友系统源码,凭借其对微信小程序、微信公众号、H5以及APP的全方位支持,成为了婚恋市场的明星产品。 首先,婚恋相亲…

力扣HOT100 - 124. 二叉树中的最大路径和

解题思路: class Solution {int max Integer.MIN_VALUE;public int maxPathSum(TreeNode root) {maxGain(root);return max;}public int maxGain(TreeNode node) {if (node null) return 0;int leftGain Math.max(maxGain(node.left), 0);int rightGain Math.ma…

go+react实现远程vCenter虚拟机管理终端

文章目录 React-VcenterDemoQuick Start React-Vcenter 基于go & react实现远程vSphere vcenter虚拟机终端console页面,提供与vcenter管理中的Launch Web Console相同的功能。 项目地址:react-vcenter Demo URL: http://localhost:3000 Quick St…

Unity 踩坑记录 Rigidbody 刚体重力失效

playerSetting > physics > Gravity > 设置 Y 的值为负数

《苍穹外卖》Day07部分知识点记录

一、菜品缓存 减少查询数据库的次数,优化性能 客户端: package com.sky.controller.user;import com.sky.constant.StatusConstant; import com.sky.entity.Dish; import com.sky.result.Result; import com.sky.service.DishService; import com.sky…

使用微软Phi-3-mini模型快速创建生成式AI应用

微软Phi-3大语言模型是微软研究院推出的新一代系列先进的小语言模型。Phi-3系列包括phi-3-mini、phi-3-small和phi-3-medium三个不同规模的版本。这些模型在保持较小的参数规模的同时,通过精心设计的训练数据集和优化的算法,实现了与大型模型相媲美的语言…

软件测试之【软件测试概论三】

读者大大们好呀!!!☀️☀️☀️ 🔥 欢迎来到我的博客 👀期待大大的关注哦❗️❗️❗️ 🚀欢迎收看我的主页文章➡️寻至善的主页 文章目录 前言测试用例的前因后果测试用例的设计方法黑盒测试用例设计方法&#x1f525…

JavaEE 初阶篇-深入了解 I/O 流(FileInputStream 与 FileOutputStream 、Reader 与 Writer)

🔥博客主页: 【小扳_-CSDN博客】 ❤感谢大家点赞👍收藏⭐评论✍ 文章目录 1.0 I/O 流概述 2.0 文件字节输入流(FileInputStream) 2.1 创建 FileInputStream 对象 2.2 读取数据 2.3 关闭流 3.0 文件字节输出流(FileOutputStream) 3.1 创建 Fi…

html、css、QQ音乐移动端静态页面,资源免费分享,可作为参考,提供InsCode在线运行演示

CSDN将我上传的免费资源私自变成VIP专享资源,且作为作者的我不可修改为免费资源,不可删除,寻找客服无果,很愤怒,(我发布免费资源就是希望大家能免费一起用、一起学习),接下来继续寻找…

智慧医院解决方案

5G智慧导航解决方案https://www.cgltzk.vip/doc/1537/ 深圳市妇幼保健院妇产科住院大楼智能化系统招标文件(技术规格书)https://www.cgltzk.vip/doc/1534/ 三甲医院智能化系统设计方案https://www.cgltzk.vip/doc/1423/ 医院智能化设计方案https://www.cgltzk.vip/doc/1422/ …

矩阵按列相乘运算的并行化实现方法

这两天一直在琢磨如下矩阵计算问题。 已知dm矩阵X和hq矩阵Y,求如下矩阵: 其中X(:,i), Y(:,j)分别表示矩阵X, Y的第i列和第j列,易知Z为dh矩阵。 如果直接串行计算矩阵Z,两个循环共有mq,则会很慢,能不能并行化…

UE5 GAS开发P34 游戏效果理论

GameplayEffects Attributes(属性)和Gameplay Tags(游戏标签)分别代表游戏中实体的特性和标识。 Attributes(属性):Attributes是用来表示游戏中实体的特性或属性的值,例如生命值、…

Ant Design Vue + js 表格计算合计

1.需要计算的数量固定&#xff08;如表1&#xff0c;已知需要计算的金额为&#xff1a;装修履约保证金 装修垃圾清运费出入证工本费 出入证押金 这四项相加&#xff0c;可以写成固定的算法&#xff09;&#xff1a; 表格样式&#xff1a; <h4 style"margin: 0 0 8px…

【数据结构】图(Graph)

文章目录 概念图的存储方式邻接矩阵邻接矩阵表示法邻接矩阵表示法的特点 邻接表邻接表表示法邻接表表示法的特点邻接表表示法的定义与实现查找插入删除其它构造函数析构函数创建图输出图 图的遍历深度优先遍历&#xff08;DFS&#xff09;广度优先遍历 图的连接分量和生成树生成…

Xilinx 7系列MMCM/PLL的使用模型

本文展示了MMCM的一些使用模型&#xff08;同样适用于PLL&#xff09;&#xff0c;如时钟网络去偏斜、具有内部反馈的MMCM和零延迟缓冲区等。 1、时钟网络去偏斜&#xff08;Clock Network Deskew&#xff09; MMCM的主要用途之一是用于时钟网络去偏斜。图3-11和图3-12展示了…