基于C++的图论算法分析

基于C++的图论算法分析

1.引言

众所周知,图论在现实中应用广泛,本文旨在巩固本人的数据结构与算法基础,也希望能帮助算法学习者快速理解图论基本算法,现就其作如下浅析。
注:

  1. 本文涉及算法(除图结构实现外)均以C++面向过程方式描述。
  2. 阅读本文前至少需熟悉链表,栈,队列,树的概念
  3. 强烈建议在PC端使用Microsoft Office阅读本文档,然后开启视图-导航窗格

2.基本术语

1.图:所谓图,即G=(V,E),其中V为顶点(节点)集,E为顶点间关系(边),一条边上的两个顶点互为邻居
2.有向,无向图:若图中顶点间关系既可以是单向,也可以是双向,也即只用箭头表示边的图,称作有向图,没有箭头的图称作无向图,无向图中顶点间关系双向。
3.连通图:图中顶点间均有路径可达,则这样的无向图称为连通图,这样的有向图为强连通图,显然在有向图中顶点间的关系是双向的。
4.路径:顶点v0到顶点vn经过的顶点序列v0,v1,v2,…vn称为v0到vn的一个路径,其中,若v1…vn-1不重复,则为简单路径
5.环:亦称回路,顶点到自身可达的路径
在这里插入图片描述在这里插入图片描述
6.简单图:不存在重复边和顶点到自身的边,满足这样条件的图是简单图,一般讨论的图论算法均建立在简单图之上。
7.出度/入度,在无向图中,顶点的度是指依附于该边的条数,无向图全部顶点的度的和等于边数的2倍,因为每条边和两个顶点关联,在有向图中,入度是以该顶点为终点的有向边的数目,出度是以该顶点为起点的有向边的数目,该顶点的度等于其出度+入度之和,有向图中所有顶点的出度和=入度和=边数。
8.连通分量,生成树:无向图的包含图中全部顶点的极小连通子图称为该图的一棵生成树,无向图的极大连通子图为该图的一个连通分量,其中生成树包含图中全部的n个顶点与n-1条边,若去除一条边,则不连通,增加一条边,则产生环路。
9.网:给图中每条边附上一个非负值(一般是正值),该值称作该边的权值(weight)
图中各边带上权值的图称为带权图或网
在这里插入图片描述在这里插入图片描述

3.基本结构:邻接矩阵与邻接表

3.1 描述

图1的邻接矩阵为
1 2 3 4 5
1 0 1 1 INF INF
2 1 0 1 1 INF
3 1 1 0 1 INF
4 INF 1 1 0 1
5 INF INF INF 1 0
其中INF表示无穷,即矩阵对应的顶点i和j之间不存在边,0表示自身到自身的边不存在
矩阵a的值a[i][j]在不为0或无穷时的值为1表示权值或存在边,这里是存在边
图2的邻接表(以出度(指向)为选择顶点的基础)为
在这里插入图片描述
在这里插入图片描述

第1列的1-5为顶点序列,每个顶点都邻接其邻居,比如顶点2的邻居是1,3,第1列的节点称为顶点节点,之后的节点称为边节点,以顶点节点为边起点,自身数值为邻接节点,即边终点,比如第1行的边节点2表示以顶点1为边起点,2为边终点,即边(1,2)

3.2 实现

在这里插入图片描述在这里插入图片描述在这里插入图片描述

理解其原理后解决算法题时图结构可作如下简写:
邻接矩阵:int arc[m][m];邻接表:Enode list[m];输入可在main函数内动态输入

4.基本算法1:图的遍历

假定图中顶点数n,边数e,如何在O(n+e)的时间内遍历图中所有顶点?一般有两种算法:
广度优先搜索(Breadth First Search ,BFS)和深度优先搜索(Depth First Search ,DFS),
在介绍算法前,先给出实现算法前的“准备工作”:
在这里插入图片描述
另外,为进一步熟悉图的结构,BFS使用邻接矩阵,DFS使用邻接表

4.1.广度优先搜索

4.1.1 描述
从一个顶点v0出发,遍历其所有邻居,再从其第1个遍历到的邻居v1开始遍历它的所有邻居,再从v0第2个遍历到的邻居v2开始遍历它的所有邻居。重复上述过程,直至所有顶点遍历完毕。
在这里插入图片描述

