A.pro读算法の8:快速搞定图的遍历

与其他数据结构一样,图也需要进行遍历操作,来访问各个数据点,以及后续对顶点和边进行操作。相对于树来说,图的结构更为复杂。

目录

1.1 概述

1.2 图的存储结构

1.3 深度优先遍历[2018.10.01完工]

1.4 广度优先遍历[2018.10.11完工]

大部分灵感来与《啊哈算法》

1.1 概述

先介绍一下图。

图(graph)是数据结构和算法学中最强大的框架之一(或许没有之一)。图几乎可以用来表现所有类型的结构或系统,从交通网络到通信网络,从下棋游戏到最优流程,从任务分配到人际交互网络,图都有广阔的用武之地。

图(graph)并不是指图形图像(image)或地图(map)。通常来说,我们会把图视为一种由“顶点”组成的抽象网络,网络中的各顶点可以通过“边”实现彼此的连接,表示两顶点有关联。

之前的博客中已经介绍了dfs和bfs。对dfs和bfs分别命名“深度”和“广度”是和他们的搜索方式有关。

请看下面这张图。

《A.pro读算法の8:快速搞定图的遍历》

如图,图(graph)就是由一些小圆点(顶点(又称节点))和连接这些小圆点的直线(边)组成的(或者,是由n个顶点和m条边组成的集合)。上图的5个顶点(编号为1、2、3、4、5)和5条边(1-2、1-3、1-5、2-4、3-5)组成。

这5个点的被访问顺序如下图。图中每个顶点右上方的数表示这个顶点是第几个被访问到的。这就是时间戳

《A.pro读算法の8:快速搞定图的遍历》

上图的遍历方法是深度优先遍历。相信很多人都知道原理,这里不多阐述。如果不理解就去看我的深度优先搜索的博文

显然,深度优先遍历是沿着图的某一条分支遍历直到末端,然后回溯,再沿着另一条进行同样遍历,直到所有的顶点被访问为止。这一过程怎么实现的呢?

1.2 图的存储结构

邻接矩阵(Adjacency Matrix),它使用二维数组来存储图的边的信息和权重。

《A.pro读算法の8:快速搞定图的遍历》

右边的邻接矩阵表示各个点的关系。表中1表示顶点i和顶点j有边。max表示没有边,0是自己到自己,处在顶点上(i=j)。

假设我现在在V1的点上,也就是(V1,V1)这个地方。由于我现在顶点1上,所以为0,表示这是自己的顶点,自己到自己。而在图中,V2、V3、V5都与V1有关系,所以在(V1,V2)、(V1,V3)、(V1,V5)都记录1,1就表示两个顶点之间有边。由于V1和V4没有边连接,所以记为没有边,即max。

《A.pro读算法の8:快速搞定图的遍历》

同理,V2与V1、V4有关联,V3、V5和V1没有连接。所以在(V2,V1)、(V2,V4)记录1,表示有边连接,(V2,V3),(V2,V5)没有关联记为max。

有读者发现了:由5个0分开的两块图形中,他们是对称的!为什么呢?

《A.pro读算法の8:快速搞定图的遍历》

它们能正好翻折过来,正好对应各个元素。

为什么呐?因为它是—-无向图。无向图就是图的边没有方向。V1到V5,或者V5到V1,都拥有同一条边。

我们用大家最好理解的—-dfs深度优先搜索遍历这张图。

输入:

5 5
1 2
1 3
1 5
2 4
3 5

输出:

1 2 4 3 5

 

#include <stdio.h>
#include <iostream>
using namespace std;
int b[101];//记录哪些点被访问过
int n,s;//n存储图的顶点个数,s记录已经访问过多少个顶点
int a[101][101];//图的边,也就是邻接矩阵
void dfs(int i)//当前顶点的编号 
{
	register int j;
	cout<<i<<' ';//输出访问到的当前点(其实这个程序也可以理解成深度搜索树) 
	s++;//访问顶点数+1
	if(s==n)//如果所有顶点都被访问完
	{
		return;
	}
	for(j=1;j<=n;j++)//从1号顶点到n号顶点依次尝试,搜索哪些顶点与当前顶点i有边相连
	{
		if(a[i][j]==1 && b[j]==0)//如果当前点i到顶点j有边,且顶点j没被访问过 
		{
			b[j]=1;//标记访问过了 
			dfs(j);//从顶点j出发继续遍历 
		}
	} 
}
int main()
{
	ios::sync_with_stdio(false);
	cin.tie(0);
	int i,j,m,x,y;//m图的边数
	cin>>n>>m;
	for(i=1;i<=n;i++)//初始化邻接矩阵 
	{
		for(j=1;j<=n;j++)
		{
			if(i==j)
			{
				a[i][j]=0;
			}
			else
			{
				a[i][j]=1<<30;
			}
		}
	}
	for(i=1;i<=m;i++)//读入顶点之间的边
	{
		cin>>x>>y;
		a[x][y]=1;
		a[y][x]=1;//无向图,所以这个也赋值为1 
	}
	b[1]=1;//访问1号顶点
	dfs(1);//从1号顶点dfs遍历 
	return 0;
}

