图 相关算法~从头学算法【广搜、 深搜、 拓扑排序、 并查集、 弗洛伊德算法、迪杰斯特拉算法】

图的相关主流算法主要有:

广度优先搜索
深度优先搜索
拓扑排序
并查集
多源最短路径(弗洛伊德算法)
单源最短路径(迪杰斯特拉算法)

       其中呢,最基本的是前两种,也就是平时常用的广搜和深搜,本文中将概要举例讲解。因为基础也很重要啊~~

        图的算法题当想不出来巧妙的方法时就只有暴力搜了!但是文中还会以例子的形式讲解在图中进行深搜时常用的两点优化技巧:1.寻找路径时在递归上的优化;2.记忆化搜索降低时间复杂度。剩下4个算法,即TurboSort、UnionFindSet、Floyd以及Dijstra算法根据其算法的功能都有非常明显的对应场景,本文将会详细的讲解算法的流程和原理。其中使用上较为巧妙的是并查集,有很多意想不到的算法题用并查集将会带来巧妙的求解方法!

        同时,本文会对并查集的常用优化,如按高度合并、按重量(秩)合并,以及路径压缩进行介绍,它绝对是一个性价比极高的数据结构,应该熟练掌握该数据结构的构建以及原理,会给你带来意想不到的收获~~拓扑排序其实是广度优先搜索的思想,俗称剥洋葱,从入度为0的节点的最外层开始剥,一层一层一层的剥开我的心…但是存在环时就剥不动了,所以拓扑排序一般是用来解决有向无环图的依赖顺序问题的。多源最短路径的弗洛伊德是支持负权重边求解的,因为它采用的是动态规划的思想,在算法进行结束给出最终结果,而常用的求解单源最短路径的迪杰斯特拉算法不支持负权重边,因为采用的是贪心策略,每加入S集合的点已保证最近距离,这就是二者主要的区别,现在看不知道是说的什么没关系,文中会详细讲解,回过来你会呵呵一笑~

       本文结构即以上介绍,分6个章节对几种主流图相关算法进行讲解,文中举例我会采用leetcode的典型原题,代码都是100%AC的,这样,我们才能从实际应用场景以及算法实现思路和原理上得到最深,最彻底的理解!下面,开始吧~~~

0 讲点啥呢?

        为啥从零开始呢?程序员绝不从一开始!那讲点啥呢?应该说点准备工作,那就说一下图的常用表示方法吧。图的表示方法就很多种,例如常用的邻接矩阵表示法、邻接表表示法以及十字链表表示法,总之,各种表示图的方法都是用不同的方式来描述图的结构,习惯用哪一种就用哪一种,甚至我们可以都将它转换成邻接矩阵,然后进行算法的实现,或者我们很懒,那不处理我们就直接使用题中给出的边缘列表也可以~下面对几种常用表示方法进行介绍。

0.1 邻接矩阵表示法

        临接矩阵表示方法就是通过一个矩阵中元素的有无来表示对应横纵座标所对应的的顶点之间是否存在相连接的边的一种表示方法。对于无向图、有向图以及加权图的表示上有对应的差别:无向图因为V0-V2之间的连接无方向,那么对应的邻接矩阵中元素arr[0][2]=arr[2][0]=1,因此无向图的邻接矩阵是对称矩阵,而对于有向图来说,当图中只存在V0 -> V2的有向边而不存在V2 -> V0的有向边时,自然有arr[0][2]=1,arr[2][0]=0,所以有向图邻接矩阵一般不对称,对于加权图,以有向加权图为例,即将邻接矩阵中对应的边的权重作为元素的值。

 

0.2  邻接表表示法

       邻接表表示法也很简单,就是用一个一维数组来存储图中所有顶点,而顶点后面都连接一个链表,这个链表表示了与该顶点相连的其他顶点(对于有向图是所有弧顶元素)。

 