如图,从顶点1出发,遍历其第1个邻居2,从2出发,遍历其两个邻居3,4,如果是按序号递增式地遍历,3应被先访问到,故在访问3,4之后,先遍历3的邻居5,6,发现5没有邻居,6也没有邻居,于是3的遍历任务结束,遍历4的邻居,4没有。于是,当前的子图遍历完成,但是图中还有另一个子图,于是若按序号递增的次序,从7开始遍历8,整个遍历才算完成,遍历次序是1 2 3 4 5 6 7 8。

可能会有人问,这里的子有向图为什么只能称作子图而不是连通分量, 在基本术语中已经讲过,无向连通图的极大连通子图称作连通分量,有向强连通图的极大强连通子图称作强连通分量,也就是“连通”对应无向图,“强连通”对应有向图,而此有向图并不是强连通图。

BFS类似于树的层次遍历,于是可将图1转换成图2。如下:

在这里插入图片描述
BFS相当于遍历两棵树,不难发现,树就是一个无环连通图,而BFS也适用于无向图(将图2中的箭头去除,遍历效果一样)。
4.1.2 实现
既然BFS类似于树的层次遍历,那么自然应该用队列来解决,于是可实现如下:

4.2.深度优先搜索

4.2.1 描述
从图中某一顶点v0出发,遍历这个顶点的第1个邻居v1,从v0的第1个邻居v1出发,遍历v1的第1个邻居v2,直到顶点没有邻居,退回上一次访问,遍历上一次顶点的第2个邻居,以此类推,直至图中所有顶点遍历完毕,若按遍历一个顶点即设为已访问的方式,DFS
类似于树的先序遍历
在这里插入图片描述

假定从顶点1出发,遍历1的第1个邻居2,从2出发,遍历2的第1个邻居3,从3出发,遍历3的第1个邻居5,结果5没有邻居,则退回到3处,遍历3的第2个邻居6,6没有邻居,3方告完全遍历,退回2,遍历4,再退回1,至此,一个子图遍历完毕,再遍历7,8,图遍历结束,可见,DFS的模式是递归式地,只要当前顶点的邻居未访问,便深入搜索邻居的邻居,进而搜索邻居的邻居的邻居…算法可实现如下:

4.2.2 实现
注意,由于邻接表在生成的过程中采用头插法,故不一定按序号递增的顺序选择邻居
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

5.基本算法2:最小生成树

带权连通无向图中边上的权值之和最小的生成树,称为最小生成树(Minimum Spanning Tree ,MST),求最小生成树的算法一般有两种:Prim算法与Kruskal算法,注意,最小权值和是唯一的,但最小生成树不一定唯一。

5.1.Prim算法

5.1.1 描述
定义两个顶点集V和U,其中V为加入生成树的顶点,U为图中剩余顶点,显然每次需要将U中顶点加入V,直到U为空,设某边为(i,j),且i属于V,j属于U,若根据不断地比较,此边成为两个顶点集合间的最短边,则将其加入V

在这里插入图片描述

从顶点1开始,显然(1,2)最短,接着V={1,2},U为图中剩余顶点,经比较(距离相同按编号递增选取),(2,3)是V和U之间最短边,加入V,V={1,2,3},接着选中(2,4),加入V,V={1,2,3,4},后续过程以此类推

5.1.2 实现
在这里插入图片描述在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

假定顶点数为n,易知Prim算法时间复杂度为O(n^2),不依赖于边数
可能有人会奇怪为什么指定源点下标是0,其实源点下标为1也一样,但是所有顶点的行号和列号就要加1了,如果行号和列号不变,而动态输入某个顶点,目前没有解决的方案,不过已足够。另外,为什么我用的两个数组名称是D和P,主要是为了Dijkstra算法统一起来,这两个算法除了核心的几句之外几乎没有区别,Dijkstra算法详见6.1。

5.2.Kruskal算法

5.2.1 描述
先将图中所有边按权值排序,每条边自成一个连通分量,假定生成树集合为T,原图的边集合为U,则先从U选出一条最短边加入T,接着若再要选取边加入T,则应按权值递增的顺序选边,若当前选取的最短边(i,j)中的i和j在T中属于不同的连通分量,则加入,否则会构成环路,不加入。具体实例的演示如下:
在这里插入图片描述