下面是bfs遍历。

广搜优先搜索

使用广度优先搜索来遍历这张图的结果如下:

《A.pro读算法の8:快速搞定图的遍历》

5个顶点的被访问顺序如下图。

《A.pro读算法の8:快速搞定图的遍历》

bfs搜索树,同层顺次遍历(向外拓展)。

首先以一个未被访问过的顶点作为起始顶点,比如1号顶点。将1号顶点加入队列中,然后将1号顶点相邻的未访问过的顶点即2、3、5号顶点依次再放入到队列中。如下图。

《A.pro读算法の8:快速搞定图的遍历》

接下来再将2号顶点相邻的未访问过的顶点4号顶点再放入到队列中。到此所有顶点都被访问过。如下图。

《A.pro读算法の8:快速搞定图的遍历》

广度优先遍历的主要思想是:首先以一个未被访问过的顶点作为起始顶点,访问所有相邻的顶点,然后对每个相邻的顶点,再访问对每个相邻的顶点,再访问它们相邻的未被访问过的顶点,直到所有顶点都被访问过,遍历结束。代码如下。

#include <stdio.h>
#include <iostream>
using namespace std;
int b[101];//记录哪些点被访问过
int n,s;//n存储图的顶点个数,s记录已经访问过多少个顶点
int a[101][101];//图的边,也就是邻接矩阵
int que[20001],head(1),tail(1);
inline void bfs()
{
	int i,j;
	while(head<tail && tail<=n)//当队列不为空 
	{
		i=que[head];//当前访问的顶点 
		for(j=1;j<=n;j++)//从1号顶点到n号顶点的尝试 
		{
			if(a[i][j]==1 && b[j]==0)//如果顶点i到顶点j是否有边且顶点j是否访问过 
			{
				que[tail]=j;//顶点j入队 
				tail++;
				b[j]=1;//走过了 
			}
			if(tail>n)//当所有点都被访问过 
			{
				break;
			}
		}
		head++;//一个顶点拓展结束后,还要继续拓展下一个点 
	}
	for(i=1;i<tail;i++)
	{
		cout<<que[i]<<' ';
	} 
}
int main()
{
	ios::sync_with_stdio(false);
	cin.tie(0);
	int i,j,m,x,y;//m图的边数
	cin>>n>>m;
	for(i=1;i<=n;i++)//初始化邻接矩阵 
	{
		for(j=1;j<=n;j++)
		{
			if(i==j)
			{
				a[i][j]=0;
			}
			else
			{
				a[i][j]=1<<30;//设2^20为max 
			}
		}
	}
	for(i=1;i<=m;i++)//读入顶点之间的边
	{
		cin>>x>>y;
		a[x][y]=1;
		a[y][x]=1;//无向图,所以这个也赋值为1 
	}
	b[1]=1;//访问1号顶点
	que[tail]=1;//1号顶点加入队列 
	tail++;
	bfs();
	return 0;
}

输入:

5 5
1 2
1 3
1 5
2 4
3 5

输出:

1 2 3 5 4

使用2种方法遍历图会得到这个图的最小生成树(Minimum Spanning Tree,MST)。算法将会在以后讨论。

等等!上面的是无向图,那有向图呢?

无向图的边构成了一个对称的矩阵,似乎浪费了一般的空间。那如果是有向图来存放,会不会把资源利用的很好呢?

有向图与无向图的区别是,它的边是有方向的。

《A.pro读算法の8:快速搞定图的遍历》

如果你已经理解了无向图,那么有向图也是很好理解的。

和邻接矩阵对比,一斜排的0没有改变,但是并不是对称的了。对于V1,它能连接V2,V3,V4这4个点,但是V2,就没有向外拓展的有向边,因为这是有向图,所以V2那一排全部是max。