0.3十字链表表示法

        十字链表表示法是为了弥补邻接表表示方式中不易计算出顶点入度的缺点而设计的一种图结构表示方法。它的特点是顶点与链表元素的结构稍有不同,其中数组中顶点结构包括data、firIn、firOut,分别表示该顶点元素数据、第一个指向当前顶点的指针,该顶点指向的第一个元素的指针。链表节点的结构包括tailVex、headVex、headLink以及tailLink,其表示意义如下:

数组中顶点结构:

data

该顶点元素数据

firIn

指向当前顶点的第一个顶点

firOut

该顶点指向的第一个元素的指针

链表中节点结构:

tailVex

指向当前顶点的前一个顶点

headVex

当前顶点

headLink

指向当前顶点的其他顶点

tailLink

前一个顶点指向的下一个顶点

       是不是很绕?看下面的图就明白了

       额,请叫我灵魂画师……再解释一下,其实我们只看蓝色字体部分,是不是就是临接表?然后再看红色字体部分,以V0为例,连接了V0 -> [1,0] -> [2,0],说明了V0顶点入度为2,分别是V1和V2顶点。这就是十字链表表示方法,它同时同两个链表表示了节点的出度和入度,所以叫十字交叉链表表示……如果看着不爽,可以将它拆成两个邻接表表示,即一个用来表示出度,一个用来表示入度,这样就清晰明了了~

1 广度优先搜索(BFS)

       广度优先搜索通常使用队列作为辅助遍历的工具,较为简单,图的BFS相对于树的BFS多出来一个判断是否已访问过的步骤,因为图是可能成环的,而树不用担心这一点,算法很简单,直接上代码(以最简单的邻接矩阵的形式传参):

       广度优先遍历通常代码如上结构,我们可以根据题目的要求改变队列中节点的出队顺序从而控制遍历顺序,该算法较简单且后面介绍的拓扑排序还会用到广度优先遍历,这里不再举例题,后面用到时再详述。

2.深度优先搜索(DFS)

       深度优先搜索通常使用栈作为辅助工具进行遍历,当然能用到栈肯定可以用递归的方式来做,它的思路是从一个节点开始,按照出度节点的顺序依次向下寻找路径,其他出度的节点暂存到栈中,然后依次搜索达到遍历全图的目的。同理,其中也需要进行节点是否访问的判断,不然成环时就造成了死循环,示例代码如下:

(1)以栈形式进行DFS:

(2)以递归形式进行DFS:

       DFS的两种方式都可以,看你习惯用那种方式,值得一提的是用栈实现的DFS和用队列实现的BFS代码结构完全相同,只是因为所使用的数据结构不同带来了两种完全不同的遍历顺序。所以算法中数据结构的作用是巨大的!要么说“数据结构与算法”呢~