首先选取最短边(2,6),再选取(2,4),(4,3),(1,5),此时再选中一条最短边(3,2),发现2在生成树T中属于{2,4,3,6}这个连通分量,而3也属于这个连通分量,如果加入,会构成环,故不选,也就是,只要边的两个端点在T中属于同一个连通分量,就不加入。接着选中(2,5),至此,算法结束,虽然选中了权值更大的边,但是满足最小生成树的条件:n个图中全部顶点,n-1条边,不构成环。

5.2.2 实现
5.2.2.1 存边与排序
按照算法描述,第1件事情就是将图中的边按权值递增的顺序排序,为此需要额外定义一个结构体,包含一条边的起点,终点,权值,图中的所有边就由这个结构体类型的数组来保存,然后将图的存储结构,假定是邻接矩阵,将其中的值存储到这个结构体数组中,再按权值进行排序,这里使用C++ STL中的排序函数sort,如果是c语言,则可用qsort。当然,手动排序也可以,只不过不方便,在解决算法题时,往往要直接使用排序函数,一般只有学习排序函数原理的时候才会手动排序。邻接矩阵存边和排序实现(局部测试)如下:
在这里插入图片描述在这里插入图片描述在这里插入图片描述

在这里插入图片描述
5.2.2.2 并查集
准备工作做完后,开始真正的核心部分:判定边加入集合的条件,前面讲过,若边不能加入集合,是因为边加入集合后会形成环路,如何判定环路呢?这里要用到一个关于树的思想:
并查集。为什么是关于树的思想?因为我们求的是树~。
所谓并查集,是一种集合的表示,它有以下3种操作:

  1. Union(s,r1,r2):把集合s中的子集合r1并入子集合r2,条件是r1和r2互不相交
  2. Find(s,x):查找集合s中单元素x所在的子集合,返回子集合的名字
  3. Initial(s):将集合s中每个元素初始化为只有一个单元的子集合
    通常用树(森林)的双亲表示作为并查集的存储结构,每个子集合以一棵树表示。所有表示子集合的树,构成表示全集合的森林,存放在双亲表示数组内。通常用数组元素下标代表元素名,用根节点的下标代表子集合名,根节点的双亲节点为负数。具体实例如下图:在这里插入图片描述
    如图1,现在有5个节点,每个节点(元素)自成一个集合,它们各自的双亲指针都是-1,我们用一个数组a来存放它们,数组a大小为5,下标分别是0,1,2,3,4,代表各元素的名字,-1则是下标对应的值也就是a[i] (i=0,1,2,3,4),现在,我们将1,2作为0的孩子,构成一个集合,将4作为3的孩子构成一个集合,则会得到下图:在这里插入图片描述
    如图2,元素1,2的双亲指针也即a[1],a[2]的值变成了0,说明它们的双亲是0,a[4]=3,说明元素4的双亲是3,那么0,3元素的值是为什么呢?以3为例,3是它所在集合的根,除了它之外,只有1个元素4,那么它的双亲指针就减去1,元素0同理,在以0元素为根的集合里,除根外有2个元素,那么其双亲指针-1就减去2,现在,我们要将以0为根的集合(假定为A)和以3为根的子集合假定为B合并,条件是这两个集合的根是不同的,现在你很容易看出来,但是计算机不知道,它需要一个函数,根据A的元素来寻找A的根,再用相同的方法寻找B的根,相互比较,才能合并。寻找元素所在集合根的函数如下:在这里插入图片描述
    在这里插入图片描述
    其中s是一个包括所有顶点的大集合,如果图2是无向图,我现在要将边(2,3)合并到A,前面说过,名字就是下标,我查找2的根,s[2]=0,继续查找,s[0]=-3<0,返回0,同理,查找3的根,是3,外部进行判定,可以合并,换句话说,2在集合A这个连通分量上,3在集合B这个连通分量上,3和2在不同的连通分量上(通过函数判根),故可以加入A。你可能想知道之前的(0,1),(0,2),(3,4)是如何合并的,原理一样,每个元素自成一个集合,其根都是-1,都小于0,你传入名字(下标)进去,返回不同的名字,自然可以将两个顶点(单元素集合)合并成一条边(两个顶点的集合)。
    假定A是最终的生成树集合,那么A和B合并,具体地就是将3的根改为0,即a[3]=0,此时0所在集合又多2个元素,其值改为-5,合并后的图如下:在这里插入图片描述
    细心的读者可能会发现,将0的值修改的过程可以省去,也就是根的值一直是-1即可。现在为了进一步巩固并查集在kruskal算法中的应用,再举前面的例子:
    在这里插入图片描述
    如图4,我要将(2,3)并入生成树集合{(2,6),(2,4),(4,3)},查找2的根,假设是2(6其实也行),为什么?因为刚开始(2,6)是最短边。现在得到根2,查找3,根也是2,说明根相同(在同一个连通分量),不能合并,否则会出现环路。
    5.2.2.3 最终实现
    有了之前的铺垫,现在使用并查集这个“利器”,问题将较容易解决,但不一定完全解决,为什么?并查集虽然重要,但要用在kruskal算法上,需要注意其特殊性:生成树!在原并查集介绍中,我们有函数Union(s,r1,r2),作用是把集合s中的子集合r1并入子集合r2,条件是r1和r2互不相交,如果你每次把边的端点输入,那么结果很可能出错,因为Union总会把r1并到r2,但是r2不一定是生成树的根!如果要较好地实现算法,那么生成树的根必须是只有一个!

