与其他数据结构一样,图也需要进行遍历操作,来访问各个数据点,以及后续对顶点和边进行操作。相对于树来说,图的结构更为复杂。
目录
1.1 概述
1.2 图的存储结构
1.3 深度优先遍历[2018.10.01完工]
1.4 广度优先遍历[2018.10.11完工]
大部分灵感来与《啊哈算法》
1.1 概述
先介绍一下图。
图(graph)是数据结构和算法学中最强大的框架之一(或许没有之一)。图几乎可以用来表现所有类型的结构或系统,从交通网络到通信网络,从下棋游戏到最优流程,从任务分配到人际交互网络,图都有广阔的用武之地。
图(graph)并不是指图形图像(image)或地图(map)。通常来说,我们会把图视为一种由“顶点”组成的抽象网络,网络中的各顶点可以通过“边”实现彼此的连接,表示两顶点有关联。
之前的博客中已经介绍了dfs和bfs。对dfs和bfs分别命名“深度”和“广度”是和他们的搜索方式有关。
请看下面这张图。
如图,图(graph)就是由一些小圆点(顶点(又称节点))和连接这些小圆点的直线(边)组成的(或者,是由n个顶点和m条边组成的集合)。上图的5个顶点(编号为1、2、3、4、5)和5条边(1-2、1-3、1-5、2-4、3-5)组成。
这5个点的被访问顺序如下图。图中每个顶点右上方的数表示这个顶点是第几个被访问到的。这就是时间戳。
上图的遍历方法是深度优先遍历。相信很多人都知道原理,这里不多阐述。如果不理解就去看我的深度优先搜索的博文。
显然,深度优先遍历是沿着图的某一条分支遍历直到末端,然后回溯,再沿着另一条进行同样遍历,直到所有的顶点被访问为止。这一过程怎么实现的呢?
1.2 图的存储结构
邻接矩阵(Adjacency Matrix),它使用二维数组来存储图的边的信息和权重。
右边的邻接矩阵表示各个点的关系。表中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。
同理,V2与V1、V4有关联,V3、V5和V1没有连接。所以在(V2,V1)、(V2,V4)记录1,表示有边连接,(V2,V3),(V2,V5)没有关联记为max。
有读者发现了:由5个0分开的两块图形中,他们是对称的!为什么呢?
它们能正好翻折过来,正好对应各个元素。
为什么呐?因为它是----无向图。无向图就是图的边没有方向。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遍历。
使用广度优先搜索来遍历这张图的结果如下:
5个顶点的被访问顺序如下图。
bfs搜索树,同层顺次遍历(向外拓展)。
首先以一个未被访问过的顶点作为起始顶点,比如1号顶点。将1号顶点加入队列中,然后将1号顶点相邻的未访问过的顶点即2、3、5号顶点依次再放入到队列中。如下图。
接下来再将2号顶点相邻的未访问过的顶点4号顶点再放入到队列中。到此所有顶点都被访问过。如下图。
广度优先遍历的主要思想是:首先以一个未被访问过的顶点作为起始顶点,访问所有相邻的顶点,然后对每个相邻的顶点,再访问对每个相邻的顶点,再访问它们相邻的未被访问过的顶点,直到所有顶点都被访问过,遍历结束。代码如下。
#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)。算法将会在以后讨论。
等等!上面的是无向图,那有向图呢?
无向图的边构成了一个对称的矩阵,似乎浪费了一般的空间。那如果是有向图来存放,会不会把资源利用的很好呢?
有向图与无向图的区别是,它的边是有方向的。
如果你已经理解了无向图,那么有向图也是很好理解的。
和邻接矩阵对比,一斜排的0没有改变,但是并不是对称的了。对于V1,它能连接V2,V3,V4这4个点,但是V2,就没有向外拓展的有向边,因为这是有向图,所以V2那一排全部是max。
提一个问题:从V1走到V4怎么走最快?
如果你是这样想的,那你就上当了~哈哈~
正确答案是: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当时就想啊,这百度地图是如何计算出行车最短方案的呢?下面是各个社区的地图。(画工不好请见谅^-^)
数据是这样给出的:
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存储这些信息。如图。
上面这个二维矩阵,表示了任意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 b c”表示社区a和社区b可以到达,路程为c。于是我们需要将a[1][2]和a[2][1]都需要初始化为c。因为这是双行道。初始化后的a数组如下。
你会发现这个表是对称的,在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节已经解释过类似这样的数据。如图。
这里还是用邻接矩阵存储这张图(因为作者太弱了)。需要注意的是这里是无向图。城市的编号就是这些顶点的编号,航班线路就是点与点之间的边。我们可以把航班路程看成“1”,然后求出最佳的路线。
首先,1号城市入队,1号城市可以到达(扩展出)2号和3号城市。2号城市可以扩展出3号和4号城市。因为3号城市已经在队中,所以只需要将4号城市入队。接下来3号城市可以扩展出4号城市和5号城市,由于4号城市已经入过队,因此只需要将5号城市入队。此时目标城市已经入队,算法结束。你可能要问:为什么扩展到5号城市就结束了呢?请结合下面的图思考一下哦。
这里是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更适用于所有边权值相同的情况。