下面针对DFS介绍一道简单的LeetCode例题,重点是题中我们会使用到路径遍历时用到的一点小的技巧,通过打表的方式遍历,通过循环的方式省去了枚举重复的代码,以及通过缓存已遍历路径降低时间复杂度的优化。

       题目如下:

       这里我们使用DFS的方式解题,思路很明确,我们通过遍历每一个节点通过DFS寻找最长递增路径,也就是两个for循环里面调用dfs,值得我们注意的是:这个题在遍历时还需要visited数据记录访问过的节点么?答案是不需要,因为题中已经要求严格递增了,所以访问的下一个节点一定要比上一个节点大,故永远不会出现死循环。

       下面我们要考虑的是DFS的实现了,对于给定的顶点nums[i][j],在调用dfs时,我们需要考虑该节点需要向上下左右四个方向进行访问,最终返回当前节点的最长递增路径长度,所以用递归比较合适。但是问题是四个方向,每一个方向我们都需要判断是否边界溢出、不溢出的情况下下一个节点值是否大于当前节点,因此四个判断条件都不相同,我们需要进行四次枚举的递归调用,这样子写出来的DFS如下图所示,很麻烦,很臃肿!

       那怎么解决这个问题,以较为优雅的方式实现这个DFS呢?我们知道,使得代码变得难看的就是这四个枚举的过程,我们要做的就是简化它!那怎么进行简化呢?就是让死的枚举代码变活,怎么变活呢?通过变量进行替代,通过for循环实现遍历,这样我们同样完成了枚举的效果,保证结果的正确性,但是代码上举大大简化了。代码如下图,我们定义一个dir数组,里面元素的0,1,-1来对方向进行控制从而达到上下左右四个方向的访问。

       但是本题的代码还有一个问题,就是效率太低,因为我们是通过遍历所有元素进行DFS的,想一下,里面有太多太多的重复计算了!例如,我们在对第一个元素进行DFS时,当向右可以访问时,我们已经计算了第二个元素的最长递增路径了,但是因为我们的遍历依次进行,所以下一次还要重复计算第二个元素的最长递增路径。所以,我们要去除这种重复的计算,降低算法的时间复杂度,自然而然的我们想到,原因出在重复计算上,那我们就把已经计算出来的结果缓存下来,下次碰到已经计算过的元素,直接返回即可!对,这就是所谓的记忆化搜索,我们只需要加一个缓存数组就能大大降低该算法的时间复杂度。其实,当我们加上cache数组时,这道题在leetcode上,我们已经可以全A了~整体代码如下:

       该题很典型,面试头条时二面手撕的就是这一题,面试官喜欢问一些优化点较多的题,层层递进,会有很好的区分度~其他的解法我们不再介绍,有兴趣可以去查一下或者想一想。

 

3.拓扑排序(TurboSort)

       拓扑排序主要是针对有向无环图的算法,定义是:通过该算法得出一个序列,使得有向无环图(DAG)中的任意一对顶点若存在边<u,v>,则在拓扑排序得到的线性序列中,顶点u一定出现在顶点v之前,即保证依赖关系的顺序性。

       可能这么描述稍微有一点学术,什么意思呢,通俗上讲就是我们要解决依赖关系,比如你想要一个孙子,怎么办?首先,你要有一个儿子,儿子哪里来,得自己生啊,怎么生呢?你要结婚啊!跟谁结婚呢?你要先有个对象啊!有对象就能结婚了么?不,你没车没房没彩礼,你丈母娘不能同意啊!所以,要想达到某一个节点,你需要先搞定其中的必要条件,当然,这些必要条件对于同一个节点的入度节点是不要求顺序的,比如你先买车还是先买房,你丈母娘都是能同意的,但是你都得买,你必须把你丈母娘的入度减到零,才能搞定丈母娘!所以这个Case的拓扑排序如下图:

       如上,拓扑排序就是在各种依赖关系中找出一些合理的线性关系来满足各种依赖,例如我们大学上课,选课时某些课程存在前驱课程,如统计学习的前驱课程有高数,现代等,没有相关基础,我们没办法学好这些高等课程。下面例题我将会介绍LeetCode上一道这样的拓扑排序题目。类似的还有项目工程关系,多个项目之间也存在依赖的先后次序。

       值得注意的是,拓扑排序不能应用于有环图,因为我们的前提条件是总能至少在图中找到一个入度为0的顶点开始拓扑,但是当图中存在环时,我们在这个环中找不到任意一个入度为0顶的点。

       之前介绍拓扑排序是利用的广度优先的思想,也是借助队列这个辅助数据结构,具体的算法流程是:首先建立一个countArr数组并统计每一个顶点的入度填到对应数组中;通过遍历,首先将所有入度为0的节点入队,并将节点总数vexCount相应减少,然后利用广度优先搜索的思路进行循环,里面注意的操作是:每当出队一个顶点时,我们将以该顶点为弧尾的弧顶顶点对应的入度countArr[i]减一,并判断该countArr[i]是否为0,为零则将该元素入队,并将vexCount减一,循环直至队列为空。就这样一层一层向内剥洋葱,最后判断vexCount是否为0,如果为0,说明我们洋葱剥成功,如果不为零,则说明这个洋葱有心儿,即成环了,无法彻底剥开。最后输出的顶点出队顺序就是该图对应的一个拓扑序列。

       具体流程图如下:

       这里还有一点值得我们思考:上一道最长递增路径中我们没有用visited数组,因为题中要求路径严格递增,所以不可能存在已访问元素再次入栈的情况,那么,这一题我们为什么也不需要用到visited数组去标记已访问的顶点呢?这里,需要一个解释!因为出现死循环的原因是在有向图中出现了环,但是注意我们的入队判断条件,是当某一个顶点的入度为0,而环中节点的入度永远不为0,故环中节点永远不可能入队,而不成环节点不会被重复访问,故不用visited数组进行标记判断。

       下面以LeetCode的210题进行介绍:

        这一题就是典型的拓扑排序,没有别的套路,拓扑排序直接上,这题较为简单,没有对拓扑排序的线性序列进行过多的要求,返回一个正确的序列即可。对于较为难的题我们需要对顶点出队的顺序进行控制,或者进行二次的拓扑排序,以满足题目对输入序列额外的要求,如LeetCode的1203题。

       下面是该题对应代码:

 