如何修改?其实很简单,增加第3个参数,假设是mstR,每次比较完,如果不等,若其中一个元素的根是mstR,则将根不是mstR的元素合并到mstR下,若两个元素的根都不是mtsR,那么随意合并即可。mstR怎么决定?也很简单,你已经将边按权值排好序了,那么取出第1条边也就是图的最短边,mstR可以设定为其边上任意两个端点之一。

OK,整个kruskal算法的核心问题才得以解决,现在就可以实现了!结果如下:
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
假定边数e,Find函数复杂度O(loge),总体复杂度O(eloge),不依赖于顶点数

6.基本算法3:路径求和

求带权有向图中某个源点到其余各顶点的最短路径(Shortest Path,SP),有如下两种算法:
其中对于Dijkstra算法,建议读者先理解5.1的Prim算法

6.1 Dijkstra算法

一般读作迪杰斯特拉,但其发音在荷兰语中应是“呆克斯tra”。
Dijkstra是著名的荷兰计算机科学家,1972年图灵奖得主,与计算机界泰斗D.E.Knuth并称为我们这个时代最伟大的计算机科学家的人。
6.1.1 描述
此算法与Prim算法很相似,共同点是每轮都要先求两个顶点集间的最短边,不同之处在于,求得最短边min后,当min+arc[k][j]<D[j],D[j]更新为不等式左边,也就是在Prim算法中D数组的意义从当前到vj的最短边权值,变成了从源点v0到vj的最短路径长(距离)在这里插入图片描述
如图,以顶点1为源点,若按照Prim算法,从顶点1到4,应选边(1,3),(3,4),而在Dijkstra算法中最先求得1到4的最短路径也与此一样,但是经过路径更新后,显然1到4的最短路径是1,2,4,具体过程是,在第1次求得1到4的最短路径后,需再次选取当前1,3,4构成的集合与剩余顶点集的最短边,显然是(1,2),那么min=2,原来D[4]距离是1+5=6,现在2到4的距离3+min=5<6,于是1到4的最短距离就变为了2+3=5,路径数组P记录新的构成最短路径的边,也就是P[4]=2,4的前驱是2。

6.1.2 实现
原来Prim算法的D数组的作用是存储当前最短边和标识顶点vi是否加入生成树,现在D的作用只是求源点v0到vi的最短距离,新增一个数组F来判定vi加入过最短路径,注意初始P元素都为v0,也就是前驱都初始化为v0,主算法实现如下:
在这里插入图片描述

如何输出顶点?这里利用了一个递归输出的思想,利用递归函数,逆序输出P数组的顶点,但是不要忘了判定当前路径是否存在。路径输出函数实现如下:
在这里插入图片描述
最后,主函数及结果如下:
在这里插入图片描述在这里插入图片描述
在这里插入图片描述
细心的读者可能会发现,1-6的最短路径有两条,当前最短边为(3,4)时更新了1-6的最短路径为1,3,6,故之后的1,2,4,6也就不选择了。最后,算法时间复杂度为O(n^2)

6.2 Floyd算法