提一个问题:从V1走到V4怎么走最快?

如果你是这样想的,那你就上当了~哈哈~

《A.pro读算法の8:快速搞定图的遍历》

正确答案是:V1->V3->V4。有没有注意到边旁边的数字?这就是权重(Weight)。你可以理解成点与点的距离。

从V1->V4需要7距离,而从V1->V3->V4只需要5距离。这个问题很适用于最短路径问题,我们将在以后讨论。

由于计划改动,邻接表的介绍放在算法11中介绍。

1.3深度优先遍历[2018.10.01完工]

问题引入

国庆小长假要到了,A.pro想去大佬lxy家去补习(谁叫A.pro是蒟蒻呢)。A.pro和大佬之间有许多错落的社区。A.pro没有去过大佬家,这是蒟蒻A.pro第一次上门。怎么办呢?机智的A.pro便想起了百度地图。百度地图一下子就给出了从A.pro到大佬家的最佳行车方案。诶!蒟蒻A.pro当时就想啊,这百度地图是如何计算出行车最短方案的呢?下面是各个社区的地图。(画工不好请见谅^-^)

《A.pro读算法の8:快速搞定图的遍历》

数据是这样给出的:

5 8
1 2 2
1 5 10
2 3 3
2 5 7
3 1 4
3 4 4
4 5 5
5 3 3

第一行的5表示有5个社区,(社区编号为1~5),8表示有8条路。接下来8行每行是一条类似于“a b c”这样的数据,表示有一条路可以从社区a到社区b,且路程为c。需要注意的是这里的路是单行的(有向图),即“a b c”仅仅表示一条路可以从社区a到社区b,并不表示社区b也有一条路可以到社区a。蒟蒻A.pro在1号社区,大佬lxy在5号社区。现在求从1号社区到5号社区的最短路径(最短路程)。

我们还是用一个二维数组a存储这些信息。如图。

《A.pro读算法の8:快速搞定图的遍历》

上面这个二维矩阵,表示了任意2个社区之间的路程。比如a[1][2]的值为2就表示从1号社区的2号社区的路程为2。和上面一样,max表示无法到达,代码用2^30表示max。比如a[1][3]的值为max,表示从1号社区到3号社区无法到达。另外,一个社区自己到自己的距离为0。

接下来要找从1号社区到5号社区的最短路径了。首先从1号社区出发,那么1号社区可以到达哪些社区呢?从上图可以看出可以看出1号社区可以到达2号社区和5号社区。那是先到2号社区还是先到5号社区呢?这里像全排列问题一样规定一个顺序:从1~n的顺序。现在先选择到2号社区,到达2号社区后接下来又该怎么办呢?依旧参照刚才对1号社区的处理方法,再来看2号社区可以到达哪些社区。从二维数组a可以看出来,第2行看出从2号社区可以到达3号社区和5号社区(其实这里可以记忆化剪枝一下)。按照之前的顺序,我们选择去3号社区。同理,3号社区可以到达4号社区,4号社区又可以到达5号社区,嗯,到达大佬家了!我们这时候已经找到了一条从1号社区到达5号社区的路径,这条路径是1->2->3->4->5。路径长度为14。那是不是结束了?

显然没有!因为1->2->3->4->5这条路径的长度不一定是最短的。因此我们还需要回到第4个社区看看有没有别的路使得让路径更短些。但是我们发现4号社区除了可以到达5号社区,再也没有别的路可以走了。此时我们需要返回到3号社区,我们发现3号社区除了一条路通往4号社区也没有其他路了,于是继续回到2号社区。2号社区还有1条路通往5号社区,于是就产生了1->2->5这条路径,路径长为9。在对2号社区尝试完后,又回到了1号社区。我们发现1号社区还有一条路是直接通往5号社区的,于是又产生了一条路径1->5,路径长为10。我们一共找到了3条路径,分别是:

1->2->3->4->5    路径长:14

1->2->5               路径长:9

1->5                    路径长:10

我们需要一个全局变量min来更新每次找到的最短路径长,最终找到的最短路长为9。我们需要一个数组记录哪些社区已经走过,避免重复走。