4.并查集(UnionFindSet)

       并查集是一种比较有意思的数据结构,其使用经常与图相关算法题相结合,例如本章节将会介绍的两道LeetCode题目,冗余连接以及情侣牵手。

       并查集这种数据结构主要用来描述集合,如可以将一堆相关点划分成几个独立的集合以及某个元素是否属于某个集合,某两个元素是否在同一集合中。结合图,那可以解决图中任意两个点之间是否存在通路,图中两个点之间是否成环等。并查集主要针对无向图。

       下面介绍并查集数据结构的实现以及几点优化:

4.1 基本并查集

       前面已经介绍了并查集的思想,下面介绍一个基于数组来实现并查集,最基本的并查集的思路是,数组下标为当前元素标号,元素值是下标元素所在的组数,当两个元素合并时,我们以较大组数元素为大哥,将另一组元素的值全部更新。具体过程如下图所示:

       该种实现所对应的代码如下:

       这种实现方式对于查找元素属于哪个集合的find方法很快,但是Union却很慢,因为在merge的时候我们需要遍历整个数组进行更新。

4.2 快Union,慢Find

       在4.1的基本实现中,我们在merge操作上花费较高的代价,一次merge时间复杂度是O(n),因为我们的做法是让同一组所有的小弟都认这一组一个人做大哥,中央集权,很累的啊!一次更新我们需要更新所有小弟的值,那我们这里换一种方式,不在让所有组内小弟都向大哥汇报,只要他们间接的能够找到自己的顶头大哥就行,从中央集权变成设立分级机构,解放大哥,提高效率!怎么做呢?看下图:

       通过这种方式,我们在进行merge操作时,只需要改变一个元素的值,也就是找到两组各自的带头大哥,让两个带头大哥进行pk,输的带头大哥跟新成另一组的带头大哥的组号即可,原组内体制保持不变。这也符合我们的逻辑,两国交战,只需将军PK就行,输了的将军以后跟着赢了的将军就行了,输将军的小弟还跟着输将军混,避免全部改编制,劳民伤财~这样效率就提高了

       因此,Find方法也需要做对应的改变,要找带头大哥,因为是树形结构,我们需要找到根元素的值,这才是真正的组号。

       因此需要改进的代码如下图,即merge方法和find方法:

 

4.3 基于高度的合并(快Union,慢find)

       上面的方法还存在一个问题,就是如图中,我们在合并的过程中,一直向上认大哥,会形成一个“大哥链”,退化成了链表,这使得我们在find的时候效率极低。为了改善这个弊端,我们衍生出两个方法,即按高度合并和按重量合并,这里说一下按高度合并,思路十分简单,就是认大哥的思路是谁组号大,谁就是大哥,这里为了改善退化成链表的这种情况,我们让高度较高者当大哥,若高度相同再让组号大的当大哥。直观的感受一下其效率上的优化:

       具体代码如下,需要注意,这是时我们在原并查集的基础上需要增加一个记录高度的数组,这是我们merge时的判断依据,并且在合并时需要根据实际情况看是否需要更新高度。

 