Dijkstra算法是求单源最短路径,也就是单个源点到其他顶点的最短路径,那么如何求得所有顶点对之间的最短路径呢?最直接的方法是在Dijkstra算法的基础上套上一层循环,时间复杂度为O(n^3),但是Floyd算法提供了一种更简洁的实现方案。
6.2.1 描述
将Dijkstra算法中的P数组拓展至二维,P[i][j]表示vi到vj需要经过的第1个顶点k到vi的距离,也即(vi,vk)的权值,那么P数组元素自然就初始化为第2维,也即P[i][j]=j,将D数组拓展至二维,D[i][j]表示vi到vj的最短距离,于是,便容易得出D[i][j]的更新条件:D[i][k]+D[k][j]<D[i][j],即路径vi,vk,vj的长度比vi到vj的路径长度(中间经过几个顶点不管)更短的话,就更新vi到vj的长度为前者,此时P[i][j]更新为P[i][k],说明vi到vj路径上的第1个顶点是vk,(vi,vk)的权值被存储

在这里插入图片描述

以上图为例,假定数组下标从1开始,遍历顶点的序号递增,那么从顶点1开始,求到顶点4的最短距离,第1次求径就得到1,2,4,此时D[1][4]=5,第2次求径,D[1][3]+D[3][4]=3<5,则D[1][4]=3, P[1][4]=P[1][3]=1(之前一轮的更新使1-3距离为1),现在顶点1到顶点4经过的第1个顶点3,1到3距离为1。

最后,输出路径时不要忘记判定路径是否存在!

6.2.2 实现
在这里插入图片描述
在这里插入图片描述在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

6.3 拓扑排序

若一个有向图不存在环,则称该类图为有向无环图(Directed Acyclic Graph,DAG),拓扑排序和关键路径算法均建立在DAG之上
AOV Network:若用DAG表示一个工程,用其中的顶点表示活动,边(弧)表示活动之间的关系(事件),则称这样的图为AOV网(Activity On Vertex Network)。
6.3.1 描述
将AOV网中的顶点排成一个序列,当且仅当满足该序列满足下列条件时,称为原图的一个拓扑排序(Topological Sort,TSort)序列:

  1. 顶点出现且仅出现一次
    2.若v到u存在路径,则v一定排在u的前面,那么u到v就不存在路径
    在这里插入图片描述
    如图,因为最先开始的活动,其入度肯定为0,因为没有活动排在它的前面,不妨选择1,那么在1结束后,原来1的邻居3,4现在与1没有关系了,3,4的入度就要减少1,现在图中入度为0的顶点是2,说明没有活动排在2前面,则它一定紧跟在活动1后面,活动2结束后,3与2没有关系了,入度减1,刚好为0,说明3之前已经没有活动,则紧跟在2后面,以此类推,直到最后所有活动结束,也即顶点都从图中摘除,就会得到一个该DAG的拓扑排序{1,2,3,4,5,6},当然拓扑排序不是唯一的,只要一个活动结束,其他入度减少至0的顶点都可排在刚结束的活动之后进行。

6.3.2 实现
拓扑排序需要用一个辅助线性结构来保存入度为0的顶点,因为前面使用了队列,现在我们
用栈来实现,首先要搜寻图中所有顶点,将所有入度为0的顶点入栈,接着遍历图,出栈,摘除栈顶顶点,相应地,修改其邻居的入度,若有邻居的入度减至0,则将这个邻居入栈,
将邻居入栈后,再出栈,访问栈顶元素的邻居。拓扑排序其实也可以用DFS来实现,有关内容,详见算法拓展7.4。现在,代码如下:

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

算法的时间复杂度O(n+e),此算法虽然能在线性时间内完成,但对于非DAG则需要额外的判定,也就是只要图中有环就不能进行拓扑排序,拓扑排序序列一定不存在,关于图中环路的判定,详见算法拓展7.3,另外需要注意的是,拓扑排序不唯一,也就是从入度为0的顶点中选取的次序无所谓。

6.4 关键路径

AOE Network:若用DAG表示一个工程,用其中的顶点表示事件,边(弧)表示活动,则称这样的图为AOE网(Activity On Edge Network)。
6.4.1 描述
在AOE网中,我们把路径上各个活动所持续时间之和称为路径长度,从源点(入度为0)到汇点(出度为0)具有的最大长度的路径叫作关键路径(Critical Path),在关键路径上的活动叫关键活动。AOE网是表示工程的,具有工程的特性,在某顶点所代表的事件发生后,从该顶点出发的各个活动才能开始,于是可得到一个事件(顶点)的最早发生时间,相应地,有最晚发生时间,我们可根据活动和事件的最早/最晚发生时间来算出AOE的最大路径长。