#include <stdio.h>
#include <iostream>
using namespace std;
int n,minx(1<<30),a[101][101],b[101];//1<<30为max 
void dfs(int i,int dis)//i是当前所在的社区编号,dis是已经走过的路程 
{
	register int j;
	if(dis>minx)//如果当前路程比已知最小路程大,没必要再寻找了 
	{
		return;
	}
	if(i==n)//如果已经到达目标社区 
	{
		minx=min(minx,dis);//取哪个路径更短 
		return;
	}
	for(j=1;j<=n;j++)//从1号社区到n号社区依次尝试 
	{
		if(a[i][j]!=1<<30 && b[j]==0)//如果当前社区i到社区j有路(即非等于max),并且没走过 
		{
			b[j]=1;//标记走过了 
			dfs(j,dis+a[i][j]);//从社区j出发,继续寻找目标社区 
			b[j]=0;//之前一步尝试完毕后,取消对社区j的标记 
		}
	}
}
int main()
{
	ios::sync_with_stdio(false);
	cin.tie(0);
	int m,i,j,x,y,z;//m为路数,x,y,z代替了上面的a,b,c 
	cin>>n>>m;
	for(i=1;i<=n;i++)
	{
		for(j=1;j<=n;j++)
		{
			if(i==j)
			{
				a[i][j]=0;
			}
			else
			{
				a[i][j]=1<<30;
			}
		} 
	}
	for(i=1;i<=m;i++)//输入社区间的道路 
	{
		cin>>x>>y>>z;
		a[x][y]=z;
	}
	b[1]=1;//标记1号社区走过了 
	dfs(1,0);//从1号社区开始出发,0表示已经走过的路程 
	cout<<minx<<endl;//从1号社区到n号社区的最短路径 
	return 0;
}

输入:

5 8
1 2 2
1 5 10
2 3 3
2 5 7
3 1 4
3 4 4
4 5 5
5 3 3

 输出:

9

现在来小结一下图的基本概念。简单的说,图就是由n个顶点和m条边组成的集合。这里的社区地图就是一张图,尽管你可能认为它是图像(image)或地图(map),图中每个社区就是一个顶点,而两个社区之间的有向公路(你可以认为是单行道)则是两个顶点的边。尽管这个定义不是很严谨,但是你应该能听懂。

在1.2节中我们已经搞清楚了2个概念:有向图和无向图。如果给图的每条边规定方向,那么得到的图被称为有向图,边也叫有向边。在有向图中,与一个点相关联的边有出边个入边之分,而与一个有向边关联的两个点也有始点和终点之分。相反地,边没有方向的图被称为无向图。如:

《A.pro读算法の8:快速搞定图的遍历》

处理无向图和处理有向图的代码几乎是一模一样的。只是在处理无向图初始化的时候有一点需要注意。“a b c”表示社区a和社区b可以到达,路程为c。于是我们需要将a[1][2]和a[2][1]都需要初始化为c。因为这是双行道。初始化后的a数组如下。

《A.pro读算法の8:快速搞定图的遍历》

你会发现这个表是对称的,在1.2.1节已经解释过,这是无向图的一个特征。在这个无向图中,我们会发现从1号社区到5号社区的最短路径不是1->2->5,而是1->3->5,路径长为7。

下面是在这个题目中无向图的代码。

#include <stdio.h>
#include <iostream>
using namespace std;
int n,minx(1<<30),a[101][101],b[101];//1<<30为max 
void dfs(int i,int dis)//i是当前所在的社区编号,dis是已经走过的路程 
{
	register int j;
	if(dis>minx)//如果当前路程比已知最小路程大,没必要再寻找了 
	{
		return;
	}
	if(i==n)//如果已经到达目标社区 
	{
		minx=min(minx,dis);//取哪个路径更短 
		return;
	}
	for(j=1;j<=n;j++)//从1号社区到n号社区依次尝试 
	{
		if(a[i][j]!=1<<30 && b[j]==0)//如果当前社区i到社区j有路(即非等于max),并且没走过 
		{
			b[j]=1;//标记走过了 
			dfs(j,dis+a[i][j]);//从社区j出发,继续寻找目标社区 
			b[j]=0;//之前一步尝试完毕后,取消对社区j的标记 
		}
	}
}
int main()
{
	ios::sync_with_stdio(false);
	cin.tie(0);
	int m,i,j,x,y,z;//m为路数,x,y,z代替了上面的a,b,c 
	cin>>n>>m;
	for(i=1;i<=n;i++)
	{
		for(j=1;j<=n;j++)
		{
			if(i==j)
			{
				a[i][j]=0;
			}
			else
			{
				a[i][j]=1<<30;
			}
		} 
	}
	for(i=1;i<=m;i++)//输入社区间的道路 
	{
		cin>>x>>y>>z;
		a[x][y]=a[y][x]=z;//这是无向图的特征 
	}
	b[1]=1;//标记1号社区走过了 
	dfs(1,0);//从1号社区开始出发,0表示已经走过的路程 
	cout<<minx<<endl;//从1号社区到n号社区的最短路径 
	return 0;
}

