数据结构 - 图

参考链接:数据结构:图(Graph)【详解】_图数据结构-CSDN博客

图的定义

图(Graph)是由顶点的有穷非空集合 V ( G ) 和顶点之间边的集合 E ( G ) 组成,通常表示为: G = ( V , E ) ,其中, G 表示个图, V 是图 G 中顶点的集合, E是图 G 中边的集合。 V={v1​,v2​,...,vn​},则用∣V∣表示图 G 中顶点的个数,也称图 G的阶,E={(u,v)∣u∈V,v∈V},用 ∣E∣表示图 G中边的条数。

图不可以是空图,就是说不能一个顶点没有。图的顶点集V一定非空,边集可以为空,次是图中只有顶点没有边

图的基本概念和术语

有向图

若E是有向边(也称弧)的有限集合时,则图G为有向图。弧是顶点的有序对,记为<v, w>,其中v,w是顶点,v称为弧尾,w称为弧头(被指向的称为头),<v,w>称为从顶点v到顶点w的弧,也称v邻接到w,或w邻接自v。

无向图

若E是无向边(简称边)的有限集合时,则图G为无向图。边是顶点的无序对,记为(v, w)或(w,v),因为(v,w)=(w,v), 其中v,w是顶点。可以说顶点w和顶点v互为邻接点。边(v, w)依附于顶点w和v,或者说边(v, w)和顶点v, w相关联。

简单图

一个图满足 1.不存在重复边 2,不存在顶点到自身的边 则称为简单图

完全图(也称简单完全图)

对于无向图,∣E∣的取值范围是 0 到n(n−1)/2,有n(n−1)/2条边的无向图称为完全图,在完全图中任意两个顶点之间都存在边。对于有向图,∣E∣的取值范围是0到n(n−1), n(n−1)条弧的有向图称为有向完全图,在有向完全图中任意两个顶点之间都存在方向相反的两条弧。G2​为无向完全图,而 G3​为有向完全图。

子图

设有两个图 G=(V,E)和G′=(V′,E′), 若 V′是  V的子集,且E′是E的子集,则称 G′是 G 的子图。若有满足V(G′)=V(G)的子图G′,则称其为G的生成子图

注意:并非V和E的任何子集都能构成G的子图,因为这样的子集可能不是图,即E的子集中的某些边关联的顶点可能并不在V的子集中

连通,连通图和连通分量

在无向图中,若从顶点V到顶点w有路径存在,则称v和w是连通的。若图中G中任意两个顶点是连通的,则称图G是连通图,否则称为非连通图。无向图中的极大连通子图称为连通分量。若一个图有 n 个顶点,并且边数小于 n - 1,则此图必是非连通图

注意:弄清连通、连通图、连通分量的概念非常重要。首先要区分极大连通子图和极小连通子图,极大连通子图是无向图的连通分量,极大即要求该连通子图包含其所有的边;极小连通子图是既要保持图连通又要使得边数最少的子图。

强连通图 ,强连通分量

在有向图中,若从顶点 v 到顶点 w 和从顶点 w 到顶点 v 之间都有路径,则称这两个顶点是强连通的。若入中任何一对顶点都是强连通的,则称此图为强连通图。有向图中的极大强连通子图称为有向图的强连通分量

强连通分量 -- >

注意:强连通图、强连通分量只是针对有向图而言的。一般在无向图中讨论连通性,在有向图中考虑强连通性。

生成树,生成森林

连通图的生成树是包含图中全部顶点的一个极小连通子图。若图中顶点数为 n,则它的生成树含有n−1条边。对生成树而言,若砍去它的一条边,则会变成非连通图,若加上一条边则会形成一个回路。在非连通图中,连通分量的生成树构成了非连通图的生成森林。图G2​的一个生成树如下图所示。

注意:包含无向图中全部顶点的极小连通子图,只有生成树满足条件,因为砍去生成树的任一条边,图将不再连通。

顶点的入度和出度

图中每个顶点的度定义为以该点为一个端点的边的数目