假定事件vk最早发生时间为evk,最晚为lvk,活动ak最早发生时间为eak,最晚lak,
且w(i,j)表示边(i,j)的权值,则有如下关系式:
式1:evk=max{evi+w(i,k)}
这就是说,若当前顶点vi的最早发生时间与其邻接的顶点vk之间的权值的和大于vk的最早发生时间,即if(evk<evi+w(i,k)),then evk= evi+w(i,k)

式2:lvk=min{lvi-w(k,i)}
即if(lvi-w(k,i)<lvk),then lvk= lvi-w(k,i)
如何记忆?熟悉贪心算法的读者可能会知道,其求最早发生时间的过程是正向,从源点开始进行一次贪心算法,求最晚发生时间的过程是逆向,从汇点出发,进行一次贪心算法。
一种较粗略的记忆方法是,求evk就是正向,故求max,加,下标从左至右是kiik,求lvk就是逆向,故求min,减,下标从左至右是kiki,相对于evk,边下标互换。

求得evk和lvk后,求eak和lak就比较容易了,假设ak(边)的起点是i,终点是j,
则有如下2式:
式3:eak=evi 式4:lak=lvj-w(i,j)
求得eak和lak后,若eak==lak,则说明ak为关键活动,所有关键活动的权值和就是AOE网的最大路径长

6.4.2 实现
关键路径算法首先要求的就是事件的最早发生时间evk,则需基于拓扑排序,正向贪心,同时还需要用到一个栈,算法如下:

在这里插入图片描述
在这里插入图片描述
求得各个事件的最早发生时间后,再按剩余关系式计算,即可得到结果

在这里插入图片描述在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

需要注意的是,若图中的最大路径不唯一,则结果会出错,比如上图的边(5,7)的权值改为9,则5至9的距离和5至7的距离是一样的,输出路径的时候会多输出一个7,最大权值和也会比原来多9,具体的优化方案留给读者作为练习,这里只讲述最基本的实现算法。

最后,算法时间复杂度为O(n+e)

7.算法拓展

注:
1.阅读此节前建议先理解前6节
2.本节更着眼于算法描述,真正实现留给读者上机练习

7.1 DFS非递归算法

实现比较简单,只需将BFS的队列改为栈即可,注意输出时和BFS的区别,BFS是在入队或出队后都可以输出,而DFS非递归(non-recursion)算法必须在出栈后输出栈顶元素,具体算法如下:

void DFS_NonRec(Graph &g,int v0){
s[++top]=v0; visited[v]=1;
while(top){
v=s[top–];Print(v);
for(v=FirstNbr(v);HasNbr(v);v=NextNbr(v))//遍历其邻居
if(!visited[v]){ s[++top]=v; visited[v]=1;}
}
}

7.2 BFS求单源最短路径

BFS也可用于求单源最短路径,相应地,也需要一个一维数组来记录v0到u的最短路径长

void BFS_MinDist(Graph &g,int v0){
InitQ(q);//初始化队列
for(i=0;i<g.vexnum;i++)d[i]=INF;//初始化路径长为无穷
d[v0]=0; EnQ(q,v0); visited[v0]=1;//v0入队
while(!QEmpty(q)){//若队非空
DeQ(q,v);Print(v);//出队
for(u=FirstNbr(v);HasNbr(u);u=NextNbr(u))//遍历其邻居
if(!visited[u]){
EnQ(q,u);visited[u]=1;
d[u]=d[v]+1;//v的邻居=v+1
}
}
}

7.3 有向/无向图判定环路

判定有向图是否有环路的方法之一是用DFS,先将visited数组的值增加1个,也就是其值有3个,分别是-1,0,1,-1表示该顶点未访问,0表示该顶点的邻居正在被访问,也就是该顶点还未完全访问,1表示该顶点已完全访问,那么在判定当前顶点邻居的时候,若该邻居的状态是-1,则深入搜索,若是0,则立即返回,按此方法判定,算法还应该增设标志,不妨设为flag,该变量初值为0,一旦图中出现环路,则赋值为1并返回,可在函数体进入之后首先判定flag,若为1,则返回,算法如下:

