目录
图:图(Graph)是由顶点的有穷非空集合和顶点之间边的集合组成,通常表示为:G(V,E),其中,G 表示一个图,V 是图 G 中顶点的集合,E 是图 G 中边的集合。
1. 图的定义
在线性表中,数据元素之间是被串起来的,仅有线性关系,每个数据元素只有一个直接前驱和一个直接后继。在树形结构中,数据元素之间有着明显的层次关系,并且每一层上的数据元素都可能和下一层中多个元素相关,但只能和上一层中一个元素相关。简而言之,线性表是一对一的关系,树形结构是一对多关系,而图是多对多的关系。
图是一种较线性表和树更加复杂的数据结构。在图形结构中。结点之间的关系可以是任意的,图中任意两个数据元素之间都可能相关。
图(Graph)是由顶点的有穷非空集合和顶点之间边的集合组成,通常表示为:G(V,E),其中,G 表示一个图,V 是图 G 中顶点的集合,E 是图 G 中边的集合。
对于图的定义,需要明确注意的几点地方:
■ 线性表中把数据元素叫元素,树中将数据元素叫结点,在图中数据元素,则称之为顶点(Vertex)(也可以称为Node)。
■ 线性表中可以没有数据元素,称为空表。树中可以没有结点,叫做空树。而图结构中,不允许没有顶点。在定义中,若V是顶点的集合,则强调了顶点集合V有穷非空(此处有争议,国内部分教材强调点集非空,在http://enwikipedia.org/wiki/Null_graph提出点集可为空)。
■ 线性表中,相邻的数据元素之间具有线性关系,在树结构中相邻两层的结点具有层次关系,而图中,任意两点之间都可能有关系,顶点之间的逻辑关系用边来表示,边集可以是空的。
1.1 各种图定义
无向边:若顶点vi到vj之间的边没有方向,则称这条边为无向边( Edge ),用无序偶对 来表示。
无向图:如果图中任意两个顶点之间的边都是无向边,则称该图为无向图(Undirected graphs)。
下图就是一个无向图,由于是无方向的,连接顶点 A 与 D 的边,可以表示成无序对(A , D),也可以写成(D , A)。
对于下图的中的无向图 ,其中顶点集合
;边集合
有向边:若从顶点 到
的边有方向,则称这条边为有向边,也称为弧(Arc)。用有序偶
来表示,
称为弧尾(Tail),
称为弧头(Head)。
有向图:如果图中任意两个顶点之间的边都是有向边,则称该图为有向图(Directed graphs)。
下图就是一个有向图,连接顶点 A 到 D 的有向边就是弧,A 是弧尾,D 是弧头,<A, D>表示弧,注意不能写成 <D , A>。
对于下图的有向图 G2 来说, ,其中顶点集合
;弧集合
。
注意:无向边用小括号“()”表示,而有向边则是用尖括号“<>”表示。
在图中,若不存在顶点到其自身的边,而同一条边不重复出现,则称这样的图为简单图。现阶段所学的和讨论的都是简单图,下图所示的两个图就不属于我们要讨论的范围。
无向完全图:在无向图中,如果任意两个顶点之间都存在边,则称该图为无向完全图。
含有 n 个顶点的无向完全图有 条边。比如下图所示就是无向完全图,因为每个顶点都要与除它以外的顶点连接,顶点 A 与 BCD 三个顶点连线,共有四个顶点,自然是 4*3 ,但由于顶点 A 与顶点 B 连线后,计算 B 与 A 连线就是重复,因此整体除以 2 ,共有 6 条边。
有向完全图:在有向图中,如果任意两个顶点之间都存在方向互为相反的两条弧,则称该图为有向完全图。
含有 n 个顶点的有向完全图有 条边,如下图所示。
结论:对于具有 n 个顶点和 e 条边数的图,无向图 ,有向图
。
有很少条边或弧的图称为稀疏图,反之称为稠密图。这里稀疏图和稠密图是模糊的概念,都是相对而言的。
权:有些图的边或弧具有与它相关的数字,这种与图的边或弧相关的数叫做权(Weight)。这些权可以表示一个顶点到另一个顶点的距离或耗费。
网:带权的图通常称为网(Network)。下图所示就是一张带权的图,即标识中国四大城市的直线距离的网,此图中的权就是两地的距离。
子图:假设有两个图 和
,如果 V′⊆V 且 E′⊆E ,则称 G′ 为 G 的子图(Subgraph)。例如下图所示带底纹的图均为左侧无向图与有向图的子图。
1.2 图的顶点与边间关系
1. 无向图
邻接点:对于无向图 ,如果(v,v′)∈ E ,则称顶点 v 和 v′ 互为邻接点(Adjacent),即 v 和 v′ 相邻接。
边(v , v′)依附(incident)于顶点 v 和 v′ ,或者说(v , v′)与顶点 v 和 v′ 相关联。
度:顶点 v 的度(Degree)是和 v 相关联的边的数目,记为 TD(v)。
例如下图所示,顶点 A 与 B 互为邻接点,边(A , B)依附于顶点 A 与 B 上,顶点 A 的度为 3 。而此时图的边数是 5 ,各个顶点度的和 = 3+2+3+2 = 10 ,推敲后发现,边数其实就是各顶点度数和的一半,多出来的一半是因为重复两次记数。简记为: 。
2. 有向图
对于有向图 ,如果 <v,v′>∈E ,则称顶点 v 邻接到顶点 v′ ,顶点 v′ 邻接自顶点 v 。弧 <v,v′> 和顶点 v,v′ 相关联。
入度:以顶点 v 为头的弧的数目称为 v 的入度(InDegree),记为 ID(v)。
出度:以 v 为尾的弧的数目称为 v 的出度(OutDegree),记为 OD(v)。
度:顶点 v 的度为 TD(v)= ID(v)+OD(v) 。
例如下图所示,顶点 A 的入度是 2(从 B 到 A 的弧,从 C 到 A 的弧),出度是 1(从 A 到 D 的弧),所以顶点 A 的度为2+1=3 。此有向图的弧有 4 条,而各顶点的出度和 = 1+2+1+0 = 4 ,各顶点的入度和 = 2+0+1+1 = 4 。所以得到:
无向图 中从顶点 v 到顶点 v′ 的路径(Path)是一个顶点序列
,其中
。例如下图所示就列举了顶点 B 到顶点 D 四种不同的路径。
如果 G 是有向图,则路径也是有向的,顶点序列应满足。例如下图所示,顶点 B 到顶点 D 有两种路径。而顶点 A 到 B ,就不存在路径。
树中根节点到任意结点的路径是唯一的,但是图中顶点与顶点之间的路径却是不唯一的。
路径的长度是路径上的边或弧的数目。例如上图左侧路径长为 2 ,右侧路径长度为 3 。
回路或环:第一个顶点到最后一个顶点相同的路径称为回路或环(Cycle)。
简单回路:序列中顶点不重复出现的路径称为简单回路。
简单回路或简单环:除了第一个顶点和最后一个顶点之外,其余顶点不重复出现的回路,称为简单回路或简单环。
下图所示中的两个图的粗线都构成环,左侧的环因第一个顶点和最后一个顶点都是 B ,且 C、D、A 没有重复出现,因此是一个简单环。而右侧的环由于顶点 C 的重复,它就不是简单环了。
1.3 连通图相关术语
连通图:在无向图 G 中,如果从顶点 v 到顶点 v′ 有路径,则称 v 和 v′ 是连通的。如果对于图中任意两个顶点 、
,
和
都是连通的,则称 G 是连通图(Connected Graph)。
下图所示的图1,它的顶点 A 到顶点 B、C、D 都是连通的,但显然顶点 A 与顶点 E 或 F 就无路径,因此不能算是连通图。下图的图2,顶点 A、B、C、D 相互都是连通的,所以它本身就是连通图。
连通分量:无向图中的极大连通子图称为连通分量。
注意连通分量的概念,它强调:
■ 要是子图;
■ 子图要是连通的;
■ 连通子图含有极大项点数;
■ 具有极大项点数的连通子图包含依附于这些顶点的所有边。
下图图1 是一个无向非连通图。但是它有两个连通分量,即图2 和图3 。而图4 ,尽管是图1 的子图,但是它却不满足连通子图的极大顶点数(图2 满足)。因此它不是图1 的无向图的连通分量。
强连通图:在有向图 G 中,如果对于每一对 、
、
,从
到
和从
到
都存在路径,则称 G 是强连通图。
强连通分量:有向图中的极大连通子图称做有向图的强连通分量。
例如下图所示,图1 并不是强连通图,因为顶点 A 到顶点 D 存在路径,而D到A就不存在路径。图2 就是强连通图,而且显然图2 是图1 的极大强连通子图,即是它的强连通分量。
连通图的生成树定义:
一个连通图的生成树是一个极小的连通子图,它含有图中全部的 n 个顶点,但只有足以构成一棵树的 n-1 条边。
比如下图所示的图1 是一普通图,但显然它不是生成树,当去掉两条构成环的边后,比如图2 或图3 ,就满足 n 个顶点 n-1 条边且连通的定义了。它们都是一棵生成树。从这里也可知道,如果一个图有 n 个顶点和小于 n-1 条边,则是非连通图,如果它多于 n-1 条边,必定构成一个环,因为这条边使得它依附的那两个顶点之间有了第二条路径。比如图2 和图3 ,随便加哪两顶点的边都构成环不过有 n-1 条边并不一定是生成树,比如图4 。
有向树:如果一个有向图恰有一个顶点的入度为 0 ,其余顶点的入度均为 1 ,则是一棵有向树。
有向树的理解比较容易,所谓入度为 0 其实就相当于树中的根节点,其余顶点入度为 1 就是说树的非根结点的双亲只有一个。
森林:一个有向图的生成森林由若干棵有向树组成,含有图中全部顶点,但有足以构成若干棵不相交的有向树的弧。
如下图所示的图1 是一个有向图。去掉一些弧后,它可以分解为两棵有向树,如图2 和图3 ,这两棵就是图1 有向图的生成森林。
1.4 图的定义与术语总结
图按照有无方向分为无向图和有向图。无向图由顶点和边构成,有向图由顶点和弧构成。弧有弧尾和弧头之分。
图按照边或弧的多少分稀疏图和稠密图。如果任意两个顶点之间都存在叫完全图,有向的叫有向完全图。若无重复的边或顶点到自身的边则叫简单图。
图中顶点之间有邻接点、依附的概念。无向图顶点的边叫做度,有向图顶点分为入度和出度。
图的边上或弧上带权则称为网
图中顶点间存在路径,两顶点存在路径则说明是连通的,如果路径最终回到起始点则则称为环,当中不重复叫做简单路径。若任意两顶点都是连通的,则图就是连通图,有向则称强连通图。图中有子图,若子图极大连通则就是连通分量,有向的则称强连通分量。
无向图中连通且 n 个顶点 n-1 条边叫生成树。有向图中一顶点入度为 0 其余顶点入度为 1 的叫有向树。一个有向图由若干棵有向树构成生成森林。
2. 图的抽象数据类型
图作为一种数据结构,它的抽象数据类型带有自己特点,正因为它的复杂,运用广泛,使得不同的应用需要不同的运算集合,构成不同的抽象数据操作。下图所示为图的基本操作:
ADT 图(Graph)
Data
定点的有穷非空集合和边的集合。
Operation
CreateGraph(*G,V,VR):按照顶点集 V 和边弧集 VR 的定义构造图 G。
DestoryGraph(*G):图 G 存在则销毁。
LocateVex(G,v):若图 G 中存在顶点 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,w):在图 G 中增添 <v,w> ,若 G 是无向图,
还需要增添对称弧 <w,v> 。
DeleteArc(*G,v,w):在图 G 中删除 <v,w> ,若 G 是无向图,
则还删除对称弧 <w,v> 。
DFSTraverse(G):对图 G 中进行深度优先遍历,在遍历过程对每个顶点调用。
HFSTraverse(G):对图 G 中进行广度优先遍历,在遍历过程对每个顶点调用。
endADT
3. 图的存储结构
图的存储结构相比较比线性表与树来说更加复杂。首先,口头上说的“顶点的位置”或者“邻接点的位置”只是一个相对的概念。其实从图的逻辑结构定义来看,图上任何一个顶点都可被看成是第一个顶点,任一顶点的邻接点之间也不存在次序关系。比如下图所示的四张图,仔细观察发现,它们其实是同一个图,只不过顶点的位置不同,就造就了表面上不太一样的感觉。
也正由于图的结构比较复杂,任意两个顶点之间都可能存在联系,因此无法以数数元素在内存中的物理位置来表示元素之间的关系,也就是说,图不可能用简单的顺序存储结构来表示。而多重链表的方式,即以一个数据域和多个指针域组成的结点表示图中的一个顶点,尽管可以实现图结构,但其实在树中,我们也已经讨论过,这是有问题的。如果各个顶点的度数相差很大,按度数最大的顶点设计结点结构会造成很多存储单元的浪费,而若按每个顶点自己的度数设计不同的顶点结构,又带来操作的不便。因此,对于图来说,如何对它实现物理存储是个难题,不过我们的前辈们已经解决了,现在我们来看前辈们提供的五种不同的存储结构。
3.1 邻接矩阵
考虑到图是由顶点和边或弧两部分组成。合在一起比较困难,那就很自然地考虑到分两个结构来分别存储。
顶点不分大小、主次,所以用一个一维数组来存储是很不错的选择。
而边或弧由于是顶点与顶点之间的关系,一维搞不定,那就考虑用一个二维数组来存储。于是我们的邻接矩阵的方案就诞生了。
图的邻接矩阵(Adjacency Matrix)存储方式是用两个数组来表示图。一个一维数组存储图中顶点信息,一个二维数组(称为邻接矩阵)存储图中的边或弧的信息。
设图 G 有 n 个顶点,则邻接矩阵是一个 的方阵,定义为:
我们来看一个实例,下图就是一个无向图。
我们可以设置两个数组,顶点数组为 ,边数组 arc[4][4] 为下图这样的一个矩阵。
简单解释一下,对于矩阵的主对角线的值,即 arc[0][0] 、arc[1][1] 、arc[2][2] 、arc[3][3] ,全为 0 是因为不存在顶点到自身的边,比如 到
。arc[0][1]=1 是因为
到
的边存在,而 arc[1][3]=0 是因为
到
的边不存在。并且由于是无向图,
到
的边不存在,意味着
到
的边也不存在。所以无向图的边数组是一个对称矩阵。
嗯?对称矩阵是什么?忘记了不要紧,复习一下。所谓对称矩阵就是 n 阶矩阵的元素满足 (0≤i , j≤n) 。即从矩阵的左上角到右下角的主对角线为轴,右上角的元素与左下角相对应的元素全都是相等的。
有了这个矩阵,我们就可以很容易地知道图中的信息。
1. 我们要判定任意两顶点是否有边无边就非常容易了。
2. 我们要知道某个顶点的度,其实就是这个顶点 在邻接矩阵中第 i 行(或第 i 列)的元素之和。比如顶点
的度就是 1+0+1+0 = 2 。
3. 求顶点 的所有邻接点就是将矩阵中第 i 行元素扫描一遍,arc[ i ][ j ] 为 1 就是邻接点。
我们再来看一个有向图样例,如下图所示:
顶点数组为 ,弧数组 arc[4][4] 为下图这样的一个矩阵。
主对角线上数值依然为 0 。但因为是有向图,所以此矩阵并不对称,比如由 到
有弧,得到 arc[1][0]=1 ,而
到
没有弧,因此 arc[0][1]=0 。
有向图讲究入度与出度,顶点 的入度为 1,正好是第
列各数之和。顶点
的出度为 2,即第
行的各数之和。
与无向图同样的办法,判断顶点 到
是否存在弧,只需要查找矩阵中 arc[ i ][ j ] 是否为 1 即可。要求
的所有邻接点就是将矩阵第 i 行元素扫描一遍,查找 arc[ i ][ j ] 为 1 的顶点。
每条边上带有权的图叫做网。那么这些权值就需要存下来,如何处理这个矩阵来适应这个需求呢?
设图 G 是网图,有 n 个顶点,则邻接矩阵是一个 的方阵,定义为:
这里 表示
或
上的权值。
表示一个计算机允许的、大于所有边上权值的值,也就是一个不可能的极限值。为什么不是 0 呢?原因在于权值
大多数情况下是正值,但个别时候可能就是 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->numVertexes; i++) /* 读入顶点信息,建立顶点表 */
{
scanf(&G->vexs[i]);
}
for (i = 0; i < G->numVertexes; i++)
{
for (j = 0; j < G->numVertexes; j++)
{
G->arc[i][j] = INFINITY; /* 邻接矩阵初始化 */
}
}
for (k = 0; k < G->numEdges; k++) /*读入 numEdges 条边,建立邻接矩阵 */
{
printf("输入边(vi,vj )上的下标i,下标j和权w:\n");
scanf("%d,*d, %d", &i, &j, &w); /* 输入边(vi,vj)上的权w */
G->arc[i][j] = w;
G->arc[j][i] = G->arc[i][j]; /* 因为是无向图,矩阵对称 */
}
}
从代码中也可以得到,n 个顶点和 e 条边的无向网图的创建,时间复杂度为 ,其中对邻接矩阵 Garc 的初始化耗费了
的时间。
3.2 邻接表
邻接矩阵是不错的一种图存储结构,但是对于边数相对顶点较少的图,这种结构是存在对存储空间的极大浪费的。
比如说,如果我们要处理下图这样的稀疏有向图,邻接矩阵中除了 arc[1][0] 有权值外,没有其他弧,其实这些存储空间都浪费掉了。
因此我们考虑另外一种存储结构方式。根据在线性表存储时,顺序存储结构就存在预先分配内存可能造成存储空间浪费的问题,于是引出了链式存储的结构。同样的,我们也可以考虑对边或弧使用链式存储的方式来避免空间浪费的问题。
再回忆在树中谈存储结构时,讲到了一种孩子表示法,将结点存入数组,并对结点的孩子进行链式存储,不管有多少孩子,也不会存在空间浪费问题。这个思路同样适用于图的存储。我们把这种数组与链表相结合的存储方法称为邻接表 (Adjacency List) 。
邻接表的处理办法是下面这样的:
1. 图中顶点用一个一维数组存储,当然,顶点也可以用单链表来存储,不过数组可以较容易地读取顶点信息,更加方便。另外,对于顶点数组中,每个数据元素还需要存储指向第一个邻接点的指针,以便于查找该顶点的边信息。
2. 图中每个顶点 的所有邻接点构成一个线性表,由于邻接点的个数不定,所以用单链表存储,无向图称为顶点
的边表,有向图则称为顶点
作为弧尾的出边表。
例如下图所示的就是一个无向图的邻接表结构。
从图中我们知道,顶点表的各个结点由 data 和 firstedge 两个域表示,data 是数据域,存储顶点的信息,firstedge 是指针域,指向边表的第一个结点,即此顶点的第一个邻接点。边表结点由 adjvex 和 next 两个域组成。adjvex 是邻接点域,存储某顶点的邻接点在顶点表中的下标,next 则存储指向边表中下一个结点的指针。比如 顶点与
、
互为邻接点,则在
的边表中,adjvex 分别为
的 0 和
的 2。
这样的结构,对于我们要获得图的相关信息也是很方便的。比如我们要想知道某个顶点的度,就去查找这个顶点的边表中结点的个数。若要判断顶点 到
是否存在边,只需要测试顶点
的边表中 adjvex 是否存在结点
的下标 j 就行了。若求顶点的所有邻接点,其实就是对此顶点的边表进行遍历,得到的 adjvex 域对应的顶点就是邻接点。
若是有向图,邻接表结构是类似的,比如下图中第一幅图的邻接表就是第二幅图。但要注意的是有向图由于有方向,我们是以顶点为弧尾来存储边表的,这样很容易就可以得到每个顶点的出度。但也有时为了便于确定顶点的入度或以顶点为弧头的弧,我们可以建立一个有向图的逆邻接表,即对每个顶点 都建立一个链接为
为弧头的表。如下图的第三幅图所示。
此时我们很容易就可以算出某个顶点的入度或出度是多少,判断两顶点是否存在弧也很容易实现。
对于带权值的网图,可以在边表结点定义中再增加一个 weight 的数据域,存储权值信息即可,如下图所示:
有了这些结构的图,下面关于结点定义的代码就很好理解了。
typedef char VertexType; /*顶点类型应由用户定义*/
typedef int EdgeType; /*边上的权值类型应由用户定义*/
#define MAXVEX 100 /*最大顶点数,应由用户定义*/
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 numVertexes,numEdges; /*图中当前顶点数和边数*/
}GraphAdjList;
对于邻接表的创建,也就是顺理成章之事。无向图的邻接表创建代码如下。
/*建立图的邻接表结构*/
void CreateALGraph (GraphAdjList *G )
{
int i,j,k;
EdgeNode *e;
printf ("输入顶点数和边数:\n");
scanf("%d,%d", &G->numVertexes, &G->numEdges); /* 输入顶点数和边数 */
for (i = 0; i < G->numVertexes; i++) /*读入顶点信息,建立顶点表 */
{
scanf(&G->adjList[i].data); /*输入顶点信息 */
G->adjList[i].firstedge = NULL; /*将边表置为空表*/
}
for (k = 0; k < G->numEdges; 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[j].firstedge = e; /*将当前顶点的指针指向 e */
/*------------------*/
}
}
这里代码中,有部分应用了单链表创建中讲解到的头插法,由于对于无向图,一条边对应都是两个顶点,所以在循环中,一次就针对 i 和 j 分别进行了插入。 本算法的时间复杂度,对于 n 个顶点 e 条边来说,很容易得出是 O(n+e)。
3.3 十字链表
对于有向图来说,邻接表是有缺陷的。关心了出度问题,想了解入度就必须要遍历整个图才能知道,反之,逆邻接表解决了入度却不了解出度的情况。有没有可能把邻接表与逆邻接表结合起来呢?答案是肯定的,就是把它们整合在一起。这就是有向图的一种存储方法:十字链表(Orthogonal List)。
我们重新定义顶点表结点结构如下表所示:
其中 firstin 表示入边表头指针,指向该顶点的入边表中第一个结点,firstout 表示出边表头指针,指向该顶点的出边表中的第一个结点。
重新定义的边表结点结构如下表所示:
其中 tailvex 是指弧起点在顶点表的下标,headvex 是指弧终点在顶点表中的下标,headlink 是指入边表指针域,指向终点相同的下一条边,taillink 是指边表指针域,指向起点相同的下一条边。如果是网,还可以再增加一个 weight 域来存储权值。
比如下图所示,顶点依然是存入一个一维数组 ,以顶点
来说,firstout 指向的是出边表中的第一个结点
。所以
边表结点的 headvex=3 ,而 tailvex 其实就是当前顶点
的下标 0 ,由于
只有一个出边顶点,所以 headlink 和 taillink 都是空。
需要重点来解释虚线箭头的含义,它其实就是此图的逆邻接表的表示。对于 来说,它有两个顶点
和
的入边。因此
的 firstin 指向顶点
的边表结点中 headvex 为 0 的结点,如上图右图中的 ① 。接着由入边结点的 headlink 指向下一个入边顶点
,如图中的 ② 。对于顶点
,它有一个入边顶点
,所以它的 firstin 指向顶点
的边表结点中 headvex 为 1 的结点,如图中的 ③ 。顶点
和
也是同样有一个入边顶点,如图中 ④ 和 ⑤ 。
十字链表的好处就是因为把邻接表和逆邻接表整合在了一起,这样既容易找到以 为尾的弧,也容易找到以
为头的弧,因而容易求得顶点的出度和入度。而且它除了结构复杂一点外,其实创建图算法的时间复杂度是和邻接表相同的,因此,在有向图的应用中,十字链表是非常好的数据结构模型。
3.4 邻接多重表
如果我们在无向图的应用中,关注的重点是顶点,那么邻接表是不错的选择,但如果我们更关注边的操作,比如对已访问过的边做标记,删除某一条边等操作,那就意味着,需要找到这条边的两个边表结点进行操作,这其实还是比较麻烦的。比如下图所示,若要删除左图的 这条边,需要对邻接表结构中右边表的阴影两个结点进行删除操作,显然这是比较烦琐的。
因此,我们也仿照十字链表的方式,对边表结点的结构进行一些改造,也许就可以避免刚才提到的问题。
重新定义的边表结点结构如下表所示:
其中 ivex 和 jvex 是与某条边依附的两个顶点在顶点表中下标。ilink 指向依附顶点 ivex 的下一条边,jlink 指向依附顶点 jvex 的下一条边。这就是邻接多重表结构。
我们来看结构示意图的绘制过程,理解了它是如何连线的,也就理解邻接多重表构造原理了。如下图所示,左图告诉我们它有 4 个顶点和 5 条边,显然,我们就应该先将 4 个顶点和 5 条边的边表结点画出来。由于是无向图,所以 ivex 是 0 、 jvex 是 1 还是反过来都是无所谓的,不过为了绘图方便,都将 ivex 值设置得与一旁的顶点下标相同。
我们开始连线,如下图所示。首先连线的 ①②③④ 就是将顶点的 firstedge 指向一条边,顶点下标要与 ivex 的值相同,这很好理解。接着,由于顶点 的
边的邻边有
和
。因此 ⑤⑥ 的连线就是满足指向下一条依附于顶点
的边的目标,注意 ilink 指向的结点的 jvex —定要和它本身的 ivex 的值相同。同样的道理,连线 ⑦ 就是指
这条边,它是相当于顶点
指向
边后的下一条。
有三条边依附,所以在 ③ 之后就有了 ⑧⑨ 。连线 ⑩ 的就是顶点
在连线 ④ 之后的下一条边。左图一共有 5 条边,所以右图有 10 条连线,完全符合预期。
邻接多重表与邻接表的差别,仅仅是在于同一条边在邻接表中用两个结点表示,而在邻接多重表中只有一个结点。这样对边的操作就方便多了,若要删除左图的 这条边,只需要将右图的 ⑥⑨ 的链接指向改为 ∧ 即可。
3.5 边集数组
边集数组是由两个一维数组构成。一个是存储顶点的信息;另一个是存储边的信息,这个边数组每个数据元素由一条边的起点下标(begin)、终点下标(end)和权(weight)组成,如下图所示。显然边集数组关注的是边的集合,在边集数组中要查找一个顶点的度需要扫描整个边数组,效率并不高。因此它更适合对边依次进行处理的操作,而不适合对顶点相关的操作。
定义的边数组结构如下表所示:
其中 begin 是存储起点下标,end 是存储终点下标,weight 是存储权值。
4. 图的遍历
图的遍历是和树的遍历类似,我们希望从图中某一顶点出发访遍图中其余顶点,且使每一个顶点仅被访问一次,这一过程就叫做图的遍历(Traversing Graph)。
树的遍历我们谈到了四种方案,应该说都还好,毕竟根结点只有一个,遍历都是从它发起,其余所有结点都只有一个双亲。可图就复杂多了,因为它的任一顶点都可能和其余的所有顶点相邻接,极有可能存在沿着某条路径搜索后,又回到原顶点,而有些顶点却还没有遍历到的情况。因此我们需要在遍历过程中把访问过的顶点打上标记,以避免访问多次而不自知。具体办法是设置一个访问数组 visited[n] ,n 是图中顶点的个数,初值为 0 ,访问过后设置为 1 。这其实在小说中常常见到,一行人在迷宫中迷了路,为了避免找寻出路时屡次重复,所以会在路口用小刀刻上标记。
对于图的遍历来说,如何避免因回路陷入死循环,就需要科学地设计遍历方案, 通常有两种遍历次序方案:它们是深度优先遍历和广度优先遍历。
4.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 有路径相通的顶点都被访问到。事实上,我们这里讲到的是连通图,对于非连通图,只需要对它的连通分量分别进行深度优先遍历,即在先前一个顶点进行一次深度优先遍历后,若图中尚有顶点未被访问,则另选图中一个未曾被访问的顶点作起始点,重复上述过程,直至图中所有顶点都被访问到为止。
如果我们用的是邻接矩阵的方式,则代码如下:
#define MAX 100
#define TRUE 1
#define FALSE 0
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 函数的代码是几乎相同的,只是在递归函数中因为将数组换成了链表而有不同,代码如下:
/*邻接表的深度优先递归算法*/
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 条边的图来说,邻接矩阵由于是二维数组,要查找每个顶点的邻接点需要访问矩阵中的所有元素,因此都需要 的时间。而邻接表做存储结构时,找邻接点所需的时间取决于顶点和边 的数量,所以是
。显然对于点多边少的稀疏图来说,邻接表结构使得算法在时间效率上大大提高。
对于有向图而言,由于它只是对通道存在可行或不可行,算法上没有变化,是完全可以通用的。
4.2 广度优先遍历
广度优先遍历(Breadth_First_SearCh),又称为广度优先搜索,简称 BFS。
如果说图的深度优先遍历类似树的前序遍历,那么图的广度优先遍历就类似于树的层序遍历了。我们将下图的第一幅图稍微变形,变形原则是顶点 A 放置在最上第一层,让与它有边的顶点 B 、 F 为第二层,再让与 B 和 F 有边的顶点 C 、 I 、 G 、E 为第三层,再将这四个顶点有边的 D 、 H 放在第四层,如下图的第二幅图所示。 此时在视觉上感觉图的形状发生了变化,其实顶点和边的关系还是完全相同的。
有了这个讲解,看代码就非常容易了。以下是邻接矩阵结构的广度优先遍历算法:
/*邻接矩阵的广度遍历算法*/
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]) /*若是未访问过就处理*/
{
visited[i] = TRUE; /*设置当前顶点访问过*/
printf("%c ", G.vexs[i]); /*打印顶点,也可以其他操作*/
EnQueue(&Q, i); /*将此顶点入队列*/
while (!QueueEmpty(Q))/* 若当前队列不为空 */
{
DeQueue(&Q, &i); /*将队中元素出队列,赋值给 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("nc ", 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; /* 指针指向下一个邻接点 */
}
}
}
}
}
对比图的深度优先遍历与广度优先遍历算法,你会发现,它们在时间复杂度上是 一样的,不同之处仅仅在于对顶点访问的顺序不同。可见两者在全图遍历上是没有优劣之分的,只是视不同的情况选择不同的算法。
不过如果图顶点和边非常多,不能在短时间内遍历完成,遍历的目的是为了寻找合适的顶点,那么选择哪种遍历就要仔细斟酌了。深度优先更适合目标比较明确,以找到目标为主要目的的情况,而广度优先更适合在不断扩大遍历范围时找到相对最优解的情况。
5. 最小生成树
假设你是电信的实施工程师,需要为一个镇的九个村庄架设通信网络做设计,村庄位置大致如下图所示,其中 是村庄,之间连线的数字表示村与村间的可通达的直线距离,比如
至
就是 10 公里(个别如
与
,
与
,
与
未测算距离是因为有高山或湖泊,不予考虑)。你们领导要求你必须用最小的成本完成这次任 务。你说怎么办?
显然这是一个带权值的图,即网结构。所谓的最小成本,就是 n 个顶点,用 n-1 条边把一个连通图连接起来,并且使得权值的和最小。在这个例子里,每多一公里就多一份成本,所以只要让线路连线的公里数最少,就是最少成本了。
如果你加班加点,没日没夜设计出的结果是如下图的方案一(粗线为要架设线路),我想你离被炒鱿鱼应该是不远了(微笑)。因为这个方案比后两个方案多出 60% 的成本会让老板气晕过去的。
方案三设计得非常巧妙,但也只以极其微弱的优势对方案二胜出,应该说很是侥幸。我们有没有办法可以精确计算出这种网图的最佳方案呢?答案当然是 Yes。
一个连通图的生成树是一个极小的连通子图,它含有图中全部的顶点,但只有足以构成一棵树的 n-1 条边。显然上图的三个方案都是上上图的网图的生成树。那么我们把构造连通网的最小代价生成树称为最小生成树(Minimum Cost Spanning Tree)。
找连通网的最小生成树,经典的有两种算法,普里姆算法和克鲁斯卡尔算法。接下来就分别来介绍一下。
5.1 普里姆( Prim )算法
为了能讲明白这个算法,我们先构造邻接矩阵,如下图的右图所示:
也就是说,现在我们已经有了一个存储结构为 MGragh 的 G(前面邻接矩阵建立的存储结构) 。G 有 9 个顶点,它的 arc 二维数组如上图的右图所示。数组中的我们用 65535 来代表 。
于是普里姆( Prim )算法代码如下。其中 INFINITY 为权值极大值,不妨是 65535,MAXVEX 为顶点个数最大值,此处大于等于 9 即可。现在假设我们自己就是计算机,在调用 MiniSpanTree_Prim 函数,输入上述的邻接矩阵后,看看它是如何运行并打印出最小生成树的。
/* Prim 算法生成最小生成树 */
void MiniSpanTree_Prim(MGraph 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++) /* 循环除下标为 0 外的全部顶点 */
{
lowcost[i] = G.arc[0][i];/*将 v0 顶点与之有边的权值存入数组 */
adjvex[i] = 0; /* 初始化都为 v0 的下标 */
}
for (i = 1; i < G.numVertexes; i++)
{
min = INFINITY; /* 初始化最小权值为 ∞ , */
/* 通常设置为不可能的大数字如 32767、65535 等 */
j = l; k = 0;
while (j < G.numVertexes) /* 循环全部顶点 */
{
if (lowcost[j] != 0 && lowcost[j] < min)
{
/* 如果权值不为0且权值小于min */
min = lowcost[j];
/* 则让当前权值成为最小值*/
k = j; /*将当前最小值的下标存入k */
}
j++;
}
printf(" (%d,*d) ", adjvex[k], k);/*打印当前顶点边中权值最小边*/
lowcost[k] = 0;/*将当前顶点的权值设置为 0 ,表示此顶点已经完成任务*/
for (j = 1; j < G.numVertexes; j++) /* 循环所有顶点 */
{
if (lowcost[j] != 0 && G.arc[k][j] < lowcost[j])
{
/*若下标为 k 顶点各边权值小于此前这些顶点未被加入生成树权值*/
lowcost[j] = G.arc[k][j]; /* 将较小权值存入 lowcost */
adjvex[j] = k; /* 将下标为 k 的顶点存入adjvex */
}
}
}
}
1. 程序开始运行,我们由第 5〜6 行,创建了两个一维数组 lowcost 和 adjvex , 长度都为顶点个数 9 。它们的作用我们慢慢细说。
2. 第 7〜9 行我们分别给这两个数组的第一个下标位赋值为 0, arjvex[0]=0 其实意思就是我们现在从顶点 开始(事实上,最小生成树从哪个顶点开始计算都无所谓,我们假定从
开始),lowcost[0]=0 就表示
已经被纳入到最小生成树中,之后凡是 lowcost 数组中的值被设置为 0 就是表示此下标的顶点被纳入最小生成树。
3. 第 10〜14 行表示我们读取上图的右图(如下图所示)邻接矩阵的第一行数据。将数值赋值给 lowcost 数组,所以此时 lowcost 数组值为 { 0,10,65535,65535,65535,11,65535,65535,65535 } ,而 arjvex 则全部为 0。此时,我们已经完成了整个初始化的工作,准备开始生成。
4. 第 15〜42 行,整个循环过程就是构造最小生成树的过程。
5. 第 17〜19 行,将 min 设置为了一个极大值 65535 ,它的目的是为了之后找到一定范围内的最小权值。j 是用来做顶点下标循环的变量,k 是用来存储最小权值的顶点下标。
6. 第 20〜30 行,循环中不断修改 min 为当前 lowcost 数组中最小值,并用 k 保留此最小值的顶点下标。经过循环后, min=10 , k=1 。注意 22 行 if 判断的 lowcost[ j ] != 0 表示已经是生成树的顶点不参与最小权值的查找。
7. 第 31 行,因 k=1 ,adjvex[ 1 ]=0,所以打印结果为(0, 1),表示 至
边为最小生成树的第一条边。如下图所示:
8. 第 32 行,此时因 k=1 我们将 lowcost[ k ]=0 就是说顶点 纳入到最小生成树中。此时 lowcost 数组值为 { 0, 0, 65535, 65535, 65535, 11, 65535, 65535, 65535 } 。
9. 第 33〜41 行,j 循环由 1 至 8 ,因 k=1 ,查找邻接矩阵的第 行的各个权值,与 lowcost 的对应值比较,若更小则修改 lowcost 值,并将 k 值存入 adjvex 数组中。因第
行有 18、16、12 均比 65535 小,所以最终 lowcost 数组的值为: { 0, 0, 18, 65535, 65535, 11, 16, 65535, 12} 。adjvex 数组的值为: { 0, 0, 1, 0, 0, 0, 1, 0, 1} 。这里第 30 行 if 判断的 lowcost [ j ] != 0 也说明
和
已经是生成树的顶点不参与最小权值的比对了。
10. 再次循环,由第 17 行到第 31 行,此时 min=11, k=5, adjvex[5]=0。因此打印结构为 ( 0,5 )。表示 至
边为最小生成树的第二条边,如下图所示:
11. 接下来执行到 42 行,lowcost 数组的值为: {0, 0, 18, 65535, 26 , 0, 16, 65535, 12 } 。adjvex 数组的值为: { 0, 0, 1, 0, 5, 0, 1, 0, 1 } 。
12. 之后,相信大家也都会自己去模拟了。通过不断的转换,构造的过程如下图中 图 1〜图 6 所示。
有了这样的讲解,再来介绍普里姆( Prim )算法的实现定义可能就容易理解一些。
假设 N= (P,{E}) 是连通网,TE 是 N 上最小生成树中边的集合。算法从 , TE={ } 开始。重复执行下述操作:在所有
,
的边
中 找一条代价最小的边
并入集合 TE,同时
并入 U,直至 U=V 为止。此时 TE 中必有 n-1 条边,则 T= (V,{TE}) 为 N 的最小生成树。
由算法代码中的循环嵌套可得知此算法的时间复杂度为 。(目前这算法只是基本实现最小生成树的构建,算法还可以优化)
5.2 克鲁斯卡( Kruskal )算法
普里姆( Prim )算法是以某顶点为起点,逐步找各顶点上最小权值的边来构建最小生成树的。这就像是我们如果去参观某个展会,例如世博会,你从一个入口进去,然后找你所在位置周边的场馆中你最感兴趣的场馆观光,看完后再用同样的办法看下一个。可我们为什么不事先计划好,进园后直接到你最想去的场馆观看呢?事实上,去世博园的观众,绝大多数都是这样做的。
同样的思路,我们也可以直接就以边为目标去构建,因为权值是在边上,直接去找最小权值的边来构建生成树也是很自然的想法,只不过构建时要考虑是否会形成环路而已。此时我们就用到了图的存储结构中的边集数组结构。以下是 edge 边集数组结构的定义代码:
/* 对边集数组Edge结构的定义 */
typedef struct
{
int begin;
int end;
int weight;
}Edge;
我们将下图左图的邻接矩阵通过程序转化为下图右图的边集数组,并且对它们按权值从小到大排序。
于是克鲁斯卡尔( Kruskal )算法代码如下。其中 MAXEDGE 为边数量的极大值,此处大于等于 15 即可,MAXVEX 为顶点个数最大值,此处大于等于 9 即可。现在假设我们自己就是计算机,在调用 MiniSpanTree_Kuskal 函数,输入下图的邻接矩阵后,看看它是如何运行并打印出最小生成树的。
/* Kruskal算法生成最小生成树*/
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.numEdges; i++) /* 循环每一条边 */
{
n = Find(parent, edges[i].begin);
m = Find(parent, edges[i].end);
if (n != m) /* 假如 n 与 m 不等,说明此边没有与现有生成树形成环路 */
{
parent[n] = m; /* 将此边的结尾顶点放入下标为起点的 parent 中 */
/* 表示此顶点已经在生成树集合中 */
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;
}
1. 程序开始运行,第 6 行之后,我们省略掉颇占篇幅但却很容易实现的将邻接矩阵转换为边集数组,并按权值从小到大排序的代码,也就是说,在第 6 行开 始,我们已经有了结构为 edge ,数据内容是下图的一维数组 edges 。
2. 第 6〜11 行,我们声明一个数组 parent ,并将它的值都初始化为 0 ,它的作用我们后面慢慢说。
3. 第 12〜22 行,我们开始对边集数组做循环遍历,开始时,i=0 。
4. 第 14 行,我们调用了第 25〜32 行的函数 Find ,传入的参数是数组 parent 和当前权值最小边 的 begin: 4 。因为 parent 中全都是 0 所以传出值使得 n=4 。
5. 第 15 行,同样作法,传入 的 end: 7。传出值使得 m=7 。
6. 第 16〜21 行,很显然 n 与 m 不相等,因此 parent[4]=7 。此时 parent 数组值 为 { 0, 0, 0, 0, 7, 0, 0, 0, 0 },并且打印得到 “(4, 7) 7” 。此时我们已经将边 纳入到最小生成树中,如下图所示:
7. 循环返回,执行 14〜21 行,此时 i=1, edge[1] 得到边 , n=2 , m=8 , parent[2]=8 ,打印结果为 “ (2, 8) 8”,此时 parent 数组值为 { 0, 0, 8, 0, 7, 0, 0, 0, 0 } ,这也就表示边
和边
已经纳入到最小生成树,如下图所示:
8. 再次执行 14〜21 行,此时 i=2 , edge[2] 得到边 , n=0, m=1, parent[0]=1,打印结果为 “ (0, 1) 10”,此时 parent 数组值为 { 1, 0, 8, 0, 7, 0, 0, 0, 0 } ,此时边
、
和
已经纳入到最小生成树,如下图所示:
9. 当 i=3 、4 、5 、6 时,分别将边 、
、
、
纳入到最小生成树中,如下图所示。此时 parent 数组值为 { 1, 5, 8, 7, 7, 8, 0, 0, 6 } ,怎么去解读这个数组现在这些数字的意义呢?
从上图的最右图 i=6 的粗线连线可以得到,我们其实是有两个连通的边集合 A 与 B 中纳入到最小生成树中的,如下图所示。当 parent[0]=1 ,表示 和
已经在生成树的边集合 A 中。此时将 parent[0]=1 的 1 改为下标,由 parent[1]=5 ,表示
和
在边集合 A 中,parent[5]=8 表示
与
在边集合 A 中,parent[8]=6 表示
与
在边集合 A 中,parent[6]=0 表示集合 A 暂时到头,此时边集合 A 有
、
、
、
、
。我们查看 parent 中没有查看的值,parent[2]=8 表示
与
在一个集合中,因此
也在边集合 A 中。再由 parent[3]=7 、 parent[4]=7 和 parent[7]=0 可 知
、
、
在另一个边集合 B 中。
10. 当 i=7 时,第 14 行,调用 Find 函数,会传入参数 edges[7].begin=5 。此时第 27 行,parent[5]=8>0,所以 f=8 ,再循环得 parent[8]=6 。因 parent[6]=0 所以 Find 返回后第 14 行得到 n=6 。而此时第 15 行,传入参数 edges[7].end=6 得到 m=6 。此时 n=m ,不再打印,继续下一循环。这就告诉我们,因为边 使得边集合 A 形成了环路。因此不能将它纳入到最小生成树中,如上图所示。
11. 当 i=8 时,与上面相同,由于边 使得边集合 A 形成了环路。因此不能将它纳入到最小生成树中,如上图所示。
12. 当 i=9 时,边 ,第 14 行得到 n=6 ,第 15 行得到 m=7 ,因此 parent[6]=7 ,打印 “( 6, 7 ) 19”。此时 parent 数组值为 { 1, 5, 8, 7, 7, 8, 7, 0, 6 } ,如下图所示。
13. 此后边的循环均造成环路,最终最小生成树即为上图所示。
我们来把克鲁斯卡尔( Kmskal )算法的实现定义归纳一下。
假设 N= (V,{E}) 是连通网,则令最小生成树的初始状态为只有 n 个顶点而无边的非连通图 T={V,{ }} ,图中每个顶点自成一个连通分量。在 E 中选择代价最小的边,若该边依附的顶点落在 T 中不同的连通分量上,则将此边加入到 T 中,否则舍去此边而选择下一条代价最小的边。依次类推,直至 T 中所有顶点都在同一连通分量上为止。
此算法的 Find 函数由边数 e 决定,时间复杂度为 O(loge) ,而外面有一个 for 循环 e 次。所以克鲁斯卡尔算法的时间复杂度为O(eloge)。
对比两个算法,克鲁斯卡尔算法主要是针对边来展开,边数少时效率会非常高, 所以对于稀疏图有很大的优势;而普里姆算法对于稠密图,即边数非常多的情况会更好一些。
6. 最短路径
在网图和非网图中,最短路径的含义是不同的。
由于非网图它没有边上的权值, 所谓的最短路径,其实就是指两顶点之间经过的边数最少的路径。
而对于网图来说, 最短路径,是指两顶点之间经过的边上权值之和最少的路径,并且我们称路径上的第 一个顶点是源点,最后一个顶点是终点。
显然,我们研究网图更有实际意义,就地图来说,距离就是两顶点间的权值之和。而非网图完全可以理解为所有的边的权值都为 1 的网。
接下来讲解两种求最短路径的算法。先来讲第一种,从某个源点到其余各顶点的最短路径问题。
你能很快计算出下图中由源点 到终点
的最短路径吗?如果不能,没关系,我们一同来研究看如何让计算机计算出来。如果能,那仅代表你智商还不错,你还是要来好好学习,毕竟真声世界的图可没这么简单,人脑是用来创造而不是做枯燥复杂的计算的。
6.1 迪杰斯特拉( Dijkstra )算法
这是一个按路径长度递增的次序产生最短路径的算法。它的思路大体是这样的。
比如说要求下图中顶点 到顶点
的最短距离,没有比这更简单的了,答案就是 1,路径就是直接
连线到
。
由于顶点 还与
、
、
连线,所以此时我们同时求得了
,
,
。
现在,我问 到
的最短距离,如果你不假思索地说是 5 ,那就犯错了。因为边上都有权值,刚才已经有
的结果是 4 ,比 5 还要小 1 个单位,它才是最短距离,如下图所示:
由于顶点 还与
、
连线,所以此时我们求得了
其实就是
,
。这里
我们用的是刚才计算出来的较小的 4 。此时我们也发现
要比
还要小。所以
到
目前的最小距离是 5 ,如下图所示:
当我们要求 到
的最短距离时,通向
的三条边,除了
没有研究过外,
的结果是 8 ,而
。因此,
到
的最短距离是 7 ,如下图所示:
好了,我想你大致明白,这个迪杰斯特拉( Dijkstra )算法是如何干活的了。它并不是一下子就求出了 到
的最短路径,而是一步步求出它们之间顶点的最短路径,过程中都是基于已经求出的最短路径的基础上,求得更远顶点的最短路径,最终得到你要的结果。
如果还是不太明白,不要紧,现在我们来看代码,从代码的模拟运行中,再次去理解它的思想。
#define MAXVEX 9
#define INFINITY 65535
typedef int Pathmatirx[MAXVEX]; /* 用于存储最短路径下标的数组 */
typedef int ShortPathTable[MAXVEX]; /* 用于存储到各点最短路径的权值和 */
/* Dijkstra 算法,求有向网 G 的 v0 顶点到其余顶点 v 最短路径P[v] ,及带权长度 D[v] */
/* P[v] 的值为前驱顶点下标,D[v] 表示 v0 到 v 的最短路径长度和。 */
void ShortestPath__Dijkstra(MGraph G, int v0, Pathmatirx *P, ShortPathTable *D)
{
int v, w, k, min;
int final[MAXVEX]; /* final [w]=l表示求得顶点v。至vw的最短路径*/
for (v = 0; v < G.numVertexes; v++) /* 初始化数据 */
{
final[v] = 0; /* 全部顶点初始化为未知最短路径状态 */
(*D)[v] = G.matirx[v0][v]; /* 将与 v0 点有连线的顶点加上权值 */
(*P)[v] = 0; /* 初始化路径数组 P 为 0 */
}
(*D)[v0] = 0; /* v0 至 v0 路径为 0 */
final[v0] = 1; /* v0 至 v0 不需要求路径 */
/* 开始主循环,每次求得 v0 到某个 v 顶点的最短路径 */
for (v = 1; v < G.numVertexes; v++)
{
min = INFINITY; /* 当前所知离 v0 顶点的最近距离 */
for (w = 0; w < G.numVertexes; w++) /* 寻找离 v0 最近的顶点 */
{
if (!final[w] && (*D)[w] < min)
{
k = w;
min = (*D)[w]; /* w 顶点离 v0 顶点更近 */
}
}
final[k] = 1; /* 将目前找到的最近的顶点置为 1 */
for (w = 0; w < G.numVertexes; w++) /*修正当前最短路径及距离*/
{
/* 如果经过 v 顶点的路径比现在这条路径的长度短的话 */
if (!final[w] && (min + G.matirx[k][w] < (*D)[w]))
{
/* 说明找到了更短的路径,修改 D[w] 和 P[w] */
(*D)[w] = min + G.matirx[k][w]; /* 修改当前路径长度 */
(*P)[w] = k;
}
}
}
}
调用此函数前,其实我们需要为下图的左图准备邻接矩阵 MGraph 的 G ,如下图的右图,并且定义参数 为 0 。
1. 程序开始运行,第 10 行 final 数组是为了 到某顶点是否已经求得最短路径的标记,如果
到
已经有结果,则 final[w]=1 。
2. 第 11〜16 行,是在对数据进行初始化的工作。此时 final 数组值均为 0 ,表示所有的点都未求得最短路径。 D 数组为 { 65535, 1, 5, 65535, 65535, 65535, 65535, 65535, 65535 } 。因为 与
和
的边权值为 1 和 5 。P 数组全为 0 ,表示目前没有路径。
3. 第 17 行,表示 到
自身,权值和结果为 0 。D 数组为 { 0, 1, 5, 65535, 65535, 65535, 65535, 65535, 65535 } 。第 18 行,表示
点算是已经求得最短路径,因此 final[0]=1 。此时 final 数组为 { 1, 0, 0, 0, 0, 0, 0, 0, 0 } 。此时整个初始化工作完成。
4. 第 20〜42 行,为主循环,每次循环求得 与一个顶点的最短路径。因此 v 从 1 而不是 0 开始。
5. 第 22〜30 行,先令 min 为 65535 的极大值,通过 w 循环,与 D[w] 比较找到最小值 min=1, k=1 。
6. 第 31 行,由 k=1 ,表示与 最近的顶点是
,并且由 D[1]=1 ,知道此时
到
的最短距离是 1 。因此将
对应的 final[1] 设置为 1 。此时 final 数组为 { 1, 1, 0, 0, 0, 0, 0, 0, 0 } 。
7. 第 32〜41 行是一循环,此循环甚为关键。它的目的是在刚才已经找到 与
的最短路径的基础上,对
与其他顶点的边进行计算,得到
与它们的当前最短距离,如下图所示。因为 min=1 ,所以本来 D[2]=5 ,现在
,
,
,因此,D 数组当前值为 { 0, 1, 4, 8, 6, 65535, 65535, 65535, 65535 } 。而 P[2]=1 , P[3]=1 ,P[4]=1 ,它表示的意思是
到
、
、
点的最短路径它们的前驱均是
。 此时 P 数组值为: { 0, 0, 1, 1, 1, 0, 0, 0, 0 } 。
8. 重新开始循环,此时 i=2 。第 22〜30 行,对 w 循环,注意因为 final[0]=1 和 final[1]=1 ,由第 25 行的 !final[w] 可知, 与
并不参与最小值的获取。通过循环比较,找到最小值 min=4 , k=2 。
9. 第 31 行,由 k=2 ,表示已经求出 到
的最短路径,并且由 D[2]=4 ,知道最短距离是 4 。因此将
对应的 final[2] 设置为 1 ,此时 final 数组为: { 1, 1, 1, 0, 0, 0, 0, 0, 0 } 。
10. 第 32〜41 行。在刚才已经找到 到
的最短路径的基础上,对
与其他顶点的边,进行计算,得到
与它们的当前最短距离,如下图所示。因为 min=4 ,所以本来 D[4]=6 ,现在
,
,因此,D 数组当前值为:{ 0, 1, 4, 8, 5, 11, 65535, 65535, 65535 } 。而原本 P[4]=1 ,此时 P[4]=2 , P[5]=2 ,它表示
到
、
点的最短路径它们的前驱均是
。此时 P 数组值为: { 0, 0, 1, 1, 2, 2, 0, 0, 0 } 。
11. 重新开始循环,此时 i=3 。第 22〜30 行,通过对 w 循环比较找到最小值 min=5 , k=4 。
12. 第 31 行,由 k=4 ,表示已经求出 到
的最短路径,并且由 D[4]=5 ,知道最短距离是 5 。因此将
对应的 final[4] 设置为 1 。此时 final 数组为: { 1, 1, 1, 0, 1, 0, 0, 0, 0 } 。
13. 第 32〜41` 行。对 与其他顶点的边进行计算,得到
与它们的当前最短距离,如下图所示。因为 min=5 ,所以本来 D[3]=8 ,现在
,本来 D[5]=11 ,现在
,另外
,
,因此,D 数组当前值为: { 0, 1, 4, 7, 5, 8, 11, 14, 65535 } 。而原本 P[3]=1 ,此时 P[3]=4 ,原本 P[5]=2 ,此时 P[5]=4 ,另外 P[6]=4 , P[7]=4 ,它表示
到
、
、
、
点的最短路径它们的前驱均是
。此时 P 数组值为:{ 0, 0, 1, 4, 2, 4, 4, 4, 0 } 。
14. 之后的循环就完全类似了。得到最终的结果,如下图所示。此时 final 数组为: { 1, 1, 1, 1, 1, 1, 1, 1, 1 } ,它表示所有的顶点均完成了最短路径的查找工作。此时 D 数组为:{ 0, 1, 4, 7, 5, 8, 10, 12, 16 } ,它表示 到各个顶点的最短路径数,比如 D[8]=1+3+1+2+3+2+4=16 。此时的 P 数组为:{ 0, 0, 1, 4, 2, 4, 3, 6, 7 } , 这串数字可能略为难理解一些。比如 P[8]=7 ,它的意思是
到
的最短路径,顶点
的前驱顶点是
,再由 P[7]=6 ,表示
的前驱是
, P[6]=3 ,表示
的前驱是
。这样就可以得到,
到
的最短路径为
,即
。
其实最终返回的数组 D 和数组 P ,是可以得到 到任意一个顶点的最短路径和路径长度的。例如
到
的最短路径并没有经过
,但我们已经知道
到
的最短路径了。由 D[5]=8 可知它的路径长度为 8 ,由 P[5]=4 可知
的前驱顶点是
,所以
到
的最短路径是
。
也就是说,我们通过迪杰斯特拉( Dijkstra )算法解决了从某个源点到其余各顶点的最短路径问题。从循环嵌套可以很容易得到此算法的时间复杂度为 ,尽管有人觉得,可不可以只找到从源点到某一个特定终点的最短路径,其实这个问题和求源点到其他所有顶点的最短路径一样复杂,时间复杂度依然是
。
这就好比,你吃了七个包子终于算是吃饱了,就感觉很不划算,前六个包子白吃了,应该直接吃第七个包子,于是你就去寻找可以吃一个就能饱肚子的包子,能够满足你的要求最终结果只能有一个,那就是用七个包子的面粉和馅做的一个大包子。这种只关注结果而忽略过程的思想是非常不可取的。
可如果我们还需要知道如 到
、
到
这样的任一顶点到其余所有顶点的最短路径怎么办呢?此时简单的办法就是对每个顶点当作源点运行一次迪杰斯特拉( Dijkstra )算法,等于在原有算法的基础上,再来一次循环,此时整个算法的时间复杂度就成了
。
对此,我们现在再来介绍另一个求最短路径的算法——弗洛伊德( Floyd ),它求所有顶点到所有顶点的时间复杂度也是 ,但其算法非常简洁优雅,能让人感觉到智慧的无限魅力。
6.2 弗洛伊德( Floyd )算法
为了能讲明白弗洛伊德( Floyd )算法的精妙所在,我们先来看最简单的案例。下图的左图是一个最简单的 3 个顶点连通网图。
我们先定义两个二维数组 D[3][3] 和 P[3][3] , D 代表顶点到顶点的最短路径权值和的矩阵。 P 代表对应顶点的最小路径的前驱矩阵。在未分析任何顶点之前,我们将 D 命名为 ,其实它就是初始的图的邻接矩阵。将 P 命名为
,初始化为图中所示的矩阵。
首先我们来分析,所有的顶点经过 后到达另一顶点的最短路径。因为只有三个顶点,因此需要查看
,得到
。
表示的是
的权值为 5 ,我们发现
,通俗的话讲就是
比直接
距离还要近。所以我们就让
,同样的
于是就有了
的矩阵。因为有变化,所以 P 矩阵对应的
和
也修改为当前中转的顶点
的下标 0 ,于是就有了
。也就是说
接下来,其实也就是在 和
的基础上继续处理所有顶点经过
和
后到达另一顶点的最短路径,得到
和
、
和
完成所有顶点到所有顶点的最短路径计算工作。
如果就用这么简单的图形来讲解代码,大家一定会觉得不能说明什么问题。所以我们还是以前面的复杂网图为例,来讲解弗洛伊德( Floyd )算法。
首先我们针对下图的左网图准备两个矩阵 和
,
就是网图的邻接矩阵,
初设为
这样的矩阵,它主要用来存储路径。
代码如下,注意因为是求所有顶点到所有顶点的最短路径,因此 Pathmatirx 和 ShortPathTable 都是二维数组。
#define MAXVEX 9
typedef int Pathmatirx[MAXVEX][MAXVEX];
typedef int ShortPathTable[MAXVEX][MAXVEX];
/* Floyd 算法,求网图 G 中各顶点 v 到其余顶点点 w 最短路径 P[v][w] 及带权长度 D[v][w] */
void ShortestPath_Floyd(MGraph G, Pathmatirx *P, ShortPathTable *D)
{
int v, w, k;
for (v = 0; v < G.numVertexes; ++v) /* 初始化 D 与 P */
{
for (w = 0; w < G.numVertexes; ++w)
{
(*D)[v][w] *= G.matirx[v][w]; /* D[v][w] 值即为对应点间的权值 */
(*P)[v][w] = w; /* 初始化 P */
}
}
for (k = 0; k < G.numVertexes; ++k)
{
for (v = 0; v < G.numVertexes; ++v)
{
for (w = 0; w<G.numVertexes; ++w)
{
if ((*D)[v][w]>(*D)[v][k] + (*D)[k][w])
{
/* 如果经过下标为 k 顶点路径比原两点间路径更短 */
/* 将当前两点间权值设为更小的一个 */
(*D)[v][w] = (*D)[v][k] + (*D)[k][w];
(*P)[v][w] = (*P)[v][k]; /* 路径设置经过下标为 k 的顶点 */
}
}
}
}
}
1. 程序开始运行,第 8〜15 行就是初始化了 D 和 P ,使得它们成为下图的两个矩阵。从矩阵也得到, 路径权值是 1 ,
路径权值是 5 ,
无边连线,所以路径权值为极大值 65535 。
2. 第 16〜31 行,是算法的主循环,一共三层嵌套,k 代表的就是中转顶点的下 标。v 代表起始顶点,w 代表结束顶点。
3. 当 k=0 时,也就是所有的顶点都经过 中转,计算是否有最短路径的变化。 可惜结果是,没有任何变化,如下图所示。
4. 当 k=1 时,也就是所有的顶点都经过 中转。此时,当 v=0 时,原本 D[0][2]=5 ,现在由于 D[0][1]+D[1][2]=4 。因此由代码的第 26 行,二者取其最小值,得到 D[0][2]=4 ,同理可得 D[0][3]=8 、D[0][4]=6 ,当 v=2、3、4 时,也修改了一些数据,请参考如下图左图中虚线框数据。由于这些最小权值的修正,所以在路径矩阵 P 上,也要作处理,将它们修改为当前的 P[v][k] 值,见代码第 21 行。
5. 接下来就是 k=2 一直到 8 结束,表示针对每个顶点做中转得到的计算结果, 当然,我们也要清楚, 是以
为基础,
是以
为基础,……,
是以
为基础,它们是有联系的,路径矩阵 P 也是如此。最终当 k=8 时,两矩阵数据如下图所示:
至此,我们的最短路径就算是完成了,你可以看到矩阵第 行的数值与迪杰斯特拉( Dijkstra )算法求得的 D 数组的数值是完全相同,都是 { 0, 1, 4, 7, 5, 8, 10, 12, 16 } 。而且这里是所有顶点到所有顶点的最短路径权值和都可以计算出。
那么如何由 P 这个路径数组得出具体的最短路径呢?以 到
为例,从下图的右图第
列,P[0][8]=1,得到要经过顶点
,然后将 1 取代 0 得到 P[1][8]=2 ,说明要经过
,然后将 2 取代 1 得到 P[2][8]=4 ,说明要经过
,然后将 4 取代 2 得到 P[4][8]=3 ,说明要经过
,…… ,这样很容易就推导出最终的最短路径值为
。
求最短路径的显示代码可以这样写:
for (v = 0; v < G.numVertexes; ++v)
{
for (w = v + l; w < G.numVertexes; w++)
{
printf("v%d-v%d weight: %d ", v, w, D[v][w]);
k = P[v][w]; /* 获得第一个路径顶点下标 */
printf(" path: %d", v); /* 打印源点 */
while (k != w); /*如果路径顶点下标不是终点*/
{
printf(" -> %d", k); /* 打印路径顶点 */
k = P[k][w]; /* 获得下一个路径顶点下标 */
}
printf(" -> %d\n", w);/*打印终点*/
}
printf("\n");
}
再次回过头来看看弗洛伊德( Floyd )算法,它的代码简洁到就是一个二重循环初始化加一个三重循环权值修正,就完成了所有顶点到所有顶点的最短路径计算。几乎就如同是我们在学习 C 语言循环嵌套的样例代码而已。如此简单的实现,真是巧妙之极,在我看来,这是非常漂亮的算法,很可惜由于它的三重循环,因此也是 时间复杂度。如果你面临需要求所有顶点至所有顶点的最短路径问题时,弗洛伊德( Floyd )算法应该是不错的选择。
另外,我们虽然对求最短路径的两个算法举例都是无向图,但它们对有向图依然有效,因为二者的差异仅仅是邻接矩阵是否对称而已。
7. 拓扑排序
说了两个有环的图应用,现在我们来谈谈无环的图应用。无环,即是图中没有回路的意思。
7.1 拓扑排序介绍
我们会把施工过程、生产流程、软件开发、教学安排等都当成一个项目工程来对待,所有的工程都可分为若干个 “活动" 的子工程。例如下图是非专业人士绘制的一张电影制作流程图,现实中可能并不完全相同,但基本表达了一个工程和若干个活动的概念。在这些活动之间,通常会受到一定的条件约束,如其中某些活动必须在另一些活动完成之后才能开始。就像电影制作不可能在人员到位进驻场地时,导演还没有找到,也不可能在拍摄过程中,场地都没有。这都会导致荒谬的结果。因此这样的工程图,一定是无环的有向图。
在一个表示工程的有向图中,用顶点表示活动,用弧表示活动之间的优先关系, 这样的有向图为顶点表示活动的网,我们称为 AOV 网( Activity On Vertex Network )。 AOV 网中的弧表示活动之间存在的某种制约关系。比如演职人员确定了,场地也联系好了,才可以开始进场拍摄。另外就是 AOV 网中不能存在回路。刚才已经举了例子,让某个活动的开始要以自己完成作为先决条件,显然是不可以的。
设 G=(V,E) 是一个具有 n 个顶点的有向图,V 中的顶点序列 ,满足若从顶点
到
有一条路径,则在顶点序列中顶点
必在顶点
之前。则我们称这样的顶点序列为一个拓扑序列。
上图这样的 AOV 网的拓扑序列不止一条。序列 是一条拓扑序列,
也是一条拓扑序列。
所谓拓扑排序,其实就是对一个有向图构造拓扑序列的过程。构造时会有两个结果,如果此网的全部顶点都被输出,则说明它是不存在环(回路)的 AOV 网;如果输出顶点数少了,哪怕是少了一个,也说明这个网存在环(回路),不是 AOV 网。
一个不存在回路的 AOV 网,我们可以将它应用在各种各样的工程或项目的流程图中,满足各种应用场景的需要,所以实现拓扑排序的算法就很有价值了。
7.2 拓扑排序算法
对 AOV 网进行拓扑排序的基本思路是:从 AOV 网中选择一个入度为 0 的顶点输出,然后删去此顶点,并删除以此顶点为尾的弧,继续重复此步骤,直到输出全部顶点或者 AOV 网中不存在入度为 0 的顶点为止。
首先我们需要确定一下这个图需要使用的数据结构。前面求最小生成树和最短路径时,我们用的都是邻接矩阵,但由于拓扑排序的过程中,需要删除顶点,显然用邻接表会更加方便。因此我们需要为 AOV 网建立一个邻接表。考虑到算法过程中始终要查找入度为 0 的顶点,我们在原来顶点表结点结构中,增加一个入度域 in ,结构如下表所示,其中 in 就是入度的数字。
因此对于下图的左图 AOV 网,我们可以得到如下图右图的邻接表数据结构。
在拓扑排序算法中,涉及的结构代码如下:
#define MAXVEX 9
typedef struct EdgeNode /* 边表结点 */
{
int adjvex; /* 邻接点域,存你该顶点对应的下标 */
int weight; /* 用于存储权值,对于非网图可以不需要 */
struct EdgeNode *next; /* 链域,指向下一个邻接点 */
}EdgeNode;
typedef struct VertexNode /* 顶点表结点 */
{
int in; /* 顶点入度 */
int data; /* 顶点域,存储顶点信息 */
EdgeNode *firstedge; /* 边表头指针 */
}VertexNode, AdjList[MAXVEX];
typedef struct
{
AdjList adjList;
int numVertexes, numEdges; /* 图中当前顶点数和边数 */
}graphAdjList, *GraphAdjList;
在算法中,我还需要辅助的数据结构—栈,用来存储处理过程中入度为 0 的顶点,目的是为了避免每个查找时都要去遍历顶点表找有没有入度为 0 的顶点。
现在我们来看代码,并且模拟运行它:
#define OK 1
#define ERROR 0
/* 拓扑排序,若 GL 无回路,则输出拓扑排序序列并返回 OK ,若有回路返回 ERROR */
Status TopologicalSort(GraphAdjList GL)
{
EdgeNode *e;
int i, k, gettop;
int top = 0; /*用于栈指针下标*/
int count = 0; /*用于统计输出顶点的个数*/
int *stack; /* 建栈存储入度为 0 的顶点 */
stack = (int *)malloc(GL->numVertexes * sizeof (int));
for (i = 0; i < GL->numVertexes; i++)
{
if (GL->adjList[i].in = 0)
{
stack[++top] = i; /* 将入度为 0 的顶点入栈 */
}
}
while (top != 0)
{
gettop = stack[top--]; /* 出栈 */
printf("%d -> ", GL->adjList[gettop].data); /* 打印此顶点 */
count++; /* 统计输出顶点数 */
for (e = GL->adjList[gettop].firstedge; e; e = e->next)
{
/* 对此顶点弧表遍历 */
k = e->adjvex;
if (!(--GL->adjList[k].in))/*将 k 号顶点邻接点的入度减 1 */
{
stack[++top] = k; /*若为 0 则入栈,以便于下次循环输出*/
}
}
}
if (count < GL->numVertexes) /* 如果 count 小于顶点数,说明存在环 */
{
return ERROR;
}
else
{
return OK;
}
}
1. 程序开始运行,第 6〜10 行都是变量的定义,其中 stack 是一个桟,用来存储整型的数字。
2. 第 12〜19 行,作了一个循环判断,把入度为 0 的顶点下标都入栈,从下图的右图邻接表可知,此时 stack 应该为: { 0, 1, 3 } ,即 、
、
的顶点入度为 0 ,如下图所示:
3. 第 20〜34 行,while 循环,当栈中有数据元素时,始终循环。
4. 第 22〜24 行, 出栈得到 gettop=3 。并打印此顶点,然后 count 加 1 。
5. 第 25〜33 行,循环其实是对 顶点对应的弧链表进行遍历,即下图中的灰色部分,找到
连接的两个顶点
和
,并将它们的入度减少一位,此时
和
的 in 值都为 1 。它的目的是为了将
顶点上的弧删除。
6. 再次循环,第 20〜34 行。此时处理的是顶点 。经过出栈、打印、count=2 后,我们对
到
、
、
的弧进行了遍历。并同样减少了它们的入度数,此时
入度为 0 ,于是由第 29〜32 行知,
入栈,如下图所示。试想, 如果没有在顶点表中加入 in 这个入度数据域,29 行的判断就必须要是循环,这显然是要消耗时间的,我们利用空间换取了时间。
7. 接下来,就是同样的处理方式了。下图展示了 的打印删除过程,后面还剩几个顶点都类似,就不图示了。
8. 最终拓扑排序打印结果为 。当然这结果并不是唯一的一种拓扑排序方案。
分析整个算法,对一个具有 n 个顶点 e 条弧的 AOV 网来说,第 12〜19 行扫描顶点表,将入度为 0 的顶点入栈的时间复杂为 ,而之后的 while 循环中,每个顶点进一次栈,出一次栈,入度减 1 的操作共执行了 e 次,所以整个算法的时间复杂度为
。
8. 关键路径
拓扑排序主要是为解决一个工程能否顺序进行的问题,但有时我们还需要解决工程完成需要的最短时间问题。比如说,造一辆汽车,我们需要先造各种各样的零件、 部件,最终再组装成车,如下图所示。这些零部件基本都是在流水线上同时生产的,假如造一个轮子需要 0.5 天时间,造一个发动机需要 3 天时间,造一个车底盘需要 2 天时间,造一个外壳需要 2 天时间,其他零部件时间需要 2 天,全部零部件集中到一处需要 0.5 天,组装成车需要 2 天时间,请问,在汽车厂造一辆车,最短需要多少时间呢?
有人说时间就是全部加起来,这当然是不对的。已经说了前提,这些零部件都是分别在流水线上同时生产的,也就是说,在生产发动机的 3 天里,可能已经生产了 6 个轮子,1.5 个外壳和 1.5 个底盘,而组装车是在这些零部件都生产好后才可以进行。因此最短的时间其实是零部件中生产时间最长的发动机 3 天+集中零部件 0.5 天+ 组装车的 2 天,一共 5.5 天完成一辆汽车的生产。
因此,我们如果要对一个流程图获得最短时间,就必须要分析它们的拓扑关系, 并且找到当中最关键的流程,这个流程的时间就是最短时间。
因此在前面讲了 AOV 网的基础上,我们来介绍一个新的概念。在一个表示工程的带权有向图中,用顶点表示事件,用有向边表示活动,用边上的权值表示活动的持续时间,这种有向图的边表示活动的网,我们称之为 AOE 网( Activity On Edge Network )。我们把 AOE 网中没有入边的顶点称为始点或源点,没有出边的顶点称为终点或汇点。由于一个工程,总有一个开始,一个结束,所以正常情况下,AOE 网只 有一个源点一个汇点。例如下图就是一个 AOE 网。其中 即是源点,表示一个工程的幵始,
是汇点,表示整个工程的结束,顶点
,分别表示事件,弧
,
,...... ,
都表示一个活动,用
表示,它们的值代表着活动持续的时间,比如弧
就是从源点开始的第一个活动
,它的时间是 3 个单位。
既然 AOE 网是表示工程流程的,所以它就具有明显的工程的特性。如有在某顶点所代表的事件发生后,从该顶点出发的各活动才能开始。只有在进入某顶点的各活动都已经结束,该顶点所代表的事件才能发生。
尽管 AOE 网与 AOV 网都是用来对工程建模的,但它们还是有很大的不同,主要体现在 AOV 网是顶点表示活动的网,,它只描述活动之间的制约关系,而 AOE 网是用边表示活动的网,边上的权值表示活动持续的时间,如下图所示两图的对比。因此, AOE 网是要建立在活动之间制约关系没有矛盾的基础之上,再来分析完成整个工程至少需要多少时间,或者为缩短完成工程所需时间,应当加快哪些活动等问题。
我们把路径上各个活动所持续的时间之和称为路径长度,从源点到汇点具有最大长度的路径叫关键路径,在关键路径上的活动叫关键活动。显然就上图的 AOE 网而言,开始→发动机完成→部件集中到位→组装完成就是关键路径,路径长度为 5.5 。
如果我们需要缩短整个工期,去改进轮子的生产效率,哪怕改动成 0.1 也是无益于整个工期的变化,只有缩短关键路径上的关键活动时间才可以减少整个工期长度。 例如如果发动机制造缩短为 2.5 ,整车组装缩短为 1.5 ,那么关键路径长度就为 4.5 ,整整缩短了一天的时间。
那么现在的问题就是如何找出关键路径。对人来说,下图的上图这样的 AOE 网,应该比较容易得出关键路径的,而对于下图的下图的 AOE 网,就相对麻烦一些,如果继续复杂下去,可能就非人脑该去做的事了。
8.1 关键路径算法原理
为了讲清楚求关键路径的算法,我还是来举个例子。假设一个学生放学回家,除掉吃饭、洗漱外,到睡觉前有四小时空闲,而家庭作业需要两小时完成。不同的学生会有不同的做法,抓紧的学生,会在头两小时就完成作业,然后看看电视、读读课外书什么的;但也有超过一半的学生会在最后两小时才去做作业,要不是因为没时间,可能还要再拖延下去。你们是不是有过暑假两个月,要到最后几天才去赶作业的坏毛病呀?这也没什么好奇怪的,拖延就是人性几大弱点之一。
这里做家庭作业这一活动的最早开始时间是四小时的开始,可以理解为 0 ,而最晚开始时间是两小时之后马上开始,不可以再晚,否则就是延迟了,此时可以理解为 2 。显然,当最早和最晚开始时间不相等时就意味着有空闲。
接着,你老妈发现了你拖延的小秘密,于是买了很多的课外习题,要求你四个小时,不许有一丝空闲,省得你拖延或偷懒。此时整个四小时全部被占满,最早开始时间和最晚开始时间都是 0 ,因此它就是关键活动了。
也就是说,我们只需要找到所有活动的最早开始时间和最晚开始时间,并且比较它们,如果相等就意味着此活动是关键活动,活动间的路径为关键路径。如果不等,则就不是。
为此,我们需要定义如下几个参数。
1. 事件的最早发生时间 etv( earliest time of vertex ):即顶点 的最早发生时间。
2. 事件的最晚发生时间 ltv( latest time of vertex ):即顶点 的最晚发生时间,也就是每个顶点对应的事件最晚需要幵始的时间,超出此时间将会延误整个工期。
3. 活动的最早开工时间 ete( earliest time of edge ):即弧 的最早发生时间。
4. 活动的最晚开工时间 lte( latest time of edge ) :即弧 的最晚发生时间,也就是不推迟工期的最晚开工时间。
我们是由 1 和 2 可以求得 3 和 4 ,然后再根据 ete[k] 是否与 lte[k] 相等来判断 是否是关键活动。
8.2 关键路径算法
我们将下图的 AOE 网转化为邻接表结构如下图所示,注意与拓扑排序时邻接表结构不同的地方在于,这里弧链表增加了 weight 域,用来存储弧的权值。
求事件的最早发生时间 etv 的过程,就是我们从头至尾找拓扑序列的过程,因此,在求关键路径之前,需要先调用一次拓扑序列算法的代码来计算 etv 和拓扑序列列表。为此,我们首先在程序开始处声明几个全局变量。
int *etv, *ltv; /* 事件最早发生时间和最迟发生时间数组 */
int *stack2; /* 用于存储拓扑序列的栈 */
int top2; /* 用于 stack2 的指针 */
其中 stack2 用来存储拓扑序列,以便后面求关键路径时使用。
下面是改进过的求拓扑序列算法。
int *etv, *ltv; /* 事件最早发生时间和最迟发生时间数组 */
int *stack2; /* 用于存储拓扑序列的栈 */
int top2; /* 用于 stack2 的指针 */
#define OK 1
#define ERROR 0
/* 拓扑排序,用于关键路径计算 */
Status TopologicalSort(GraphAdjList GL)
{
EdgeNode *e;
int i, k, gettop;
int top = 0; /* 用于栈指针下标 */
int count = 0; /* 用于统计输出顶点的个数 */
int *stack; /* 建栈将入度为 0 的顶点入栈 */
stack = (int *)malloc(GL->numVertexes * sizeof (int));
for (i = 0; i < GL->numVertexes; i++)
{
if (0 == GL->adjList[i].in)
{
stack[++top] = i;
}
}
//----------------------增加代码----------------
top2 = 0; /* 初始化为 0 */
etv = (int *)malloc(GL->numVertexes*sizeof(int)); /* 事件最早发生时间 */
for (i = 0; i < GL->numVertexes; i++)
{
etv[i] = 0; /* 初始化为 0 */
}
stack2 = (int *)malloc(GL->numVertexes*sizeof(int)); /* 初始化 */
//-----------------------------------------------
while (top != 0)
{
gettop = stack[top--];
count++;
stack2[++top2] = gettop; /* 将弹出的顶点序号压入拓扑序列的栈——增加代码 */
for (e = GL->adjList[gettop].firstedge; e; e = e->next)
{
k = e->adjvex;
if (!(--GL->adjList[k].in))
{
stack[++top] = k;
}
//----------------------增加代码----------------
if ((etv[gettop] + e->weight) > etv[k]) /* 求各顶点事件最早发生时间值 */
{
etv[k] = etv[gettop] + e->weight;
}
//----------------------------------------------
}
}
if (count < GL->numVertexes)
{
return ERROR;
}
else
{
return OK;
}
}
代码中,除增加了部分代码外,与前面讲的拓扑排序算法没有什么不同。
第 12〜26 行为初始化全局变量 etv 数组、 top2 和 stack2 的过程。第 32 行就是将本是要输出的拓扑序列压入全局栈 stack2 中。第 41〜44 行很关键,它是求 etv 数组的每一个元素的值。比如说,假如我们已经求得顶点对应的 etv[0]=0 ,顶点 对应的 etv[1]=3 ,顶点
对应的 etv[2]=4 ,现在我们需要求顶点
对应的 etv[3] ,其实就是求
与
的较大值。显然 3+5<4+8 ,得到 etv[3]=12 ,如下图所示。在代码中 e->weight 就是当前弧的长度。
由此我们也可以得出计算顶点 即求 etv[k] 的最早发生时间的公式是:
其中 P[K] 表示所有到达顶点 的弧的集合。比如上图的 P[3] 就是
和
两条弧。
是弧
上的权值。
下面我们来看求关键路径的算法代码。
/* 求关键路径,GL 为有向网,输出 GL 的各项关键活动 */
void CriticalPath(GraphAdjList GL)
{
EdgeNode *e;
int i, gettop, k, j;
int ete, lte; /* 声明活动最早发生时间和最迟发生时间变量 */
TopologicalSort(GL); /* 求拓扑序列,计算数组 etv 和 stack2 的值 */
ltv = (int *)malloc(GL->numVertexes*sizeof(int)); /* 事件最晚发生时间 */
for (i = 0; i < GL->numVertexes; i++)
{
ltv[i] = etv[GL->numVertexes - l]; /* 初始化 ltv */
}
while (top2 != 0) /* 计算 ltv */
{
gettop = stack2[top2--]; /* 将拓扑序列出栈,后进先出 */
for (e = GL->adjList[gettop].firstedge; e; e = e->next)
{
/* 求各顶点事件的最迟发生时间 ltv 值 */
k = e->adjvex;
if (ltv[k] - e->weight < ltv[gettop])/* 求各顶点事件最晚发生时间 ltv */
{
ltv[gettop] = ltv[k] - e->weight;
}
}
}
for (j = 0; j < GL->numVertexes; j++) /* 求 ete, lte 和关键活动 */
{
for (e = GL->adjList[j].firstedge; e; e = e->next)
{
k = e->adjvex;
ete = etv[j]; /* 活动最早发生时间 */
lte = ltv[k] - e->weight;/* 活动最迟发生时间 */
if (ete == lte) /* 两者相等即在关键路径上 */
{
printf("<v%d,v%d> length: %d ,", GL->adjList[j].data, GL->adjList[k].data, e->weight);
}
}
}
}
1. 程序开始执行。第 6 行,声明了 ete 和 lte 两个活动最早最晚发生时间变量。
2. 第 7 行,调用求拓扑序列的函数。执行完毕后,全局变量数组 etv 和栈 stack 的值如下图所示,top2=10 。也就是说,对于每个事件的最早发生时间,我们已经计算出来了。
3. 第 8〜12 行为初始化全局变量 ltv 数组,因为 etv[9]=27 ,所以数组 ltv 当前的值为: { 27, 27, 27, 27, 27, 27, 27, 27, 27, 27 }
4. 第 13〜25 行为计算 ltv 的循环。第 15 行,先将 stack2 的栈头出栈,由后进先出得到 gettop=9 。根据邻接表中, 没有弧表,所以第 16〜24 行循环体未执行。
5. 再次来到第 15 行,gettop=8 ,在第 16〜24 行的循环中, 的弧表只有一条
,第 19 行得到 k=9 ,因为 ltv[9]-3 < ltv[8] ,所以 ltv[8]=ltv[9]-3=24 ,如下图所示:
6. 再次循环,当 gettop=7、5、6 时,同理可算出 ltv 相对应的值为 19、25、 13 ,此时 ltv 值为:{ 27, 27, 27, 27, 27, 13, 25, 19, 24, 27 }
7. 当 gettop=4 时,由邻接表可得到 有两条弧
、
,通过第 16〜24 行的循环,可以得到 ltv[4]=min ( ltv[7] - 4 , ltv[6] - 9 ) = min ( 19 - 4 , 25 - 9 )=15 ,如下图所示:
此时你应该发现,我们在计算 ltv 时,其实是把拓扑序列倒过来进行的。因此我们可以得出计算顶点 即求 ltv[k] 的最晚发生时间的公式是:
其中 S[K] 表示所有从顶点 出发的弧的集合。比如上图的 S[4] 就是
和
两条弧,
是弧
上的权值。
就这样,当程序执行到第 26 行时,相关变量的值如下图所示,比如 etv[1]=3 而 ltv[1]=7 ,表示的意思就是如果时间单位是天的话,哪怕 这个事件在第 7 天才开始,也可以保证整个工程的按期完成,你可以提前
事件开始时间,但你最早也只能在第 3 天开始。跟我们前面举的例子,是先完成作业再玩还是先玩最后完成作业一个道理。
8. 第 26〜38 行是来求另两个变量活动最早开始时间 ete 和活动最晚开始时间 lte ,并对相同下标的它们做比较。两重循环嵌套是对邻接表的顶点和每个顶点的弧表遍历。
9 . 当 j=0 时,从 点开始,有
和
两条弧。当 k=2 时, ete=etv[j]=etv[0]=0 。
,此时 ete=lte ,表示弧
是关键活动,因此打印。当 k=1 时,
。
,此时
因此
并不是关键活动,如下图所示:
这里需要解释一下, ete 本来是表示活动 的最早开工时间,是针对弧来说的。但只有此弧的弧尾顶点
的事件发生了,它才可以开始,因此 ete=etv[k] 。
而 lte 表示的是活动 的最晚开工时间,但此活动再晚也不能等
事件发生才开始,而必须要在
事件之前发生,所以
。就像你晚上 23 点睡觉,你不能说到 23 点才开始做作业,而必须要提前 2 小时,在 21 点开始,才有可能按时完成作业。
所以最终,其实就是判断 ete 与 lte 是否相等,相等意味着活动没有任何空闲,是关键活动,否则就不是。
10. j=1 一直到 j=9 为止,做法是完全相同的,关键路径打印结果为 “ ”,最终关键路径如下图所示:
分析整个求关键路径的算法,第 7 行是拓扑排序,时间复杂度为 ,第 9〜12 行时间复杂度为
,第 13〜25 行时间复杂度为
,第 26〜38 行时间复杂也为
,根据我们对时间复杂度的定义,所有的常数系数可以忽略,所以最终求关键路径算法的时间复杂度依然是
。
实践证明,通过这样的算法对于工程的前期工期估算和中期的计划调整都有很大的帮助。不过注意,本例是唯一一条关键路径,这并不等于不存在多条关键路径的有向无环图。如果是多条关键路径,则单是提高一条关键路径上的关键活动的速度并不能导致整个工程缩短工期,而必须提高同时在几条关键路径上的活动的速度。这就像仅仅是有事业的成功,而没有健康的身体以及快乐的生活,是根本谈不上幸福的人生一样,三者缺一不可。
9. 总结回顾
图是计算机科学中非常常用的一类数据结构,有许许多多的计算问题都是用图来定义的。由于图也是最复杂的数据结构,对它讲解时,涉及到数组、链表、栈、队列、树等之前学的几乎所有数据结构。因此从某种角度来说,学好了图,基本就等于理解了数据结构这门课的精神。
图的存储结构我们一共讲了五种,如下图所示,其中比较重要的是邻接矩阵和邻接表,它们分别代表着边集是用数组还是链表的方式存储。十字链表是邻接矩阵的一种升级,而邻接多重表则是邻接表的升级。边集数组更多考虑的是对边的关注。用什么存储结构需要具体问题具体分析,通常稠密图,或读存数据较多,结构修改较少的图,用邻接矩阵要更合适,反之则应该考虑邻接表。
图的存储结构 | |||
---|---|---|---|
邻接矩阵 | 邻接表 | 边集数组 | |
十字链表 | 邻接矩阵 |
图的遍历分为深度和广度两种,各有优缺点,就像人在追求卓越时,是着重深度还是看重广度,总是很难说得清楚。
图的应用一共谈了三种应用:最小生成树、最短路径和有向无环图的应用。
最小生成树,我们讲了两种算法:普里姆( Prim )算法和克鲁斯卡尔( Kruskal ) 算法。普里姆算法像是走一步看一步的思维方式,逐步生成最小生成树。而克鲁斯卡尔算法则更有全局意识,直接从图中最短权值的边入手,找寻最后的答案。
最短路径的现实应用非常多,我们也介绍了两种算法。迪杰斯特拉( Dijkstra )算法更强调单源顶点查找路径的方式,比较符合我们正常的思路,容易理解原理,但算法代码相对复杂。而弗洛伊德( Floyd )算法则完全抛开了单点的局限思维方式,巧妙地应用矩阵的变换,用最清爽的代码实现了多顶点间最短路径求解的方案,原理理解有难度,但算法编写很简洁。
有向无环图时常应用于工程规划中,对于整个工程或系统来说,我们一方面关心的是工程能否顺利进行的问题,通过拓扑排序的方式,我们可以有效地分析出一个有向图是否存在环,如果不存在,那它的拓扑序列是什么?另一方面关心的是整个工程完成所必须的最短时间问题,利用求关键路径的算法,可以得到最短完成工程的工期以及关键的活动有哪些。
注:本博客是本人在学习《大话数据结构》后整理的笔记,用于自己以后的复习与回顾,博客中的照片是本人从《大话数据结构》中截取的。