输出:

7

 此外求图上两点间的最短路径,除了使用深度优先搜索以外,还可以使用广度优先搜索、Floyd、Bellman-Ford、Dijkstra等等,这些将以后阐述。

深度优先遍历,类似于树的先序遍历。

1.4广度优先遍历[2018.10.11完工]

广度优先遍历,类似于树的层序遍历。

问题引入

A.pro和fx君一起坐飞机去旅游。他们现在在1号城市,目标到达5号城市。可是1号城市并没有到达5号城市的直航。fx君问A.pro怎么到达,机智的A.pro拿出了GPS,收集到了一些航班信息。现在A.pro想知道,有没有一种乘坐方式,使得转机次数最少。

输入:

5 7 1 5
1 2
1 3
2 3
2 4
3 4
3 5
4 5

输出:

2

第一行的5表示有5个城市(城市编号为1~5),7表示有7条航线,1表示起点城市,5是目标城市。接下来7行表示从a到b有航线,在1.3节已经解释过类似这样的数据。如图。

《A.pro读算法の8:快速搞定图的遍历》

这里还是用邻接矩阵存储这张图(因为作者太弱了)。需要注意的是这里是无向图。城市的编号就是这些顶点的编号,航班线路就是点与点之间的边。我们可以把航班路程看成“1”,然后求出最佳的路线。

首先,1号城市入队,1号城市可以到达(扩展出)2号和3号城市。2号城市可以扩展出3号和4号城市。因为3号城市已经在队中,所以只需要将4号城市入队。接下来3号城市可以扩展出4号城市和5号城市,由于4号城市已经入过队,因此只需要将5号城市入队。此时目标城市已经入队,算法结束。你可能要问:为什么扩展到5号城市就结束了呢?请结合下面的图思考一下哦。

《A.pro读算法の8:快速搞定图的遍历》

这里是bfs,原理是向外扩展点。因此搜到5号城市也就自然找到最短路径了。

dfs也可以做,请读者自己写出。

bfs代码:

#include <stdio.h>
#include <iostream>
using namespace std;
int n,inx,outx,que[4001][3],a[51][51],b[4001];//que[i][1]城市编号,que[i][2]转机次数 
inline void bfs()
{
	int i,j,head(1),tail(1);
	que[tail][1]=inx;//入队 
	que[tail][2]=0;
	tail++;
	b[inx]=1;//标记 
	while(head<tail)
	{
		i=que[head][1];//当前队列中首城市的编号 
		for(j=1;j<=n;j++)//从1~n城市尝试 
		{
			if(a[i][j]!=1<<30 && b[j]==0)//如果城市i到城市j有航班且没走过 
			{
				que[tail][1]=j;//入队 
				que[tail][2]=que[head][2]+1;//转机次数+1 
				tail++;
				b[j]=1;//biaoji
			}
			if(que[tail-1][1]==outx)//如果到达目标城市 
			{
				cout<<que[tail-1][2]<<endl;
				return;
			}
		}
		head++;//别忘了 
	}
}
int main()
{
	ios::sync_with_stdio(false);
	cin.tie(0);
	int m,i,j,x,y;//m为路数,x,y,z代替了上面的a,b,c 
	cin>>n>>m>>inx>>outx;
	for(i=1;i<=n;i++)//初始化邻接矩阵 
	{
		for(j=1;j<=n;j++)
		{
			if(i==j)
			{
				a[i][j]=0;
			}
			else
			{
				a[i][j]=1<<30;
			}
		} 
	}
	for(i=1;i<=m;i++)//输入城市间的航班 
	{
		cin>>x>>y;
		a[x][y]=a[y][x]=1;//这是无向图的特征 
	}
	bfs();
	return 0;
}

在这里bfs会更快。bfs更适用于所有边权值相同的情况。

    原文作者:数据结构之图
    原文地址: https://blog.csdn.net/apro1066/article/details/82818495
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