void DFS (Graph& g,int v,int flag){
if(flag==1)return;
visited[v]=0;//v在访问中
for(v=FirstNbr(v);HasNbr(v);v=NextNbr(v))//遍历其邻居
switch(visited[v]){
case -1:DFS(g,v,flag);break;
case 0:flag=1;return;
}
visited[v]=1;//v完全访问
}
你可能会疑问,该算法为什么不适用无向图,答案是能判定环路,但是无法遍历无向图,原理很简单,判定该顶点v的邻居时,因为v也是v的邻居的邻居,也就是v是其邻居的父节点,算法会误认为存在环路,改进的方法和BFS判定环路的算法类似,每次判定其邻居的时候,如果是父节点就忽略
BFS判定环路的算法如下:

bool BFSLoop (Graph &g,int v0){
InitQ(q);//初始化队列
EnQ(q,v0); visited[v0]=1;//v0入队
while(!QEmpty(q)){//若队非空
DeQ(q,father);Print(father);//出队
for(v=FirstNbr(father);HasNbr(v);v=NextNbr(v))//遍历其邻居
if(v!=father)//若不是父节点,即父节点一概忽略
{
if(!visited[v]){ EnQ(q,v);visited[v]=1;}
else return false;//若已访问,则报告存在环路
// v还没被访问完,邻居却已访问完了,那么肯定有环路
}
}
return true;//图已遍历完毕且不存在环路
}

上述判定环路算法的前提是每次选取的邻居都是不同的,但无论是否相同,其对应的算法实现都比较简单,在此不作赘述。判定图存在环路的另一种方法是利用拓扑排序,设置一个计数器,初始化为0,并在每次顶点出栈后+1,栈空后,若计数器小于原图节点数,则报告存在环路,原理是环路中任一个顶点,其入度数都至少是1,无法从图中摘除,读者若要真正实现拓扑排序,也应加入此判定以提高算法的鲁棒性。

7.4 基于DFS的拓扑排序

如果DFS中visited的数组如上节所述,那么DFS中每一个顶点被完全访问的次序,恰按逆序给出了图的其中一个拓扑排序。
回顾此图,假定DFS从顶点1出发,如果访问顶点的邻居是按序递增的话,那么最终得到的序列是{4,6,5,3,1,2},读者可回顾6.3.2的实现,其中一个拓扑排序序列与此序列恰好逆序,那么原理又是什么呢?

若一个顶点v被完全访问,说明其邻居皆被完全访问,也就是v的出度对应的顶点皆被完全访问,v与其邻居都已经“打过招呼”了,按6.3.1的说法,v若入度为0,那么v与其邻居的关系应该结束,v应从图中摘除。由于DFS的递归特性,先被完全访问的顶点u,其父节点甚至祖先还未被完全访问,也就是即便u与其邻居都“打过招呼”了,u的入度还不是0,不能从图中摘除,那么其最高祖先是否就一定能先从图中摘除了呢?显然,若把当前祖先,子孙比作一棵树,还有树之外的顶点,就如上图所示的那样,2才是第1个需要摘除的顶点。如果DFS从2出发,那么最终得到{4,6,5,3,2,1},1就成为第1个需摘除的顶点。

既然被完全访问的顶点按逆序给出图的一个拓扑排序,那么用栈来保存是最合适不过的了,具体算法如下:
void DFS _TSort(Graph& g,int v){//环路判定由调用函数中的计数器实现
visited[v]=0;//v在访问中
for(v=FirstNbr(v);HasNbr(v);v=NextNbr(v))//遍历其邻居
if(visited[v]==-1)DFS(g,v);
visited[v]=1; s[++top]=v;//若完全访问,入栈
}
得到栈后,其出栈顺序就是图的一个拓扑排序。

7.5 旅行商问题

旅行商问题(Travelling Salesman Problem ,TSP)是经典的图论问题,问题大致描述了给定一系列城市和每对城市之间的距离,求解访问每一座城市一次并回到起始城市的最短回路,解决此问题的算法很多,在这里讲述其中一种算法,更多有关的算法及研究可自行网搜,资源丰富(百度相关词条数约260万)。我们现在要解决的问题是TSP的一个简单版本:

