图(Graph)是由顶点的有穷非空集合和顶点之间边的集合组成,通常表示为:G(V,E)。
其中G表示一个图,V是图G中顶点的集合,E是图G中边的集合。
一、图的定义
在线性表中,元素数据之间是串起来的,仅由线性关系,每个数据元素只有一个直接前驱和一个直接后继。
在树形结构中,数据元素之间有着明显的层次关系,并且每一层上的数据元素可能和下一层中多个元素相关,但只能和上一层的一个元素相关。这和一对父母可能有多个孩子,但一个孩子只能有一对父母是一个道理。可现实中,人与人之间关系就非常复杂,比如我认识的朋友,可能他们之间也互相认识,这就不是简单的一对一、一对多,研究人际关系很自然会考虑到多对多的情况。那就是我们今天要研究的主题——图。图是一种较线性表和树更加复杂的数据结构。在图形结构中,结点之间的关系可以是任意的,图中任意两个数据元素之间都可能相关。
图(Graph)是由顶点的有穷集合和顶点之间边的集合组成,通常表示为:G(V , E),G表示一个图,V表示G中订单的集合,E是G中边的集合。
对于图的定义,我们需要明确几个注意的地方。
- 线性表中我们把数据元素叫元素,树中将数据元素叫结点,在图中数据元素,我们称之为顶点(Vertex)。
- 线性表中我们可以没有数据元素,称为空表。树中可以没有结点,叫做空树。但是在图结构中,不允许没有顶点。在定义中,V是顶点的集合,则强调了顶点集合V的又穷非空。
- 线性表中,相邻的数据元素之间具有线性关系,树结构中,相邻两层的结点具有层次关系,而图中,任意两个顶点都可能有关系,顶点之间的逻辑关系用边来表示,边集可以是空的。
1.各种图定义
无向边:若顶点vi到vj之间的边没有方向,则称这条边为无向边(Edge),用无序偶对(vi , vj)来表示。如果图中任意两个顶点之间的边都是无向边,则称该图为无向图。由于是无方向的,连接顶点A和顶点D的边,可以表示成无序对(A,D),也可以写成(D,A)。
有向边:若从顶点vi到vj的边有方向,则称这条边为有向边,也称为弧(Arc)。用有序偶< vi,vj>,vi称为弧尾(Tail),vj称为弧头(Head)。如果图中任意两个顶点之间的边都是有向的,则称该图为有向图(Directed graphs)。连接顶点A到D的有向边就是弧,A是弧尾,D是弧头,< A,D>表示弧,注意不能写成< D, A>。
注意:无向边用小括号“( )”表示,而有向边用尖括号表示“<>”表示。
在图中,若不存在顶点到其自身的边,且同一条边不重复出现,则成这样的图为简单图。我们在这边讨论的都是简单图。
在无向图中,如果任意两个顶点之间都存在边,则成该图为无向完全图。含有n个顶点的完全无向图有((n-1) * n / 2)条边。
在有向图中,如果任意两个顶点之间都存在方向互为相反的两条弧,则称该图为有向完全图。含有n个顶点的完全有向图有n x (n - 1)条边。
从这里我们也可以得出结论:
对于有n个顶点和e条边的图,无向图0≤e≤n(n-1)/2,
有向图0≤ e ≤ n(n-1)。
有很少条边或弧的图称为稀疏图,否则称为稠密图。但是这里的稀疏和稠密是模糊的概念,都是相对而言的、
有些图的边或弧具有他相关的数字,这种与图的边或弧相关的数叫做权(Weight)。这些权可以表示从一个顶点到另一顶点的距离活耗费。这种带权的图通常称为网(Network)。
假设有两个图G = (V,{E’})和G’ = (V’ , {E’}),如果V’ 是V子集,E’是E的子集,则称G’是G的子图(SubGraph)。
2.图的顶点与边间关系
对于无向图G = (V,{E}),如果边(v , v’) ∈ E,则称顶点v和v’互为邻接点(Adjacent),即v和v’相邻接。边(v,v’)依附(incident)于顶点v和v’,或者说(v,v’)与顶点v和v’相关联。顶点v的度(Degree)是和v相关联的边的数目,记为TD(v)。无向图的边数就是各顶点度数和的一半,多出的一半是因为重复两次计数。简记之,e = ½ ∑TD(vi)。
对于有向图G = (V,{E}),如果弧< v,v’> ∈ E,则称顶点v邻接到顶点v’,顶点v’邻接自顶点v。弧< v,v’>和顶点v、v’相关联。以顶点v为头的弧的数目称为v的入度OD(v);以v为尾的弧的数目称为v的出度(OutDegree),记为OD(v);顶点v的度为TD(v) = ID(v) + OD(v)。有向图的边等与各顶点的出度和等与各顶点的入度和。
e = ∑ID(vi) = ∑OD(vi)。
无向图G = (V,{E})中从顶点v到顶点v’的路径(Path)是一个顶点序列(v = Vi,0 , Vi,1 ,…, Vi,m = v’)其中(Vi,j-1 , Vi,j)∈E ,1 ≤ j ≤ m。
如果G是有向图,则路径也是有向的,顶点序列就满足< Vi,j-1,Vi,j>∈E ,1≤ j ≤ m。
树种根结点到任意结点的路径是唯一的,但是图中顶点与顶点之间的路径却是不唯一的。
路径的长度是路径上的边或弧的数目。
第一个顶点到最后一个顶点相同的路径称为回路或环(Cycle)。序列中顶点不重复出现的路径称为简单路径。除了第一个顶点和最后一个顶点之外,其余顶点不重复出现的回路,称为简单回路或环。
3.连图相关术语
在无向图G中,如果从顶点v到顶点v’有路径,则称v和v’是连通的。如果对于图中任意两个顶点vi,vj ∈ V,vi和vj都是连图的,则称G是连图图(Connected Graph)。
无向图中的极大连图子图称为连通分量。注意连图分量的概念,他强调:
- 要是子图;
- 子图要是连通的;
- 连图子图含有极大顶点数;
- 具有极大顶点数的连通子图包含依附于这些顶点的所有边。
在有向图G中,如果每一对vi,vj∈V,vi ≠ vj,从vi到vj和从vj 到vi都存在路径,则称G是强连通图。有向图中的极大强连通子图称做有向图的强连通分量。
现在我们再来看连通图的生成树定义。
所围的一个连通图的生成树是一个极小的连通子图,它含有图中全部n个顶点,但只有足以构成一棵树的n - 1条边。不过n-1条边不一定是生成树。
如果一个有向图恰有一个顶点的入度为0,其余顶点的入度均为1,则是一棵有向树。对有向树的理解比较容易,所谓入度为0其实就相当于树种的根结点,其余顶点入度为1就是说树的非根结点的双亲只有一个。一个有向图的生成森林由若干棵有向树组成,含有图中全部顶点,但只有足以构成若干棵不相交的有向树的弧。
4.图的定义和术语总结
图按照有无方向来分,分为无向图和有向图。无向图由顶点和边组成,有向图由顶点和弧组成。弧有弧尾和弧头之分。
图按照边或弧的多少分稀疏图和稠密图。如果两个顶点之间都存在边叫完全图,有向的叫有向完全图。若无重复的边或顶点到自身的边叫简单图。
图中顶点之间有邻接点、依附的概念。无向图顶点的边数叫做度,有向图顶点分为入度和出度。
图上的边或弧上带权的称为网。
图中顶点之间存在路径,两顶点之间存在路径说明是连通的,如果路径最终回到起始点则称为环,当中不重复叫简单路径。若任意两顶点都是连通的,则图就是连通图。有向则称为强连通图。图中有子图,若子图极大连图则就是连图分量,有向的则称强连图分量。
无向图中连通且n个顶点n -1条边叫生成树。有向图中一顶点的入度为0其余项入度为1的叫有向树。一个有向图由若干棵有向树构成生成森林。
二、图的抽象数据类型
图作为一种数据结构,它的抽象数据类型带有自己的特点,正因为它的复杂,运用广泛,使得不同的应用需要不同的运算集合,构成不同的抽象数据操作。我们这里就来看看图的基本操作。
ADT图(Graph)
Data
顶点的有穷非空集合和边的集合。
Operation
CreateGraph(*G,V,VR):按照顶点集V和边弧集VR的定义构造图G。
DestroyGraph(*G):图G存在则销毁
LocateVex(G,u):若图中存在顶点u,则返回图中位置。
GetVex(G,V):返回图G中顶点v的值。
PutVex(G,v,value):将图G中顶点v赋值value。
FirstAdjVex(G,*v):返回顶点v的一个邻接顶点,若顶点在G无邻接顶点返回空。
NextAdjVex(G,v,*w):返回顶点v想对于顶点w的下一个邻接顶点,若w是v的最后一个邻接点则返回空。
InsertVex(*G,v):在图G中增添新顶点v。
DeleteVex(*G,v):删除图G中顶点v及其相关的弧。
InsertArc(*G,v,x):在图G中增添弧<v,w>,若G是无向图,还需要增添对称呼<w,v>。
DeleteArc(*G,v,x):在图G中删除弧<v,w>,若G是无向图,则还删除对称弧。
DFSTraverse(G):在图G中进行深度优先遍历,在遍历过程中对每个顶点调用
HFSTraverse(G):在图G中进行广度优先遍历,在遍历过程对每个顶点调用。
三、图的存储结构
图的存储结构相较线性表来说就更加复杂了。首先,我们口头上说的“顶点的位置”或“邻接点的位置”只是一个相对的概念。其实从图的逻辑结构定义来看,图上任何一个顶点可被看做第一个顶点,任一顶点的邻接点也不存在次序关系。
也正由于图的结构比较复杂,任意两个顶点之间都可能存在联系,因此无法以数据元素在内存中的物理位置来表示元素之间的关系,也就是说,图不可能用简单的顺序存储结构来表示。而多重链表的方式,即以一个数据域和多个指针域组成的结点表示图中的一个顶点,尽管可以实现图结构,但其实是有问题的。如果各个顶点之间的度数相差很大,按度数最大设计不同的顶点结构,又带来操作的不便。现在我们来看前辈们提供的五种不同的存储结构。
1.邻接矩阵
考虑到图是由顶点和边或弧两部分组成。合在一起比较困难,那就很自然地考虑到分两个结果来分别存储。顶点不分大小、主次,所以用一个一维数组来存储是很不错的选择。而边或弧是顶点与顶点之间的关系,一维搞不定,那就考虑用一个二维数组来存储。于是邻接矩阵的方案就诞生了。
图的邻接矩阵(Adjacency Matrix)存储方式是用两个数组来表示图。一个一维数组存储图中顶点信息,一个二维数组(称为邻接矩阵)存储图中的边或弧的信息。
设G有n个顶点,则邻接矩阵是一个n x n的方阵,定义为:
arc[i][j] = 1 , 若(vi,vj)∈E 或 < vi ,vj >∈E
= 0 ,反之
我们可以设置两个数组,顶点数组为vertex[4] = {v0,v1,v2,v3},边数组arc[4][4]。简单的解释一下,对于矩阵的主对角线的值,即a[0][0]、a[1][1]、a[2][2]、a[3][3],全为0是因为不存在顶点到自身的边,比如v0到v0。arc[0][1] =1是因为v0到v1的边存在,而a[1][3] = 0是因为v1到v3的边不存在。所以无向图的边数组是一个对称矩阵(n阶矩阵aij = aji)。即从矩阵的左上角到右下角的主对角线为轴,右上角的元与左下角的元全都是相等的。
有了这个矩阵,我们可以很容易地知道图中的信息。
- 我们要判定任意两顶点是否有边无边就非常容易了。
- 我们要知道某个顶点的度,其实就是这个顶点vi在邻接矩阵第i行(或第i列)的元素之和。比如顶点vi的度就是1+0+1+0 = 2。
- 求顶点vi的所有邻接点就是将矩阵中第i行元素扫描一遍,arc[i][j]为1就是邻接点。
再来看一个有向图样例。
顶点数组为vertex[4] = {v0,v1,v2,v3},弧度组arc[4][4]。主对角线上的数值依然为0。但是因为是有向图,所以此矩阵并不堆成。比如v1到v0有弧,得到arc[1][0] = 1,v0到v1没有弧,因此arc[0][1] = 0。
有向图讲究入度与出度,顶点v1的入度为1,正好是第v1列各数之和。顶点v1的出度为2。即第v1行各数之和。
与无向图同样的方法,判断顶点vi到vj是否存在弧,只需要查找矩阵中arc[i][j]是否为1即可。要求vi的所有邻接点就是将矩阵第i行元素扫描一遍,查找arc[i][j]为1的顶点。
在图的术语中,我们提到了网的概念,页就是每条边上带有权的图叫做网。那么这些权值就需要存下来,如何处理这个矩阵来适应这个需求的呢?我们有办法。
设图G是网图,有n个顶点,则邻接矩阵是一个n x n的方阵,定义为:
a[i][j] = W,若(vi,vj)∈E或< vi,vj>∈ E
0 ,若i = j
∞,反之
这里的wij表示(vi,vj)或< vi,vj>上的权值,∞表示一个计算机允许的、大于所有边上权值的值,也就是一个不可能的权值。为什么不是0呢?原因在于权值wij大多数情况下是正值,但个别时候可能就是0,甚至是负值。因此必须要用一个不可能的值来代表不存在。
那么邻接矩阵是如何实现图的创建的呢?我们先来看图的邻接存储的结构,代码如下 。
typedef char VertexType; //顶点类型应由用户定义
typedef int EdgeType; //边上的权值类型应由用户定义
#define MAXVEX 100 //最大顶点数,应由用户定义
#define INFINITY 65535 //用65535来代表∞
typedef struct
{
VertexType vexs[MAXVEX];//顶点表
EdgeType arc[MAXVEX][MAXVEX];//邻接矩阵
int numVertexes,numEdges; //图中当前顶点数和边数。
}MGraph;
有了这个结构定义,我们构造一个图,其实就是给顶点表和边表输入数据的过程。我们来看无向图的创建代码。
//建立无向图网的邻接矩阵表示
void CreateMGraph(MGraph *G)
{
int i,j,k,w;
printf("输入顶点数和边数:\n");
scanf("%d,%d",&G->numVertexes,&G->numEdges);//输入顶点数和边数
for(i = 0;i < G->numberVertextes;i++)//读取顶点信息,建立顶点表
scanf(&G->vexs[i]);
for(i = 0;i < G->numVertexes;i++)
for(j = 0;j < G->numVertexes;j++)
G->acr[i][j] = INFINITY;//邻接矩阵初始化
for(k = 0;k < G->numEdge;k++)
{
printf("输入边(vi,vj)上的下标i,下标j和权w:\n");
scanf("%d,%d,%d",&i,&j,&w);//输入(i,j)上的权w
G->arc[i][j] = w;
G->arc[j][i] = G->arc[i][j];//因为是无向图,矩阵对称
}
}
从代码中也可以得到,n个顶点和e条边的无向图的创建,事件复杂度为O(n + n² + e),其中对邻接矩阵的初始化耗费了O(n²)的时间。
2.邻接表
邻接矩阵是不错的一种图存储结构,但是我们也发现,对于边数相对顶点较少的图,这种结构是存在对存储空间的极大浪费的。比如说,我们要处理稀疏有向图,邻接矩阵中除了一个有权值外,没有其他弧,其实这些存储空间都浪费掉了。
因此我们考虑另外一种存储方式。回忆我们在线性表时谈到,顺序存储结构就存在预先分配内存可能造成存储空间浪费的问题,于是引出了链式存储结构。同样的,我们可以考虑对边使用链式存储的方式来避免空间浪费的问题。
再回忆我们在树中谈存储结构时,讲到了一种孩子表示法,将结点存入数组,并对结点的孩子进行链式存储,不管有多少个孩子,也不会存在空间浪费问题。这个思路同样适用于图的存储。我们把这种数组与链表向结合的存储方式称为邻接表(Adjacency List)。
邻接表的处理办法是这样。
- 图中顶点用一个一维数组存储,当然,顶点也可以用单链表来存储,不过数组可以较容易地读取顶点信息,更加方便。另外,对于顶点数组中,每个数据元素还需要存储指向第一个邻接点的指针,以便于查找该顶点的边信息。
- 图中每个顶点vi的所有邻接点构成一个线性表,由于邻接点的个数不定,所以用单链表存储,无向图称为顶点vi的边表,有向图则称为顶点vi作为弧尾的出边表。
顶点的各个结点到哪由data和firstedge两个域表示,data是数据域,存储顶点的信息,firstedge是指针域,指向边表的第一个结点,即此顶点的第一个邻接点。边表结点由adjvex和next两个域组成。adjvex是邻接点域,存储某顶点的邻接点在顶点表中的下标,next则存储指向边表中下一结点的指针。比如v1顶点与v0、v2互为邻接点,则在v1的边表中,adjust分别为v0的0和v2的2。
这样的结构,对于我们要获得图相关信息也是很方便的。比如我们想要知道某个顶点的度,就去查找这个顶点的边表中结点的个数。若要判断顶点v1到vj是否存在边,只需要测试顶点vi的边表中adjvex是否存在结点vj的下标j就行了。若顶点所有邻接点,其实就是对此顶点的边表进行遍历,得到的adjvex域对应的顶点就是邻接点。
若是有向图,邻接表结构是类似的,但要注意是有向图,由于有方向,我们是以顶点为弧尾来存储边表的,这样很容易得到每个顶点的出度。但也有时为了便于确定顶点的入度或以顶点为弧头的弧,我们可以建立一个有向图的逆邻接表,即对每个顶点vi都建立一个链接为vi为弧头的表。
此时我们很容易就可以算出某个顶点的入度或出度是多少,判断两顶点是否存在弧也很容易实现。
对于带权值的网图,可以在结点定义中再增加一个weight的数据域,存储权值的信息即可。
有了这些结构的图,下面关于结点定义的代码就很好理解了。
typedef char VerexType;//顶点类型应由用户定义
typedef int EdgeType;//边上权值类型应由用户定义
typedef struct EdgeNode//边表结点
{
int adjvex;//邻接点域,存储该顶点对应的下标
EdgeType weight;//用于存储权值,对于非网图可以不需要
struct EdgeNode *next;//链域,指向下一个邻接点
}EdgeNode;
typedef struct VertexNoe//顶点表结点
{
VertexType data;//顶点域,存储顶点信息
EdgetNode *firstNode;//边表头指针
}VertexNode , AdjList[MAXVEX];
typedef struct
{
Adjust adjList;
int numVertexes,numEdges;//图中当前顶点数和边数
}GraphAdjust;
对于邻接表的创建,也就是顺理成章之事。无向图的邻接表创建代码如下。
//建立图的邻接表结构
void CreateALGraph(GraphAdjList *G)
{
int i,j,k;
EdgeNode *e;
printf("输入顶点数和边数:\n");
scanf("%d,%d",&G->numVertexes,&G->numEdges);//输入顶点数和边数
for(int i = 0 ;i < G->numVertexes;i++)//读入顶点信息,建立顶点表
{
scanf(&G->adjList[i].data);//输入顶点信息
G->adjList[i].firstedge = null;//将表置为空表
}
for(k = 0;k < G->numberVertexes;k++)//建立链表
{
printf("输入边(vi,vj)上的顶点序号:\n");
scanf("%d,%d",&i,&j);//输入边(vi,vj)上的顶点序号
e = (EdgeNode *)malloc(sizeof(EdgeNode));//向内存申请空间,生成边表结点
e->adjvex = j;//邻接序号为j
e->next = G->adjList[i].firstedge;//将e指针指向当前顶点指向的结点
G->adjList[i].firstedge = e;//将当前顶点的指针指向e
e = (EdgeNode *)malloc(sizeof(EdgeNode));//向内存申请空间,生成边表结点
e->adjvex = i;//邻接序列号为i
e->next = G->adjList[j].firstedge;//将e指针指向当前顶点指向的结点
G->adjList[i].firstedge = e;//将当前顶点指针指向e
}_
}
上面最后一个for()循环里的,我们用到了单链表创建中讲解到的头插法,由于对于无向图,一条边对应都是两个顶点,所以在循环中,分别针对i和j进行了插入。本章算法的时间复杂度,对于n个顶点e条边来说,很容易得出是O(n + e)。
3.十字链表
对于有向图来说,邻接表是由缺陷的。关心了出度问题,向了解入度就必须要遍历正个图才能知道,反之,逆邻接表解决了入度却不了解出度的情况。有没有可能把邻接表与邻接表与逆邻接表结合起来呢?答案是肯定的,就是把它们整合在一起。这就是我们现在要讲的有向图的一种存储方法:十字链表(Orthogonal List)。
我们重新定义顶点表结点结构如表所示。
data | firstin | firstout |
---|
其中firstin表示入边表头指针,指向该顶点的入边表中第一个结点,firstout表示出边表头指针,指向该顶点的出表中的第一个结点。
重新定义的边表结点如表所示。
tailvex | headvex | headlink | taillink |
---|
其中tailvex是指弧起点在顶点表的下标,
headvex是指弧终点在顶点表的下标,
headlink是指入边表指针域,指向终点相同的下一条边,
taillink是指边表指针域,指向起点相同的下一条边。
如果是网,还可以增加一个weight域来存储权值。
对于v0来说,他有两个顶点v1和v2的入边。因此v0的firstin指定顶点v1的边表结点中headvex为0的结点。
对于顶点v1来说,他有一个入边顶点v2,所以它的firstin指向顶点v2边表结点中headvex为1的结点。
十字链表的好处就是因为把邻接表和逆邻接表整合在了一起,这样既容易找到以v1为尾的弧,也容易找到以v1为头的弧,因而容易求得顶底的入度和出度。而且它除了结构复杂一点外,创建图算法的事件复杂度和邻接表是相同的。因此,在有向图的应用中,十字链表是非常好的数据结构模型。
4.邻接多重表
讲了有向图的优化存储结构,对于无向图的邻接表,有没有问题呢?如果我们在无向图的应用中,关注的重点是顶点,那么邻接表是一个不错的选择,但如果我们更关注到边的操作,比如对已访问过的边做标记,删除某一条边的操作,那就意味着,需要找到这条边上的两个表结点进行操作,这时期还是比较麻烦的。
因此,我们也仿照十字链表的方式,对边表结点的结构进行一些改造,也许就可以避免刚才提到的问题。
重新定义边表结构如表所示。
ivex | ilink | jvex | jlink |
---|
其中ivex和jvex是与某条边依附的两个顶点在顶点表中的下标。ilink指向依附顶点ivex的下一条边,jlink指向依附顶点jvex的下一条边。这就是邻接多重表结构。
首先连线的①②③④就是将顶点的firstedge指向一条边,顶点下标要与ivex的值相同,这很好理解,接着由于顶点v0的(v0,v1)边的邻边有(v0,v3)和(v0,v2),因此⑤⑥的连线就是满足指向下一条依附于顶点v0的边的目标。注意ilink指向的结点的jvex一定要和它本身的ivex的值相同。同理连线⑦就是指的(v1,v0)这条边,他是顶点v1指向(v1,v2)边后的下一条。v2有三条边依附,所以有了⑧⑨。做图一共有5条边,所以右图有10条连线,完全符合预期。
到这里,大家应该可以明白,邻接多重表和邻接表的区别。仅仅在于同一条边在邻接表中用两个结点表示,而在邻接多重链表中只有一个结点。这样对边的操作就简单多了,若要删除左图的(v0,v2)这条边,只需要将右图的⑥⑨的链接指向该为^即可。
5.边集数组
边集数组是由两个一维数组构成。一个是存储顶点的信息;另一个是存储边的信息,这个边数组每个数据元素由一条边的起点下标(begin)、终点下标(end)和权(weight)组成。
显然边集数组关注的是边的集合,在边集数组中要查找一个顶点的度需要扫描这个边集数组,效率并不高,在边集数组中要查找一个顶点的度 需要扫描正个边集数组,效率并不高。因此它更适合对边依次进行处理的操作,而不适合对顶点相关的操作。
顶点数组:
v0 | v1 | v2 | v3 |
---|
定义的边数组结构如下表所示。
begin | end | weight |
---|
其中begin是存储起点下表,end存储终点下表,weight存储权值。
四、图的遍历
我们经常会面临这样的问题:我们需要使用某东西的时候,发现这个东西不见了。找东西的策略因人而异,有些人因为找东西因为没有规划,当一样东西找不到时,往往会反复地找,甚至某些抽屉找个四五遍,另一些地方却一次没有找过。找东西是没有什么标准方法的,不过今天我们学过了图的遍历以后,你至少应该在找东西时,更加科学地规划寻找方案,而不至于手忙脚乱。
图的遍历是和树的遍历类似,我们希望从图的某一顶点出发访遍图中其余顶点,且使每个顶点仅被访问一次,这一过程叫做图的遍历(Traversing Graph)。
树的遍历我们谈到了四种方案,应该说都还好,毕竟根结点只有一个,遍历都是从它发起,其余结点都只有一个双亲。可图就复杂多了,因为它的任一顶点都可能和其余所有顶点邻接,极有可能沿着某路径搜索后,又回到原点,而有些顶点还没有遍历。因此我们需要在遍历过程中把访问过的顶点打上标记,以避免多次访问而不自知。具体办法是设置一个访问数组visited[n],n是图中顶点的个数,初值为0,访问过后设置为1。
对于图的遍历来说,如何避免因回路陷入死循环,就需要科学地设计遍历方案,通常有两种:深度优先遍历和广度优先遍历。
1.深度优先遍历
深度优先遍历(Depth_First Search),也有称为深度优先搜索,简称为DFS。
具体思想就如同找钥匙方案,无论从哪一间房开始都可以,比如主卧室,然后从房间的一个角开始,将房间内的墙角、床头柜、床上、床下、衣柜里、衣柜上、前面的电视柜等挨个寻找,不放过任何死角,形象比喻就是翻个底朝天,然后寻找下一间,直到找到为止。
为了更好地理解深度优先遍历,我们来做一个游戏。
假设你需要完成一个任务,要求你从下图所示的一个迷宫中,从顶点A开始要走遍所有的图顶点并坐上标记。
很显然我们是需要策略的,否则在这四通八达的同道中乱窜,要想完成任务那就只能是碰运气。如果你学过深度优先遍历,这个任务就不难完成了。
首先我们从顶点A开始,做上表示走过的标记,面前有两条路,通向B和F,我们给自己定一个原则,在没有碰到重复顶点的情况下,始终向右手边走,于是我们走到了B顶点。此时发现有三条分支,分别通向顶点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有路径想通的顶点都被访问到。事实上,我们这里讲的是连通图,对于非连通图,只需要对他的连通分量分别进行深度优先遍历,即在先前一个顶点进行深度优先遍历后,若图中尚有顶点未被访问,则另选图中一个未曾被访问的顶点作起始点。重复上述动作,直至图中所有顶点都被访问到为止。
如果我们用的是邻接矩阵的方式,则代码如下:
邻接矩阵存储的结构
typedef char VertexType;
typedef int EdgeType;
#define MAXVEX 100
#define INDINITY 65535
typedef struct
{
VertexType vexs[MAXVEX];//顶点表
EdgeType arc[MAXSIZE][MAXVEX];//邻接矩阵,可看作表
int numVertexes,numEdges;//图中当前的顶点数和边数
}MGraph;
typedef int Boolean;//Boolean是布尔类型,其值是TRUE或FALSE
Boolean visited[MAX];//访问标志的数组
//邻接矩阵的深度优先递归算法
void DFS(MGraph G,int i)
{
int j;
visited[i] = TRUE;
printf("%c ",G.vexs[i]);
for(j = 0,j < G.numVertexes;j++)
if(G.arc[i][j] == 1 && !visited[j])
DFS(G,j);//对访问的邻接点递归调用
}
//邻接矩阵的深度遍历操作
void DFSTraverse(MGraph G)
{
int i;
for( i = 0;i < G.numVertexes;i++)
visited[i] = FALSE;//初始所有顶点状态都是未访问状态
for(i = 0;i < G.numVertexes;i++)
if(!visited[i])//对未访问的顶点调用DFS,若是连通图,只会执行一次
DFS(G,i);
}
代码执行过程,其实就是我们刚才迷宫找寻所有顶点的过程。
如果图结构是邻接表结构,其DFSTraverse函数的代码几乎是相同的,只是在递归函数中因为将数组换成了链表而有不同,代码如下。
邻接表存储结构:
typedef char VertexType;//顶点类型应由用户定义
typedef int EdgeType;//边上的权值类型应由用户定义
typedef struct EdgeNode //边表结点
{
int adjvex;//邻接点域,存储该顶点对应的下标
EdgeType weight;//用户存储权值,对于非网图可以不需要
struct EdgeNode *next;//链域,指向下一个邻接点
}EdgeNode;
typedef struct VertexNode //顶点表结点
{
VertexType data;//顶点域,存储顶点信息
EdgeNode *firstedge;//边表头指针
}VertexNode,AdjList[MAXVEX];
typedef struct
{
AdjList adjList;
int numVertexed,numEdges;//图中当前顶点数和边数
}GraphAdjList;
//邻接表的深度优先递归算法
void DFS(GraphAdjList GL,int i)
{
EdgeNode *p;
visited[i] = TRUE;
printf("%c",GL->adjList[i].data);//打印顶点,也可以执行其他操作
p = GL->adjList[i].firstedge;
while(p)
{
if(!visited[p->adjvex])
DFS(GL,p->adjvex);//对访问的邻接顶点递归调用
p = p->next;
}
}
//邻接表的深度遍历操作
void DFSTraverse(GraphAdjList GL)
{
int i;
for(i = 0;i < GL->numVertexes;i++)
visited[i] = FALSE;//初始所有顶点状态都是未访问过状态
for(i = 0;i < GL->numVertexes;i++)
if(!visited[i])//对未访问过的顶点调用DFS,若是连通图,只会执行一次
DFS(GL,i);
}
对比两个不同存储结构的深度优先遍历算法,对于n个顶点e条边的图来说,邻接矩阵由于是二维数组,要查找每个顶点的邻接点需要访问矩阵中的所有元素,因此都需要O(n²)的时间。而邻接表做存储结构时,找邻接点所需的时间取决于顶点和边的数量,所以是O(n + e)。显然对于点多边少的稀疏图来说,邻接表使得所发在时间效率上大大提高。
对于有向图来说,由于它只是对通道存在可行或不可行,算法上没有变化,都是完全可以通用的。
2.广度优先遍历
广度优先遍历(Brcadth_First Search),又称广度优先搜索,简称BFS。
还是以找钥匙的例子为例。钥匙不可能丢到衣柜定或者厨房油烟机里,深度优先遍历意味着要彻底查找玩一个房间才查找下一个房间,这未必是最佳方案。不妨把家里所有房间简单看一遍,看看钥匙是不是放在很显眼的位置上,如果没有,那再看下一个房间,这样一步步扩大查找的范围。
如果说图的深度优先遍历类似于树的前序遍历,那么图的广度优先遍历就类似于树的层序遍历。
有了这个讲解,我们来看代码就非常容易了。以下是邻矩阵的广度优先遍历算法。
//邻接矩阵的广度遍历算法
void BFSTraverse(MGraph G)
{
int i,j;
Queue Q;
for(i = 0;i < G.numVertexes;i++)
visited[i] = FALSE;
InitQueue(&Q);//初始化一辅助用的队列
for(i = 0;i < G.numVertexes;i++)//对每一个顶点做循环
{
if(!visted[i])//若是未访问过就处理
{
visited[i] = TRUE;//设置当前顶点访问过
printf("%c ",G.vexs[i]);//打印顶点,也可以其他操作
EnQueue(&Q,i);//将此顶点加入队列
while(!QueueEmpty(Q))//若当前队列不为空
{
DeQueue(&Q,i);
for(j = 0;j < G.numVertexes;j++)
{
//判断其他顶点若与当前顶点存在边且未访问过
if(G.arc[i][j] = 1 && !visited[j])
{
visited[j] = TRUE;//强找到的此顶点标记为已昂文
printf("%c" , G.vexs[j]);//打印顶点
EnQueue(&Q,j);//将找到的此顶点加入队列
}
}
}
}
}
}
对于邻接表的广度优先遍历,代码与邻接矩阵差异不大。
//邻接表的广度遍历算法
void BFSTraverse(GraphAdjList GL)
{
int i ;
EdgeNode *p;
Queue Q;
for(i = 0 ;i < GL->numVertexes;i++)
visited[i] = FALSE;
InitQueue(&Q);
for(i = 0;i < GL->numVertexes;i++)
{
if(!visited[i])
{
visited[i] = TRUE;
printf("%c",GL->adjList[i].data);//打印顶点,也可以其他操作
EnQueue(&Q,i);
while(!QueueEmpty(Q))
{
Dequeue(&Q,i);
p = GL->adjList[i].firstedge;//找到当前顶点边表链表头指针
while(p)
{
if(!visited[p->adjvex])//若此顶点未被访问
{
visited[p->adjvex] = TRUE;
printf("%c",GL->adjList[p->adjvex].data);
EnQueue(&Q,p->adjvex);//将此顶点加入队列
}
p = p->next;
}
}
}
}
}
对比图的深度优先与广度优先遍历算法,你就会发现,它们在时间复杂度上是一样的,不同之处仅仅在于对顶点访问的顺序不同。可见两种在全图遍历上是没有优势之分的,只是视不同的情况选择不同的算法。
不过如果图的顶点和边非常多,不能再短时间内遍历完成,遍历的目的是为了寻找合适的顶点,那么选择哪种遍历就要仔细斟酌了。深度优先更适合目标比较明确,以找到目标为主要目的的情况,而广度优先更适合在不断扩大遍历范围时找到相对最优解的情况。