写在前面:本文仅供个人学习使用。《大话数据结构》通俗易懂,适合整体做笔记输出,构建体系。并且文中很多图片来源于该书,如有侵权,请联系删除。
7.2 图的定义
在线性表中,数据元素之间是被串起来的,仅有线性关系,每个数据元素只有一个直接前驱和一个直接后继。在树形结构中,数据元素之间有着明显的层次关系,并且每一层上的数据元素可能和下一层中多个元素相关,但只能和上一层中一个元素相关。这和一对父母可以有多个孩子,但每个孩子却只能有一对父母是一个道理。可现实中,人与人之间关系就非常复杂,比如我认识的朋友,可能他们之间也互相认识,这就不是简单的一对一、一对多,研究人际关系很自然会考虑多对多的情况。那就是我们今天要研究的主题----图。图是一种较线性表和树更加复杂的数据结构。在图数据结构中,结点之间的关系可以是任意的,图中任意两个数据元素之间都可能相关。
前面同学可能觉得树的术语好多,可来到图之后,你就知道,什么才叫做真正的术语多。不过术语再多也是有规律可循的,让我们开始“图”世界的征途。如图7-2-1所示,先来看定义。
图(Graph)是由顶点的有穷非空集合和顶点之间边的集合组成,通常表示为:G(V,E),其中,G表示一个图,V是图G中顶点的集合,E是图G中边的集合。
对于图的定义,我们需要明确几个注意的地方。
- 线性表中我们把数据元素叫元素,树中元素数据叫结点,在图中数据元素,我们称之为顶点(Vertex).
- 线性表中可以没有数据元素,称为空表。树中可以没有结点,称为空树。那么对于图呢?在图结构中,不允许没有顶点。在定义中,若V是顶点的集合,则强调了顶点集合V有穷非空。
- 线性表中,相邻的数据元素之间具有线性关系,树结构中,相邻两层的结点具有层次关系,而在图中,任意两个顶点之间都可能有关系,顶点之间的逻辑关系用边来表示,边集可以是空的。
7.2.1 各种图定义
无向边:若顶点 v i v_i vi 到 v j v_j vj之间的边没有方向,则称这条边为无向边(edge),用无序偶对 ( v i , v j ) (v_i,v_j) (vi,vj) 表示。如果图中任意两个顶点之间的边都是无向边,则称该图为无向图(Undirected graphs)。图7-2-2就是一个无向图,由于是无方向的,连接顶点A与D的边,可以表示成无序对(A,D),或者(D,A).
对于图7-2-2中的无向图G1来说, G 1 = { V 1 , { E 1 } } G_1=\{V_1,\{E_1\}\} G1={V1,{E1}},其中顶点集合 V 1 = { A , B , C , D } V_1=\{A,B,C,D\} V1={A,B,C,D},边集 E 1 = { ( A , B ) , ( B , C ) , ( C , D ) , ( D , A ) , ( A , C ) } E_1=\{(A,B),(B,C),(C,D),(D,A),(A,C)\} E1={(A,B),(B,C),(C,D),(D,A),(A,C)}.
有向边:若顶点 v i v_i vi 到 v j v_j vj之间的边有方向,则称这条边为有向边,也称之为弧(Arc),用有序偶 < v i , v j > <v_i,v_j> <vi,vj> 表示, v i v_i vi称为弧尾(Tail) v j v_j vj称为弧头(Head)。如果图中任意两个顶点之间的边都是有向边,则称该图为有向图(Directed graphs).图7-2-3就是一个有向图。连接顶点A到D的有向边就是弧,A是弧尾,D是弧头,<A,D>表示弧,注意不能写成<D,A>.
对于图7-2-3中的有向图G2来说, G 2 = { V 2 , { E 2 } } G_2=\{V_2,\{E_2\}\} G2={V2,{E2}},其中顶点集合 V 2 = { A , B , C , D } V_2=\{A,B,C,D\} V2={A,B,C,D},边集 E 2 = { < A , D > , < B , A > , < B , C > , < C , A > } E_2=\{<A,D>,<B,A>,<B,C>,<C,A>\} E2={<A,D>,<B,A>,<B,C>,<C,A>}.
看清楚了,无向边用小括号表示,而有向边用尖括号表示。
在图中,若不存在顶点到其自身的边,且同一条边不重复出现,则称这样的图为简单图。我们课程里讨论的都是简单图。显然下图中的两个图就不属于我们要讨论的范围。
在无向图中,如果任意两个顶点之间都存在边,则称该图为无向完全图。含有n个顶点的无向完全图有
n
(
n
−
1
)
2
\frac{n(n-1)}{2}
2n(n−1)条边。比如图7-2-5就是无向完全图,因为每个顶点都要与除它以外的顶点相连,顶点A与BCD三个顶点连线;共有4个顶点,自然是4*3,但由于顶点A和顶点B连线后,计算B与A连线就是重复,因此要整体除以2,共有六条边。
在有向图中,如果任意两个顶点之间都存在方向相反的两条弧,则称该图为有向完全图。含有n个顶点的有向完全图有n(n-1)条边,如下图。
从这里也可以得出结论,对于具有n个顶点和e条边的图,无向图 0 ≤ e ≤ n ( n − 1 ) 2 0≤e≤\frac{n(n-1)}{2} 0≤e≤2n(n−1),有向图 0 ≤ e ≤ n ( n − 1 ) 0≤e≤{n(n-1)} 0≤e≤n(n−1)
有很少条边或弧的图称为稀疏图,反之称为稠密图。这里稀疏和稠密是模糊的概念,都是相对而言的。
有些图的边或弧具有与它相关的数字,这种与图的边或弧相关的数叫做权(weight)。这些权可以表示从一个顶点到另一个顶点的距离或耗费。这种带权的图通常称为网(Network).图7-2-7就是一张带权的图,此图中的权就是两地的距离。
假设有两个图
G
=
{
V
,
{
E
}
}
G=\{V,\{E\}\}
G={V,{E}}和
G
′
=
{
V
′
,
{
E
′
}
}
G'=\{V',\{E'\}\}
G′={V′,{E′}},如果
V
′
⊆
V
,
E
′
⊆
E
V'\subseteq V ,E' \subseteq E
V′⊆V,E′⊆E,则称G’ 为G的子图(Subgraph),例如下图带底纹的图均为左侧无向图和有向图的子图。
7.2.2 图的顶点与边之间的关系
对于无向图
G
=
{
V
,
{
E
}
}
G=\{V,\{E\}\}
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).
例如图7-2-8左侧上方的无向图,顶点A和B互为邻接点,边(A,B)依附于顶点A和B,顶点A的度为3.而此图的边数是5,各个顶点的度之和为:3+2+3+2=10,推敲后发现,边数其实就是各顶点度数和的一般,多出的一半是因为重复两次计数。简记之,
e
=
1
2
Σ
i
=
1
n
T
D
(
v
i
)
e=\frac{1}{2}\Sigma_{i=1}^{n}TD(v_i)
e=21Σi=1nTD(vi)
对于有向图 G = { V , { E } } G=\{V,\{E\}\} G={V,{E}},如果弧<v,v’>∈E,则称顶点v临界到顶点v’,顶点v’临界自顶点v。弧<v,v’>和顶点v,v’相关联。以顶点v为头的弧的数目称为v的入度(InDgree),记为ID(v);以v为尾弧的数目称为v的出度(OutDegree),记为OD(v);顶点v的度为TD(v)=ID(v)+OD(v)。 例如图7-2-8左下方的有向图,顶点A的入度为2,出度为1,所以顶点A的度为3.此有向图的弧有4条,而各顶点的出度和=1+2+1=4,各顶点的入度和=2+0+1+1=4,所以得到 e = Σ i = 1 n I D ( v i ) = Σ i = 1 n O D ( v i ) e=\Sigma_{i=1}^{n}ID(v_i)=\Sigma_{i=1}^{n}OD(v_i) e=Σi=1nID(vi)=Σi=1nOD(vi)。
无向图 G = { V , { E } } G=\{V,\{E\}\} G={V,{E}}中,从顶点v到顶点v’的路径(path)是一个顶点序列 ( v = v i , 0 , v i , 1 , . . . , v i , m = v ′ ) (v=v_{i,0},v_{i,1},...,v_{i,m}=v') (v=vi,0,vi,1,...,vi,m=v′),例如图7-2-9中就列举了顶点B到顶点D四种不同的路径。
如果G是有向图,则路径也是有向的,顶点序列应满足 < v i , j − 1 , v i , j > ∈ E , i ≤ j ≤ m <v_{i,j-1},v_{i,j}> ∈E,i≤j≤m <vi,j−1,vi,j>∈E,i≤j≤m.例如图7-2-10,顶点B到D 有两种路径,而顶点A到B,就不存在路径。
树中根结点到任意结点的路径是唯一的,但是图中顶点与顶点之间的路径却是不唯一的。
路径的长度是路径上的边或弧的数目。图7-2-9中上面两条路径长度为2,下面两条路径长度为3。图7-2-10左侧路径长度为2,右侧长度为3.
第一个顶点到最后一个顶点相同的路径称为回路或环(Cycle)。序列中顶点不重复出现的路径称为简单路径。除了第一个顶点和最后一个顶点以外,其余顶点不重复出现的回路,称为简单回路或简单环。图7-2-11中两个图的粗线都构成环,左侧的环因为第一个顶点和最后一个顶点都是B,且C、D、A没有重复出现,因此是一个简单环。而右侧的环,由于顶点C的重复,它就不是简单环了。
7.2.3 连通图相关术语
在无向图中,如果顶点v到顶点v’有路径,则称顶点v和v’是连通的。如果对于图中任意两个顶点 v i , v j ∈ E , v i 和 v j 都 是 连 通 的 v_i,v_j ∈E,v_i和v_j都是连通的 vi,vj∈E,vi和vj都是连通的,则称G是连通图(Connected Graph)。图7-2-12的图1,它的顶点A到顶点B、C、D都是连通的,但显然顶点A与顶点E或F之间没有路径,因此不能算连通图。而图2,顶点A,B,C,D相互都是连通的,所以它本身是连通图。
无向图中的极大连通子图称为连通分量。注意连通分量的概念,它强调:
- 要是子图
- 子图要是连通的
- 连通子图含有极大顶点数
- 具有极大顶点数的连通子图包含依附于这些顶点的所有边
这里我对“连通子图含有极大顶点数”的理解:首先是连通子图,但是不能再多添一个顶点还能使它联通,若是如此,则该连通子图不是连通分量。顾名思义,极大顶点数,若还能添加顶点,则不是极大顶点数。
图7-2-12的图1是一个无向非连通图。但是它有两个连通分量,即图2和图3.而图4,尽管是图1的子图,但是它不满足连通子图的极大顶点数(图2满足)。因此它不是图1的无向图的连通分量。
在有向图G中,如果对于每一对
v
i
,
v
j
∈
V
,
v
i
≠
v
j
,
从
v
i
到
v
j
和
从
v
j
到
v
i
都
存
在
路
径
v_i,v_j ∈V,v_i≠v_j,从v_i到v_j和从v_j到v_i都存在路径
vi,vj∈V,vi=vj,从vi到vj和从vj到vi都存在路径,则称G是强连通图。有向图中的极大强连通子图称作有向图的强连通分量。
例如图7-2-13,图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,则是一棵有向树。 对有向树的理解比较容易,所谓入度为零其实就相当于树中的根结点,其余顶点入度为1就是说,树的非根结点的双亲只有一个。一个有向图的生成森林由若干棵有向树组成,含有图中全部顶点,但只有足以构成若干棵不相交的有向树的弧。如图7-2-15的图1是一棵有向图。去掉一些弧后,它可以分解为两棵有向树,如图2和图3,这两棵就是图1有向图的生成森林。
7.2.4 图的定义与术语总结
图按照有无方向分为无向图和有向图。无向图由顶点和边组成,有向图由顶点和弧构成。弧由弧尾和弧头之分。
图按照边或弧的多少分为稀疏图和稠密图。如果任意两个顶点之间都存在边叫完全图,有向的叫做有向完全图。若无重复的边或顶点到自身的边则叫做简单图。
图中顶点之间有邻接点、依附的概念。无向图顶点的边数叫做度,有向图顶点分为入度和出度。
图上的边或弧带权则称为网。
图中顶点间存在路径,两顶点存在路径则说明是连通的。如果路径最终回到起始点则称为环,当中不重复叫做简单环。若任意两顶点都是连通的,则图就是连通图,有向则称强连通图。图中有子图,若子图极大连通就是连通分量,有向的则称强连通分量。
无向图中连通且n个顶点有n-1条边叫生成树。有向图中一顶点入度为0其余顶点入度为1的叫有向树。一个有向图由若干棵有向树构成生成森林。
7.3 图的抽象数据类型
图的基本操作
ADT 图 (Graph)
Data
顶点的有穷非空集合和边的集合
Operation
CreateGraph(*G,V,VR):按照顶点集v和边弧集VR的定义构造图
DestroyGraph(*G):图G存在则销毁
LocateVex(G,u):若图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):删除图中顶点v及其相关的弧
InsertArc(*G,v,w):在图G中增添弧<v,w>,若G是无向图,还需要增添对称弧<w,v>
DeleteArc(*G,v,w):在图G中删除弧<v,w>,若G是无向图,则还删除对称弧<w,v>
DFSTraverse(G):对图G进行深度优先遍历,在遍历过程中对每个顶点调用
BFSTraverse(G):在图G中进行广度优先遍历,在遍历过程中对每个顶点调用。
endADT
7.4 图的存储结构
图的存储结构相较线性表与树来说就更加复杂了。首先,我们口头上说的”顶点的位置“或”邻接点的位置“只是一个相对的概念。其实从图的逻辑结构定义来看,图上任何一个顶点都可以被看成是第一个顶点,任意顶点的邻接点之间也不存在次序关系。比如图7-4-1中的四张图,仔细观察发现,他们其实是同一个图,只不过顶点的位置不同,就造成了表象不太一样的感觉。
也正由于图的结构比较复杂,任意两个顶点之间都可能存在联系,因此无法以数据在内存中的物理位置来表示元素之间的关系,也就是说,图不可能用简单的顺序存储结构来表示。而多重链表的方式,即以一个数据域和多个指针域组成的结点表示图中的一个顶点,尽管可以实现图结构,但其实在树中,我们也已经讨论过,这是有问题的。如果各个顶点的度数相差很大,按度数最大的顶点设计结点结构会造成很多存储空间的浪费,而若按每个顶点自己的度数设计不同的顶点结构,又带来操作不便。因此,对于图来说,如何对它实现物理存储是个难题,不过我们的前辈已经解决了,现在我们来看看前辈们提供的五种不同的存储结构。
‘
7.4.1 邻接矩阵
考虑到图是由顶点和边或弧两部分组成。合在一起比较困难,那就很自然地考虑到分两个结构来分别存储。顶点不分大小、主次,所以用一个一维数组来存储是一个不错的选择。而边或弧由于是顶点与顶点之间的关系,一维搞不定,那就考虑用一个二维数组来存储。于是我们邻接矩阵的方案就诞生了。
图的邻接矩阵(Adjacent Matrix)存储方式是用两个数组来表示图。一个一维数组存储图中结点信息,一个二维数组(称为邻接矩阵)存储图中的边或弧的信息。
设图G有n个顶点,则邻接矩阵是一个n×n的方阵,定义为
我们来看一个实例,图7-4-2的左图就是一个无向图。
我们可以设置两个数组,顶点数组为 v e r t e x [ 4 ] = { v 0 , v 1 , v 2 , v 3 } vertex[4]=\{ v_0,v_1,v_2,v_3 \} vertex[4]={v0,v1,v2,v3},边数组arc[ 4][ 4] 为图7-4-2 右图这样的一个矩阵。
简单解释一下,对于矩阵的主对角线的值,即 a r c [ 0 ] [ 0 ] , a r c [ 1 ] [ 1 ] , a r c [ 2 ] [ 2 ] , a r c [ 3 ] [ 3 ] arc[0][0],arc[1][1],arc[2][2],arc[3][3] arc[0][0],arc[1][1],arc[2][2],arc[3][3],全为0是因为不存在顶点到自身的边,比如 v 0 → v 0 ( × ) v_0→v_0(×) v0→v0(×)。 a r c [ 0 ] [ 1 ] = 1 arc[0][1]=1 arc[0][1]=1是因为 v 0 → v 1 v_0→v_1 v0→v1的边存在,而 a r c [ 1 ] [ 3 ] = 0 arc[1][3]=0 arc[1][3]=0是因为 v 1 → v 3 v_1→v_3 v1→v3的边不存在。并且由于是无向图, v 1 → v 3 v_1→v_3 v1→v3的边不存在,意味着 v 3 → v 1 v_3→v_1 v3→v1的边也不存在。所以无向图的边数组是一个对称矩阵。
嗯?对称矩阵是什么? 忘记了不要紧,复习一下。 所谓对称矩阵就是n阶矩阵的元满足 a i j = a j i , ( 0 ≤ i , j ≤ n ) a_{ij}=a_{ji},(0 ≤i,j≤ n) aij=aji,(0≤i,j≤n).即从矩阵的左上角到右下角的主对角线为轴,右上角的元与左下角相对应的元全都是相等的。
有了这个矩阵,我们就可以很容易地得出图中的信息。
- 我们要判定任意两顶点有边无边就非常容易了。
- 我们要知道某个结点的度,其实就是这个结点 v i v_i vi在临界矩阵中第i行(或第i列)的元素之和。比如顶点 v 1 的 度 = 1 + 0 + 1 + 0 = 2 v_1的度=1+0+1+0=2 v1的度=1+0+1+0=2
- 求顶点 v i v_i vi的所有邻接点就是将矩阵中第i行元素扫描一遍,arc[i][j]为1就是邻接点。
我们再来看一个有向图样例。如图7-4-3所示的左图。
顶点数组为 v e r t e x [ 4 ] = { v 0 , v 1 , v 2 , v 3 } vertex[4]=\{ v_0,v_1,v_2,v_3 \} vertex[4]={v0,v1,v2,v3},弧数组arc[ 4][ 4] 为图7-4-3 右图这样的一个矩阵。 主对角线上数值依然为0.但因为是有向图,所以此矩阵不对称, a r c [ 0 ] [ 1 ] = 0 arc[0][1]=0 arc[0][1]=0是因为 v 0 → v 1 v_0→v_1 v0→v1的弧不存在,而 a r c [ 1 ] [ 3 ] = 0 arc[1][3]=0 arc[1][3]=0是因为 v 1 → v 0 v_1→v_0 v1→v0的弧存在,所以 a r c [ 1 ] [ 0 ] = 1 arc[1][0]=1 arc[1][0]=1。
有向图讲究入度和出度,顶点v1的入度为1,正好是第v1列各数之和。顶点v1的出度为2,正好第v1行的各数之和。
与无向图同样的办法,判断顶点 v i 和 v j v_i 和 v_j vi和vj是否存在弧,只需要查找矩阵中arc[i] [j] 是否等于1即可。要求 v i v_i vi的所有邻接点就是将矩阵的第i行元素扫描一遍,查找arc[i] [j] =1的顶点。
在图的术语中,我们提到了网的概念,也就是每条边上带有权的图叫做网。那么这些权值就需要存下来,如何处理这个矩阵来适应这个需求呢? 我们由办法。
设图G是网图,有n个顶点,则邻接矩阵是一个n×n的方阵,定义为:
这里 w i j w_{ij} wij表示 ( v i , v j ) 或 < v i , v j > (v_i,v_j)或<v_i,v_j> (vi,vj)或<vi,vj>上的权值。∞表示一个计算机允许的、大于所有边上权值的值,也就是一个不可能取到的极限值。有同学会问,为什么不是0呢? 原因在于权值 w i j w_{ij} wij大多数情况下是正值,但个别时候可能就是0,甚至有可能是负值。因此必须要用一个不可能的值来代表不存在。如图7-4-4左图就是一个有向网图,右图就是它的邻接矩阵。
那么邻接矩阵是如何实现图的创建的呢? 我们先来看看图的邻接矩阵存储的结构,代码如下。
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(int i=0;i<G->numVertexes;i++) scanf(&G->vesx[i]);
for(int i=0;i<G->numVertexes;i++)
for(int j=0;j<G->numVertexes;j++)
G->arc[i][j]=INFINITY;//邻接矩阵初始化
for(k=0;k<G->numEdges;k++){
printf("输入边(vi,vj)上的下标i,下标j 和权w:\n");
scanf("%d,%d,%d",&i,&j,&w);
G->arc[i][j]=w;
G->arc[j][i]=G->arc[i][j];//无向图,矩阵对称。
}
}
从代码可以看出,n个顶点和e条边的无向网图的创建,时间复杂度为 O ( n + n 2 + e ) O(n+n^2+e) O(n+n2+e),其中对邻接矩阵G.arc的初始化耗费了 O ( n 2 ) O(n^2) O(n2)的时间。
7.4.2 邻接表
邻接矩阵是不错的一种图存储结构,但是我们也发现,对于边数相对顶点较少的图,这种结构是存在对存储空间的极大浪费的。比如说,如果我们要处理图7-4-5这样的稀疏有向图,邻接矩阵中除了arc[1][0] 有权值外,没有其他弧,其实这些存储空间都浪费掉了。
因此我们考虑另一种存储结构方式。回忆我们在线性表时谈到,顺序存储结构就存在预先分配内存可能造成存储空间浪费的问题,于是引出了链式存储结构。同样的,我们也可以考虑对边或弧使用链式存储的方式来避免空间浪费的问题。
再回忆我们在树中谈论存储结构时,讲到了一种孩子表示法,将结点存入数组,并对结点的孩子进行链式存储,不管有多少孩子,也不会存在空间浪费问题。这个思路同样适用于图的存储。我们把这种数组与链表相结合的存储方法称为邻接表(Adjacent List)。
邻接表的处理办法是这样的。
- 图中顶点用一个一维数组存储,当然,顶点也可以用单链表来存储,不过数组可以较容易地读取顶点信息,更加方便。另外,对于顶点数组中,每个数据元素还需要存储指向第一个邻接点的指针,以便于查找该顶点的边信息。
- 图中每个顶点 v i v_i vi的所有邻接点构成一个线性表,由于邻接点的个数不定,所以用单链表存储,无向图称为顶点 v i v_i vi的边表,有向图称为顶点 v i v_i vi作为弧尾的出边表。
例如图7-4-6所示的就是一个无向图的邻接表结构。
从图中我们知道,顶点表的各个结点有data和firstedge两个域表示,data是数据域,存储顶点的信息,firstedge是指针域,指向边表的第一个结点,即此结点的第一个邻接点。边表结点有adjvex和next两个域组成。adjvex是邻接点域,存储某顶点的邻接点在顶点表中的下标,next则存储指向边表中下一个结点的指针。比如v1顶点与v0、v2互为邻接点,则在v1的边表中,adjvex分别为v0的0和v2的2.
这样的结构,对于我们要获得图的相关信息也是很方便的。比如我们要想知道某个顶点的度,就去查找这个顶点的边表中结点的个数。若要判断结点 v i → v j v_i→v_j vi→vj是否存在边,只需要测试顶点vi的边表中adjvex是否存在结点vj的下标j就行了。若要求顶点的所有邻接点,其实就是对此顶点的边表进行遍历,得到的adjvex域对应的顶点就是邻接点。
若是有向图,邻接表的结构是类似的。比如图7-4-7中第一幅图的邻接表就是第二幅图。但要注意的是有向图由于有方向,我们是以顶点为弧尾来存储边表的,这样很容易就可以得到每个顶点的出度。但有时为了便于确定顶点的入度或以顶点为弧头的弧,我们可以建立一个有向图的逆邻接表,即对每个顶点 v i v_i vi都建立一个链接为 v i v_i vi的弧头的表。如图7-4-7第三幅图所示。
此时我们很容易就可以算出某个顶点的入度或出度是多少,判断两顶点是否存在弧也很容易实现。
对于带权值的网图,可以在边表结点定义中再增加一个weight的数据域,存储权值信息即可。如图7-4-8所示。
有了这些结构的图,下面关于结点定义的代码就很好理解了
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 numVertexes,numEdges;// 图中当前顶点数和边数
}GraphAdjList;
对于邻接表的创建,也就是顺理成章的事情,无向图的邻接表创建代码如下。
//建立图的邻接表结构
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->numEdges;k++){
printf("输入边(vi,vj)上顶点序号:\n");
scanf("%d,%d",&i,&j);
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)
7.4.3 十字链表
对于有向图来说,邻接表是有缺陷的。关心了出度问题,想了解入度就必须遍历整个图来能知道,反之,逆邻接表解决了入度却不了解出度的情况。有没有可能把邻接表与逆邻接表结合起来呢? 答案是肯定的,就是把它们整合在一起。这就是我们现在要讲的有向图的一种存储方法:十字链表(Orthogonal List).
我们重新定义顶点表结点结构如下表所示。
其中firstin表示入边表头指针,指向该顶点的入边表中第一个结点,firstout表示出边表头指针,指向该顶点的出边表的第一个结点。
重新定义的边表结点结构如下表。
其中tailvex是指弧起点在顶点表的下标,headvex是指弧终点在顶点表中的下标,headlink是指入边表指针域,指向终点相同的下一条边,taillink是指出边表指针域,指向起点相同的下一条边。如果是网,还可以再增加一个weight域来存储权值。
比如图7-4-10,顶点依次是存入一个一维数组 { v 0 , v 1 , v 2 , v 3 } \{ v_0,v_1,v_2,v_3 \} {v0,v1,v2,v3},实线箭头指针的图示完全与图7-4-7的邻接表相同。就以顶点v0来说,firstout指向是出边表中的第一个结点v3.所以v0边表结点的headvex=3,而tailvex其实就是当前顶点v0的下标0,由于v0只有一个出边顶点,所以headlink和taillink 都为空。
我们重点需要来解释虚线箭头的含义,它其实就是此图的逆邻接表的表示。对于v0来说,它有两个顶点v1和v2的入边。因此v0的firsrtin指向顶点v1的边表结点中headvex为0的结点,如图7-4-10图中的①。接着由入边结点的headlink指向下一个入边顶点v2,如图中的②。对于顶点v1,它有一个入边顶点v2,所以它的firstin指向顶点v2的边表结点中headvex为1 的结点,如图中的③。顶点v2和v3也是同样有一个入边顶点,如图中④和⑤。
下图为图7-4-7.
十字链表的好处就是因为把邻接表和逆邻接表整合在了一起,这样既容易找到以vi为尾的弧,也容易找到以vi为头的弧,因而容易求得顶点的出度和入度。而且它除了结构复杂一点外,其实创建图算法的时间复杂度是和邻接表相同的,因此,在有向图的应用中,十字链表是非常好的数据结构模型。
7.4.4 邻接多重表
讲了有向图的优化存储结构,对于无向图的邻接表,有没有问题呢?如果我们在无向图的应用中,关注的重点是顶点,那么邻接表是不错的选择,但如果我们更关注边的操作,比如对已访问过的边做标记,删除某一条边等操作,那就意味着,需要找到这条边的两个边表结点进行操作,这其实还是比较麻烦的。比如图7-4-11,若要删除左图的 ( v 0 , v 2 ) (v_0,v_2) (v0,v2)这条边,需要对邻接表结构中右边表的阴影两个结点进行删除操作,显然这是比较繁琐的。
因此,我们也仿照十字链表的方式,对边表结构进行一些改造,也许就可以避免刚才提到的问题。
重新定义的边表结点结构如下表所示。
其中ivex和jvex是与某条边依附的两个顶点在顶点表中下标。ilink指向依附顶点ivex的下一条边,jlink指向依附顶点jvex的下一条边。这就是邻接多重表结构。
我们来看结构示意图的绘制过程,理解了它是如何连线的,也就理解邻接多重表的构造原理了。如图7-4-12所示,左图告诉我们它有4个顶点和5条边,显然,我们就应该先将4个顶点和5条边的边表结点画出来。由于是无向图,所以ivex是0,jvex是1还是反过来就无所谓了,不过为了绘制方便,都将ivex值设置得与一旁的顶点下标相同。
我们开始连线,如图7-4-13所示。
首先连线的①②③④就是将顶点的firstedge指向一条边,顶点下标要与ivex的值相同,这很好理解。
接着,由于顶点v0的 ( v 0 , v 1 ) (v_0,v_1) (v0,v1)边的临边有 ( v 0 , v 3 ) (v_0,v_3) (v0,v3)和 ( v 0 , v 2 ) (v_0,v_2) (v0,v2),因此⑤⑥的连线就是满足指向下一条依附于顶点v0的边的目标,注意ilink指向的结点的jvex一定要和它本身的ivex的值相同。
同样的道理,连线⑦就是指 ( v 1 , v 0 ) (v_1,v_0) (v1,v0)这条边,它是相当于顶点v1指向 ( v 1 , v 2 ) (v_1,v_2) (v1,v2)边后的下一条。
v2有3条边依附,所以在③之后就有了⑧⑨。连线⑩的就是顶点v3在连线④之后的下一条边。
左图共有5条边,所以右图有10条连线,完全符合预期。
到这里,大家应该明白,邻接多重表与邻接表的区别,仅仅是在于同一条边在邻接表中用两个结点表示,而在邻接多重表中只有一个结点。这样对边的操作就方便多了。若要删除左图的 ( v 0 , v 2 ) (v_0,v_2) (v0,v2)这条边,只需要将右图的⑥⑨的链接指向改为“^”即可。
7.4.5 边集数组
边集数组是由两个一维数组构成的。一个存储顶点的信息;另一个存储边的信息,这个边数组每个数据元素由一条边的起点下标(begin),终点下标(end)和权(weight)组成。如图7-4-14所示。
定义的边数组结构如表所示。
其中begin存储起点下标,end存储终点下标,weight存储权值。
7.5 图的遍历
图的遍历和树的遍历类似,我们希望从图中某一顶点出发访问遍图中其余顶点,且使得每一个顶点仅被访问一次,这一过程就叫做图的遍历(Traversing Graph).
树的遍历我呢谈到了四种方案,应该说都还好,毕竟根结点只有一个,遍历都是从它开始,其余所有结点都只有一个双亲。可是图就复杂多了,因为它的任一结点都可能和其余的所有结点相邻接,极有可能存在沿着某条路径搜索后,又回到原顶点,而有些顶点却还没有访问到的情况。因此,我们需要在遍历过程中把访问过的顶点打上标签,以避免访问多次而不自知。 具体办法是设置一个访问数组visit[n],n是图中顶点的个数,初始值为0,访问过后设置为1.这其实在小说中常常见到,一行人在迷宫中迷了路,为了避免找寻出路时屡次重复,所以会在路口用小刀刻上标记。
对于图的遍历来说,如何避免因回路陷入死循环,就需要科学地设计遍历方案,通常有两种遍历次序方案:它们是深度优先遍历和广度优先遍历。
7.5.1 深度优先遍历
深度优先遍历(Depth First Search),也称为深度优先搜索,简称为DFS。它的具体思想是一条道走到黑,然后再走另一条。
为了更好地理解深度优先遍历,我们来做一个游戏。
假设逆需要完成一项任务,要求你在图7-5-2左图中这样的一个迷宫中,从顶点A开始要走遍所有的图顶点并做上标记,注意不是简单地看着这样的平面图走哦,而是如同现实般地在只有高墙和通道的迷宫中去完成任务。
很显然我们是需要策略的,否则在这四通八达的通道中乱窜,要想完成任务那就只能碰运气了。如果你学过深度优先遍历,这个任务就不难完成了。
首先我们从顶点A开始,做上表示走过的记号后,面前有两条路,通向F和B,我们给自己定一个原则,在没有碰到任何重复顶点的情况下,始终是向右手边走,于是走到了顶点B。这个行走过程,可参看图7-5-2的右图。 此时发现有三条分支,分别通向顶点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个顶点。
反应快的同学一定会感觉到,深度优先遍历其实就是一个递归的过程,如果再敏感一些,会发现其实转换成图7-5-2右图后,就像是一棵树的前序遍历,没错,它就是。
它从图中某个顶点v出发,访问此顶点,然后从v的未访问过的邻接点出发深度优先遍历图,直到图中所有和v有路径相通的顶点都被访问到。事实上,我们这里讲到的是连通图,对于非连通图,只需要对它的连通分量分别进行深度优先遍历,即在先前一个顶点进行一次深度优先遍历后,若图中尚有顶点未被访问,则另选图中一个未曾未访问过的顶点作为起点,重复上述过程,直至图中所有顶点都被访问为止。
复习一下前面的邻接矩阵的声明:
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;
如果我们用的是邻接矩阵的方式,则深度优先遍历代码如下:
typedef int Boolean;
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 numVertexes,numEdges;// 图中当前顶点数和边数
}GraphAdjList;
邻接表结构的深度优先遍历算法
//邻接表的深度优先递归算法
void DFS(GraphAdjList GL,int i){
EdgeNode *p;
visited[i]=TRUE;
printf("%c ",GL->adjList[i].data); //打印结点,其他操作也可以
p=GL->adjList[i].firstedge; //p是指针
while( p ){
if( !visited[p->adjvex])
DFS(GL, p->adjvex); //对未访问过的邻接顶点递归调用
p=p->next;
}
}
//邻接表的深度遍历操作
void DFSTraverse(GraphAdjList GL){
int i;
for(int i=0;i< GL->numVertexes;i++)
visited[i]=FALSE;// 初始化所有结点都是未访问过状态
for(int i=0;i<GL->numVertexes;i++)
if( !visited[i])
DFS(GL,i);
}
对比两个不同存储结构的深度优先遍历算法,对于n个顶点和e条边的图来说,邻接矩阵由于是二维数组,要查找每个顶点的邻接点需要访问矩阵中的所有元素,因此都需要 O ( n 2 ) O(n^2) O(n2)的时间。
而邻接表做存储结构时,找邻接点所需要的时间取决于顶点和边的数量,所以是深度优先遍历时间为O(n+e).显然,对于点多边少的稀疏图来说,邻接表结构使得算法在时间效率上大大提高。
对于有向图来说,由于它只是对通道存在可行和不可行,算法上没有变化,是完全可以通用的。这里就不再赘述了。
7.5.2 广度优先遍历
广度优先遍历(Breadth First Search),又称为广度优先搜索,简称BFS。
如果说图的深度优先遍历类似于树的前序遍历,那么图的广度优先遍历就类似于树的层序遍历了。 我们将图7-5-3的第一幅图稍微变形,变形原则是顶点A放置在最上面第一层,让与它有边的顶点B、F为第二层,再让与B、F有边的顶点C,I,G,E为第三层,再将这四个顶点有边的D,H放在第四层,如图7-5-3的第二幅图所示。 此时在视觉上感觉图的形状发生了变化,其实顶点和边的关系还是完全相同的。
有了这个讲解,我们来看代码就非常容易了。以下是邻接矩阵结构的广度优先遍历算法
//邻接矩阵的广度遍历算法
void BFSTraverse(MGgaph 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.arg[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(int 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); //将队中元素出队列,赋值给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;//指针指向下一个邻接点
}
}
}
}
}
对比图的深度优先遍历和广度优先遍历算法,你会发现,它们在时间复杂度上是一样的,不同之处仅仅在于对顶点访问的顺序不同。可见两者在全图遍历上是没有优劣之分的,只是视不同情况选择不同的算法。
不过如果图顶点和边非常多,不能在短时间内遍历完成,遍历的目的是为了寻找合适的顶点,那么选择哪种遍历就要仔细斟酌了。深度优先更适合目标比较明确,以找到目标为主要目的的情况,而广度优先更适合在不断扩大搜索范围时找到相对最优解的情况。
7.6 最小生成树
假设你是电信的施工工程师,需要为一个镇的九个村庄架设通信网络做设计,村庄位置大致如图7-6-1,其中v0~v8是村庄,之间连线的数字表示村与村之间的可通达的直线距离,比如v0到v1就是10公里(个别点比如v0和v6未测算是因为有高山或者湖泊,不予考虑)。你们领导要求你必须用最小的成本完成这次任务。你会怎么办?
显然这是一个带权值的图,即网结构。所谓的最小成本,就是n个结点,用n-1条边把一个连通图连接起来,并且使得权值的和最小。在这个例子中,每多一公里就多一份成本,所以只要让线路连线的公里数最少,就是最少成本了。
如果你加班加点,没日没夜地设计出的结果是如图7-6-2的方案一(粗线表示要架设的线路),我想你你被炒鱿鱼应该是不远了,因为这个方案比后两个方案多出60%的成本。
方案三设计得非常巧妙,但也是仅仅以微弱的优势对方案二胜出,应该说是很侥幸。我们有没有办法可以精确计算出这种网图的最佳方案呢? 答案当然是yes。
我们在讲图的定义和术语时,曾经提到过,一个连通图的生成树是一个极小的连通子图,它含有图中全部的顶点,但只有足以构成一棵树的n-1条边。显然图7-6-2的三个方案都是7-6-1的网图的生成树。那么我们把构造连通网的最小代价生成树称为最小生成树(Minimum Cost Spanning Tree).
找连通图的最小生成树,经典的有两种算法,普利姆算法和克鲁斯卡尔算法。我们就分别来介绍一下。
7.6.1 普利姆(Prim)算法
为了能讲明白这个算法,我们先构造图7-6-1的邻接矩阵,如图7-6-3的右图所示。
也就是说,我们现在已经有了一个存储结构为MGraph的G,G中有9个顶点,它的arc二维数组如图7-6-3右图所示,数组中我们用65535代替∞。
于是普利姆算法代码如下,左侧数字为行号。其中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加入生成树。
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){
if(lowcost[j]!= 0 && lowcost[j]<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]){
lowcost[j]=G.arc[k][j];
adjvex[j]=k;
}
}
}
}
1 程序开始运行,我们由6~7行 创建了两个一维数组lowcost和adjvex,长度都为顶点个数9。它们的作用我们慢慢细说。
2 第 8~9 行 我们分别给这两个数组的第一个下标位赋值为0,arjvex[0]=0其实意思就是我们现在从顶点v0开始(事实上,最小生成树从哪个顶点开始计算都无所谓,我们假定从v0开始),lowcost[0]=0,就表示v0已经被纳入到最小生成树中,之后凡是lowcost数组中的值被设置为0就是表示此下标的顶点被纳入到最下生成树.
3 第11~14行表示我们读取图7-6-3的右图邻接矩阵的第一行数据。将数值赋值给lowcost数组,所以此时lowcost数组值为{0,10,65535,65535,65535,11,65535,65535,65535},而arjvex则全部为0.此时,我们已经完成了整个初始化的工作,准备开始生成。
4 第16~35行,整个循环过程就是构造最小生成树的过程。
5 第17~18行,将min设置为一个极大值65535,它的目的是为了之后找到一定范围内的最小权值。j是用来做顶点下标循环的变量,k是用来存储最小权值的顶点下标。
6 第19~25行,循环中不断修改min为当前lowcost数组中最小值,并用k保留此最小值的顶点下标。经过循环后,min=10,k=1.注意 if判断的lowcost[j]!=0 表示已经是生成树的顶点不参与最小权值的查找。
7 第26行,因为k=1,adjvex[1]=0,所以打印结果为(0,1) ,表示v0到v1边为最小生成树的第一条边,如下图所示。
8 第27行,此时因为k=1,我们将lowcost[k]=0就是说顶点v1纳入到最小生成树中,此时lowcost数组为{0,0,65535,65535,65535,11,65535,65535,65535}。
9 第28~35行,j循环从1到8,因k=1,查找邻接矩阵的第v1行的各个权值,与lowcost的对应值比较,若更小则修改lowcost值,并将k值存入adjvex数组中。因第v1行有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 也说明 v0和v1已经是生成树的顶点不参与最小权值的比对了。
10 再次循环,由16~26行,此时min=11,k=5,adjvex[5]=0,因此打印(0,5).表示v0到v5边为最小生成树的第二条边,如下图所示。
11 接下来执行到36行,lowcost数组为{0,0,18,65535,26,0,16,65535,12},adjvex数组为{0,0,1,0,5,0,1,0,1}
12 之后,相信大家可以自己去模拟了。通过不断的转换,构造的构成如下图7-6-6所示。
有了这样的讲解,再来介绍Prim算法的实现定义可能就容易理解一些。
假设 N = ( P , { E } ) N=(P,\{E\}) N=(P,{E})是连通图,TE是N上最小生成树中边的集合。算法从 U = { u 0 } , ( u 0 ∈ V ) , T E = { } U=\{u_0\},(u_0 ∈ V),TE=\{ \} U={u0},(u0∈V),TE={}开始。重复执行下述操作:在所有 u ∈ U , v ∈ V − U 的 边 ( u , v ) ∈ E u ∈ U,v∈V-U的边(u,v)∈E u∈U,v∈V−U的边(u,v)∈E中找一条代价最小的边 ( u 0 , v 0 ) (u_0,v_0) (u0,v0)并入集合TE,同时v0并入U,直至U=V为止。此时TE中必有n-1条边, T = ( V , { T E } ) T=(V,\{TE\}) T=(V,{TE}) 为N的最小生成树。
简单描述一下:Prim算法就是每次找和我们这个集合 相连的最短的边。
由算法代码中的循环嵌套可得知Prim算法的时间复杂度为
O
(
n
2
)
O(n^2)
O(n2).
7.6.2 克鲁斯卡尔(Kruskal)算法
现在我们来换一种思考方式,普利姆算法是以某顶点为起点,逐步找各顶点上最小权值的边来构建最小生成树。这就像我们如果去参观某个展会,例如世博会,你从一个入口进去,然后找你所在位置周边的场馆中你最感兴趣的场馆参观,看完后再回用同样的办法去看下一个。可我们为什么不事先计划好,进园后直接到你最想去的场馆观看呢?事实上,去世博园的观众,绝大多数都是这样做的。
同样的思路,我们也可以直接就以边为目标去构建,因为权值是在边上的,直接去找最小权值的边来构建生成树也是很自然的想法,只不过构建时要考虑是否会形成环路而已。此时我们用到了图的存储结构中的边集数组结构。以下是edge边集数组结构的定义代码
//对边集数组Edge结构的定义
typedef struct{
int begin;
int end;
int weight;
}Edge;
我们将图7-6-3的邻接矩阵通过程序转化为图7-6-7的右图的边集数组,并且对它们按照权值从小到大排序。
于是克鲁斯卡尔(Kruskal)算法代码如下,左侧数字是行号。其中MAXEDGE为边数量的极大值,此处大于等于15即可,MAXVEX为顶点个数最大值,此处大于等于9即可。现在假设我们自己就是计算机,在调用MiniSpanTree_Kruskal函数,输入图7-6-3右图的邻接矩阵后,看看它是如何运行并打印出最小生成树的。
void MiniSpanTree_Kurskal(MGraph G){
int i,n,m;
Edge edges[MAXEDGES];
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].bedin);
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 *paarent ,int f){ //查找连线顶点的尾部下标
while(parent[f]>0)
f=parent[f];
return f;
}
1 程序开始执行,第6行之后,我们省略掉颇占篇幅但却很容易实现的将邻接矩阵转换为边集数组,并按权值从小到大排序的代码,也就是说,在第6行开始,我们已经有了结构为edge,数据内容是图7-6-7的右图的一维数组edges。
2 第5~7行,我们声明一个数组parent,并将它的值都初始化为0,它的作用我们稍后慢慢说。
3 第9~18行,我们开始对边集数组做循环遍历,开始时,i=0;
4 第10行,我们调用了第22~27行的函数Find,传入的参数是数组parent和当前权值最小边 ( v 4 , v 7 ) (v_4,v_7) (v4,v7)的begin:4.因为parent中全都是0所以传出值使得n=4.
5 第11行,同样做法,传入 ( v 4 , v 7 ) (v_4,v_7) (v4,v7)的end:7。传出值使得m=7.
6 第12~16行,很显然n和m不相等,因此parent[4]=7。此时parent数组值为{0,0,0,0,7,0,0,0,0},并且打印得到 “(4,7) 7” 。此时我们已经将边 ( v 4 , v 7 ) (v_4,v_7) (v4,v7)纳入到最小生成树中,如图7-6-8所示。(加粗的是右下角的v4到v7)
7 循环返回,执行10~17行,此时i=1,edge[1]得到边 ( v 2 , v 8 ) (v_2,v_8) (v2,v8),n=2,m=8,parent[2]=8,此时parent数组值为{0,0,8,0,7,0,0,0,0},并且打印得到 “(2,8) 8” 。此时我们已经将边 ( v 4 , v 7 ) 和 边 ( v 2 , v 8 ) (v_4,v_7)和边(v_2,v_8) (v4,v7)和边(v2,v8)纳入到最小生成树中,如图7-6-9所示。
8 再次执行10~17行,此时i=2,edge[2]得到边
(
v
0
,
v
1
)
(v_0,v_1)
(v0,v1),n=0,m=1,parent[0]=1,此时parent数组值为{1,0,8,0,7,0,0,0,0},并且打印得到 “(0,1) 10” 。此时我们已经将边
(
v
4
,
v
7
)
,
边
(
v
2
,
v
8
)
,
(
v
0
,
v
1
)
(v_4,v_7),边(v_2,v_8),(v_0,v_1)
(v4,v7),边(v2,v8),(v0,v1)纳入到最小生成树中,如图7-6-10所示。
9 当i=3,4,5,6时,分别将边
(
v
0
,
v
5
)
,
边
(
v
1
,
v
8
)
,
(
v
3
,
v
7
)
,
(
v
1
,
v
6
)
(v_0,v_5),边(v_1,v_8),(v_3,v_7),(v_1,v_6)
(v0,v5),边(v1,v8),(v3,v7),(v1,v6)纳入到最小生成树中,如图7-6-10所示。,此时parent数组值为{1,5,8,7,7,8,0,0,6},怎么去解读这个数组现在这些数字的意义呢?
从上图的最右图i=6的粗线连线可以看到,我们其实是有两个连通的边集合A与B纳入到最小生成树中,如图7-6-12所示。当parent[0]=1,表示v0和v1已经在生成树的边集合A中。此时将parent[0]=1的1改成下标,由parent[1]=5,表示v1和v5在边集合A中,parent[5]=8表示v5与v8在边集合A中,parent[8]=6表示v8和v6在边集合A中,parent[6]=0表示集合A暂时到头,此时边集合A有 v0,v1,v5,v8,v6.我们查看parent中没有查看的值,parent[2]=8表示v2与v8在一个集合中,因此v2也在边集合A中。再由parent[3]=7,parent[4]=7,parent[7]=0可以知道v3、v4,v7在另一个边集合B中。
10 当i=7,第10行,调用Find函数,会传入参数edges[7].begin=5,此时 第 行,parent[5]=8>0,所以f=8,再循环得到parent[8]=6.因parent[6]=0,所以Find返回后第10行得到n=6.而此时第11行,传入参数edges[7].end=6,得到m=6.此时m=n,不再打印,继续下一循环。这就告诉我们,因为边 ( v 5 , v 6 ) (v_5,v_6) (v5,v6)使得边集合A形成了环路。因此不能将它纳入到最小生成树中,如图7-6-12所示。
11 当i=8时,与上面相同,由于边 ( v 1 , v 2 ) (v_1,v_2) (v1,v2)使得边集合A形成了环路。因此不能将它纳入到最小生成树中,如图7-6-12所示。
12 当i=9时,边 ( v 6 , v 7 ) (v_6,v_7) (v6,v7),第10行得到n=6,第11行得到m=7,因此parent[6]=7,打印“(6,7) 19” 。此时parent数组值为{1,5,8,7,7,8,7,0,6}。如图7-6-13所示。
13 . 此后边的循环均造成环路,最终最小生成树即为图7-6-13所示。
好了,我们来把克鲁斯卡尔(Kruscal)算法的实现定义归纳一下来结束这一节的讲解。
假设 N = ( V , { E } ) N=(V,\{E\}) N=(V,{E})是连通图,则令最小生成树的初始状态为只有n个顶点而无边的非连通图 T = ( V , { } ) T=(V,\{\}) T=(V,{}),图中每个顶点自成一个连通分量。在E中选择代价最小的边,若该边依附的顶点落在T中不同的连通分量上,则将该边加入到T中,否则舍去该边而选择下一条代价最小的边。以此类推,直至T中所有顶点都在同一连通分量上为止。
此算法的Find函数复杂度由边数e决定,时间复杂度为 O ( l o g e ) O(loge) O(loge),而外面有一个for循环e此。所以克鲁斯卡尔算法的时间复杂度为 O ( e l o g e ) O(eloge) O(eloge)。
对比两个算法,克鲁斯卡尔算法主要是针对边来展开,边数少时效率会非常高,所以对于稀疏图有很大的优势。而普利姆算法对于稠密图,即边数非常多的情况会更好一些。
7.7 最短路径
我们时常会面临着对路径选择的决策问题。例如在北京、上海、广州等城市,因其城市面积较大,乘地铁或公交都要考虑从A点到B点,如何环程到达? 比如图7-7-1这样的地铁网图,如果不是专门做研究,对于刚接触的人来说,都会犯迷糊。
现实中,每个人需求不同,选择方案不尽相同。有人为了省钱,它需要的是路程最短(定价以路程长短为标准),但可能由于线路班次少,换乘站间距离较长等原因并不省时间;而另一些人,为了要赶飞机火车或者早晨上班不迟到,她最大的需求是时间要短;还有一类人,如老人行动不便,或者上班族下班,忙碌一整天累得要死,他们都不想多走路,关键是要换乘少,这样可以多休息会儿。 这些都是老百姓的需求,简单的图形可以靠人的经验和感觉,但复杂的道路或地铁网就需要计算机通过算法来提供最佳的方案。我们今天就要来研究关于图的最短路径的问题。
在网图和非网图中,最短路径的含义是不同的。由于非网图它没有边上的权值,所谓的最短路径,其实就是指两顶点之间经过的边数最少的路径;而对于网图来说,最短路径,是指两顶点之间经过的边上权值之和最少的路径,并且我们称路径上的第一个顶点是源点,最后一个顶点是终点。 显然,我们研究网图更有实际意义,就地图来说,距离就是两顶点间的权值之和。而非网图完全可以理解为所有的边的权值都为1的网。
我们要讲解两种求最短路径的算法。先来讲第一种,从某个源点到其余各顶点的最短路径问题。
你能很快地计算出图7-7-2中由源点v0到终点v8的最短路径吗? 如果不能,没关系,我们一同来研究看看如何让计算机计算出来。如果能,哼哼,那仅代表你智商还不错,你还是要来好好学习,毕竟真实世界的图可没这么简单,好了,我们开始吧。
7.7.1 迪杰斯特拉(Dijkstra)算法
这是一个按路径长度递增的次序产生最短路径的算法。它的思路大体上是这样的。
比如说要求图7-7-3中顶点v0到顶点v1的最短距离,没有比这更简单的了,答案就是1,路径就是直接v0连接v1.
由于顶点v1还与顶点v2、v3、v4连线,所以此时我们同时求得了v0→v1→v2=1+3=4,v0→v1→v3=1+7=8,v0→v1→v4=1+5=6.
现在,我问v0到v2的最短距离,如果你不假思索地说是5,那就犯错了。因为边上都有权值,刚才已经有v0→v1→v2=1+3=4,比5还要小1个单位,它才是最短距离,如图7-7-4所示。
由于顶点v2还与v4、v5连接,所以此时我们同时求得v0→v2→v4其实就是v0→v1→v2→v4=5,v0→v2→v5=4+7=11,这里v0→v2我们用刚计算出来的最小的4.此时我们发现v0→v1→v2→v4=5要比v0→v1→v4=6还要小。所以v0到v4目前的最小距离是5,如下图所示。
当我们要求v0到v3的最短路时,通向v3的三条边,除了v6没有研究过外,v0→v1→v3=1+7=8,而v0→v4→v3=5+2=7.因此,v0到v3的最短距离是7,如下图所示。
好了,我想你大致了解,这个迪杰斯特拉算法是如何干活的了。它并不是一下子就求出来v0到v8的最短路径,而是一步步求出它们之间的顶点的最短路径,过程中都是基于已经求出的最短路径的基础上,求得更远顶点的最短路径,最终得到你想要的结果。
如果还是不太明白,不要紧,现在我们来看代码,从代码的模拟运行中,再次去理解它的思想。
完整代码,包括创建图,求最短路。可运行:
#include "stdio.h"
#include "stdlib.h"
#include "io.h"
#include "math.h"
#include "time.h"
#define OK 1
#define ERROR 0
#define TRUE 1
#define FALSE 0
#define MAXEDGE 20
#define MAXVEX 20
#define INFINITY 65535
typedef int Status; /* Status是函数的类型,其值是函数结果状态代码,如OK等 */
typedef struct
{
int vexs[MAXVEX];
int arc[MAXVEX][MAXVEX];
int numVertexes, numEdges;
}MGraph;
typedef int Patharc[MAXVEX]; /* 用于存储最短路径下标的数组 */
typedef int ShortPathTable[MAXVEX];/* 用于存储到各点最短路径的权值和 */
/* 构建图 */
void CreateMGraph(MGraph *G)
{
int i, j;
/* printf("请输入边数和顶点数:"); */
G->numEdges=16;
G->numVertexes=9;
for (i = 0; i < G->numVertexes; i++)/* 初始化图 */
{
G->vexs[i]=i;
}
for (i = 0; i < G->numVertexes; i++)/* 初始化图 */
{
for ( j = 0; j < G->numVertexes; j++)
{
if (i==j)
G->arc[i][j]=0;
else
G->arc[i][j] = G->arc[j][i] = INFINITY;
}
}
G->arc[0][1]=1;
G->arc[0][2]=5;
G->arc[1][2]=3;
G->arc[1][3]=7;
G->arc[1][4]=5;
G->arc[2][4]=1;
G->arc[2][5]=7;
G->arc[3][4]=2;
G->arc[3][6]=3;
G->arc[4][5]=3;
G->arc[4][6]=6;
G->arc[4][7]=9;
G->arc[5][7]=5;
G->arc[6][7]=2;
G->arc[6][8]=7;
G->arc[7][8]=4;
for(i = 0; i < G->numVertexes; i++)
{
for(j = i; j < G->numVertexes; j++)
{
G->arc[j][i] =G->arc[i][j];
}
}
}
/* Dijkstra算法,求有向网G的v0顶点到其余顶点v的最短路径P[v]及带权长度D[v] */
/* P[v]的值为前驱顶点下标,D[v]表示v0到v的最短路径长度和 */
void ShortestPath_Dijkstra(MGraph G, int v0, Patharc *P, ShortPathTable *D)
{
int v,w,k,min;
int final[MAXVEX];/* final[w]=1表示求得顶点v0至vw的最短路径 */
for(v=0; v<G.numVertexes; v++) /* 初始化数据 */
{
final[v] = 0; /* 全部顶点初始化为未知最短路径状态 */
(*D)[v] = G.arc[v0][v];/* 将与v0点有连线的顶点加上权值 */
(*P)[v] = -1; /* 初始化路径数组P为-1 */
}
(*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.arc[k][w]<(*D)[w]))
{ /* 说明找到了更短的路径,修改D[w]和P[w] */
(*D)[w] = min + G.arc[k][w]; /* 修改当前路径长度 */
(*P)[w]=k;
}
}
}
}
int main(void)
{
int i,j,v0;
MGraph G;
Patharc P;
ShortPathTable D; /* 求某点到其余各点的最短路径 */
v0=0;
CreateMGraph(&G);
ShortestPath_Dijkstra(G, v0, &P, &D);
printf("最短路径倒序如下:\n");
for(i=1;i<G.numVertexes;++i)
{
printf("v%d - v%d : ",v0,i);
j=i;
while(P[j]!=-1)
{
printf("%d ",P[j]);
j=P[j];
}
printf("\n");
}
printf("\n源点到各顶点的最短路径长度为:\n");
for(i=1;i<G.numVertexes;++i)
printf("v%d - v%d : %d \n",G.vexs[0],G.vexs[i],D[i]);
return 0;
}
运行结果
最短路径倒序如下:
v0 - v1 :
v0 - v2 : 1
v0 - v3 : 4 2 1
v0 - v4 : 2 1
v0 - v5 : 4 2 1
v0 - v6 : 3 4 2 1
v0 - v7 : 6 3 4 2 1
v0 - v8 : 7 6 3 4 2 1
源点到各顶点的最短路径长度为:
v0 - v1 : 1
v0 - v2 : 4
v0 - v3 : 7
v0 - v4 : 5
v0 - v5 : 8
v0 - v6 : 10
v0 - v7 : 12
v0 - v8 : 16
在调用函数前,我们需要为图7-7-7的左图准备邻接矩阵MGraph的G,如右图所示,并且定义参数v0为0.
1 程序开始运行,第4行final数组是为了v0到某顶点是否已经求得最短路径的标记,如果v0到vw已经有结果,则final[w]=1.
2 第5~10行,是在对数据进行初始化工作。此时final数组值均为0,表示所有的点都未求得最短路径。D数组为{65535, 1, 5, 65535, 65535, 65535, 65535, 65535, 65535 }。因为v0与v1和v2的边权值是1和5.P数组全为0,表示目前没有路径。
3 第11行,表示v0到v0自身,权值和结果为0.D数组为{0, 1, 5, 65535, 65535, 65535, 65535, 65535, 65535 }.第12行,表示v0点算是已经求得最短路径,因为final[0]=1.此时final 数组为{1,0,0,0,0,0,0,0,0},此时整个初始化过程完成。
4 第13~33行,为主循环,每次循环求得v0与一个顶点的最短路径。因此v从1而不是0开始。
5 第15~23行,先令min为65535的极大值,通过w循环,与D[w]比较找到最小值 min=1,k=1.
6 第24行,由k=1,表示与v0最近的顶点是v1,并且由D[1]=1,知道此时v0到v1的最短距离是1。因此将v1对应的final[1]设置为1.此时final数组为{1,1,0,0,0,0,0,0,0}。
7 第25~32行是一个循环,此循环甚为关键。它的目的是在刚才已经找到v0与v1的最短路的基础上,对v1与其他顶点的边进行计算,得到v0与它们的当前的最短距离,如下图所示。因为min=1,所以本来D[2]=5,现在v0→v1→v2=D[2]=min+3=4,v0→v1→v3=D[3]=min+7=8,v0→v1→v4=D[4]=min+5=6.因此,D数组当前值为{0, 1, 4, 8, 6, 65535, 65535, 65535, 65535 }.而P[2]=1,P[3]=1,P[4]=1,它表示的意思是v0到v2、v3、v4点的最短路径它们的前驱均为v1,此时P数组(P[v]的值为前驱顶点下标)的值为{0,0,1,1,1,0,0,0,0}。
8 重新开始循环,此时i=2.第15~23行,对w循环,注意因为final[0]=1,final[1]=1,由第18行 !final[w]可知,v0与v1并不参与最小值的获取。通过循环比较,找到最小值min=4,k=2.
9 第24行,由k=2,表示已经求出v0到v2的最短路径,并且由D[2]=4,知道最短距离为4.因此将v2对应的final[2]设置为1,此时final数组为:{1,1,1,0,0,0,0,0,0}。
10 第25~32行。在刚才已经找到v0与v2的最短路径的基础上,对v2与其他顶点的边,进行计算,得到v0与它们的当前最短距离,如图7-7-9所示。
因为min=4,所以本来D[4]=6,现在v0→v2→v4=D[4]=min+1=5,v0→v2→v5=D[5]=min+7=11,因此,D数组当前值为{0, 1, 4, 8, 5, 11, 65535, 65535, 65535 }.而本身P[4]=1,此时P[4]=2,P[5]=2,它表示v0到v4、v5的最短路径它们前驱均为2.此时P数组为{0,0,1,1,2,2,0,0,0}。
11 重新开始循环,此时i=3.第15~223行,通过对w循环比较找到最小值min=5,k=4.
12 第24行,由k=4,表示已经求出v0到v4的最短路径,并且由D[4]=5,知道最短路径为5.因此将v4对应的final[4]设置成1,此时final数组为{1,1,1,0,1,0,0,0,0}。
13 第25~32行,对v4与其他顶点的边进行计算,得到v0与它们的当前最短距离,如图7-7-10所示。因为min=5,所以本来D[3]=8,现在变成min+2=7=D[3],另外D[6]=min+6=11,D[7]=min+9=14,因此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,它表示v0到v3、v5、v6、v7点的最短路径它们的前驱均为v4.此时P数组值为{0,0,1,4,2,4,4,4,0}.
14 之后的循环就完全类似了。得到最终的结果,如图7-7-11所示。此时final数组为{1,1,1,1,1,1,1,1,1}.它表示所有的顶点均完成了最短路径的查找工作,此时D数组为{0, 1, 4, 7, 5, 8, 10, 12, 16 },它表示v0到各个顶点的最短路径,比如D[8]=16,此时P数组为{0,0,1,4,2,4,3,6,7},这串数字描述的是每个顶点的最短路径上的前驱。
其实,最终返回的数组D和数组P,是可以得到v0到任意一个顶点的最短路径和路径长度的。比如v0到v8的最短路径并没有经过v5,但我们已经知道v0到v5的最短路径了。由D[5]=8,可以知道它的路径长度为8,由P[5]=4,可知v5的前驱顶点是v4,所以v0到v5的最短路径是v0→v1→v2→v4→v5.
也就是说,我们通过迪杰斯特拉算法解决了从某个源点到其余各顶点的最短路径问题。从循环嵌套可以很容易得到Dijkstra的时间复杂度为 O ( n 2 ) O(n^2) O(n2),尽管有同学觉得,可不可以只找到从源点到某一特定终点的最短路径,其实这个问题和求源点到其他所有顶点的最短路径一样复杂,时间复杂度依然是 O ( n 2 ) O(n^2) O(n2).
可如果我们还需要知道v3到v5,v1到v7这样的任一顶点到其余所有顶点的最短路径怎么办呢? 此时简单的办法是对每个顶点当作源点运行一次迪杰斯特拉算法,等于在原有算法的基础上,再来一次循环,此时整个算法的复杂度就成了 O ( n 3 ) O(n^3) O(n3).
对此,我们现在再来介绍另一种求最短路的算法----弗洛伊德(Floyd),它求所有顶点到所有顶点的时间复杂度也是 O ( n 3 ) O(n^3) O(n3),但其算法非常简洁优雅,能让人感觉到智慧的无限魅力。
7.7.2 弗洛伊德(Floyd)算法
为了能讲明白弗洛伊德算法的精妙所在,我们先来看看最简单的案例。图7-7-12的左图是一个最简单的3个顶点连通网图。
我们先来定义两个二维数组D[3][3],P[3][3],D代表顶点到顶点的最短路径权值和。P是代表对应顶点的最小路径的前驱矩阵。在未分析任何顶点之前,我们将D命名为 D − 1 D^{-1} D−1,其实它就是初始图的邻接矩阵,将P命名为 P − 1 P^{-1} P−1,初始化为图中所示的矩阵。
首先我们来分析,所有的顶点经过v0后到达另一顶点的最短路径。因为只有三个顶点,因此需要查看v1→v0→v2,得到 D − 1 [ 1 ] [ 0 ] + D − 1 [ 0 ] [ 2 ] = 2 + 1 = 3 D^{-1}[1][0]+D^{-1}[0][2]=2+1=3 D−1[1][0]+D−1[0][2]=2+1=3, D − 1 [ 1 ] [ 2 ] D^{-1}[1][2] D−1[1][2]表示的是v1到v2的权值为5,我们发现 D − 1 [ 1 ] [ 0 ] + D − 1 [ 0 ] [ 2 ] < D − 1 [ 1 ] [ 2 ] D^{-1}[1][0]+D^{-1}[0][2]<D^{-1}[1][2] D−1[1][0]+D−1[0][2]<D−1[1][2],通俗地说,就是v1→v0→v2比直接v1→v2距离更近。所以,我们就让 D − 1 [ 1 ] [ 2 ] = D − 1 [ 1 ] [ 0 ] + D − 1 [ 0 ] [ 2 ] = 2 + 1 = 3 D^{-1}[1][2]=D^{-1}[1][0]+D^{-1}[0][2]=2+1=3 D−1[1][2]=D−1[1][0]+D−1[0][2]=2+1=3,同样的 D − 1 [ 2 ] [ 1 ] = 3 D^{-1}[2][1]=3 D−1[2][1]=3,于是就有了 D 0 D^{0} D0矩阵。因为有变化,所以P矩阵对应的 P − 1 [ 1 ] [ 2 ] , P − 1 [ 2 ] [ 1 ] P^{-1}[1][2],P^{-1}[2][1] P−1[1][2],P−1[2][1]也修改为当前中转的顶点v0的下标0,于是就有了 P 0 P^{0} P0矩阵。
也就是说
D
0
[
v
]
[
w
]
=
m
i
n
{
D
−
1
[
v
]
[
w
]
,
D
−
1
[
v
]
[
0
]
+
D
−
1
[
0
]
[
w
]
}
D^{0}[v][w]= min \{ D^{-1}[v][w],D^{-1}[v][0]+D^{-1}[0][w] \}
D0[v][w]=min{D−1[v][w],D−1[v][0]+D−1[0][w]}
接下来,其实也就是 D 0 D^{0} D0和 P 0 P^{0} P0 基础上继续处理所有顶点经过v1和v2后到达另一顶点的最短路径,得到 D 1 D^{1} D1, P 1 P^{1} P1 , D 2 D^{2} D2, P 2 P^{2} P2 完成所有顶点到所有顶点的最短路径的计算工作。
如果我就用这枚简单的网图来讲解代码,大家一定会觉得不会说明什么问题。所以我们还是以前面的复杂网图为例,来讲解弗洛伊德代码。
首先我们针对图7-7-13的左网图准备两个矩阵
D
−
1
D^{-1}
D−1,
P
−
1
P^{-1}
P−1,
D
−
1
D^{-1}
D−1就是网图的邻接矩阵,
P
−
1
P^{-1}
P−1出设为
P
[
i
]
[
j
]
=
j
P[i][j]=j
P[i][j]=j 这样的矩阵 ,它主要用来存储路径。
代码如下,注意因为是求所有顶点到所有顶点的最短路径,因此Pathmatrix 和ShortPahtTable 都是二维数组。
运行结果如下,代码往下滑
各顶点间最短路径如下:
v0-v1 weight: 1 path: 0 -> 1
v0-v2 weight: 4 path: 0 -> 1 -> 2
v0-v3 weight: 7 path: 0 -> 1 -> 2 -> 4 -> 3
v0-v4 weight: 5 path: 0 -> 1 -> 2 -> 4
v0-v5 weight: 8 path: 0 -> 1 -> 2 -> 4 -> 5
v0-v6 weight: 10 path: 0 -> 1 -> 2 -> 4 -> 3 -> 6
v0-v7 weight: 12 path: 0 -> 1 -> 2 -> 4 -> 3 -> 6 -> 7
v0-v8 weight: 16 path: 0 -> 1 -> 2 -> 4 -> 3 -> 6 -> 7 -> 8
v1-v2 weight: 3 path: 1 -> 2
v1-v3 weight: 6 path: 1 -> 2 -> 4 -> 3
v1-v4 weight: 4 path: 1 -> 2 -> 4
v1-v5 weight: 7 path: 1 -> 2 -> 4 -> 5
v1-v6 weight: 9 path: 1 -> 2 -> 4 -> 3 -> 6
v1-v7 weight: 11 path: 1 -> 2 -> 4 -> 3 -> 6 -> 7
v1-v8 weight: 15 path: 1 -> 2 -> 4 -> 3 -> 6 -> 7 -> 8
v2-v3 weight: 3 path: 2 -> 4 -> 3
v2-v4 weight: 1 path: 2 -> 4
v2-v5 weight: 4 path: 2 -> 4 -> 5
v2-v6 weight: 6 path: 2 -> 4 -> 3 -> 6
v2-v7 weight: 8 path: 2 -> 4 -> 3 -> 6 -> 7
v2-v8 weight: 12 path: 2 -> 4 -> 3 -> 6 -> 7 -> 8
v3-v4 weight: 2 path: 3 -> 4
v3-v5 weight: 5 path: 3 -> 4 -> 5
v3-v6 weight: 3 path: 3 -> 6
v3-v7 weight: 5 path: 3 -> 6 -> 7
v3-v8 weight: 9 path: 3 -> 6 -> 7 -> 8
v4-v5 weight: 3 path: 4 -> 5
v4-v6 weight: 5 path: 4 -> 3 -> 6
v4-v7 weight: 7 path: 4 -> 3 -> 6 -> 7
v4-v8 weight: 11 path: 4 -> 3 -> 6 -> 7 -> 8
v5-v6 weight: 7 path: 5 -> 7 -> 6
v5-v7 weight: 5 path: 5 -> 7
v5-v8 weight: 9 path: 5 -> 7 -> 8
v6-v7 weight: 2 path: 6 -> 7
v6-v8 weight: 6 path: 6 -> 7 -> 8
v7-v8 weight: 4 path: 7 -> 8
最短路径D
0 1 4 7 5 8 10 12 16
1 0 3 6 4 7 9 11 15
4 3 0 3 1 4 6 8 12
7 6 3 0 2 5 3 5 9
5 4 1 2 0 3 5 7 11
8 7 4 5 3 0 7 5 9
10 9 6 3 5 7 0 2 6
12 11 8 5 7 5 2 0 4
16 15 12 9 11 9 6 4 0
最短路径P
0 1 1 1 1 1 1 1 1
0 1 2 2 2 2 2 2 2
1 1 2 4 4 4 4 4 4
4 4 4 3 4 4 6 6 6
2 2 2 3 4 5 3 3 3
4 4 4 4 4 5 7 7 7
3 3 3 3 3 7 6 7 7
6 6 6 6 6 5 6 7 8
7 7 7 7 7 7 7 7 8
可运行源代码
#include "stdio.h"
#include "stdlib.h"
#include "io.h"
#include "math.h"
#include "time.h"
#define OK 1
#define ERROR 0
#define TRUE 1
#define FALSE 0
#define MAXEDGE 20
#define MAXVEX 20
#define INFINITY 65535
typedef int Status; /* Status是函数的类型,其值是函数结果状态代码,如OK等 */
typedef struct
{
int vexs[MAXVEX];
int arc[MAXVEX][MAXVEX];
int numVertexes, numEdges;
}MGraph;
typedef int Patharc[MAXVEX][MAXVEX];
typedef int ShortPathTable[MAXVEX][MAXVEX];
/* 构建图 */
void CreateMGraph(MGraph *G)
{
int i, j;
/* printf("请输入边数和顶点数:"); */
G->numEdges=16;
G->numVertexes=9;
for (i = 0; i < G->numVertexes; i++)/* 初始化图 */
{
G->vexs[i]=i;
}
for (i = 0; i < G->numVertexes; i++)/* 初始化图 */
{
for ( j = 0; j < G->numVertexes; j++)
{
if (i==j)
G->arc[i][j]=0;
else
G->arc[i][j] = G->arc[j][i] = INFINITY;
}
}
G->arc[0][1]=1;
G->arc[0][2]=5;
G->arc[1][2]=3;
G->arc[1][3]=7;
G->arc[1][4]=5;
G->arc[2][4]=1;
G->arc[2][5]=7;
G->arc[3][4]=2;
G->arc[3][6]=3;
G->arc[4][5]=3;
G->arc[4][6]=6;
G->arc[4][7]=9;
G->arc[5][7]=5;
G->arc[6][7]=2;
G->arc[6][8]=7;
G->arc[7][8]=4;
for(i = 0; i < G->numVertexes; i++)
{
for(j = i; j < G->numVertexes; j++)
{
G->arc[j][i] =G->arc[i][j];
}
}
}
/* Floyd算法,求网图G中各顶点v到其余顶点w的最短路径P[v][w]及带权长度D[v][w]。 */
void ShortestPath_Floyd(MGraph G, Patharc *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.arc[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的顶点 */
}
}
}
}
}
int main(void)
{
int v,w,k;
MGraph G;
Patharc P;
ShortPathTable D; /* 求某点到其余各点的最短路径 */
CreateMGraph(&G);
ShortestPath_Floyd(G,&P,&D);
printf("各顶点间最短路径如下:\n");
for(v=0; v<G.numVertexes; ++v)
{
for(w=v+1; 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");
}
printf("最短路径D\n");
for(v=0; v<G.numVertexes; ++v)
{
for(w=0; w<G.numVertexes; ++w)
{
printf("%d\t",D[v][w]);
}
printf("\n");
}
printf("最短路径P\n");
for(v=0; v<G.numVertexes; ++v)
{
for(w=0; w<G.numVertexes; ++w)
{
printf("%d ",P[v][w]);
}
printf("\n");
}
return 0;
}
1 程序开始运行,第4~11行就是初始化了D和P,使得它们成为图7-7-13的两个矩阵。从矩阵也得到,v0→v1路径权值为1,v0→v2路径权值为5,vo→v3无边连线,所以路径权值为65535无穷大。
2 第12~25行,是算法的主循环,一共三层嵌套,k代表的是中转顶点的下标。v代表起始顶点,w代表结束顶点.
3 当k=0时,也就是所有顶点都经过v0中转,计算是否有最短路径的变化。可惜结果是,没有任何变化。如图7-7-14所示。
4 当k=1时,也就是所有顶点都经过v1中转。此时,当v=0时,原本D[0][2]=5,现在由于D[0][1]+D[1][2]=4,因此由代码的第20行,二者取其最小者,得到D[0][2]=4.同理可得D[0][3]=8,D[0][4]=6,当v=2,3,4时,也修改了一些数据,请参考如图7-7-15左图中虚线框数据。由于这些小权值的修正,所以在路径矩阵P上,也要做处理,将它们都改为当前的P[v][k]值,见代码第21行。
5 接下来就是k=2 一直到8结束,表示针对每个顶点做中转得到的计算结果,当然,我们也要清楚, D 0 D^{0} D0是以 D − 1 D^{-1} D−1为基础, D 1 D^{1} D1是以 D 0 D^{0} D0为基础,……, D 8 D^{8} D8是以 D 7 D^{7} D7为基础,最终当k=8时,两矩阵数据如下图所示。
至此,我们的最短路径就算是完成了,你可以看到矩阵第v0行的数值和迪杰斯特拉算法求得的D数值的数值完全相同的,都是{0,1,4,7,5,8,10,12,16}.而且这里是所有顶点到所有顶点的最短路径权值和都可以计算出来。
那么如何由P这个路径数组得到具体的最短路径呢? 以v0到v8为例,从图7-7-16的右图第v8列,P[0][8]=1,得到要经过顶点v1,然后将1取代0得到P[1][8]=2,说明要经过v2,然后将2取代1得到P[2][8]=4,说明要经过v4,然后将4取代2得到P[4][8]=3,说明要经过v3,……,这样很容易推导出最终的最短路径为: v0→v1→v2→v4→v3→v6→v7→v8.
再次回过头来看弗洛伊德算法,它的代码简洁到就是一个二重数组初始化加一个三重循环权值修正,就完成了所有顶点到所有顶点的最短路径计算。 如此简单的实现,真是巧妙至极,在我看来,这是非常漂亮的算法,很可惜弗洛伊德算法的时间复杂度是 O ( n 3 ) O(n^3) O(n3),如果你面临要求所有顶点到所有顶点的最短路径问题时,弗洛伊德算法应该是不错的选择。
另外,我们虽然对求最短路径的两个算法举例都是无向图,但它们对有向图依然有效,因为二者的差异仅仅是邻接矩阵是否对称而已。
7.8 拓扑排序
说了两个有环的图的应用,现在我们来谈谈无环的图的应用。无环,即图中没有回路。
7.8.1 拓扑排序介绍
我们会把施工过程、生产流程、软件开发、教学安排等都当成一个项目工程来对待,所有的工程都可分为若干个“活动”的子工程。
在一个表示工程的有向图中,用顶点表示活动,用弧表示活动之间的优先关系,这样的有向图为顶点表示活动的网,我们称为AOV网(Activity On Vertex Network)。AOV网中的弧表示活动之间存在的某种制约关系。比如演职人员确定了,场地也联系好了,才可以开始进场拍摄。另外就是AOV图中不能存在回路。
设 G = ( V , { E } ) G=(V,\{E\}) G=(V,{E})是一个具有n个顶点的有向图,V中的顶点序列 v 1 , v 2 , . . . , , v n v_1,v_2,...,,v_n v1,v2,...,,vn,满足若从顶点 v i → v j v_i→v_j vi→vj有一条路径,则在顶点序列中顶点 v i v_i vi必在顶点 v j v_j vj之前。则我们称这样的顶点序列为一个拓扑序列。
下图中有很多条拓扑序列,顺着箭头方向的都是拓扑序列。
所谓拓扑排序,其实就是对一个有向图构造拓扑序列的过程。构造时会有两个结果,如果此网的全部顶点都被输出,则说明它是不存在环的AOV网;如果输出顶点数少了,哪怕是少了一个,也说明这个网存在环,不是AOV网。
一个不存在回路的AOV网,我们可以将它应用在各种各样的工程或项目的流程图中,满足各种场景的需求,所以实现拓扑排序的算法就很有价值了。
7.8.2 拓扑排序算法
对AOV网进行拓扑排序的基本思路是:从AOV网中选择一个入度为0的顶点输出,然后删去此顶点,并删除以此顶点为尾的弧,继续重复此步骤,直到输出全部顶点或者AOV网中不存在入度为0的顶点为止。
首先我们需要确定一下这个图需要使用的数据结构。前面求最小生成树和最短路径时,我们用的都是邻接矩阵,但由于拓扑排序的过程中,需要删除顶点,显然用邻接表会更加方便。因此我们需要为AOV网建立一个邻接表。考虑到算法过程中始终要查找入度为0的顶点,我们在原来顶点表结构中,增加一个入度域in,结构如表7-8-1所示,其中in就是入度的数字。
因此对于图7-8-2的第一幅图AOV图,我们可以得到如第二幅图的邻接表数据结构。
在拓扑排序算法中,涉及的结构代码如下。
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的顶点。
现在来看代码,并且模拟运行它。
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].firsredge;e;e=e->next){//对此顶点弧表进行遍历
k=e->adjvex;
if ( !(--GL->adjList[k].in)) //将k号顶点邻接点的入度-1
stack[++top]=k;//若为0则入栈,以便于下次循环输出
}
}
if( count< GL->numVerteses) //如果count小于顶点数,说明存在环
return ERROR;
else return OK;
}
1 程序开始运行,第3~7行都是变量的定义,其中stack为一个栈,用来存储整型的数字。
2 第8~10行,做了一个循环判断,把入度为0的顶点下标都入栈,从图7-8-3的右图邻接表可知,此时stack应为{0,1,3},即v0、v1、v3的顶点入度为0,如下图所示。
3 第12~23行,while循环,当栈中有数据元素时,始终循环。
4 第14~16行,v3出栈得到gettop=3,并打印此顶点,然后count+1
5 . 第17~22行,循环其实是对v3顶点对应的弧链表进行遍历,即图7-8-4中的灰色部分,找到v3连接的两个顶点v2和v13,并将它们的入度减少1,此时v2和v13的in值都为1.它的目的是为了将v3顶点上的弧删除。
6 再次循环,第12~23行。此时处理的是顶点v1.经过出栈、打印,count=2后,我们对v1到v2,v4,v8的弧进行了遍历。并同样减少了它们的入度数,此时v2的入度为0,于是由第20 ~21行可知,v2入栈,如图7-8-5所示。试想,如果没有在顶点表中加入in这个入度数据域,20行的判断就必须要循环,这显然是要耗费时间的,我们利用空间换取了时间。
7 接下来,就是同样的处理方式了。图7-8-6展示了v2、v6、v0、v4、v5、v8的打印删除过程,后面剩下的几个结点都类似。就不图示了。
8最终拓扑排序打印结果为:3→1→2→6→0→4→5→8→7→12→9→10→13→11.当然这结果并不是唯一的一种拓扑排序方案。
分析整个算法,对一个具有n个顶点e条弧的AOV网来说,第8~10行扫描顶点表,将入度为0的顶点入栈的时间复杂度为O(n),而之后的while循环中,每个顶点进栈一次、出栈一次,入度减1的操作共执行了e次,所以整个算法的时间复杂度为O(n+e).
7.9 关键路径
拓扑排序主要是为了解决一个工程能否顺利进行的问题,但有时我们还需要解决工程完成需要的最短时间问题。比如说,造一辆车,我们需要先造各种零件、部件,最终在组装成车,如图7-9-1所示。这些零部件基本都是在流水线上同时生产的,假如造一个轮子需要0.5天,造一个发动机需要3天,造一个底盘需要2天时间,其他零部件时间需要2天,全部零件集中到一处需要1天,组装成车需要2天时间,请问,在汽车厂造一辆车,最短需要多少时间呢?
有人说时间就是全部加起来,这当然是不对的。我已经说了前提,这些零部件都是分别在流水线上同时生产的,也就是说在完成一些任务的同时并行完成另一任务。
因此,我们如果要对一个流程图获得最短时间,就必须分析它们的拓扑关系,并且找到当中最关键的流程,这个流程的时间就是最短时间。
因此在前面讲了AOV网的基础上,我们来介绍一个新的概念。在一个表示工程的带权有向图中,用顶点表示事件,用有向边表示活动,用边上权值表示活动的持续时间,这种有向图的边表示活动的网,我们称之为AOE网(Activity On Edge Network)。我们把AOE网中没有入边的顶点称为始点或源点,没有出边的顶点称为终点或汇点。由于一个工程,总有一个开始,一个结束,所以正常情况下,AOE网只有一个源点一个汇点。例如图7-9-2就是一个AOE网。其中v0即是源点,表示一个工程的开始,v9是汇点,表示整个工程的结束,顶点 v 0 , v 1 , . . . , v 9 v_0,v_1,...,v_9 v0,v1,...,v9分别表示事件,弧 < v 0 , v 1 > , < v 0 , v 2 > , . . . < v 8 , v 9 > <v_0,v_1>,<v_0,v_2>,...<v_8,v_9> <v0,v1>,<v0,v2>,...<v8,v9>都表示一个活动,用 a 0 , a 1 , . . . , a 12 a_0,a_1,...,a_{12} a0,a1,...,a12表示,它们的值代表着活动持续的时间,比如弧$<v_0,v_1>,就是从源点开始的第一个活动a0,它的时间是3个单位。
既然AOE图是表示工作流程的,所以它就具有明显的工程特性。如有在某顶点所代表的事件发生后,从该顶点出发的各活动才能开始。只有在进入某顶点的各活动都已经结束,该顶点所代表的事件才能发生。
尽管AOE网与AOV网都是用来对工程建模的,但它们还是由很大的不同,主要体现在AOV网是顶点表示活动的网,它只描述活动之间的制约关系,而AOE网是用边表示活动的网,边上的权值表示活动持续的时间,如图7-9-3所示两图的对比。因此,AOE网是建立在活动之间制约关系没有矛盾的基础上之上,再来分析完成整个工程至少需要多少时间,或者为缩短完成工程所需时间,应当加快哪些活动等问题。
我们把路径上各个活动所持续的时间之和称为路径长度,从源点到汇点具有最大长度的路径叫做关键路径,在关键路径上的活动叫关键活动。 显然就图7-9-3的AOE网而言,开始→发动机完成→部件集中到位→组装完成就是关键路径,路径长度为5.5.
如果我们要缩短整个工期,去改进轮子的生产效率,哪怕改动成0.1也是无益于整个工期的变化,只有缩短关键路径上的关键活动时间才可以减少整个工期长度。例如如果发动机制造缩短为2.5,整车组装缩短为1.5,那么关键路径长度就是4.5.整整缩短了一天的时间。
那么现在的问题就是如何找出关键路径。对人来说,图7-9-3第二幅这样的AOE网,应该比较容易得出关键路径,而对于图7-9-2的AOE图,就相对麻烦一些,如果继续复杂下去,可能就非人脑该去做的事情了。
7.9.1 关键路径算法原理
为了讲清楚关键路径的算法,我还是来举个例子。假设一个学生放学回家,除掉吃饭、洗漱外,到睡觉前有4个小时空闲,而家庭作业需要两个小时完成。不同的同学会有不同的做法,抓紧的学生,会在头两个小时就完成作业,然后看看电视、读读课外书;但也有超过一半的学生会在最后两个小时才去做作业,要不是因为没时间,可能还要拖延下去。下面的同学不要笑,说的就是你,你们是不是有过暑假两个月,要到最后几天才赶去写作业?
这里做家庭作业这一活动的最早开始时间是4小时的开始,可以理解为0,而最晚开始时间是两个小时之后马上开始,不可以再晚,否则就是延迟了,此时可以理解为2.显然,当最早和最晚开始时间不相等时就意味着有空闲。
接着,你老妈发现了你拖延的小秘密,于是买了很多的课外习题,要求你在4个小时的时间内,不许有一丝空闲,省得你拖延或偷懒。此时整个四小时全部被占满,最早开始时间和最晚开始时间都是0,因此它就是关键活动了。
也就是说,我们只需要找到所有活动的最早开始时间和最晚开始时间,并且比较它们,如果相等就意味着此活动是关键活动,活动间的路径为关键路径。如果不等,则就不是。
为此,我们需要定义如下几个参数。
1 事件的最早发生时间etv(earliest time of vertex):即顶点vk的最早发生时间。
补充:事件的最早发生时间 ETV(earliest time of vertex):可以等价理解为旧活动的最早结束时间 或 新活动的最早开始时间
2 事件的最晚发生时间ltv(Latest time of vertex):即顶点vk的最晚发生时间,也就是每个顶点对应的事件最晚需要开始的时间,超出此时间将会延误整个工期。
补充:事件的最晚发生时间 LTV(latest time of vertex):可以等价理解为旧活动的最晚结束时间 或 新活动的最晚开始时间
3 活动的最早开工时间ete(earliest time of edge):即弧ak的最早发生时间
补充:活动的最早开始时间 ETE(earliest time of edge):所有前导活动都完成,可以开始的时间。
4 活动的最晚开工时间lte(latest time of edge):即弧ak的最晚发生时间,也就是不推迟工期的最晚开工时间。
补充:活动的最晚开始时间 LTE(latest time of edge):不推迟工期的最晚开工时间
我们是由1和2可以求得3和4,然后再根据ete[k] 是否与let[k] 相等来判断ak是否是关键活动。
7.9.2 关键路径算法
我们将图7-9-2的AOE网转化为邻接表结构如图7-9-4所示,注意与拓扑排序时邻接表结构不同的地方在于,这里弧链表增加了weight域,用来存储弧的权值。
求事件的最早发生时间etv的过程,就是我们从头至尾找拓扑序列的过程,因此,在求关键路径之前,需要先调用一次拓扑序列算法的代码来计算etv和拓扑序列列表。为此,我们首先在程序开始处声明几个全局变量。
int *etv,*ltv;//事件最早发生时间和最晚发生时间
int *stack2;//用于存储拓扑序列的栈
int top2;//用于stack2的指针
其中stack2用来存储拓扑序列,以便后面求关键路径时使用。
下面是改进过的求拓扑序列算法。
/* 拓扑排序 */
Status TopologicalSort(GraphAdjList GL)
{ /* 若GL无回路,则输出拓扑排序序列并返回1,若有回路返回0。 */
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) /* 将入度为0的顶点入栈 */
stack[++top]=i;
top2=0;
etv=(int *)malloc(GL->numVertexes * sizeof(int) ); /* 事件最早发生时间数组 */
for(i=0; i<GL->numVertexes; i++)
etv[i]=0; /* 初始化 */
stack2=(int *)malloc(GL->numVertexes * sizeof(int) );/* 初始化拓扑序列栈 */
printf("TopologicalSort:\t");
while(top!=0)
{
gettop=stack[top--]; //出栈
printf("%d -> ",GL->adjList[gettop].data);
count++; /* 输出i号顶点,并计数 */
stack2[++top2]=gettop; /* 将弹出的顶点序号压入拓扑序列的栈 */
for(e = GL->adjList[gettop].firstedge; e; e = e->next)
{
k=e->adjvex;
if( !(--GL->adjList[k].in) ) /* 将i号顶点的邻接点的入度减1,如果减1后为0,则入栈 */
stack[++top]=k;
if((etv[gettop] + e->weight)>etv[k]) /* 求各顶点事件的最早发生时间etv值 */
etv[k] = etv[gettop] + e->weight;
}
}
printf("\n");
if(count < GL->numVertexes)
return ERROR;
else
return OK;
}
代码中,相较于前面的拓扑排序算法稍加修改。
第11 ~ 15行行为初始化全局变量etv数组、top2和stack2的过程。第21行就是将本是要输出的拓扑序列压入全局栈stack2中。第27~28行很关键,它是求etv数组的每一个元素的值。比如说,加入我们已经求得顶点v0对应的etv[0]=0,顶点v1对应的tvt[1]=3,顶点v2对应的etv[2]=4,现在我们需要求顶点v3对应的etv[3],其实就是求etv[1]+len<v1,v3>与 etv[2]+len<v2,v3> 的较大值。显然是3+5< 4+8,得到 etv[3]=12,如图7-9-5所示。在代码中e->weight 就是当前弧的长度。
问题:在AOE图中事件的最早开始时间为什么取最大值?
这里为什么是最大值呢?是因为《大话数据结构》这本书中对事件的最早开始时间的定义不清晰!!!
这里笔者做一下说明?
事件的最早开始时间,需要活动的最早开始时间的支持,而活动的最早时间指的是前驱事件都完成需要的时间!!!只有活动(弧)开始了,事件(顶点)才有可能开始!!! 所以,这里活动的最早开始时间取得是etv[1]+len<v1,v3>与 etv[2]+len<v2,v3> 的较大值
由此我们也可以得出计算顶点vk最早发生时间的公式是
其中P[k]表示所有到达顶点vk的弧的集合。比如图7-9-5的P[3]就是<v1,v3>和<v2,v3>两条弧。 len<vi,vk>是弧<vi,vk>上的权值。
下面来看求关键路径的算法代码
void CriticalPath(GraphAdjList GL){
EdgeNode *e;
int i,gettop,k,j;
int ete,lte; //声明活动最早发生时间和最晚发生时间变量
TopoligicalSort(GL);//求拓扑序列,计算数组etv(事件最早发生时间)和stack2的值。
ltv=(int *) malloc(GL->numVertexes*sizeof(int)); //事件最晚发生时间
for(i=0;i<GL->numVertexes;i++)
ltv[i]=etv[GL->numVertexes-1];//初始化ltv
while(top2!=0){
gettop=stack2[top2--];//将拓扑序列出栈,后进先出
for(e=GL->adjList[gettop].firstedge;e;e=e->next){
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 \n",GL->adjList[j].data,GL->adjList[k].data,e->weight);
}
}
}
测试结果
TopologicalSort: 0 -> 1 -> 2 -> 3 -> 4 -> 6 -> 5 -> 7 -> 8 -> 9
etv: 0 -> 3 -> 4 -> 12 -> 15 -> 11 -> 24 -> 19 -> 24 -> 27
ltv: 0 -> 7 -> 4 -> 12 -> 15 -> 13 -> 25 -> 19 -> 24 -> 27
<v0 - v2> length: 4
<v2 - v3> length: 8
<v3 - v4> length: 3
<v4 - v7> length: 4
<v7 - v8> length: 5
<v8 - v9> length: 3
1 程序开始执行。第5行,声明了ete和lte两个活动最早最晚发生时间变量。
2 第6行,调用求拓扑序列的函数。执行完毕后,全局变量数组etv和栈stack的值如图所示,top2=10.也就是说,对于每个事件的最早发生时间,我们已经计算出来了。
3 第7~9行为初始化全局变量ltv数组,因为etv[9]=27,所以lev数组当前值为{27,27,27,27,27,27,27,27,27,27}.
4 第10~19行为计算ltv的循环。第12行,先将stack2的栈头出栈,由后进先出得到gettop=9。根据邻接表,v9没有弧表,所以13 ~18行循环体未执行。
5 再次来到第12行,gettop=8,在第13~18行的循环中,v8的弧表只有一条<v8,v9> ,e=9,所以第15行k=9,因为ltv[9 ] - 3 < ltv[8] ,所以 ltv[8] = ltv[9]-3=27-3=24. 如图7-9-7所示。
6 再次循环,当gettop=7,5,6时,同理可算出ltv相对应的值为19,25,13. 此时ltv数组值为{27,27,27,27,27,13,25,19,24,27}.
7 当 gettop=4时,由邻接表可以得到v4有两条弧 <v4,v6>,<v4,v7>,通过第13~18行的循环,可以得到 ltv[4]=min( ltv[7]-4,ltv[6]-9)=15, 如下图所示。
此时,你应该发现,我们在计算ltv时,其实是把拓扑序列倒过来进行的。因此我们可以得到计算顶点vk的最晚发生时间的公式(也就是求ltv[k]):
其中S[k] 表示所有从顶点vk出发的弧的集合。比如图7-9-8的S[4]就是 <v4,v6>和<v4,v7>两条弧,len<vk,vj>是弧<vk,vj>上的权值。
就这样,当程序执行到第20行的时候,相关变量(etv表示事件的最早发生时间,ltv表示事件的最晚发生时间)的值如图7-9-9所示。
比如 etv[1]=3,而ltv[1]=7,表示的意思是如果时间单位是天的话,哪怕v1这个事件在第7天才开始,也可以保证整个工程的按期完成,你可以提前v1事件开始时间,但你最早也只能在第3天开始。跟我们前面举的例子,实现完成作业再玩还是先玩最后完成作业一个道理。
8 第20~31行是来求另两个变量活动最早开始时间ete和活动最晚开始时间lte,并对相同下标进行比较。两重循环嵌套是对邻接表的顶点和每个顶点的弧表遍历。
9 当j=0时,从v0点开始,有<v0,v2> ,<v0,v1>两条弧。
当e=2,即k=2时:
ete=etv[j]=etv[0]=0,;//活动最早发生时间
lte=ltv[k]-e->weight=ltv[2]-len<v0,v2>=4-4=0;//活动最迟发生时间
此时 ete=lte,表示弧<v0,v2>是关键活动,因此打印。
当e=1,即k=1时:
ete=etv[j]=etv[0]=0,;//活动最早发生时间
lte=ltv[k]-e->weight=ltv[1]-len<v0,v1>=7-3=4;//活动最迟发生时间
此时 ete≠lte,表示弧<v0,v1>不是关键活动,如下图。
这里需要解释一下,ete本来是表示活动<vk,vj>的最早开工时间,是针对弧来说的。但只有此弧的弧尾(在前面的是弧尾)顶点vk的事件发生了,它才可以开始,因此ete=etv[k].
而lte表示的是活动<vk,vj>的最晚开工时间,但此活动再晚也不能等vj事件发生才开始,而必须要在vj事件发生之前发生,所以lte=ltv[j]-len<vk,vj>
,就像你晚上23点睡觉,你不能说到23点才开始做作业,而必须要提前两个小时,在21点开始,才有可能按时完成作业。
所以,最终,其实就是判断ete和lte是否相等,相等意味着活动没有任何空闲,是关键活动,否则就不是了。
10 j=1 一直到j=9 为止,做法是完全相同的,关键路径打印结果为
<v0 - v2> length: 4
<v2 - v3> length: 8
<v3 - v4> length: 3
<v4 - v7> length: 4
<v7 - v8> length: 5
<v8 - v9> length: 3
最终关键路径如图所示。
分析整个求关键路径的算法,时间复杂度为O(n+e).
7.10 总结回顾
图的存储结构我们一共讲了5种,如下图。其中比较重要的是邻接矩阵和邻接表,它们分别代表着边集是用数组还是用链表的方式存储。 十字链表是邻接矩阵的一种升级,而邻接多重表则是邻接表的升级。边集数组更多考虑的是对边的关注。 用什么存储结构需要具体问题具体分析,通常稠密图、存取数据较多结构修改较少的图,用邻接矩阵更为合适,反之则应考虑邻接表。
图的遍历有DFS和BFS两种,各有优缺点。
图的应用我们讲了三种:最小生成树、最短路径和有向无环图。
最小生成树,我们讲了两种算法:Prim和Kruskal算法。普利姆算法像是走一步看一步的思维方式,逐步生成最小生成树。而克鲁斯卡尔算法则更有全局意识,直接从图中最短权值的边入手,找寻最后答案。
最短路径的现实应用非常多,我们也介绍了两种算法。迪杰斯特拉算法更强调单源顶点查找路径的方法,而弗洛伊德算法完全抛开了单点的局限性思维,巧妙地应用矩阵的变换,用最清爽的代码实现了多顶点间最短路径求解的方案。
有向无环图时常应用于工程规划中,对于整个工程或系统来说,我们一方面关心的是工程能否顺利进行的问题。通过拓扑排序的方式,我们以可以有效地分析出一个有向图是否存在环,如果不存在,那它的拓扑序列是什么?另一方面关心的是整个工程完成所必须的最短时间问题,利用求关键路径的算法,可以得到最短完成工程的工期以及关键的活动有哪些。
至此,这一章的内容学习完成。