4.4 基于重量的合并(快Union,慢find)

       基于重量合并的思路和基于高度一样,只是判断依据从高度变成了重量,所谓重量也就是组员数量,谁的小弟多谁当老大,这样做的好处是可以方便的通过weight数组查找每一个集合的元素总数,另外,这在加速find没有多大效果,但是我们可以通过后面的路径压缩来降低高度进行弥补,所以总体利大于弊~思路简单不再画图,直接上代码:

 

4.5 路径压缩(按重量进行合并)

       所谓路径压缩,就是降低高度,缩短长度,怎么缩短呢?也就是对我们的集合内小弟的元素的编制进行改变,比如将高度为5的编制通过两次操作压缩成3,那在哪里进行操作呢,我们的目的是为了改善find的效率,一般就在find中进行修正。怎么改编制去降低高度呢,很简单,我们让小弟升官,让小弟直接成为大哥的大哥的小弟,那小弟原来的大哥就变成小弟的同级,高度自然降低了。可能说的不太形象,直接看下图:

       代码实现上也很简单,我们只需要在慢find方法上加上一行让小弟升官的命令即可:id[ele] = id[id[ele]],代码如下:

 

       至此,我们对并查集数据结构的优化介绍暂告一段落,下面通过两个例题介绍一个并查集在算法题中的使用场景:

       第一个是LeetCode第684题 冗余链接:

       这一题要求我们输入第一个冗余的边,也就是成环的第一条边。一种笨方法是我们通过构造邻接矩阵,然后通过DFS找出图中存在的环,然后再截取出成环的组成顶点,倒序遍历输入边序列,找到环中出现的最后一条边输出即可,这种方式也能够100%AC,但是效率较低且编程较为复杂。

       其实,这一题我们可以用并查集解题。成环,说明该环所有元素在一个集合中,我们遍历输入边列表,依次将元素进行合并,当第一个出现的重复合并的两个元素时,说明这两个元素本来已经在一个集合中了,再次合并意味着成环了!这条边就是应该输出的冗余边~ 所以我们要对并查集进行修改,让merge操作返回boolean类型的值,当返回false时,直接输出该对应边即可。代码如下(并查集采用上面的按重量合并、路径压缩的方式):

并查集只截取修改的merge方法:

 

另一个例题是LeetCode的第765题,题目描述如下图 :一起来感受一下并查集给我们解题带来意想不到的简便吧~

       这一题看着较为复杂,实际上我们可以割裂开看,假设存在2N个人,其中情侣如果坐在一起,则不需要换位置。如果两对情侣相互坐错了位置,那么只需要交换一次即可。如果三队情侣互相坐错了位置,即(CP_1_a,CP_2_b), (CP_2_a,CP_3_b), (CP_3_a,CP_1_b)那么需要交换两次。可以发现,我们只需要找出最后一共有多少个集合即可。当N对情侣形成N-1个集合,说明有一对情侣坐错了位置,我们需要进行一次交换,当有N-2和集合时,说明有两对情侣和别的情侣坐错了位置,我们需要两次交换,当有i个集合时,我们需要进行N-i次交换。

       所以我们的目的是找出合并后有多少个集合,毫无疑问,直接采用并查集进行合并,最后返回集合数即可。还需要对并查集做一点修改,即在merge代码中加入计算集合数的操作,代码如下:

并查集只截取修改的merge方法:

 

并查集还有很多的应用场景,例如,判断至少要修多少条铁路使得图联通等,应该熟练掌握这一个性价比极高的数据结构~

 

