一、图的基本概念
在线性表中,数据元素之间是被串起来的,仅有线性关系,每个数据元素最多只有一个直接前驱和一个直接后继。在树形结构中,数据元素之间有着明显的层次关系,并且每一层的数据元素可能和下一层中多个元素相关,但只能和上一层中一个元素相关。图是一种较线性表和树更加复杂的数据结构。**在图形结构中,结点之间的关系可以是任意的,图中任意两个数据元素之间都可能相关。**
1.1、图(Graph)的定义
图是由顶点的有穷非空集合V(G)和顶点之间边的集合E(G)组成,通常表示为:G=(V,E),其中G表示图,V是图G中顶点的集合,E是图G中边得集合。若V={V1,V2,V3,...,Vn},则用|V|表示图中顶点的个数,也称为图的阶,,用|E|表示图G中边的条数。
注意:线性表可图中不能一个顶点也以是空表,树可以是空树,但是图不能是空图。也就是说图中不能一个顶点也没有,图的顶点集合V一定非空,但是边集E可以为空,此时的图只有顶点没有边。
1.2、图的基本概念和术语
1.2.1、有向图
若E是有向边(也称弧)的有限集合时,则图G为有向图。弧是顶点的有序对,记为,其中v,w是顶点,v为弧尾,w为弧头,称为从顶点v到顶点w的弧,也称v邻接到w,或w邻接自v。
上述所示为图,其可表示为:
1.2.2、无向图
若E是无向边(简称边)的有限集合时,则图G为无向图。边是顶点的无序对,记为或者,因为=,其中v,w是顶点。可以说顶点v和顶点w互为邻接点。边依附于顶点v和w,或者说边与顶点v和w相关联。
上图所示的无向图可表示为:
1.2.3、简单图
一个图G若满足:
1. 不存在重复边;
2.不存在顶点到自身的边;
则称图G为简单图,上述两幅示例均为简单图。(数据结构中只讨论简单图)
1.2.4、多重图
若图G中某两个顶点之间的边数多于一条,又允许顶点通过同一条边和自己关联,则G为多重图。多重图的定义和简单图的定义是相对的。
1.2.5、完全图(也称简单完全图)
对于无向图,|E|的取值范围是,有条边的无向图称为完全图,在完全图中任意两个顶点之间都存在边。对于有向图,|E|的取值范围是,有条弧的有向图被称为有向完全图,在有向完全图中,任意两个顶点之间都存在方向相反的两条弧。
为无向完全图:
为有向完全图:
1.2.6、子图
设有两个图,,若是的子集,且是的子集,则称是的子集。若有满足(即图要有图的所有顶点)的子图,则称其为的生成子图。上图中的就是的子图。
注意:并非V和E的任何子集都能构成G的子图,因为某些子集可能不是图,即E的子集中某些边关联的顶点可能不在这个V的子集中。
1.2.7、连通、连通图和连通分量
在无向图中,若从顶点v到顶点w有路径存在,则称v和w是连通的。若图G中任意两个顶点都是连通的,则称图G为连通图,否则称为非连通图。无向图的极大连通子图称为连通分量。若一个图有n个顶点,并且边数小于n-1,则此图必是非连通图。如下图(a)所示,图中有3个连通分量,如图(b)所示:
注意:区分极大连通子图和极小连通子图:极大连通子图要求其包含所有的边;极小连通子图则是既要保持图连通又要使得边数量最少。
1.2.8、强连通图、强连通分量
在有向图中,若从顶点v到顶点w和从顶点w到顶点v之间都有路径,则称顶点v和顶点w是强连通的。若图中任意一对顶点都是强连通的,则称此图为强连通图。有向图的极大强连通子图称为有向图的强连通分量,图的强连通分量如下:
注意:强连通图、强连通分量是针对有向图来讲的,对于无向图,是讨论其连通性。
1.2.9、生成树、生成森林
连通图的生成树是包含图中全部顶点的一个极小连通子图。若图中顶点数为n,则它的生成树含有n-1条边。对生成树而言,若砍去它的一条边,则会变成非连通图,若加上一条边,则会形成一个回路。在非连通图中,连通分量的生成树构成了非连通图的生成森林。图的一个生成树如下图所示:
注意:包含无向图中全部顶点的极小连通子图,只有生成树满足条件,因为砍去生成树的任一条边,图都将不再连通。
1.2.10、顶点的度、入度和出度
图中每个顶点的度定义为以该顶点为一个端点的边的数目。
对于无向图,顶点v的度是指依附于该顶点的边的条数,记为。
在具有n个顶点、e条边的无向图中,顶点度数之和为:,因为每条边都与两个顶点相关联。
对于有向图,顶点v的度分为入度和出度,入度是以顶点v为终点的有向边的数目,记为;出度则是以顶点v为起点的有向边的数目,记为。顶点的度等于其入度和出度之和,即。
在具有n个顶点、e条边的有向图中,,即有向图的全部顶点的入度之和和出度之和相等,并等于边数。因为每条边都有一个起点和一个终点。
1.2.11、边的权和网
在一个图中,每条边都可以标上具有某种含义的数值,该数值称为该边的权值。这种边上带权值的图称为带权图,也称网。
1.2.12、稠密网、稀疏网
边数很少的图称为稀疏图,反之成为稠密图。稀疏和稠密本身就是模糊的概念,稀疏图和稠密图常是相对而言的,一般当图G满足|E|<|V|log|V|时,可以将图G视为稀疏图。
1.2.13、路径、路径长度和回路
顶点到顶点之间的一条路径是指顶点序列,当然关联的边也可以理解为路径的构成要素。路径上边的数目称为路径长度。第一个顶点和最后一个顶点相同的路径称为回路或环。若一个图有n个顶点,并且有大于n-1条边,则此图一定有环。
1.2.14、简单路径、简单回路
在路径序列中,顶点不重复出现的路径被称为简单路径。除第一个顶点和最后一个顶点外,其余顶点不重复出现的叫简单回路。
1.2.15、距离
从顶点v出发到顶点w的最短路径若存在,则此路径的长度称为从v到w的距离。若从v到w根本不存在路径,则记该距离为无穷。
1.2.16、有向树
一个顶点的入度为0、其余顶点的入度均为1的有向图,称为有向树。
二、图的存储结构
由于图的结构比较复杂,任意两个顶点之间都可能存在联系,因此无法以数据元素在内存中的物理位置来表示元素之间的关系,也就是说,图不可能用简单的顺序存储结构来表示。而多重链式的方式,要么会造成很多存储单元的浪费,要么又带来操作的不便。因此,对于图来说,如何对它实现武理存储是个难题,下面介绍五种不同的存储结构。
2.1、邻接矩阵(Adjacency Matrix)
图的邻接矩阵存储方式是用两个数组来表示图。一个一维数组存储图中顶点信息,一个二维数组(即邻接矩阵)存储图中的边(或是弧)的信息。
设图G有n个顶点,则邻接矩阵A是一个n*n的方阵,定义为:
①无向图的邻接矩阵示例:
由图可以看出:
1. 无向图的邻接矩阵一定是一个对称矩阵,因此,在实际存储邻接矩阵时可采用对成矩阵的压缩存储(只存储下三角或上三角矩阵的元素)。
2. 对于无向图,邻接矩阵的第i行中非零元素(或非无穷元素)的个数正好是第i个顶点的度。
3. 求顶点的所有邻接点就是将矩阵元素中第i行元素扫描一遍,为1的元素的相关顶点就是其邻接点。
②有向图的邻接矩阵示例:
从上图可看出:
1. 行为起点,列为终点;
2. 主对角线上依然全为0,但是因为是有向图,所以邻接矩阵并不对称;
3. 顶点所在行的元素之和为其出度,所在列元素之和为其入度;
4. 与无向图相同,判断顶点到是否有弧,只需查找邻接表中对应元素是否为1。
③对于带权图而言,若 顶点和之间有边相连,则邻接矩阵中对应处存储着该边对应的权值:
以有向网为例:
定义出邻接矩阵的存储结构为:
#define MaxVertexNum 100 //顶点数目的最大值
typedef char VertexType; //顶点数据的类型
typedef int EdgeType; //带权图边上权值的数据类型
typedef struct{
VertexType Vex[MaxVertexNum];//顶点数组
EdgeType Edge[MaxVertexNum][MaxVertexNum];//边数组
int vexnum, arcnum;//图的当前顶点数和弧数
}MGraph;
注意:
1. 在简单应用中,可直接用二维数组作为图的邻接矩阵(顶点信息等均可省略);
2. 当邻接矩阵中的元素仅表示相应的边是否存在时,EdgeType可定义为值为0和1的枚举类型;
3. 无向图的邻接矩阵是对称矩阵,当其规模特大时可采用压缩存储;
4. 邻接矩阵表示法的空间复杂度为,其中n为图中的顶点数;
5. 用邻接矩阵法存储图,很容易确定图中任意两个顶点之间是否有边相连。但是,要确定图中有多少条边,则必须按行、按列对每个元素进行检测,时间代价很大;
6. 稠密图适合用邻接矩阵存储法。
2.2、邻接表
为降低存储空间的浪费,图的邻接表结合了顺序存储和链式存储。
所谓邻接表,是指对图G中的每个顶点建立一个单链表,第i个单链表中的结点表示依附于顶点 的边(对于有向图则是以顶点为尾的弧),这个单链表就称为顶点的边表(对于有向图则称为出边表)。边表的头指针和顶点的数据信息采用顺序存储(称为顶点表),所以在邻接表中存在两种结点:顶点表结点和边表结点,如下图所示。
顶点表结点由顶点域(data)和指向第一条邻接边的指针(firstarc) 构成,边表(邻接表)结点由邻接点域(adjvex)和指向下一条邻接边的指针域(nextarc) 构成。
无向图的邻接表的实例如下图所示:
有向图的邻接表的实例如下图所示:
此时我们很容易就可以算出某个顶点的入度或出度是多少,判断两顶点是否存在弧也很容易实现。
对于带权值的网图,可以在边表结点定义中再增加一个weight的数据域,存储权值信息即可。
图的邻接表存储结构定义如下:
#define MaxVex 100
typedef char VerTexType;
typedef int EdgeType;
//边表结点
typedef struct EdgeNode{
int adjvex;//指向顶点的下标或位置
EdgeType weight;//权值
struct EdgeNode *next;//指向下一个邻接点
}EdgeNode;
//顶点表结点
typedef strut VexNode{
VertexType data;//顶点域,存储顶点信息
EdgeType *firstedge;//边表指针
}VexNode, Adjlist[MaxVex];
//邻接表
typedef struct{
Adjlist adjlist;
int vertexnum, edgenum;//当前图的顶点数和边数
}
图的邻接表存储方法具有以下特点:
- 若G为无向图,则所需的存储空间为O(|V|+2|E|);若G GG为有向图,则所需的存储空间为O(|V|+|E|),前者的倍数2是由于在无向图中,每条边在邻接表中出现了两次;
- 对于稀疏图,采用邻接表表示将极大地节省存储空间。
- 在邻接表中,给定一顶点,能很容易地找出它的所有邻边,因为只需要读取它的邻接表。在邻接矩阵中,相同的操作则需要扫描一行,花费的时间为O ( n )。但是,若要确定给定的两个顶点间是否存在边,则在邻接矩阵中可以立刻查到,而在邻接表中则需要在相应结点对应的边表中查找另一结点,效率较低。
- 在有向图的邻接表表示中,求一个给定顶点的出度只需计算其邻接表中的结点个数;但求其顶点的入度则需要遍历全部的邻接表。因此,也有人采用逆邻接表的存储方式来加速求解给定顶点的入度。当然,这实际上与邻接表存储方式是类似的。
- 图的邻接表表示并不唯一,因为在每个顶点对应的单链表中,各边结点的链接次序可以是任意的,它取决于建立邻接表的算法及边的输入次序。
2.3、十字链表
十字链表是有向图的一种链式存储结构。
对于有向图来说,邻接表是有缺陷的。关心了出度问题,想了解入度就必须要遍历整个图才能知道,反之,逆邻接表解决了入度却不了解出度的情况。有没有可能把邻接表与逆邻接表结合起来呢?答案是肯定的,就是把它们整合在一起。这就是我们现在要介绍的有向图的一种存储方法:十字链表(Orthogonal List)。
我们重新定义顶点表结点结构如下表所示:
其中firstin表示入边表头指针,指向该顶点的入边表中第一个结点,firstout 表示出边表头指针,指向该顶点的出边表中的第一个结点。
重新定义的边表结点结构如下表所示:
其中tailvex 是指弧起点在顶点表的下标,headvex 是指弧终点在顶点表中的下标,headlink是指入边表指针域,指向终点相同的下一条边,taillink是指边表指针域,指向起点相同的下一条边。如果是网,还可以再增加一个weight域来存储权值。
接下来通过一个例子详细介绍十字链表的结构。
如下图所示,顶点依然是存入一个一维数组{V0,V1,V2,V3},实线箭头指针的图示完全与的邻接表的结构相同。就以顶点V0来说,firstout 指向的是出边表中的第一个结点V3。所以V0边表结点的headvex=3,而tailvex就是当前顶点V0的下标0,由于V0只有一个出边顶点,所以headlink和taillink都是空。
我们重点需要来解释虚线箭头的含义,它其实就是此图的逆邻接表的表示。对于V0来说,它有两个顶点V1和V2的入边。因此V0的firstin指向顶点V1的边表结点中headvex为0的结点,如上图右图中的①。接着由入边结点的headlink指向下一个入边顶点V2,如图中的②。对于顶点V1,它有一个入边顶点V2,所以它的firstin指向顶点V2的边表结点中headvex为1的结点,如图中的③。顶点V2和V3也是同样有一个入边顶点,如图中④和⑤。
十字链表的好处就是因为把邻接表和逆邻接表整合在了一起, 这样既容易找到以V1为尾的弧,也容易找到以V1为头的弧,因而容易求得顶点的出度和入度。而且它除了结构复杂一点外,其实创建图算法的时间复杂度是和邻接表相同的,因此,在有向图的应用中,十字链表是非常好的数据结构模型。
2.4、邻接多重表
邻接多重表是无向图的另一种链式存储结构。
在邻接表中,容易求得顶点和边的各种信息,但在邻接表中求两个顶点之间是否存在边而对边执行删除等操作时,需要分别在两个顶点的边表中遍历,效率较低。比如下图中,若要删除左图的(V0,V2) 这条边,需要对邻接表结构中右边表的阴影两个结点进行删除操作,显然这是比较烦琐的。
重新定义的边表结点结构如下表所示:
其中ivex和jvex是与某条边依附的两个顶点在顶点表中下标。ilink 指向依附顶点ivex的下一条边,jlink 指向依附顶点jvex的下一条边。这就是邻接多重表结构。
每个顶点也用一一个结点表示,它由如下所示的两个域组成:
其中,data 域存储该顶点的相关信息,firstedge 域指示第一条依附于该顶点的边。
我们来看结构示意图的绘制过程,理解了它是如何连线的,也就理解邻接多重表构造原理了。如下图所示,左图告诉我们它有4个顶点和5条边,显然,我们就应该先将4个顶点和5条边的边表结点画出来。
我们开始连线,如图,首先连线的①②③④就是将顶点的firstedge指向一条边,顶点下标要与ivex的值相同,这很好理解。接着,由于顶点V0的(V0,V1) 边的邻边有(V0,V3) 和(V0,V2)。 因此⑤⑥的连线就是满足指向下一条依附于顶点V0的边的目标,注意ilink指向的结点的jvex一定要和它本身的ivex的值相同。同样的道理,连线⑦就是指(V1,V0) 这条边,它是相当于顶点V1指向(V1,V2) 边后的下一条。V2有三条边依附,所以在③之后就有了⑧⑨。连线④的就是顶点V3在连线④之后的下一条边。 左图一共有5条边,所以右图有10条连线,完全符合预期。
到这里,可以明显的看出,邻接多重表与邻接表的差别,仅仅是在于同一条边在邻接表中用两个结点表示,而在邻接多重表中只有一个结点。 这样对边的操作就方便多了,若要删除左图的(V0,V2)这条边,只需要将右图的⑥⑨的链接指向改为NULL即可。
2.5、边集数组
边集数组是由两个一维数组构成。一个是存储顶点的信息;另一个是存储边的信息,这个边数组每个数据元素由一条边的起点下标(begin)、 终点下标(end)和权(weight)组成,如下图所示。显然边集数组关注的是边的集合,在边集数组中要查找一个顶点的度需要扫描整个边数组,效率并不高。因此它更适合对边依次进行处理的操作,而不适合对顶点相关的操作。
三、图的遍历
图的遍历是和树的遍历类似,我们希望从图中某一顶点出发访遍图中其余顶点,且使每一个顶点仅被访问一次,这一过程就叫做图的遍历(Traversing Graph)。
对于图的遍历来,通常有两种遍历次序方案:①深度优先遍历②广度优先遍历
3.1、深度优先遍历
深度优先遍历(Depth First Search),也有称为深度优先搜索,简称为DFS。
3.1.1、DFS算法
深度优先遍历类似于树的先序遍历。如其名称中所暗含的意思一样,这种搜索算法所遵循的搜索策略是尽可能“深”地搜索一个图。它的基本思想如下:首先访问图中某一起始点v出发,访问与v邻接且未被访问的任一顶点w,再访问与w邻接且未被访问的任一顶点...重复上述过程。当不能再继续向下访问时,依次退回到最近被访问的顶点,若它还有邻接顶点未被访问过,则从该点开始继续上述搜索过程,直至图中所有顶点均被访问过为止。
一般情况下,其递归形式的算法十分简洁,算法过程如下:
bool visited[MaxSize];//访问标记数组
//从顶点出发,深度优先遍历图G
void DFS(Graph G, int v){
int w;
visit(v);//访问顶点
visited[v]=true;//设已访问标记
//FirstNeighbor(G,v):求图G中V的第一个邻接点,若有则返回顶点号,否则返回-1
//NextNeighbor(G,v,w):假设图G中顶点w是顶点v的一个邻接点,返回除w外的顶点v
for(w = FisrtNeighbor(G,v);w>=0;w = NextNeighbor(G,w,v)){
if(!visited[w]){
DFS(G,w);
}
}
}
//对图进行深度优先遍历
void DFSTraverse(MGraph G){
int v;
for(v=0;v<G.vexnum;++v){
visited[v] = false;//初始化已访问的标记数据
}
for(v=0;v<G.vexnum;++v){//从v=0开始遍历
if(!visited[v]){
DFS(G,v);
}
}
}
以下面这个无向图为例:
其深度优先遍历的结果为:abdehcfg(相当于树的先序遍历,根左右)。
3.1.2、DFS算法的性能分析
DFS是一个递归算法,需要借助一个工作栈,故其空间复杂度为O(v)。
对于n个顶点e条边的图来说,邻接矩阵由于是二维数组,要查找每个顶点的邻接点需要访问矩阵中的所有元素,因此都需要的时间。而邻接表做存储结构时,找邻接点所需的时间取决于顶点和边的数量,所以是。 显然对于点多边少的稀疏图来说,邻接表结构使得算法在时间效率上大大提高。
对于有向图而言,由于它只是对通道存在可行或不可行,算法上没有变化,是完全可以通用的。
3.1.3、深度优先的生成树和生成森林
深度优先搜索会产生一棵深度优先树,当然这是有条件的,即对连通图调用DFS才能产生深度优先树,否则会产生深度优先森林,如下图就是深度优先森林。基于邻接表存储的深度优先树是不唯一的。
3.2、广度优先遍历
广度优先算法(Breadth First Search),又称为广度优先搜索,简称BFS。
3.2.1、BFS算法
图的广度优先遍历就类似于树的层序遍历。
广度优先算法是一种分层的查找过程,每向前走一步就可能访问一批顶点,不像深度优先遍历那样会回退,因此BFS不是递归算法。为了实现逐层的访问,算法必须借助一个队列,以记录正在访问顶点的下一层顶点。
//邻接矩阵的广度优先遍历算法
void BFSTraverse(MGraph G){
int i, j;
Queue Q;
for(i=0;i<G.vexnum;i++){
visited[i]=false;
}
InitQueue(&Q);//初始化用于辅助的队列
for(i=0;i<G.vexnum;i++){
if(!visited[i]){
visited[i]=true;//设置为当前访问过
visit(i);//访问顶点
EnQueue(&Q,i);//将此顶点入队
//若此队列不为空
while(!QueueEmpty(Q)){
DeQueue(&Q,i);//顶点i出队
//FirstNeighbor(G,v):求图G中顶点v的第一个邻接点,若有则返回顶点号,否则返回-1。
//NextNeighbor(G,v,w):假设图G中顶点w是顶点v的一个邻接点,返回除w外顶点v
for(j=FirstNeighbor(G, i); j>=0; j=NextNeighbor(G, i, j)){
//检验i的所有邻接点
if(!visited[j]){
visit(j); //访问顶点j
visited[j] = TRUE; //访问标记
EnQueue(&Q, j); //顶点j入队列
}
}
}
}
以下面这个无向图为例:
其广度优先遍历的结果为:abcdefgh(相当于树的层序遍历)
3.2.1、BFS算法性能分析
无论是邻接表还是邻接矩阵的存储方式,BFS 算法都需要借助一个辅助队列Q, n个顶点均需入队一次,在最坏的情况下,空间复杂度为O(V)。
采用邻接表存储方式时,每个顶点均需搜索一次(或入队一次), 在搜索任一顶点的邻接点时,每条边至少访问一次,算法总的时间复杂度为O(V+E)。采用邻接矩阵存储方式时,查找每个顶点的邻接点所需的时间为O(V),故算法总的时间复杂度为。
注意:图的邻接矩阵表示是唯一的,但对于邻接表来说,若边的输入次序不同,生成的邻接表也就不同。因此对于同一幅图,基于邻接矩阵的遍历所得到的DFS序列和BFS序列是唯一的,基于邻接表的遍历得到的DFS序列和BFS序列是不唯一的。
3.3、图的遍历与图的连通性
图的遍历算法可以用判断图的连通性。
对于无向图来说,若无向图是连通的,则从任一结点出发, 仅需一次遍历就能够访问图中的所有顶点;若无向图是非连通的,则从某一个顶点出发,一次遍历只能访问到该顶点所在连通分量的所有顶点,而对于图中其他连通分量的顶点,则无法通过这次遍历访问。对于有向图来说,若从初始点到图中的每个顶点都有路径,则能够访问到图中的所有顶点,否则不能访问到所有顶点。
故在BFSTraverse ()或DFSTraverse ()中添加了第二个for循环,再选取初始点,继续进行遍历,以防止一次无法遍历图的所有顶点。对于无向图,上述两个函数调用BFS (G,i)或DFS(G,i)的次数等于该图的连通分量数;而对于有向图则不是这样,因为一个连通的有向图分为强连通的和非强连通的,它的连通子图也分为强连通分量和非强连通分量,非强连通分量一次调用BFS (G, i)或DFS (G, i)无法访问到该连通分量的所有顶点。
四、最小生成树
一个连通图的生成树是一个极小的连通子图,它含有图中全部的顶点,但只有足以构成一棵树的n − 1 n-1n−1条边,若砍去它的一条边,则会使生成树变成非连通图;若给它增加一条边,则会形成图中的一条回路。对于一个带权连通无向图G=(V,E),生成树不同,其中边的权值之和最小的那棵生成树(构造连通网的最小代价生成树),称为G的最小生成树(Minimum-Spanning-Tree, MST)。
构造最小生成树有多种算法,但大多数算法都利用了最小生成树的下列性质:假设G=(V,E)是一个带权连通无向图,U是顶点集V的一个非空子集。若<u,v>是一条具有最小权值的边,其中u∈U,v∈V−U,则必存在一棵包含边(u,v)的最小生成树。
基于该性质的最小生成树算法主要有Prim算法和Kruskal算法,它们都基于贪心算法的策略。
下面介绍一个通用的最小生成树算法:
GENERIC_MST(G){
T=NULL;
while T 未形成一棵生成树;
do 找到一条最小代价边(u, v)并且加入T后不会产生回路;
T=T U (u, v);
}
通用算法每次加入一条边以逐渐形成一棵生成树,下面介绍两种实现上述通用算法的途径。
4.1、普利姆(Prim)算法
Prim算法构造最小生成树的过程如下:初始时从图中任取一顶点(如顶点加入树T,此时树中只含有一个顶点,之后选择一个与当前T中顶点集合距离最近的顶点,并将该顶点和相应的边加入T,每次操作后T中的顶点数和边数都增1。以此类推,直至图中所有的顶点都并入T,得到的T就是最小生成树。此时T中必然有n-1条边。
通俗点说就是:从一个顶点出发,在保证不形成回路的前提下,每找到并添加一条最短的边,就把当前形成的连通分量当做一个整体或者一个点看待,然后重复“找最短的边并添加”的操作。
Prim算法步骤如下:
假设是连通图,其最小生成树,是最小生成树中边的集合。
1. 初始化:向空树中添加图中任一顶点,使得,(即生成树T中只有一个顶点,没有边)。
2. 循环(重复下列操作直至U=V,即将图G的所有顶点都装进去):从图G中选择(V-U即还未被装进去的剩余顶点集合)且具有最小权值的边,加入树T,置。
首先,构建一个邻接矩阵如下:
于是普里姆(Prim) 算法代码如下,左侧数字为行号。其中INFINITY为权值极大值,不妨设65535,MAXVEX 为顶点个数最大值,此处大于等于9即可。
/*Prim算法生成最小生成树*/
void MiniSpanTree_Prim(G){
int min, i, j, k;
int adjvex[MAXVEX]; //保存相关顶点下标
int lowcost[MAXVEX]; //保存相关顶点间边的权值
lowcost[0] = 0; //初始化第一个权值为0,即v0加入生成树
//lowcost的值为0,在这里就是此下标的顶点已经加入生成树
adjvex[0] = 0; //初始化第一个顶点下标为0
for(i=1; i<G.numVertexes; i++){
lowcost[i] = G.arc[0][i]; //将v0顶点与之组成边的权值存入数组
adjvex[i] = 0; //初始化都为v0的下标
}
for(i=1; i<G.numVertexes; i++){
min = INFINITY; //初始化最下权值为∞,通常设置一个不可能的很大的数字
j = 1; k = 0;
//循环全部顶点
while(j < G.numVertexes){
//如果权值不为0且权值小于min
if(lowcost[j] != 0 && lowcost[j] < min){
min = lowcost[j]; //则让当前权值成为最小值
k = j; //将当前最小值的下标存入k
}
j++;
}
print("(%d, %d)", adjvex[k], k); //打印当前顶点边中权值的最小边
for(j=1; j<G.numvertexes; j++){
//若下标为k顶点各边权值小于此前这些顶点未被加入生成树权值
if(lowcost[j] != 0 && G.arc[k][j] < lowcost[j]){
lowcost[j] = G.arc[k][j]; //将较小权值存入lowcost
adjvex[j] = k; //将下标为k的顶点存入adjvex
}
}
}
}
由算法代码中的循环嵌套可得知此算法的时间复杂度为。
4.2、克鲁斯卡尔(Kruskal)算法
与Prim算法从顶点开始扩展最小生成树不同,Kruskal 算法是一种按权值的递增次序选择合适的边来构造最小生成树的方法。
Kruskal算法构造最小生成树的过程如下图所示。初始时为只有n个顶点而无边的非连通图T=V,,每个顶点自成一个连通分量,然后按照边的权值由小到大的顺序,不断选取当前未被选取过且权值最小的边,若该边依附的顶点落在T中不同的连通分量上,则将此边加入T,否则舍弃此边而选择下一条权值最小的边。以此类推,直至T中所有顶点都在一个连通分量上。
/*对边集数组Edge结构的定义*/
typedef struct{
int begin;
int end;
int weight;
}Edge;
我们将下面左图的邻接矩阵通过程序转化为右图的边集数组,并且对它们按权值从小到大排序。
于是Kruskal算法代码如下,左侧数字为行号。其中MAXEDGE为边数量的极大值,此处大于等于15即可,MAXVEX为顶点个数最大值,此处大于等于9即可。
/*Kruskar算法生成最小生成树*/
void MiniSpanTree_Kruskal(MGraph G){
int i, n, m;
Edge edges[MAXEDGE]; //定义边集数组
int parent[MAXVEX]; //定义一数组用来判断边与边是否形成环路
/*此处省略将邻接矩阵G转化为边集数组edges并按照权由小到大排序的代码*/
for(i=0; i<G.numVertexes; i++){
parent[i] = 0; //初始化数组为0
}
for(i=0; i<G.numVertexes; i++){
n = Find(parent, edges[i].begin);
m = Find(parent, edge[i],end);
/*假如n与m不等,说明此边没有与现有生成树形成环路*/
if(n != m){
/*将此边的结尾顶点放入下标为起点的parent中
表示此顶点已经在生成树集合中*/
parent[n] = m;
printf("(%d, %d, %d)", edges[i].begin,
edges[i].end, edges[i].weight);
}
}
}
/*查找连线顶点的尾部下标*/
int Find(int *parent, int f){
while(parent[f] > 0){
f = parent[f];
}
return f;
}
此算法的Find函数由边数e决定,时间复杂度为O(loge),而外面有一个for循环e次。所以克鲁斯卡尔算法的时间复杂度为O(eloge)。
对比两个算法,克鲁斯卡尔算法主要是针对边来展开,边数少时效率会非常高,所以对于稀疏图有很大的优势;而普里姆算法对于稠密图,即边数非常多的情况会更好一些。
五、最短路径
在网图和非网图中,最短路径的含义是不同的。由于非网图它没有边上的权值,所谓的最短路径,其实就是指两顶点之间经过的边数最少的路径;而对于网图来说,最短路径,是指两顶点之间经过的边上权值之和最少的路径,并且我们称路径上的第一个顶点是源点,最后一个顶点是终点。
我们以上图为例,通俗点说,这个迪杰斯特拉(Dijkstra) 算法,它并不是一下子求出了v0到v8的最短路径,而是一步步求出它们之间顶点的最短路径,过程中都是基于已经求出的最短路径的基础上,求得更远顶点的最短路径,最终得到你要的结果。
5.1、迪杰斯特拉(Dijkstra)算法
Dijkstra算法设置一个集合S记录已求得的最短路径的顶点。
在构造的过程中还设置了个辅助数组:
dist[]:记录从源点v0到其他各顶点当前的最短路径长度,它的初态为:若从v0到vi;有弧,则dist[i]为弧上的权值;否则置dist[i]为。
例如,对图6.17中的图应用 Dijkstra算法求从顶点1出发至其余顶点的最短路径的过程,如表6.1所示。算法执行过程的说明如下。
- 初始化:集合S初始为v1可达v2和v5,v1不可达v3和v4,因此dist[]数组各元素的初值依次设置为dist[2]=10,dist[3]=,dist[4]=,dist[5]=5。
- 第一轮:选出最小值dist[5],将顶点v5并入集合S,即此时已找到v1到ν5的最短路径。当v5加入S后,从v1到集合S中可达顶点的最短路径长度可能会产生变化。因此需要更新dist[]数组。v5可达v2,因v1→v5→v2的距离8比dist[2]=10小,更新dist[2]=8;v5可达v3,v1→v5→v3的距离14,更新dist[3]=14;v5可达v4,v1→v5→v4的距离7,更新dist[4]=7。
- 第二轮:选出最小值dist[4],将顶点ν4并入集合S。继续更新dist[]数组。ν4不可达v2,dist[2]不变;v4可达v3,v1→v5→v4→v3的距离13比dist[3]小,故更新dist[3]=13。
- 笫三轮:选出最小值dist[2],将顶点ν2并入集合S。继续更新dist[]数组。v2可达ν3,v1→v5→v2→v3的距离9比dist[3]小,更新dist[3]=9。
- 第四轮:选出唯一最小值dist[3],将顶点v3并入集合S,此时全部顶点都已包含在S中。
显然,Dijkstra 算法也是基于贪心策略的。使用邻接矩阵或者带权的邻接表表示时,时间复杂度为。
人们可能只希望找到从源点到某个特定顶点的最短路径,但这个问题和求解源点到其他所有顶点的最短路径一样复杂,时间复杂度也为。
5.2、弗洛伊德(Floyd)算法
算法目的:求出每一对顶点之间的最短路径
算法的时间复杂度:
使用动态规划的思想,将问题的求解分为多个阶段:
对于n个顶点的图,求任意一对顶点到之间的最短路径可分为如下几个阶段:
#初始:不允许在其他点中转,最短路径是?
#0:若允许在中转,最短路径是?
#1:若允许在,中转,最短路径是?
#2:若允许在,,中转,最短路径是?
...
#n-1:若允许在,,,...,中转,最短路径是?
int A[MaxVertexNum][MaxVertexNum];
int path[MaxVertexNum][MaxVertexNum];
//......准备工作,根据图的信息初始化矩阵A和path
void Floyd(MGraph G){
int i,j,k;
// 初始化部分
for(i=0;i<G.vexnum;i++){
for(j=0;j<G.vexnum;j++){
A[i][j]=G.Edge[i][j];
path[i][j]=-1;
}
}
//算法核心部分
for(int k = 0; k<n; k++){ //考虑以vk作为中转点
for(int i = 0; i<n; i++){ //遍历整个矩阵,i为行号,j为列号
for(int j = 0; j<n; j++){
if(A[i][j]>A[i][k]+A[k][j]){ //以vk做中转点时,路径更短
A[i][j]=A[i][k]+A[k][j]; //更新最短路径
path[i][j] = k; //中转点
}
}
}
}
}
六、拓扑排序
6.1、有向无环图
有向无环图:若一个有向图中不存在环,则称为有向无环图,简称为DAG(Directed Acyclic Graph)。
DAG描述表达式的步骤:
1. 把各个操作数不重复地排成一排
2. 标出各个运算符的生效顺序
3. 按顺序加入运算符,注意“分层”
4. 从底层向上检查同层运算符是否可以合体
DAG描述表达式为:
6.2、定义
在一个表示工程的有向图中,用顶点表示活动,用弧表示活动之间的优先关系,这样的有向图为顶点活动图,成为AOV(Activity On VertexNetwork)。
若用DAG表示一个工程,其顶点表示活动,用有向边表示活动必须先于活动进行的这样一种关系。在AOV网中,活动是活动的直接前驱,活动是活动的直接后继,这种前驱和后继的关系具有传递性,任何活动不能以自身作为自己的前驱和后继。
设是一个具有n个顶点的有向图,V中的顶点序列满足若从顶点到顶点有一条路径,则在顶点序列中顶点必在顶点之前,则称这样的顶点序列为一个拓扑序列。
所谓拓扑序列,其实就是对一个有向图构造拓扑的过程,每个AOV网都有一个或多个拓扑排序序列。
6.3、算法
拓扑排序解决的是一个工程能否顺利进行的问题。
对一个AOV网进行拓扑排序的算法有很多,下面介绍比较常用的一种方法的步骤:
1. 从AOV网中选择一个没有前驱(入度为0)的顶点并输出;
2. 从网中删去该顶点及所有以其为起点的有向边;
3.重复步骤1和步骤2,直到当前的AOV网为空或者当前网中不存在无前驱的顶点为止,如果输出的顶点不等于网中顶点数,就意味着该网中存在环(回路),即不是AOV网。
上图所示为拓扑排序过程的示例。每一轮选择一个入度为0的顶点并输出,然后删除该顶点和所有以它为起点的有向边,最后得到拓扑排序的结果为{1,2,4,3,5}。
拓扑算法的实现如下:
bool TopologicalSort(Graph G){
InitStack(S);//初始化一个栈,用于存储入度为0的顶点
for(int i = 0; i<G.vexnum; i++){
if(indgree[i] == 0){
Push(S,i);//将所有入度为0的顶点入栈
}
}
int count = 0;//记录已经输出的顶点数
while(!IsEmpty(S)){
Pop(S,i);//顶点出栈
printf("%d",i);//输出顶点
count++;
//
for(p=G.vertices[i].finstarc;p;p=p->nextstarc){
//将所有i指向的顶点的入度减1(删去以i为起点的边),并且将入度减为0的顶点入栈
v=p->adjvex;
if(!--indgree[v]){
Push(S,v);
}
}
}
if(count < G.vexnum){
return false;//输出顶点少了,图中有回路,拓扑排序失败
}else{
return true;//拓扑排序成功
}
}
由于输出每个顶点的同时还要遍历以其为起点的边,故拓扑排序的时间复杂度为,用深度优先遍历也可实现拓扑排序。
注意:
1. 入度为0的顶点,即没有前驱活动的或者前驱活动已经完成的顶点,工程可以从这个顶点所代表的活动开始或继续;
2. 若一个顶点有多个直接后继,则拓扑排序序列通常不唯一;若各个顶点有唯一的前驱后继关系,则拓扑排序序列唯一;
3. 由于AOV网中各顶点的地位平等,每个顶点编号是人为的,因此可以按照拓扑排序的结果重新编号,生成AOV网新的邻接存储矩阵,这种邻接矩阵可以是三角矩阵,但对于一般的图来说,若其邻接矩阵是三角矩阵,则存在拓扑序列,反之则不一定。
七、关键路径
7.1、定义
关键路径解决的是解决工程需要的最短时间问题。
在带权有向图中,以顶点表示事件,以边表示活动,以边上的权值表示完成该活动所需的开销(如所需的时间等),称之为用边表示活动的网络,简称AOE(Activity On Edge)网。AOV网和AOE网都是有向无环图,不同之处在于他们的顶点和边所代表的含义是不同的,AOE网中的边有权值,而AOV网中的边无权值,只表示顶点之间的前后关系。
AOE网具有以下两个性质:
1. 只有在某顶点事件所代表的事情发生后,从该顶点出发的各有向边所代表的活动才能开始;
2. 只有在进入某顶点的各有向边所代表的活动都已结束时,该顶点所代表的事件才能发生。
如上图的AOE网,在其中仅有一个入度为0的顶点,称为开始顶点(源点),它表示整个工程的开始;网中也仅存在一个出度为0的顶点,称为结束顶点(汇点),它表示整个工程的结束。我们把路径上各个活动持续的时间之和称为路径长度,从源点到汇点具有最大长度的路径叫做关键路径,在关键路径上的活动叫关键活动。
完成整个工程的最短时间就是关键路径的长度,若关键活动不能按时完成,则整个工程的完成时间就会延长。
7.2、算法
重要参数:
1. 事件最早发生的时间:即顶点的最早发生时间;
2. 事件最晚发生的时间:即顶点的最晚发生时间,也就是每个顶点对应事件最晚需要开始的时间,超出此时间,整个工程会被延误。
3. 活动最早开始时间:即边的最早开始时间;
4. 活动最晚开始时间:即边的最晚开始时间,也就是不延误工期的最晚开工时间;
5. 一个活动的最晚开始时间和其最早开始时间的差额:是指该活动完成的时间余量,即在不增加完成整个工程所需总时间的情况下,活动可以拖延的时间。若一个活动的时间余量为0,则说明该活动必须要如期完成,否则就会延误整个工程,所以称即的活动是关键活动。
求关键路径的算法步骤如下:
1. 从源点出发,令ve(源点)=0,按照拓扑排序求其余顶点的最早发生时间ve(),,其中是的所有前驱;
2. 从汇点出发,令vl(汇点)=ve(汇点),按逆拓扑排序求其余顶点的最晚发生时间vl(),,其中是的所有后继;
3. 根据各顶点的ve()求所有弧的最早开始时间e(),弧的最早开始时间等于弧起点的最早开始时间;
4. 根据各顶点的vl()求所有弧的最晚开始时间l(),弧的最晚开始时间等于该弧终点的最晚开始时间减去该弧持续的时间;
5. 求AOE网中所有活动的差额d(),找到所有d()=0的活动构成关键路径。
- 求ve():初始ve(1)=0,在拓扑排序输出顶点的过程中,求得ve(2)=3,ve(3)=2,ve(4)=max{ve(2)+2,ve(3)+4}=max{5,6}=6,ve(5)=6,ve(6)=max{ve(5)+1,ve(4)+0,ve(3)+3}=max{7,8,5}=8。
- 求vl():初始vl(6)=ve(6)=8,在逆拓扑排序出栈过程之中,求得vl(5)=7,vl(4)=6,vl(3)=min{vl(4)−4,vl(6)−3}=min{2,5}=2,vl(2)=min{vl(5)−3,vl(4)−2}=min{4,4}=4,vl(1)必然为0而无需再求。
- 弧的最早开始时间e()等于该弧的起点的顶点的ve(),求得结果如上表所示。
- 弧的最迟开始时间l(i)等于该弧的终点的顶点的vl()减去该弧持续的时间,求得结果如上表所示。
- 根据l(i)−e(i)=0的关键活动,得到的关键路径为(v1,v3,v4,v6)。
对于关键路径,需要注意以下几点:
①关键路径上的所有活动都是关键活动,它是决定整个工程的关键因素,因此可通过加快关键活动来缩短整个工程的工期。但也不能任意缩短关键活动,因为一旦缩短到一定的程度,该关键活动就可能会变成非关键活动。
②网中的关键路径并不唯一,且对于有几条关键路径的网,只提高一条关键路径上的关键活动速度并不能缩短整个工程的工期,只有加快那些包括在所有关键路径上的关键活动才能达到缩短工期的目的。