Shrek是一个大山里的邮递员,每天负责给所在地区的n个村庄派发信件。但由于道路狭窄,年久失修,村庄间的道路都只能单向通过,甚至有些村庄无法从任意一个村庄到达。这样我们只能希望尽可能多的村庄可以收到投递的信件。Shrek希望知道如何选定一个村庄A作为起点(我们将他空投到该村庄),依次经过尽可能多的村庄,路途中的每个村庄都经过仅一次,最终到达终点村庄B,完成整个送信过程,请你帮助Shrek计算出符合条件的最长道路经过的村庄数。

该问题可被抽象为:求DAG中的包含最多顶点数的路径上的顶点数,该问题没有涉及图中各边的权值,如果求的是路径,那么权值可设为1,利用关键路径算法求出最大路径,现在,只需求出顶点数,那么边上的权值可以忽略,转而计算从源点到每个顶点所经过的顶点数,求最大值即可。

我们给每个顶点附上一个属性vexn,表示源点到该顶点所经过的最多顶点数,初始化为1,因为该问题要求遍历图中每个顶点,而且顶点不重复,故基于的算法框架是拓扑排序。于是,在每次摘除入度为0的顶点v后,遍历其邻居u,则比较v的vexn+1和u的vexn,u的vexn取较大者,图的遍历结束后,利用一重循环求最大的vexn即可。

虽然总体时间复杂度仍是O(n+e),但是应对求最大的vexn的方法进行优化,方法就是利用动态规划的思想,定义一个变量maxvn,初始化为1,在每次得到u的vexn后,比较maxvn和u的vexn的大小,maxvn取其中较大者,这样,在图的遍历结束后,maxvn即所求的最大顶点数,具体算法如下:

void TSP(){
for(i=0;i< n;i++)if(!list[i].in)s[++top]=i;
while(top){
v=s[top–];
for(Enode*p=list[v].fstE;p;p=p->next){
u=p->adjvex;
if(!(–list[u].in)) s[++top]=u;
list[u].vexn =max( list[v].vexn+1, list[u].vexn);
maxvn =max(list[u].vexn, maxvn);
}
}
}

8.结语

图是数据结构的精髓,掌握图算法,相当于把握了数据结构的命脉,图论算法在工程,流程图,导航系统等中也有着广泛应用,甚至求机场等重要场所的某个待维护的“关节点”(双连通域分解)中也有应用,本文提及算法,若能做到“信手拈来”,我想无论是在数据结构,计算几何,或离散数学的学习,还是考研,算法竞赛中至少不会没有信心面对。掌握图论算法,无论是对将来的计算机学科方面的深造,还是对数学,工学方面的深造都有很大的帮助。

再说下本人写此文之初衷,本人现在是一名大三计算机相关专业的学生,因考研复习时复习到图算法,而之前一直未有完全巩固,于是心血来潮,写下此文,写此文的过程也是不断巩固与学习的过程,本人意识到,算法描述和上机实现存在一定差距,只有相关算法上机真正实现过了,只看算法描述才能真正领悟其本质。另外,虽然本文绝大多数内容是全凭本人对算法的理解而写出来的,但还是免不了对经典算法书籍的参考,现列于下:

1.《大话数据结构》 程杰著,清华大学出版社
此书是适合数据结构入门的一本好书,书中对常见算法的讲解通俗易懂,不过不足之处是有大量篇幅描述算法在计算机上运行的过程,读者可根据需要略过。

2.《2020王道考研数据结构复习指导》,王道论坛编写组编,电子工业出版社
王道考研系列书籍一直是计算机考研的“复习利器”,对于绝大多数院校的计算机考研来说,完全搞懂王道的所有内容=考研计算机专业课高分

3.《数据结构(C++语言描述)》,邓俊辉著,清华大学出版社
清华计算机系教材,知识讲解深入浅出,细致,循序渐进,图文并茂,知识范围广,从基础数据结构到高级数据结构(伸展树,红黑树,左式堆,跳转表等等),满足不同人群的需要,其配套的在线MOOC教程更是受广大算法学习者的青睐。当然,要搞透该教材的内容并不简单,读者可根据需求选学,其习题解析也可参考。
最后,作为一个和计算机或数学相关的学习者,学好图论算法只是今后学习生涯中的一个重要环节,算法学习,任重而道远,仅此与诸位共勉,再接再厉!

        **曾天佑**
		**2019年5月6日于郑州航空工业管理学院**
  • 2
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

柚纸君@blog

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值