5.多源最短路径(佛洛依德算法)

       对于加权的图,难免的,我们要计算图的最短路径,常用的方法是针对多源最短路径的佛洛依德算法以及针对单源的最短路径算法,迪杰斯特拉算法。而且的区别在前言中说过,不是有无向,而是是否支持负权重边以及采用的思路和时间复杂度。Floyd算法支持负权重,采用动态规划的思路,多源,时间复杂度O(n3),Dijstra算法不支持负权重,采用贪心策略,单源,时间复杂度O(n2)。

       首先介绍一个佛洛依德算法,该算法是采用动态规划的思想对路径距离进行N次更新,N是顶点数,其中对顶点(i , j)之间的以k顶点更新的条件是:如果dis[i][j] > dis[i][k]+dis[k][j],则将dis[i][j]更新成dis[i][k]+dis[k][j]。更新完成后我们得到的是整个图上任意两点之间的最短距离。

       具体更新方式看下图(引自zhangvae的博客):

http://static.oschina.net/uploads/img/201409/19193311_5TGB.jpg

       上面我们只是通过Floyd更新邻接矩阵得到了图中任意两点间的最短距离,我们还应该能够通过对Floyd算法进行处理,得到任意两点间最短距离的路径。这里面我们需要初始化一个记录两个节点间中间节点的路经点的矩阵path[][],在Floyd更新距离时,将该对应成功更新两点间距离的点记录在path矩阵对应的位置上。在进行完迭代后,可以通过递归的方式从path矩阵中取出任意两点间的路径。以下图为例:

https://img-blog.csdnimg.cn/2019052813322920.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzM0ODQyNjcx,size_16,color_FFFFFF,t_70

图中我们进行佛洛依德更新之后,得到填充后的path矩阵,例如我们需要寻找顶点(0, 6)之间的最短路径,其实path[0][6]可能是图中3或4顶点,根据更新的顺序的不同而不同,但是并没有关系,并不影响我们最终结果的正确性。因为假设path[0][6]=3,那么下一步递归findPath中,我们将要寻找path[3][6],将会得到4节点,反之亦然,我们得到的最终路径一定是0 -> 3 -> 4 -> 6。

具体代码如下图所示:

       其中我们对path矩阵都初始化为-1,所以findPath的终止条件是mid==-1。

       例题我们将会介绍LeetCode上第743题 网络延迟时间,不过不放在本节,而是放到下一节介绍完迪杰斯特拉之后一起采用这两种方法解决该题,也比较一下不同。

6.单源最短路径(迪杰斯特拉算法)

       迪杰斯特拉算法是用来求解单源最短路径的,采用的是贪心的策略,具体的算法思路是设置两个集合,S集合存放已经求得最短路径的顶点集合,T集合时还未求得最短路径的集合,设我们要求的最短路径的起点是source,图的顶点数是vexNum,则需要进行vexNum-1次贪心操作,将所有顶点放入S集合。每次寻找S集合外距离点source最近的顶点index,加入到S集合,然后以这个index顶点为中介更新matrix矩阵中各个顶点与source顶点之间的距离(这里注意:如果是有向图,实际上是更新了source这一行,如果是无向图,则还更新了对称部分)。进行vexNum-1操作后,如果是连通图,则所有顶点都已放入S集合。思路很简单,不再对上述步骤画图,不太清楚的话可以在网上找一下详细的图示,很多很好的图~~

       这里需要注意的是,我们对迪杰斯特拉算法也应该会求source到各个顶点的最短路径,在本算法中,path的记录比上一节介绍的Floyd算法要简单很多,因为我们是依次贪心地将顶点加入到S集合并用该index顶点进行更新的,那么我们只需要在更新时将使用index顶点更新最短距离的点追加到对应的path[i]上即可。

代码实现如下图:

       下面我们介绍一道LeetCode上的第743题 网络延迟时间:

       这种题目非常的直接,就是让我们求出顶点K到图中各个顶点的最短距离,并取出其中的最长距离。第一个想法肯定是单源最短路径的迪杰斯特拉算法,当然用佛洛依德算法也可以求解,时间复杂度稍高,但是弗洛伊德算法编程更加简单直接。下面用两种方法分别求解:

1.佛洛依德算法:

2.迪杰斯特拉算法:

       二者由于时间复杂度不同,如下图可以看到使用Djistra算法的耗时要比Floyd低很多,对于大Case,我们还是用Dijstra算法进行求解才能全A。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章