对于无向边,全部顶点的度的和等于边数的 2 倍,因为每条边和两个顶点相关联

对于有向图,顶点 v 的度分为 入度 和 出度 ,入度是以顶点 v 为终点的度,出度是以这个顶点为起点的度。在有向图中全部顶点入度和出度的和等于边数

边的权和网

在一个图中,每条边都可以标上具有某种含义的数值,该数值称为该边的权值,这种边上带有权值的图称为带权图,也称为 

稠密图,稀疏图

相对而言的模糊概念,边数很少的图称为稀疏图,反之为稠密图,一般当图G满足∣E∣<∣V∣log∣V∣时,可以将G视为稀疏图。

路径,路径长度和回路

顶点 vp​到顶点 vq​之间的一条路径是指顶点序列 vp,vi,vi2,...,vq 当然关联的边也可以理解为路径的构成要素。路径上边的数目称为路径长度。第一个顶点和最后一个顶点相同的路径称为回路。若一个图有n个顶点,并且有大于n−1条边,则此图一定有环。

简单路径,简单回路

在路径序列中,顶点不重复出现的路径称为简单路径。除第一个顶点和最后一个顶点外,其余顶点不重复出现的回路叫做简单回路

距离

从顶点 u 出发到顶点 v 的最短路径若出现,则此路径的长度称为从 u 到 v 的距离,若从 v 到 u 根本不存在路径,则记该距离为无穷(\infty

有向树

一个顶点的入度为 0 ,其余顶点的入度均为 1 的有向图,称为有向树

图的存储结构

无法以数据元素在内存中的物理位置来表示元素之间的关系,图不可能用简单的顺序存储结构来表示,而多重链表的方式,要么造成很多存储单元的浪费,要么带来操作的不便。

邻接矩阵

图的邻接矩阵存储方式是用两个数组来表示图,一个一维数组存储图中顶点信息,一个二维数组(称为邻接矩阵)存储图中的边或弧的信息

设图G有n个顶点,则邻接矩阵A是一个n*n的方阵,定义为:

  • 无向图的邻接矩阵一定是一个对称矩阵(即从矩阵的左上角到右下角的主对角线为轴,右上角的元与左下角相对应的元全都是相等的)。 因此,在实际存储邻接矩阵时只需存储上(或下)三角矩阵的元素。(平均节省一半的存储空间,比较小的节省不了一半,属于是文字游戏)
  • 对于无向图,邻接矩阵的第i行(或第i列)非零元素(或非\infty元素)的个数正好是第i个顶点的度TD(vi​)。比如顶点v1​的度就是1+0+1+0=2。
  • 求顶点vi​的所有邻接点就是将矩阵中第i行元素扫描一遍,A[i][j]为 1就是邻接点。

  • 主对角线上数值为0,矩阵不对称
  • 有向图讲究入度和出度,顶点  v1​的入度为1,正好是第v1​列各数之和。顶点v1​的出度为2,即第v1​行的各数之和

对于带权图而言,若顶点 vi 和 vj 之间有边相连,则邻接矩阵中对应存放着该边对应的权值

注意:
①在简单应用中,可直接用二维数组作为图的邻接矩阵(顶点信息等均可省略)。
②当邻接矩阵中的元素仅表示相应的边是否存在时,EdgeType可定义为值为0和1的枚举类型。
③无向图的邻接矩阵是对称矩阵,对规模特大的邻接矩阵可采用压缩存储。
④邻接矩阵表示法的空间复杂度为O(n2), 其中n为图的顶点数∣V∣。
⑤ 用邻接矩阵法存储图,很容易确定图中任意两个顶点之间是否有边相连。但是,要确定图中有多少条边,则必须按行、按列对每个元素进行检测,所花费的时间代价很大。
⑥ 稠密图适合使用邻接矩阵的存储表示。

邻接表

当一个图为稀疏层时(边数相对顶点较少),使用邻接矩阵法显然浪费大量的存储空间

邻接表法结合了顺序存储链式存储方法,大大减少了浪费

所谓邻接表,是指对图G中的每个顶点 vi​建立一个单链表,第 i 个单链表中的结点表示依附于顶点 vi​ 的边(对于有向图则是以顶点vi​为尾的弧),这个单链表就称为顶点vi​的边表(对于有向图则称为出边表)。边表的头指针和顶点的数据信息采用顺序存储(称为顶点表),所以在邻接表中存在两种结点:顶点表结点和边表结点,如下图所示。

顶点表结点由顶点域(data)和指向第一条邻接边的指针(firstarc) 构成,边表(邻接表)结点由邻接点域(adjvex)和指向下一条邻接边的指针域(nextarc) 构成。

此时我们很容易就可以算出某个顶点的入度或出度是多少,判断两顶点是否存在弧也很容易实现。
对于带权值的网图,可以在边表结点定义中再增加一个weight的数据域,存储权值信息即可。

#define MAXVEX 100	//图中顶点数目的最大值
type char VertexType;	//顶点类型应由用户定义
typedef int EdgeType;	//边上的权值类型应由用户定义
/*边表结点*/
typedef struct EdgeNode{
	int adjvex;	//该弧所指向的顶点的下标或者位置
	EdgeType weight;	//权值,对于非网图可以不需要
	struct EdgeNode *next;	//指向下一个邻接点
}EdgeNode;

/*顶点表结点*/
typedef struct VertexNode{
	Vertex data;	//顶点域,存储顶点信息
	EdgeNode *firstedge	//边表头指针
}VertexNode, AdjList[MAXVEX];

/*邻接表*/
typedef struct{
	AdjList adjList;
	int numVertexes, numEdges;	//图中当前顶点数和边数
}

图的邻接表存储方法具有以下特点:

  • 若G为无向图,则所需的存储空间为O(|V| + 2|E| );若 G为有向图,则所需的存储空间为 O(|V| + |E| ) 
  • 对于稀疏图,采用邻接表表示将极大的节省存储空间
  • 在邻接表中,给定一顶点,能很容易找出它的所有临边,因为只需要读取它的邻接表。在邻接矩阵中,相同的操作则需要扫描一行,花费的时间为 O ( n ) O(n) O(n)。但是,若要确定给定的两个顶点间是否存在边,则在邻接矩阵中可以立刻查到,而在邻接表中则需要在相应结点对应的边表中查找另一结点,效率较低。
  • 在有向图的邻接表表示中,求一个给定顶点的出度只需计算其邻接表中的结点个数;但求其顶点的入度则需要遍历全部的邻接表。因此,也有人采用逆邻接表的存储方式来加速求解给定顶点的入度。当然,这实际上与邻接表存储方式是类似的。
  • 图的邻接表表示并不唯一,因为在每个顶点对应的单链表中,各边结点的链接次序可以是任意的,它取决于建立邻接表的算法及边的输入次序。

十字链表

十字链表是有向图的一种链式存储结构

对于有向图来收,邻接表没办法同时省时的了解入度和出度,邻接表可以快速了解出度,逆邻接表了解入度,其余的都得遍历整个表才能直到。十字链表就是把它俩整合在一起

重新定义顶点表节点结构:

其中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​为头的弧,因而容易求得顶点的出度和入度。而且它除了结构复杂一点外,其实创建图算法的时间复杂度是和邻接表相同的,因此,在有向图的应用中,十字链表是非常好的数据结构模型。

邻接多重表

邻接多重表是无向图的另一种链式存储结构

在邻接表中,容易求得顶点和边的各种信息,但在邻接表中求两个顶点之间是否存在边而对边执行删除等操作时,需要分别在两个顶点的边表中遍历,效率较低。比如下图中,若要删除左图的(V0​,V2​) 这条边,需要对邻接表结构中右边表的阴影两个结点进行删除操作,显然这是比较烦琐的。

重新定义的边表节点结构:

其中ivex和jvex是与某条边依附的两个顶点在顶点表中下标。ilink 指向依附顶点ivex的下一条边,jlink 指向依附顶点jvex的下一条边。这就是邻接多重表结构。

每个顶点也用一一个结点表示,它由如下所示的两个域组成。

其中,data 域存储该顶点的相关信息,firstedge 域指示第一条依附于该顶点的边。

我们来看结构示意图的绘制过程,理解了它是如何连线的,也就理解邻接多重表构造原理了。如下图7所示,左图告诉我们它有4个顶点和5条边,显然,我们就应该先将4个顶点和5条边的边表结点画出来。

注意ilink指向的结点的jvex一定要和它本身的ivex的值相同

边集数组

边集数组是由两个数组构成。一个是存储顶点的信息;另一个是存储边的信息,这个边数组每个数据元素由一条边的起点下标(begin)、 终点下标(end)和权(weight)组成,如下图所示。显然边集数组关注的是边的集合,在边集数组中要查找一个顶点的度需要扫描整个边数组,效率并不高。因此它更适合对边依次进行处理的操作,而不适合对顶点相关的操作

图的遍历

图的遍历是和树的遍历类似,我们希望从图中某一顶点出发访遍图中其余顶点,且使每一个顶点仅被访问一次, 这一过程就叫做图的遍历(Traversing Graph)。
对于图的遍历来,通常有两种遍历次序方案:它们是深度优先遍历和广度优先遍历。

深搜 DFS

bool visited[MAX_VERTEX_NUM];	//访问标记数组
/*从顶点出发,深度优先遍历图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 = FirstNeighbor(G, v); w>=0; w=NextNeighor(G, v, w)){
		if(!visited[w]){	//w为u的尚未访问的邻接顶点
			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);
		}
	}
}

DFS 算法的性能分析

DFS算法是一个递归算法,需要借助一个递归工作栈,故其空间复杂度为O(V)

对于n个顶点e条边的图来说,邻接矩阵由于是二维数组,要查找每个顶点的邻接点需要访问矩阵中的所有元素,因此都需要 O(V^2)的时间。而邻接表做存储结构时,找邻接点所需的时间取决于顶点和边的数量,所以是O(V+E)。 显然对于点多边少的稀疏图来说,邻接表结构使得算法在时间效率上大大提高。
对于有向图而言,由于它只是对通道存在可行或不可行,算法上没有变化,是完全可以通用的。

深度优先的生成树和生成森林

深度优先搜索会产生一棵深度优先生成树。 当然,这是有条件的,即对连通图调用DFS才能产生深度优先生成树,否则产生的将是深度优先生成森林,如下图所示。基于邻接表存储的深度优先生成树是不唯一的 。

广度优先遍历

广度优先遍历(Breadth 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(!visited[i]){
			vivited[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入队列
					}
				}
			}
		}
	}
}

BFS 算法性能分析:

无论是邻接表还是邻接矩阵的存储方式,BFS算法都需要借助一个辅助队列Q,n 个顶点均需要入队一次,在最差的情况下,空间复杂度为 O(V)

采用邻接表存储方式时,每个顶点均需搜索一次(或入队一次), 在搜索任一顶点的邻接点时,每条边至少访问一次,算法总的时间复杂度为 O(V+E)。采用邻接矩阵存储方式时,查找每个顶点的邻接点所需的时间为 O(V),故算法总的时间复杂度为 O(V2)。

注意:图的邻接矩阵表示是唯一的,但对于邻接表来说,若边的输入次序不同,生成的邻接表也不同。因此,对于同样一个图,基于邻接矩阵的遍历所得到的DFS序列和BFS序列是唯一的,基于邻接表的遍历所得到的DFS序列和BFS序列是不唯一的。

图的遍历与图的连通性

图的遍历算法可以用来判断图的连通性
对于无向图来说,若无向图是连通的,则从任一结点出发, 仅需一次遍历就能够访问图中的所有顶点;若无向图是非连通的,则从某一个顶点出发,一次遍历只能访问到该顶点所在连通分量的所有顶点,而对于图中其他连通分量的顶点,则无法通过这次遍历访问。对于有向图来说,若从初始点到图中的每个顶点都有路径,则能够访问到图中的所有顶点,否则不能访问到所有顶点。
故在BFSTraverse ()或DFSTraverse ()中添加了第二个for循环,再选取初始点,继续进行遍历,以防止一次无法遍历图的所有顶点。对于无向图,上述两个函数调用BFS (G,i)或DFS(G,i)的次数等于该图的连通分量数;而对于有向图则不是这样,因为一个连通的有向图分为强连通的和非强连通的,它的连通子图也分为强连通分量和非强连通分量,非强连通分量一次调用BFS (G, i)或DFS (G, i)无法访问到该连通分量的所有顶点。
如下图所示为有向图的非强连通分量。

最小生成树

一个连通图的生成树是一个极小的连通子图,它含有图中全部的顶点,但只是足以构成一棵树的 n - 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);
}

通用算法每次加入一条边以逐渐形成一棵生成树,下面介绍两种实现上述通用算法的途径。

普里姆算法(Prim)

Prim算法构造最小生成树的过程如下图所示。初始时从图中任取一顶点(如顶点加入树T,此时树中只含有一个顶点,之后选择一个与当前T中顶点集合距离最近的顶点,并将该顶点和相应的边加入T,每次操作后T中的顶点数和边数都增1。以此类推,直至图中所有的顶点都并入T,得到的T就是最小生成树。此时T中必然有n-1条边。

/*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
			}
		}
	}
}

由算法代码中的循环嵌套可得知此算法的时间复杂度为O(n^2) 。

克鲁斯卡尔(Kruskal)算法

与Prim算法从顶点开始扩展最小生成树不同,Kruskal 算法是一种按权值的递增次序选择合适的边来构造最小生成树的方法。

Kruskal算法构造最小生成树的过程如下图所示。初始时为只有n个顶点而无边的非连通图 T = V ,每个顶点自成一个连通分量,然后按照边的权值由小到大的顺序,不断选取当前未被选取过且权值最小的边,若该边依附的顶点落在T中不同的连通分量上,则将此边加入T,否则舍弃此边而选择下一条权值最小的边。以此类推,直至 T中所有顶点都在一个连通分量上。

算法思路:
我们可以直接就以边为目标去构建,因为权值是在边上,直接去找最小权值的边来构建生成树也是很自然的想法,只不过构建时要考虑是否会形成环路而已。此时我们就用到了图的存储结构中的边集数组结构。以下是edge边集数组结构的定义代码:

/*对边集数组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)算法

Dijkstra算法用于构建单源点的最短路径—,即图中某个点到任何其他点的距离都是最短的。例如,构建地图应用时查找自己的坐标离某个地标的最短距离。可以用于有向图,但是不能存在负权值。

我们以上图为例,通俗点说,这个迪杰斯特拉(Dijkstra) 算法,它并不是一下子求出了v0​到 v8​的最短路径,而是一步步求出它们之间顶点的最短路径,过程中都是基于已经求出的最短路径的基础上,求得更远顶点的最短路径,最终得到你要的结果。

Dijkstra算法设置一个集合S记录已求得的最短路径的顶点。
在构造的过程中还设置了个辅助数组:
dist[]:记录从源点v0​到其他各顶点当前的最短路径长度,它的初态为:若从v0​到vi​;有弧,则dist[i]为弧上的权值;否则置dist[i]为\infty

显然,Dijkstra 算法也是基于贪心策略的。使用邻接矩阵或者带权的邻接表表示时,时间复杂度为 O(V^2) 
人们可能只希望找到从源点到某个特定顶点的最短路径,但这个问题和求解源点到其他所有顶点的最短路径一样复杂,时间复杂度也为O(V^2)

弗洛伊德( Floyd )算法

定义一个n阶方阵序列,A^{(-1)}A^{(0)},... ,A^{(n - 1)}  其中:

                                                            A^{(-1)}[i][j] = arcs[i][j]

A^{(k)}[i][j] 是从顶点 vi 到 vj ,中间顶点的序号不大于 k 的最短路径的长度。Floyd 算法是一个迭代的过程,没迭代一次,在最短路径上就多考虑一个顶点。经过 n 次迭代后,所得到的A^{(n - 1)}[i][j],就是对应的最短路径,即方阵中保存了任意一对顶点之间的最短路径

Floyd算法的时间复杂度为O(V^3)。不过由于其代码很紧凑,且并不包含 其他复杂的数据结构,因此隐含的常数系数是很小的,即使对于中等规模的输入来说,它仍然是相当有效的。
Floyd算法允许图中有带负权值的边,但不允许有包含带负权值的边组成的回路。Floyd 算法同样适用于带权无向图,因为带权无向图可视为权值相同往返二重边的有向图。

拓扑排序

定义

在一个表示工程的有向图中,用顶点表示活动,用弧表示活动之间的优先关系,这样的有向图为顶点表示活动的网,我们称为AOV网( Activity On VertexNetwork)。

若用DAG图(有向无环图)表示一个工程,其顶点表示活动,用有向边 <Vi​,Vj​>表示活动 Vi​必须先于活动 Vj​进行的这样一种关系。在AOV网中,活动 Vi​是活动 Vj​的直接前驱,活动 Vj​是活动Vi​的直接后继,这种前驱和后继关系具有传递性,且任何活动Vi​不能以它自己作为自己的前驱或后继。

设G=(V,E)是一个具有n个顶点的有向图,V中的顶点序列 V1​,V2​,...Vn​,满足若从顶点Vi​到 Vj​有一条路径,则在顶点序列中顶点 Vi​必在顶点Vj​之前。则我们称这样的顶点序列为一个拓扑序列。

所谓拓扑排序,其实就是对一个有向图构造拓扑序列的过程。每个AOV网都有一个或多个拓扑排序序列。

leetcode 题目 -- 课程表

算法

对一个AOV网进行拓扑排序的算法有很多,下面介绍比较常用的一种方法的步骤:

  • ①从AOV网中选择一个没有前驱的顶点并输出。
  • ②从网中删除该顶点和所有以它为起点的有向边。
  • ③重复①和②直到当前的AOV网为空或当前网中不存在无前驱的顶点为止。如果输出顶点数少了,哪怕是少了一个,也说明这个网存在环(回路),不是AOV网。

上图所示为拓扑排序过程的示例。每一轮选择一个入度为0的顶点并输出,然后删除该顶点和所有以它为起点的有向边,最后得到拓扑排序的结果为{1,2,4,3,5}。
拓扑排序算法的实现如下:

bool TopologicalSort(Graph G){
	InitStack(S);	//初始化栈,存储入度为0的顶点
	for(int i=0; i<G.vexnum; i++){
		if(indegree[i] == 0){
			Push(S, i);	//将所有入度为0的顶点进栈
		}
	}
	int count = 0;	//计数,记录当前已经输出的顶点数
	while(!IsEmpty(S)){	//栈不空,则存在入度为0的顶点
		Pop(S, i);	//顶点元素出栈
		printf("%d ", i);	//输出顶点i
		count++;
		for(p=G.vertices[i].finstarc; p; p=p->nextarc){
			//将所有i指向的顶点的入度减1,并且将入度减为0的顶点压入栈S
			v = p->adjvex;
			if(!--indegree[v]){
				Push(S, v);	//入度为0,则入栈
			}
		}
	}
	if(count < G.vexnum){
		return false;	//输出顶点少了,有向图中有回路,排序失败
	}else{
		return true;	//拓扑排序成功
	}
}

由于输出每个顶点的同时还要删除以它为起点的边,故拓扑排序的时间复杂度为O(V+E)。
此外,利用深度优先遍历也可实现拓扑排序。

用拓扑排序算法处理AOV网时,应注意以下问题:
①入度为零的顶点,即没有前驱活动的或前驱活动都已经完成的顶点,工程可以从这个顶点所代表的活动开始或继续。
②若一个顶点有多个直接后继,则拓扑排序的结果通常不唯一;但若各个顶点已经排在一个线性有序的序列中,每个顶点有唯一的前驱后继关系,则拓扑排序的结果是唯一的。
③由于AOV网中各顶点的地位平等,每个顶点编号是人为的,因此可以按拓扑排序的结果重新编号,生成AOV网的新的邻接存储矩阵,这种邻接矩阵可以是三角矩阵;但对于一般的图来说,若其邻接矩阵是三角矩阵,则存在拓扑序列;反之则不一定成立。

关键路径

定义

拓扑排序主要是为解决一个工程能否顺序进行的问题,但有时我们还需要解决工程完成需要的最短时间问题。

在带权有向图中,以顶点表示事件,以有向边表示活动,以边上的权值表示完成该活动的开销(如完成活动所需的时间),称之为用边表示活动的网络,简称AOE网。AOE网和AOV网都是有向无环图,不同之处在于它们的边和顶点所代表的含义是不同的,AOE网中的边有权值;而AOV网中的边无权值,仅表示顶点之间的前后关系。

AOE网具有以下两个性质:

  • ①只有在某顶点所代表的事件发生后,从该顶点出发的各有向边所代表的活动才能开始;
  • ②只有在进入某顶点的各有向边所代表的活动都已结束时,该顶点所代表的事件才能发生。

如上图的AOE网,在AOE网中仅有一个入度为0的顶点,称为开始顶点(源点),它表示整个工程的开始;网中也仅存在一个出度为0的顶点,称为结束顶点(汇点),它表示整个工程的结束。我们把路径上各个活动所持续的时间之和称为路径长度,从源点到汇点具有最大长度的路径叫关键路径,在关键路径上的活动叫关键活动
完成整个工程的最短时间就是关键路径的长度,即关键路径上各活动花费开销的总和。这是因为关键活动影响了整个工程的时间,即若关键活动不能按时完成,则整个工程的完成时间就会延长。因此,只要找到了关键活动,就找到了关键路径,也就可以得出最短完成时间。

算法

在分析算法之前,需要了解几个重要的参数:

1.事件的最早发生时间ve:即顶点Vk​的最早发生时期。
2.事件的最晚发生时间vl:即顶点Vk​的最晚发生时间,也就是每个顶点对应的事件最晚需要开始的时间,超出此时间将会延误整个工期。
3.活动的最早开始时间e:即弧 ai​的最早发生时间。
4.活动的最晚开始时间l:即弧ai​的最晚发生时间,也就是不推迟工期的最晚开工时间。
5.一个活动ai​的最迟开始时间l(i)和其最早开始时间e(i)的差额d(i)=l(i)−e(i):它是指该活动完成的时间余量,即在不增加完成整个工程所需总时间的情况下,活动 ai​可以拖延的时间。若一个活动的时间余量为零,则说明该活动必须要如期完成,否则就会拖延整个工程的进度,所以称 l(i)−e(i)=0即 l(i)=e(i)的活动ai​是关键活动。

求关键路径的算法步骤如下:

  • 从源点出发,令 ve(源点)=0, 按拓扑排序求其余顶点的最早发生时间 ve()。
  • 从汇点出发,令 vl(汇点)=ve(汇点),按逆拓扑排序求其余顶点的最迟发生时间 vl()。
  • 根据各顶点的ve()值求所有弧的最早开始时间e()。
  • 根据各顶点的 vl()值求所有弧的最迟开始时间l()。
  • 求AOE网中所有活动的差额d(), 找出所有 d()=0的活动构成关键路径

对于关键路径,需要注意以下几点:
①关键路径上的所有活动都是关键活动,它是决定整个工程的关键因素,因此可通过加快关键活动来缩短整个工程的工期。但也不能任意缩短关键活动,因为一旦缩短到一定的程度,该关键活动就可能会变成非关键活动。
②网中的关键路径并不唯一,
且对于有几条关键路径的网,只提高一条关键路径上的关键活动速度并不能缩短整个工程的工期,只有加快那些包括在所有关键路径上的关键活动才能达到缩短工期的目的。

好文推荐:共计10万余字!数据结构知识详细梳理!_各个数据结构发明者的整理清单-CSDN博客

  • 7
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值