图(Graph)是一种较线性表和树更为复杂的数据结构。在线性表中,数据元素之间仅有线性关系,每个数据元素只有一个直接前驱和一个直接后继;在树形结构中,数据元素之间有着明显的层次关系,并且每一层上的数据元素可能和下一层中多个元素(即其孩子结点)相关,但只能和上一层中一个元素(即其双亲结点)相关;
而在图形结构中,结点之间的关系可以是任意的,图中任意两个数据元素之间都可能相关。图的应用极为广泛。
对于“图论”的具体问题,我们在“离散数学”课程中可以详细学习,此处,我们仅简单对图论的知识作一部分使用,而我们主要学习的是图在计算机上的存储结构,以及如何实现图的操作等问题。
在图中的数据元素通常称作顶点( V e r t e x Vertex Vertex), V V V是顶点的有穷非空集合; V R VR VR是两个顶点之间的关系的结合。
【注意】线性表可以是空表,树可以是空树,但图不可以是空图。图中不能一个顶点也没有,图的顶点集 V V V一定非空,但两个顶点之间关系的集合 V R VR VR可以为空,此时图中只有顶点,没有顶点之间的关系。
ADT Graph {
数据对象V:V是具有相同特性的数据元素的集合,称为顶点集。
数据关系R:
R = {VR}
VR = {<v, w> | v,w∈V且P(v,w);<v, w>表示从v到w的弧,谓词P(v,w)定义了弧<v, w>的意义或信息}
基本操作P:
CreateGraph(&G, V, VR):
初始条件:V是图的顶点集,VR是图中弧的集合。
操作结果:按V和VR的定义构造图G。
DestroyGraph(&G):
初始条件:图G存在。
操作结果:销毁图G。
LocateVex(G, u):
初始条件:图G存在,u和G中顶点有相同特征。
操作结果:若G中存在顶点u,则返回该顶点在图中的位置,否则返回其他信息。
GetVex(G, v):
初始条件:图G存在,v是G中某个顶点。
操作结果:返回v的值。
PutVex(&G, v, value):
初始条件:图G存在,v是G中某个顶点。
操作结果:对v赋值value。
FirstAdjVex(G, v):
初始条件:图G存在,v是G中某个顶点。
操作结果:返回v的第一个邻接顶点。若顶点v在G中没有邻接顶点,则返回“空”。
NextAdjVex(G, v, w):
初始条件:图G存在,v是G中某个顶点,w是v的邻接顶点。
操作结果:返回v的(相对于w的)下一个邻接顶点。若w是v的最后一个邻接点,则返回“空”。
InsertVex(&G, v):
初始条件:图G存在,v和图中顶点有相同特征。
操作结果:在图G中增添新顶点v。
DeleteVex(&G, v):
初始条件:图G存在,v是G中某个顶点。
操作结果:删除G中顶点v及其相关的弧。
InsertArc(&G, v, w):
初始条件:图G存在,v和w是G中两个顶点。
操作结果:在G中增添弧<v, w>,若G是无向的,则还增添对称弧<w, v>。
DeleteArc(&G, v, w):
初始条件:图G存在,v和w是G中两个顶点。
操作结果:在G中删除弧<v, w>,若G是无向的,则还删除对称弧<w, v>。
DFSTraverse(G, visit()):
初始条件:图G存在,Visit是顶点的应用函数。
操作结果:对图进行深度优先遍历。在遍历过程中对每个顶点调用函数Visit一次且仅一次。一旦visit()失败,则操作失败。
BFSTraverse(G, Visit()):
初始条件:图G存在,Visit是顶点的应用函数。
操作结果:对图进行广度优先遍历。在遍历过程中对每个顶点调用函数Visit一次且仅一次。一旦visit()失败,则操作失败。
}ADT Graph
在图中的数据元素通常称作顶点( V e r t e x Vertex Vertex), V V V是顶点的有穷非空集合; V R VR VR是两个顶点之间的关系的结合。
若 < v , w > ∈ V R
若 < v , w > ∈ V R
例如上图(a)中 G 1 G_1 G1是有向图,定义此图的谓词 P ( v , w ) P(v,w) P(v,w)则表示从 v v v到 w w w的一条单向通路。
G 1 = ( V 1 , { A 1 } ) 其中 : V 1 = { v 1 , v 2 , v 3 , v 4 } , A 1 = { < v 1 , v 2 > , < v 1 , v 3 > , < v 3 , v 4 > , < v 4 , v 1 > } G_1=(V_1,\{A_1\})\\ 其中:V_1=\{v_1,v_2,v_3,v_4\},\\ A_1=\{
上图(b)中 G 2 G_2 G2为无向图。
G 2 = ( V 2 , { E 2 } ) 其中 : V 2 = { v 1 , v 2 , v 3 , v 4 , v 5 } , E 2 = { ( v 1 , v 2 ) , ( v 1 , v 4 ) , ( v 2 , v 3 ) , ( v 2 , v 5 ) , ( v 3 , v 4 ) , ( v 3 , v 5 ) } G_2=(V_2,\{E_2\})\\ 其中:V_2=\{v_1,v_2,v_3,v_4,v_5\},\\ E_2=\{(v_1,v_2),(v_1,v_4),(v_2,v_3),(v_2,v_5),(v_3,v_4),(v_3,v_5)\} G2=(V2,{E2})其中:V2={v1,v2,v3,v4,v5},E2={(v1,v2),(v1,v4),(v2,v3),(v2,v5),(v3,v4),(v3,v5)}
我们用 n n n表示图中顶点数目,用 e e e表示边或弧的数目。在下面的讨论中,我们不考虑顶点到其自身的弧或边,即若 < v i , v j > ∈ V R
那么,对于无向图, e e e的取值范围是 0 到 n ( n − 1 ) 2 0到\frac{n(n-1)}{2} 0到2n(n−1)。有 n ( n − 1 ) 2 \frac{n(n-1)}{2} 2n(n−1)条边的无向图称为完全图(Completed graph)。
对于有向图, e e e的取值范围是 0 到 n ( n − 1 ) 0到n(n-1) 0到n(n−1)。有 n ( n − 1 ) n(n-1) n(n−1)条弧的有向图称为有向完全图。
有很少条边或弧(如: e < n l o g n e
有时图的边或弧具有与它相关的数,这种与图的边或弧相关的数叫做权(Weight)。这些权可以表示从一个顶点到另一个顶点的距离或耗费。
这种带权的图通常称为网(Network)。
假设有两个图 G = ( V , { E } ) G=(V,\{E\}) G=(V,{E})和 G ′ = ( V ′ , { E ′ } ) G'=(V',\{E'\}) G′=(V′,{E′}),如果 V ′ ⊆ V V'\subseteq V V′⊆V且 E ′ ⊆ E E'\subseteq E E′⊆E,则称 G ′ G' G′为 G G G的子图(Subgraph)。
若对于子图 G ′ G' G′,满足 V ′ = V V'=V V′=V,则称其为 G G G的生成子图。
注意:并非 V V V和 E E E的任何子集都能构成 G G G的子图,因为你 V ′ V' V′中的某些顶点可能不存在了,无法保证 E ′ E' E′中的边都存在。
图7.2为前文图7.1中 G 1 、 G 2 G_1、G_2 G1、G2子图的例子。
无向图顶点的度
对于无向图 G = ( V , { E } ) G=(V,\{E\}) G=(V,{E}),如果边 ( v , v ′ ) ∈ E (v,v')∈E (v,v′)∈E,则称顶点 v v v和 v ′ v' v′互为邻接点(Adjacent),即 v v v和 v ′ v' v′相邻接。边 ( v , v ′ ) (v,v') (v,v′)依附(Incident)于顶点 v v v和 v ′ v' v′,或者说 ( v , v ′ ) (v,v') (v,v′)和顶点 v v v和 v ′ v' v′相关联。顶点 v v v的度(Degree)是和 v v v相关联的边的数目,记为 T D ( V ) TD(V) TD(V)。
例如 G 2 G_2 G2中顶点 v 3 v_3 v3的度是3。
有向图顶点的入度、出度、度
对于有向图 G = ( V , { A } ) G=(V,\{A\}) G=(V,{A}),如果弧 < v , v ′ > ∈ A
例如,图 G 1 G_1 G1中顶点 v 1 v_1 v1的入度 I D ( v 1 ) = 1 ID(v_1)=1 ID(v1)=1,出度 O D ( v 1 ) = 2 OD(v_1)=2 OD(v1)=2,度 T D ( v 1 ) = I D ( V 1 ) + O D ( v 1 ) = 3 TD(v_1)=ID(V_1)+OD(v_1)=3 TD(v1)=ID(V1)+OD(v1)=3。
度与边/弧数目的关系
一般地,如果顶点 v i v_i vi的度记为 T D ( v i ) TD(v_i) TD(vi),那么一个有 n n n个顶点, e e e条边或弧的图,满足如下关系
e = 1 2 ∑ i = 1 n T D ( v i ) e=\frac{1}{2}\sum_{i=1}^nTD(v_i) e=21i=1∑nTD(vi)
可见,对于具有 n n n个顶点、 e e e条边的无向图, ∑ i = 1 n T D ( v i ) = 2 e \sum_{i=1}^nTD(v_i)=2e ∑i=1nTD(vi)=2e,即无向图的全部顶点的度之和等于边数的2倍,因为每条边和两个顶点相关联。
对于具有 n n n个顶点、 e e e条边的有向图 ∑ i = 1 n I D ( v i ) = ∑ i = 1 n O D ( v i ) = e \sum_{i=1}^nID(v_i)=\sum_{i=1}^nOD(v_i)=e ∑i=1nID(vi)=∑i=1nOD(vi)=e,即有向图的全部顶点的入度之和与出度之和相等,并且等于弧数,这是因为每条弧都有一个起点和一个终点。(每一条弧都必然会“贡献”1入度、1出度。)
无向图的路径
无向图 G = ( V , { E } ) G=(V,\{E\}) G=(V,{E})中从顶点 v v v到顶点 v ′ 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′),其中 ( v i , j − 1 , v i , j ) ∈ E , 1 ≤ j ≤ m (v_{i,j-1},v_{i,j})∈E,1≤j≤m (vi,j−1,vi,j)∈E,1≤j≤m。
有向图的路径
如果 G G G是有向图,则路径也是有向的,顶点序列应满足 < v i , j − 1 , v i , j > ∈ E , 1 ≤ j ≤ m
路径长度
路径的长度是路径上的边或弧的数目。
简单路径
序列中顶点不重复出现的路径称为简单路径。
回路/环
第一个顶点和最后一个顶点相同的路径称为回路或环。
简单回路/简单环
除了第一个顶点和最后一个顶点之外,其余顶点不重复出现的回路,称为简单回路或简单环。
距离
从顶点 u u u出发到顶点 v v v的最短路径若存在,则此路径的长度称为从 u u u到 v v v的距离。
若从 u u u到 v v v根本不存在路径,则记该距离为无穷( ∞ ∞ ∞)。
连通
在无向图 G G G中,如果从顶点 v v v到顶点 v ′ v' v′有路径,则称 v v v和 v ′ v' v′是连通的。
连通图
如果对于该无向图中任意两个顶点 v i 、 v j ∈ V v_i、v_j∈V vi、vj∈V, v i v_i vi和 v j v_j vj都是连通的,则称 G G G是连通图(Connected Graph)。否则称为非连通图。
下图7.3(a)中的图 G 3 G_3 G3是一个非连通图。而前文所述(下方也有)中的图7.1(b)中的 G 2 G_2 G2则就是一个连通图。
连通分量
所谓连通分量(Connected Component),指的是无向图中的极大连通子图。
下图7.3(a)虽然是一个非连通图,但是 G 3 G_3 G3有 3 3 3个连通分量,如图7.3(b)所示。
强连通图
在有向图 G G G中,如果对于每一对 v i , v j ∈ V , v i ≠ v j v_i,v_j∈V,v_i≠v_j vi,vj∈V,vi=vj,从 v i v_i vi到 v j v_j vj和从 v j v_j vj到 v i v_i vi都存在路径,则称 G G G是强连通图。
强连通分量
有向图中的极大强连通子图称作有向图的强连通分量。
例如图7.1(a)中的 G 1 G_1 G1不是强连通图,但它有两个强连通分量,如图7.4所示。
生成树
一个连通图的生成树是一个极小连通子图,它含有图中全部顶点,但只有足以构成一棵树的 n − 1 n-1 n−1条边。
图7.3是图7.3中 G 3 G_3 G3中最大连通分量的一棵生成树。
如果在一棵生成树上添加一条边,必定构成一个环,因为这条边使得它依附的那两个顶点之间有了第二条路径;若砍去一棵生成树的一条边,则它会变成非连通图。
一棵有 n n n个顶点的生成树有且仅有 n − 1 n-1 n−1条边。如果一个图有 n n n个顶点和小于 n − 1 n-1 n−1条边,则是非连通图。如果它多于 n − 1 n-1 n−1条边,则一定有环。但是,有 n − 1 n-1 n−1条边的图不一定是生成树。
生成森林
如果一个有向图恰有一个顶点的入度为 0 0 0,其余顶点的入度均为 1 1 1,则是一棵有向树。
一个有向图的生成森林由若干棵有向树组成,含有图中全部顶点,但只有足以构成若干棵不相交的有向树的弧。
图7.6所示为其一例。
【注意】对于“极大连通子图”和“极小连通子图”理解到位、区分清楚。极大连通子图是无向图的连通分量,“极大”的意思即要求该连通子图包含其所有的边;而极小连通子图是一个连通图(连通图是仅对无向图而言的)的生成树,即既要保持图的连通,又要使得边数最少的子图。
在前述图的基本操作的定义中,关于“顶点的位置”和“邻接点的位置”只是一个相对的概念。因为,从图的逻辑结构的定义来看,图中的顶点之间不存在全序的关系,即无法将图中顶点排列成一个线性序列,任何一个顶点都可被看成是第一个顶点。另一方面,任一顶点的邻接点之间也不存在次序关系。
但为了操作方便,我们需要将图中顶点按任意的顺序排列起来(这个排列关系和 V R VR VR无关)。由此,所谓“顶点在图中的位置”指的是该顶点在这个人为的随意排列中的位置(或序号)。同理,可对某个顶点的所有邻接点进行排队,在这个排队中自然形成了第一个或第 k k k个邻接点。若某个顶点的邻接点个数大于 k k k,则称第 k + 1 k+1 k+1个邻接点为第 k k k个邻接点的下一个邻接点,而最后一个邻接点的下一个邻接点为“空”。
图的存储必须要完整、准确地反映顶点集和边集的信息。根据不同图的结构和算法,采用不同的存储方式将对程序的效率产生相当大的影响,因此所选的存储结构应适合于待求解的问题。
用一个一维数组存储图中顶点的信息,用一个二维数组存储图中边的信息(即各顶点之间的邻接关系),存储顶点之间邻接关系的二维数组称为邻接矩阵。
/* ---- 图的数组(邻接矩阵)存储表示 ---- */
#define INFINITY INT_MAX //最大值∞
#define MAX_VERTEX_NUM 20 //最大顶点个数
typedef enum { DG,DN,UDG,UDN } GraphKind; //{有向图,有向网,无向图,无向网}
typedef struct ArcCell {
VRType adj; //VRType是顶点关系类型。对无权图,用1或0表示相邻否;对带权图,则为权值类型
InfoType *info; //该弧相关信息的指针
}ArcCell, AdjMatrix[MAX_VERTEX_NUM][MAX_VERTEX_NUM];
typedef struct {
VertexType vexs[MAX_VERTEX_NUM]; //顶点向量
AdjMatrix arcs; //邻接矩阵
int vexnum, arcnum; //图的当前顶点数和弧数
GraphKind kind; //图的种类标志
}MGraph;
以二维数组表示有 n n n个顶点的图时,需存放 n n n个顶点信息和 n 2 n^2 n2个弧信息的存储量。
图(无权图)的邻接矩阵可定义为:(包括有向图、无向图)
A [ i ] [ j ] = { 1 , 若 < v i , v j > 或 ( v i , v j ) ∈ V R 0 , 反之 A[i][j]= \begin{cases} 1,\quad若
若考虑无向图的邻接矩阵的对称性,则可采用压缩存储的方式只存入矩阵的下三角(或上三角)元素。
借助于邻接矩阵容易判定任意两个顶点之间是否有边(或弧)相连,并容易求得各个顶点的度。
对于无向图,顶点 v i v_i vi的度是邻接矩阵中第 i i i行(或第 i i i列)的元素之和,即
T D ( v i ) = ∑ j = 0 n − 1 A [ i ] [ j ] ( n = M A X _ V E R T E X _ N U M ) TD(v_i)=\sum_{j=0}^{n-1}A[i][j]\\ (n=MAX\_VERTEX\_NUM) TD(vi)=j=0∑n−1A[i][j](n=MAX_VERTEX_NUM)
对于有向图,第 i i i行的元素之和为顶点 v i v_i vi的出度 O D ( v i ) OD(v_i) OD(vi),第 j j j列的元素之和为顶点 v j v_j vj的入度 I D ( v j ) ID(v_j) ID(vj)。
网(带权图)的邻接矩阵可定义为:(包括有向带权图、无向带权图)
A [ i ] [ j ] = { w i , j , 若 < v i , v j > 或 ( v i , v j ) ∈ V R ∞ , 反之 A[i][j]= \begin{cases} w_{i,j},\quad若
邻接矩阵表示法的空间复杂度为 O ( n 2 ) O(n^2) O(n2),其中 n n n为图的顶点数 ∣ V ∣ |V| ∣V∣。
无向(带权)图的邻接矩阵一定是一个对称矩阵(并且唯一)。因此,在实际存储邻接矩阵时只需存储上(或下)三角矩阵的元素。
对于无向(带权)图,邻接矩阵的第 i i i行(或第 i i i列)非零元素(或非 ∞ ∞ ∞元素)的个数正好是顶点 i i i的度 T D ( v i ) TD(v_i) TD(vi)。
对于有向(带权)图,邻接矩阵的第 i i i行非零元素(或非 ∞ ∞ ∞元素)的个数正好是顶点 i i i的出度 O D ( v i ) OD(v_i) OD(vi);第 i i i列非零元素(或非 ∞ ∞ ∞元素)的个数正好是顶点 i i i的入度 I D ( v i ) ID(v_i) ID(vi)。
用邻接矩阵存储图,很容易确定图中任意两个顶点之间是否有边相连。但是,要确定图中有多少条边,则必须按行、按列对每个元素进行检测,所花费的时间代价很大。
稠密图适合使用邻接矩阵的存储表示。
设图 G G G的邻接矩阵为 A A A, A n A^n An的元素 A n [ i ] [ j ] A^n[i][j] An[i][j]等于由顶点 i i i到顶点 j j j的长度为 n n n的路径的数目。(这个结论应该是不看权值的了)
邻接表(Adjacency List)是图的一种链式存储结构。在邻接表中,对图的每个顶点建立一个单链表,第 i i i个单链表中的结点表示依附于顶点 v i v_i vi的边(对有向图是以顶点 v i v_i vi为尾的弧)。
**表结点(边表结点):**每个结点由 3 3 3个域组成,其中邻接点域( a d j v e x adjvex adjvex)指示与顶点 v i v_i vi邻接的点在图中的位置;链域( n e x t a r c nextarc nextarc)指示下一条边或弧的结点;数据域( i n f o info info)存储和边或弧相关的信息,如权值等。
**表头结点(顶点表结点):**每个链表上附设一个表头结点。在表头结点中,除了设有链域( f i r s t a r c firstarc firstarc)指向链表中第一个结点之外,还设有存储 v i v_i vi的名或其它有关信息的数据域( d a t a data data)。
这些表头结点通常以顺序结构的形式存储(也不是不可以链式存储),以便随机访问任一顶点的链表。
例如下图7.10(a)和(b)所示分别为图7.1中 G 1 G_1 G1和 G 2 G_2 G2的邻接表。
下图为王道书上的例子:
/* ---- 图的邻接表存储表示 ---- */
#define MAX_VERTEX_NUM 20
typedef struct ArcNode {
int adjvex; //该弧所指向的顶点的位置
struct ArcNode *nextarc; //指向下一条弧的指针
InfoType *info; //该弧相关信息的指针
}ArcNode;
typedef struct VNode {
VertexType data; //顶点信息
ArcNode *firstarc; //指向第一条依附该顶点的弧的指针
}VNode, AdjList[MAX_VERTEX_NUM];
typedef struct {
AdjList vertices; //图的邻接表
int vexnum, arcnum; //图的当前顶点数和弧数
int kind; //图的种类标志
}ALGraph;
在无向图的邻接表中,顶点 v i v_i vi的度恰为第 i i i个链表中的结点数;而在有向图中,第 i i i个链表中的结点个数只是顶点 v i v_i vi的出度,为求入度,必须遍历整个邻接表,在所有链表中其邻接点域的值为 i i i的结点个数是顶点 v i v_i vi的入度。
因此,有时为了便于确定顶点的入度或以顶点 v i v_i vi为头的弧,可以建立一个有向图的逆邻接表,即对每个顶点 v i v_i vi建立一个链接以 v i v_i vi为头的弧的表。
例如下图©所示为有向图 G 1 G_1 G1的逆邻接表。
图的基本操作是独立于图的存储结构的。而对于不同的存储方式,操作算法的具体实现会有着不同的性能。在设计具体算法的实现时,应考虑采用何种存储方式的算法效率会更高。
图的基本操作主要包括:
Adjacent(G, x, y):判断图G是否存在弧<x, y>或边(x, y)。
Neighbors(G, x):列出图G中与结点x邻接的边。
InsertVertex(G, x):在图G中插入顶点x。
DeleteVertex(G, x):从图G中删除顶点x。
AddEdge(G, x, y):若边(x, y)或弧<x, y>不存在,则向图G中添加之。
RemoveEdge(G, x, y):若边(x, y)或弧<x, y>存在,则从图G中删除之。
FirstNeighbor(G, x):求图G中顶点x的第一个邻接点,若有则返回顶点号。若x没有邻接点或图中不存在x,则返回-1。
NextNeighbor(G, x, y):假设图G中顶点y是顶点x的一个邻接点,返回除y外顶点x的下一个邻接点的顶点号,若y是x的最后一个邻接点,则返回-1。
Get_edge_value(G, x, y):获取图G中边(x, y)或弧<x, y>对应的权值。
Set_edge_value(G, x, y, v):设置图G中边(x, y)或弧<x, y>对应的权值为v。
此外,还有图的遍历算法,具体见下文内容。
和树的遍历类似,在此,我们希望从图中某一顶点出发访遍图中其余顶点,且使每一个顶点仅被访问一次。这一过程就叫做图的遍历(Traversing Graph)。
图的遍历算法是求解图的连通性问题、拓扑排序和求关键路径等算法的基础。
然而,图的遍历要比树的遍历复杂得多。因为图的任一顶点都可能和其余的顶点相邻接。所以在访问了某个顶点之后,可能沿着某条路径搜索之后,又回到该顶点上。
为了避免同一顶点被访问多次,在遍历图的过程中,必须记下每个已访问过的顶点。为此,我们可以设一个辅助数组 v i s i t e d [ 0.. n − 1 ] visited[0..n-1] visited[0..n−1],它的初始值置为“假”或者零,一旦访问了结点 v i v_i vi,便置 v i s i t e d [ i ] visited[i] visited[i]为“真”或者为被访问时的次序号。
通常有两条遍历图的路径:深度优先搜索和广度优先搜索。它们对无向图和有向图都适用。
深度优先搜索(Depth_First Search, D F S DFS DFS)遍历类似于树的先根遍历,是树的先根遍历的推广。
假设初始状态是图中所有顶点未曾被访问,则深度优先搜索可从图中某个顶点 v v v出发,访问此顶点,然后依次从 v v v的未被访问的邻接点出发深度优先遍历图,直至图中所有和 v v v有路径相通的顶点都被访问到;若此时图中尚有顶点未被访问,则另选图中一个未曾被访问的顶点作起始点,重复上述过程,直至图中所有顶点都被访问到为止。
以上图(a)中无向图 G 4 G_4 G4为例,深度优先搜索遍历图的过程如下图(b)所示。
【注】图中以带箭头的实线表示遍历时的访问路径,以带箭头的虚线表示回溯的路径。图中的小圆圈表示,在访问至这个结点时,它的所有邻接点中已经被访问过的邻接点。
对上图(a)做DFS过程的说明:假设从顶点 v 1 v_1 v1出发进行搜索,在访问了顶点 v 1 v_1 v1之后,选择邻接点 v 2 v_2 v2。因为 v 2 v_2 v2未曾访问,则从 v 2 v_2 v2出发进行搜索。以此类推,接着从 v 4 、 v 8 、 v 5 v_4、v_8、v_5 v4、v8、v5出发进行搜索。
在访问了 v 5 v_5 v5之后,由于 v 5 v_5 v5的邻接点都已被访问,则搜索回到 v 8 v_8 v8。由于同样的理由,搜索继续回到 v 4 、 v 2 v_4、v_2 v4、v2,直至 v 1 v_1 v1。
此时由于 v 1 v_1 v1的另一个邻接点未被访问,则搜索又从 v 1 v_1 v1到 v 3 v_3 v3,再继续进行下去。
由此,得到的顶点访问序列为:
v 1 → v 2 → v 4 → v 8 → v 5 → v 3 → v 6 → v 7 v_1→v_2→v_4→v_8→v_5→v_3→v_6→v_7 v1→v2→v4→v8→v5→v3→v6→v7
显然,这是一个递归的过程。
为了在遍历过程中便于区分顶点是否已被访问,需附设访问标志数组 v i s i t e d [ 0.. n − 1 ] visited[0..n-1] visited[0..n−1],其初值为"false",一旦某个顶点被访问,则其相应的分量置为"true"。
Boolean visited[MAX_VERTEX_NUM]; //访问标志数组
void DFS(Graph G, int v) { //从顶点v出发,深度优先遍历图G
visit(v); //访问顶点v
visited[v] = TRUE; //设已访问标记
for(w=FirstNeighbor(G,v); w>=0; w=NextNeighbor(G,v,w)) { //w≥0表示存在邻接点
if(!visited[w]) //w为v的尚未访问的邻接顶点
DFS(G,w); //对v的尚未访问的邻接顶点w递归调用DFS
}
}
void DFSTraverse(Graph G) {
for(v=0;v<G.vexnum;++v) //访问标志数组初始化
visited[v] = FALSE;
for(v=0;v<G.vexnum;++v) { //本代码中是从v=0开始遍历
if(!visited[v]) //对尚未访问的顶点调用DFS
DFS(G,v);
}
}
分析上述算法,在遍历图时,对图中每个顶点至多调用一次DFS函数,因为一旦某个顶点被标志成已被访问,就不再从它出发进行搜索。
因此,遍历图的过程实质上是对每个顶点查找其邻接点的过程。
其耗费的时间则取决于所采用的存储结构。
当用二维数组表示邻接矩阵作图的存储结构时,查找某一顶点的邻接点所需时间为 O ( ∣ V ∣ ) O(|V|) O(∣V∣),其中, ∣ V ∣ |V| ∣V∣为图中顶点数。因此,总的时间复杂度为 O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2)。
而当以邻接表作图的存储结构时,查找所有顶点的邻接点总共所需时间合计为 O ( ∣ E ∣ ) O(|E|) O(∣E∣),其中 ∣ E ∣ |E| ∣E∣为无向图中边的数或有向图中弧的数,而访问顶点总共所需时间合计为 O ( ∣ V ∣ ) O(|V|) O(∣V∣)。因此,总的时间复杂度是 O ( ∣ V ∣ + ∣ E ∣ ) O(|V|+|E|) O(∣V∣+∣E∣)。
DFS算法是一个递归算法,需要借助一个递归工作栈,故其空间复杂度为 O ( ∣ V ∣ ) O(|V|) O(∣V∣)。
广度优先搜索(Broadth_First Search, B F S BFS BFS)遍历类似于树的按层次遍历的过程。
假设从图中某顶点 v v v出发,在访问了 v v v之后依次访问 v v v的各个未曾访问过的邻接点,然后分别从这些邻接点出发依次访问它们的邻接点,并使“先被访问的顶点的邻接点”先于“后被访问的顶点的邻接点”被访问,直至图中所有已被访问的顶点的邻接点都被访问到。若此时图中尚有顶点未被访问,则另选图中一个未曾被访问的顶点作起始点,重复上述过程,直至图中所有顶点都被访问到为止。
换句话说,广度优先搜索遍历图的过程是以 v v v为起始点,由近至远,依次访问和 v v v有路径相通且路径长度为 1 , 2 , . . . 1,2,... 1,2,...的顶点。
例如,对上图(a)中图 G 4 G_4 G4进行广度优先搜索遍历的过程如下图©所示。
首先访问 v 1 v_1 v1,和 v 1 v_1 v1的邻接点 v 2 、 v 3 v_2、v_3 v2、v3;然后依次访问 v 2 v_2 v2的邻接点 v 4 、 v 5 v_4、v_5 v4、v5,与 v 3 v_3 v3的邻接点 v 6 、 v 7 v_6、v_7 v6、v7;最后访问 v 4 v_4 v4的邻接点 v 8 v_8 v8。由于这些顶点的邻接点均已被访问,并且图中所有顶点都被访问,由此完成了图的遍历。
得到的顶点访问序列为:
v 1 → v 2 → v 3 → v 4 → v 5 → v 6 → v 7 → v 8 v_1→v_2→v_3→v_4→v_5→v_6→v_7→v_8 v1→v2→v3→v4→v5→v6→v7→v8
和深度优先搜索类似,在遍历的过程中也需要设置一个访问标志数组。
并且,为了顺次访问路径长度为 2 、 3 、 . . . 2、3、... 2、3、...的顶点,需附设队列以存储已被访问的路径长度为 1 , 2 , . . . 1,2,... 1,2,...的顶点。
算法1,递归算法:
/* ---- 广度优先递归遍历图G ---- */
bool visited[MAX_VERTEX_NUM]; //访问标记数组
void BFS(Graph G, int v) { //从顶点v出发,广度优先遍历图G
visit(v); visited[v] = TRUE; //访问初始顶点v,并对v做已访问标记
EnQueue(Q, v); //顶点v入队
while(!isEmpty(Q)) {
DeQueue(Q, u); //队头元素出队并置为u
for(w=FirstNeighbor(G,u); w>=0; w=NextNeighbor(G,u,w)) { //检测其所有邻接点
if(!visited[w]) { //w为顶点u尚未访问的邻接点
visit(w); visited[w] = TRUE; //访问顶点w,并对w做已访问标记
EnQueue(Q, w); //顶点w入队列
}
}
}
}
void BFSTraverse(Graph G) { //对图G进行广度优先遍历
for(i=0; i<G.vexnum; ++i)
visited[i] = FALSE; //访问标记数组初始化
InitQueue(Q); //初始化辅助队列Q,初始为空
for(i=0; i<G.vexnum; ++i) { //从0号顶点开始遍历
if(!visited[i]) //对每个连通分量调用一次BFS
BFS(G, i); //Vi未访问过,从Vi开始BFS
}
}
算法2,非递归算法:
/* ---- 按广度优先非递归遍历图G。使用辅助队列Q和访问标志数组visited ---- */
void BFSTraverse(Graph G) {
for(v=0; v<G.vexnum; ++v)
visited[v] = FALSE; //访问标记数组初始化
InitQueue(Q); //初始化辅助队列Q,初始为空
for(v=0; v<G.vexnum; ++v) {
if(!visited[v]) { //v尚未访问
visited[v] = TRUE; visit(v); //访问v,并标记v已访问
EnQueue(Q, v); //v入队
while(!isEmpty(Q)) { //队列非空
DeQueue(Q, u); //队头元素出队并置为u
for(w=FirstAdjVex(G,u); w>=0; w=NextAdjVex(G,u,w)) {
if(!visited[w]) { //w为u的尚未访问的邻接点
visited[w] = TRUE; visit(w);
EnQueue(Q,w);
}//if
}//for
}//while
}//if
}//for
}//BFSTraverse
实际上上面两个算法没区别,算法2只是将算法1的两个函数合并整理了一下,形成一个大的完整函数。
不难看出,图的广度优先搜索的过程与二叉树的层序遍历是完全一致的,这也说明了图的广度优先搜索遍历是二叉树的层次遍历算法的扩展。
分析上述算法,每个顶点至多进一次队列。遍历图的过程实质上是通过边或弧找邻接点的过程,因此广度优先搜索遍历图的时间复杂度和深度优先搜索遍历相同,两者不同之处仅仅在于对顶点访问的顺序不同。
若要具体分析的话,如下:
无论邻接表还是邻接矩阵的方式,BFS算法都需要借助一个辅助队列 Q Q Q, n n n个顶点均需入队一次,在最坏的情况下,空间复杂度为 O ( ∣ V ∣ ) O(|V|) O(∣V∣)。
采用邻接表存储方式时,每个顶点均需搜索一次(或入队一次),故这一块的时间复杂度为 O ( ∣ V ∣ ) O(|V|) O(∣V∣),而在搜索某一个顶点的邻接点时,每条边至少访问一次,故这一块的时间复杂度为 O ( ∣ E ∣ ) O(|E|) O(∣E∣)。故算法的总的时间复杂度为 O ( ∣ V ∣ + ∣ E ∣ ) O(|V|+|E|) O(∣V∣+∣E∣)。
采用邻接矩阵存储方式时,查找某个顶点的邻接点所需的时间为 O ( ∣ V ∣ ) O(|V|) O(∣V∣),而每个顶点的邻接点都需要查一遍,故算法总的时间复杂度为 O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2)。
【注意】
图的邻接矩阵表示是唯一的,但对于邻接表来说,若边的输入次序不同,生成的邻接表也不同。因此,对于同样一个图,基于邻接矩阵的遍历所得到的DFS序列和BFS序列是唯一的,基于邻接表的遍历所得到的DFS序列和BFS序列是不唯一的。
假设要在 n n n个城市之间建立通信联络网,则连通 n n n个城市只需要 n − 1 n-1 n−1条线路。这时,自然会考虑这样一个问题,如何在最节省经费的前提下建立这个通信网。
在每两个城市之间都可以设置一条线路,相应地都要付出一定的经济代价。 n n n个城市之间,最多可能设置 n ( n − 1 ) / 2 n(n-1)/2 n(n−1)/2条线路,那么,如何在这些可能的线路中选出 n − 1 n-1 n−1条,以使总的耗费最少呢?
可以用连通网(网,即带权图)来表示 n n n个城市以及 n n n个城市间可能设置的通信线路,其中网的顶点表示城市,边表示两城市之间的线路,赋予边的权值表示相应的代价。
对于 n n n个顶点的连通网可以建立许多不同的生成树,每一棵生成树都可以是一个连通网。现在,我们要选择这样一棵生成树,使总的耗费最少。这个问题就是构造连通网的最小代价生成树(Minimum Cost Spanning Tree)(简称最小生成树)的问题。一棵生成树的代价就是树上各边的代价之和。
一个连通图的生成树包含图中的所有顶点,并且只含尽可能少的边。对于生成树来说,若砍去它的一条边,则会使生成树变成非连通图;若给它增加一条边,则会形成图中的一条回路。
对于一个带权连通无向图 G = ( V , E ) G=(V,E) G=(V,E),生成树不同,每棵树的权(即树中所有边上的权值之和)也可能不同。
设 R R R为 G G G的所有生成树的集合,若 T T T为 R R R中边的权值之和最小的那棵生成树,则 T T T称为 G G G的最小生成树(Minimum-Spanning Tree, M S T MST MST)。
不难看出,最小生成树具有如下特点:
1)最小生成树不是唯一的,即最小生成树的树形不唯一, R R R中可能有多个最小生成树。当图 G G G中的各边权值互不相等时, G G G的最小生成树是唯一的;若无向连通图 G G G的边数比顶点数少 1 1 1,即 G G G本身是一棵树时,则 G G G的最小生成树就是它本身。
2)最小生成树的边的权值之和总是唯一的,虽然最小生成树不唯一,但其对应的边的权值之和总是唯一的,而且是最小的。
3)最小生成树的边数为顶点数减1。
构造最小生成树可以有多种算法。其中多数算法利用了最小生成树的下列一种简称为 M S T MST MST的性质:
假设 N = ( V , { E } ) 是一个连通网, U 是顶点集 V 的一个非空子集。 若 ( u , v ) 是一条具有最小权值(代价)的边,其中 u ∈ U , v ∈ V − U , 则必存在一棵包含边 ( u , v ) 的最小生成树。 假设N=(V,\{E\})是一个连通网,U是顶点集V的一个非空子集。\\ 若(u,v)是一条具有最小权值(代价)的边,其中u∈U,v∈V-U,\\ 则必存在一棵包含边(u,v)的最小生成树。 假设N=(V,{E})是一个连通网,U是顶点集V的一个非空子集。若(u,v)是一条具有最小权值(代价)的边,其中u∈U,v∈V−U,则必存在一棵包含边(u,v)的最小生成树。
【证明】(反证法)
假设网 N N N的任何一棵最小生成树都不包含 ( u , v ) (u,v) (u,v)。
设 T T T是连通网上的一棵最小生成树,当将边 ( u , v ) (u,v) (u,v)加入到 T T T中时,由生成树的定义, T T T中必存在一条包含 ( u , v ) (u,v) (u,v)的回路。另一方面,由于 T T T是生成树,则在 T T T上必存在另一条边 ( u ′ , v ′ ) (u',v') (u′,v′),其中 u ′ ∈ U , v ′ ∈ V − U u'∈U,v'∈V-U u′∈U,v′∈V−U,且 u 和 u ′ u和u' u和u′之间, v 和 v ′ v和v' v和v′之间均有路径相通。
删去边 ( u ′ , v ′ ) (u',v') (u′,v′),便可消除上述回路,同时得到另一棵生成树 T ′ T' T′。因为 ( u , v ) (u,v) (u,v)的代价不高于 ( u ′ , v ′ ) (u',v') (u′,v′),则 T ′ T' T′的代价亦不高于 T T T, T ′ T' T′是包含 ( u , v ) (u,v) (u,v)的一棵最小生成树。由此和假设矛盾。
基于这个性质,我们可以给出一个通用的最小生成树算法:
GENERIC_MST(G) {
T = NULL;
while T未形成一棵生成树;
do 找到一条最小代价边(u,v)并且加入T后不会产生回路;
T = T∪(u,v);
}
普利姆(Prim)算法和克鲁斯卡尔(Kruskal)算法是两个利用 M S T MST MST性质构造最小生成树的算法。
假设 N = ( V , { E } ) N=(V,\{E\}) N=(V,{E})是连通网, T E TE TE是 N N 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∈U,v∈V-U u∈U,v∈V−U的边 ( u , v ) ∈ E (u,v)∈E (u,v)∈E中找一条代价最小的边 ( u 0 , v 0 ) (u_0,v_0) (u0,v0)并入集合 T E TE TE,同时 v 0 v_0 v0并入 U U U,直至 U = V U=V U=V为止。
此时 T E TE TE中必有 n − 1 n-1 n−1条边,则 T = ( V , { T E } ) T=(V,\{TE\}) T=(V,{TE})为 N N N的最小生成树。
个人用大白话叙述一下:说白了,就是,有两个结点集合, 集合 1 集合1 集合1(即集合 U U U)存放的是已经被纳入最小生成树的那些结点; 集合 2 集合2 集合2(即集合 V − U V-U V−U)存放的是其余还尚未纳入最小生成树、游离在外的那些结点。
首先任取一个顶点,将其加入到 集合 1 集合1 集合1当中。之后,每次添加一条边,这条边的要求是:以 集合 1 集合1 集合1中的某结点为起点,且以 集合 2 集合2 集合2中的某结点为终点所构成,且是所有可能的选取方法之中代价最小的那个。当这条边被添加进去之后,也就意味着 集合 2 集合2 集合2中的一个结点,此时已经被纳入了 集合 1 集合1 集合1当中。然后再同样开始下一轮构建,如此反复,直到所有结点都被纳入最小生成树。
【王道书上对应的说明】
Prim算法构造最小生成树的过程:
初始时从图中任取一顶点加入树 T T T,此时树中只含有一个顶点。
每一轮,选择一个与当前 T T T中顶点集合距离最近的顶点(不是具体的哪个顶点,而是当前已经纳入树 T T T之中任意的某一个结点均有可能;并且之前被选择过的也不妨碍之后再次被选择),并将该顶点和对应的边加入树 T T T,每次操作后 T T T中的顶点数和边数都增1。以此类推,直至图中所有的顶点都并入 T T T,得到的 T T T就是最小生成树。此时 T T T中必然有 n − 1 n-1 n−1条边。
Prim算法的步骤:
假设 G = ( V , E ) G=(V,E) G=(V,E)是连通图,其最小生成树 T = ( U , E T ) T=(U,E_T) T=(U,ET), E T E_T ET是最小生成树中边的集合。
初始化:向空树 T = ( U , E T ) T=(U,E_T) T=(U,ET)中添加图 G = ( V , E ) G=(V,E) G=(V,E)的任意一个顶点 u 0 u_0 u0,使 U = { u 0 } U=\{u_0\} U={u0}, E T = ∅ E_T=∅ ET=∅。
循环:从图G中选择满足 { ( u , v ) ∣ u ∈ U , v ∈ V − U } \{(u,v)|u∈U,v∈V-U\} {(u,v)∣u∈U,v∈V−U}且具有最小权值的边 ( u , v ) (u,v) (u,v),加入树 T T T,置 U = U ∪ { v } U=U∪\{v\} U=U∪{v}, E T = E T ∪ { ( u , v ) } E_T=E_T∪\{(u,v)\} ET=ET∪{(u,v)}。(直至 U = V U=V U=V时结束循环)
Prim算法的简单代码描述如下:
void Prim(G, T) {
T = ∅; //初始化空树
U = {w}; //添加任意一个顶点w
while( (V-U) != ∅ ) { //若树中不含全部顶点
设(u,v)是使u∈U与v∈(V-U),且权值最小的边;
T = T ∪ {(u,v)}; //边归入树
U = U ∪ {v}; //顶点归入树
}
}
Prim算法的时间复杂度为 O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2),不依赖于 ∣ E ∣ |E| ∣E∣,因此它适用于求解边稠密的图的最小生成树。
虽然采用其他方法能改进Prim算法的时间复杂度,但增加了实现的复杂性。
【注意】西交可能会考Prim算法的改进方法。此处暂时先不深入拓展。
与 P r i m Prim Prim算法从顶点开始扩展最小生成树不同, K r u s k a l Kruskal Kruskal算法是一种按权值的递增次序选择合适的边来构造最小生成树的方法。假设连通网 N = ( V , { E } ) N=(V,\{E\}) N=(V,{E}),则令最小生成树的初始状态为只有 n n n个顶点而无边的非连通图 T = ( V , { } ) T=(V,\{\}) T=(V,{}),图中每个顶点自成一个连通分量。在 E E E中选择代价最小的边,若该边依附的顶点落在 T T T中不同的连通分量上,则将此边加入到 T T T中,否则舍去此边而选择下一条代价最小的边。以此类推,直至 T T T中所有顶点都在同一连通分量上为止。
个人理解:一个无向图,有若干个连通分量(即连通子图;若只有一个结点,则这个结点自成一个连通分量),每次加入一个代价最小的边,这条边使得图中两个连通分量之间(两个连通分量肯定是不相连的)进行连通。或者按照树与森林的角度去理解,图中有一个个的子树,每次选边的时候,都是将两棵不同子树进行相连的过程,而非将一棵树内部的两结点相连的过程(而且这会形成回路);选好边后,将这条边加入,完成两棵树的合并,直到整个森林合并成一棵树。
【王道书上对应的说明】
Kruskal算法的步骤:
假设 G = ( V , E ) G=(V,E) G=(V,E)是连通图,其最小生成树 T = ( U , E T ) T=(U,E_T) T=(U,ET)。
初始化: U = V , E T = ∅ U=V,E_T=∅ U=V,ET=∅。即每个顶点构成一棵独立的树, T T T此时是一棵仅含 ∣ V ∣ |V| ∣V∣个顶点的森林。
循环:按 G G G的边的权值递增顺序依次从 E − E T E-E_T E−ET中选择一条边,若这条边加入后 T T T不构成回路,则将其加入 E T E_T ET,否则舍弃,直到 E T E_T ET中含有 n − 1 n-1 n−1条边(即整个图构成一棵树)。
Kruskal算法的简单实现描述:
void Kruskal(V,T) {
T = V; //初始化树T,仅含顶点
numS = n; //连通分量数
while(numS > 1) { //若连通分量数大于1
从E中取出权值最小的边(v,u);
if(v和u属于T中不同的连通分量) {
T = T ∪ {(v,u)}; //将此边加入生成树中
numS--; //连通分量树减1
}
}
}
上述算法对 e e e条边各扫描一次,假如以“堆”来存放网中的边,则每次选择最小代价的边仅需 O ( l o g 2 ∣ E ∣ ) O(log_2|E|) O(log2∣E∣)的时间(第一次需 O ( e ) O(e) O(e))。又生成树 T T T的每个连通分量可看成是一个等价类,则构造 T T T加入新的边的过程类似于求等价类的过程,由此可以以 M F S e t MFSet MFSet类型(并查集)来描述 T T T,使构造 T T T的过程仅需 O ( ∣ E ∣ l o g 2 ∣ E ∣ ) O(|E|log_2|E|) O(∣E∣log2∣E∣)的时间。由此,克鲁斯卡尔算法的时间复杂度为 O ( e l o g e ) O(eloge) O(eloge)。 e e e为网中边的数目,因此,它相对于Prim算法而言,适合于求边稀疏的网的最小生成树。
一个无环的有向图称作有向无环图(directed acycline graph),简称 D A G DAG DAG图。 D A G DAG DAG图是一类较有向树更一般的特殊有向图,如下图展示了有向树、DAG图和有向图的例子。
有向无环图是描述含有公共子式的表达式的有效工具。
有向无环图也是描述一项工程或系统的进行过程的有效工具。除最简单的情况之外,几乎所有的工程(project)都可分为若干个称作活动(activity)的子工程,而这些子工程之间,通常受着一定条件的约束,如其中某些子工程的开始必须在另一些子工程完成之后。对整个工程和系统,人们关心的是两个方面的问题:一是工程能否顺利进行;二是估算整个工程完成所必须的最短时间。对应于有向图,即为进行拓扑排序和求关键路径的操作。
例如下述表达式: ( ( a + b ) ∗ ( b ∗ ( c + d ) ) + ( c + d ) ∗ e ) ∗ ( ( c + d ) ∗ e ) ((a+b)*(b*(c+d))+(c+d)*e)*((c+d)*e) ((a+b)∗(b∗(c+d))+(c+d)∗e)∗((c+d)∗e)
首先,可以用前面所学的二叉树来表示,如下左图所示。
但仔细观察此式,可以发现有一些相同的子表达式,如 ( c + d ) (c+d) (c+d)和 ( c + d ) ∗ e (c+d)*e (c+d)∗e等,在二叉树中,它们也重复出现。若利用有向无环图,则可实现对相同子式的共享,从而节省存储空间。如下右图所示为表示同一表达式的有向无环图。
什么是拓扑排序(Topological Sort)?简单地说,由某个集合上的一个偏序得到该集合上的一个全序,这个操作称之为拓扑排序。
回顾离散数学中关于偏序和全序的定义:
若集合 X X X上的关系是自反的、反对称的和传递的,则称 R R R是集合 X X X上的偏序关系。
设 R R R是集合 X X X上的偏序(Partial Order),如果对每个 x , y ∈ X x,y∈X x,y∈X必有 x R y xRy xRy或 y R x yRx yRx,则称 R R R是集合 X X X上的全序关系。
直观地看,偏序指集合中仅有部分成员之间可比较,而全序指集合中全体成员之间均可比较。
例如,下图所示的两个有向图,图中弧 < x , y >
个人理解:说人话就是,看(b)图,根据这个说法,我完全可以知道图中各个结点、两两的大小比较关系,
v1<=v2,v1<=v3,v1<=v4;v2<=v3,v2<=v4;v3<=v4
,这就叫全序。但是对于(a)图,我只能知道v1<=v2,v1<=v3,v1<=v4;v2<=v4;v3<=v4
,而我无法获知v2
和v3
谁大谁小,这就叫偏序了。
若在(a)的有向图上人为的加一个表示v2≤v3
的弧,则(a)表示的也为全序。
这个全序称为拓扑有序(Topological Order),而由偏序定义得到拓扑有序的操作便是拓扑排序。
【王道书上说明】
若用 D A G DAG DAG图表示一个工程,其顶点表示活动,用有向边 < V i , V j >
在 A O V 网 AOV网 AOV网中,活动 V i V_i Vi是活动 V j V_j Vj的直接前驱,活动 V j V_j Vj是活动 V i V_i Vi的直接后继,这种前驱和后继关系具有传递性,且任何活动 V i V_i Vi不能以它自己作为自己的前驱或后继。
【严蔚敏教材说明】
一个表示偏序的有向图可用来表示一个流程图。图中每一条有向边表示两个子工程之间的次序关系(领先关系)。
例如,一个软件专业的学生必须学习一系列基本课程,其中有些课程是基础课,它独立于其他课,如《高等数学》;而另一些课程必须在学完作为它的基础的先修课程才能开始。如,在《程序设计基础》和《离散数学》学完之前就不能开始《数据结构》。这些先决条件定义了课程之间的领先(优先)关系。这个关系可以用有向图更清楚地表示,如下图所示。
课程编号 | 课程名称 | 先决条件 |
---|---|---|
C 1 C_1 C1 | 程序设计基础 | 无 |
C 2 C_2 C2 | 离散数学 | C 1 C_1 C1 |
C 3 C_3 C3 | 数据结构 | C 1 , C 2 C_1,C_2 C1,C2 |
C 4 C_4 C4 | 汇编语言 | C 1 C_1 C1 |
C 5 C_5 C5 | 语言的设计和分析 | C 3 , C 4 C_3,C_4 C3,C4 |
C 6 C_6 C6 | 计算机原理 | C 11 C_{11} C11 |
C 7 C_7 C7 | 编译原理 | C 5 , C 3 C_5,C_3 C5,C3 |
C 8 C_8 C8 | 操作系统 | C 3 , C 6 C_3,C_6 C3,C6 |
C 9 C_9 C9 | 高等数学 | 无 |
C 10 C_{10} C10 | 线性代数 | C 9 C_9 C9 |
C 11 C_{11} C11 | 普通物理 | C 9 C_9 C9 |
C 12 C_{12} C12 | 数值分析 | C 9 , C 10 , C 1 C_9,C_{10},C_1 C9,C10,C1 |
图中顶点表示课程,有向边(弧)表示先决条件。若课程 i i i是课程 j j j的先决条件,则图中有弧 < i , j > <i,j>。
这种用顶点表示活动,用弧表示活动间的优先关系的有向图称为顶点表示活动的网(Activity On Vertex Network),简称 A O V AOV AOV网。
在网中,若从顶点 i i i到顶点 j j j有一条有向路径,则 i i i是 j j j的前驱; j j j是 i i i的后继。若 < i , j > <i,j>是网中一条弧,则 i i i是 j j j的直接前驱; j j j是 i i i的直接后继。
在 A O V AOV AOV网中,不应该出现有向环,因为存在环意味着某项活动应该以自己为先决条件。显然,这是荒谬的。若设计出这样的流程图,工程便无法进行。而对数据的数据流图来说,则表明存在一个死循环。
因此,对给定的 A O V AOV AOV网应首先判定网中是否存在环。检测的办法是对有向图构造其顶点的拓扑有序序列,若网中所有顶点都在它的拓扑有序序列中,则该 A O V AOV AOV网中必定不存在环。
例如,上图7.27的有向图有如下两个拓扑序列:
(当然,还有其他的拓扑有序序列)学生必须按照拓扑有序的顺序来安排学习计划。
( C 1 , C 2 , C 3 , C 4 , C 5 , C 7 , C 9 , C 10 , C 11 , C 6 , C 12 , C 8 ) 和 ( C 9 , C 10 , C 11 , C 6 , C 1 , C 12 , C 4 , C 2 , C 3 , C 5 , C 7 , C 8 ) (C_1,C_2,C_3,C_4,C_5,C_7,C_9,C_{10},C_{11},C_6,C_{12},C_8)\\ 和\\ (C_9,C_{10},C_{11},C_6,C_1,C_{12},C_4,C_2,C_3,C_5,C_7,C_8) (C1,C2,C3,C4,C5,C7,C9,C10,C11,C6,C12,C8)和(C9,C10,C11,C6,C1,C12,C4,C2,C3,C5,C7,C8)
如何进行拓扑排序?解决的方法很简单:
(1)在有向图中选一个没有前驱的结点且输出之;
(2)从图中删除该顶点和所有以它为尾的弧。
重复上述两步,直至全部顶点均已输出,或者当前图中不存在无前驱的结点为止。(后一种情况则说明有向图中存在环)
例如:
以上图(a)中的有向图为例,图中, v 1 v_1 v1和 v 6 v_6 v6没有前驱,则可任选一个。假设先输出 v 6 v_6 v6,在删除 v 6 v_6 v6及弧 < v 6 , v 4 > , < v 6 , v 5 >
, <v6,v4>,<v6,v5>之后,只有顶点 v 1 v_1 v1没有前驱,则输出 v 1 v_1 v1且删去 v 1 v_1 v1及弧 < v 1 , v 2 ><v1,v2>, < v 1 , v 3 > <v1,v3>和 < v 1 , v 4 > <v1,v4>,之后 v 3 v_3 v3和 v 4 v_4 v4都没有前驱。以此类推,可从中任选一个继续进行。 最终得到该有向图的拓扑有序序列为: v 6 − v 1 − v 4 − v 3 − v 2 − v 5 v_6-v_1-v_4-v_3-v_2-v_5 v6−v1−v4−v3−v2−v5。
拓扑排序算法
如何在计算机中实现?针对上述两步操作,我们可采用邻接表作有向图的存储结构,且在头结点中增加一个存放顶点入度的数组(indegree)。入度为零的顶点即为没有前驱的顶点,删除顶点及以它为尾的弧的操作,则可以弧头顶点的入度减1来实现。
为了避免重复检测入度为零的顶点,可另设一栈暂存所有入度为零的顶点,由此可得拓扑排序的算法如下所示。
Status Topological(ALGraph G) {
//有向图G采用邻接表存储结构
//若G无回路,则输出G的顶点的一个拓扑序列并返回OK,否则ERROR
FindInDegree(G, indegree); //对各顶点求入度,indegree[0...vernum-1]
InitStack(S);
for(i=0; i<G.vexnum; ++i) { //建立零入度顶点栈S
if(!indegree[i])
Push(S, i); //入度为0者进栈
}
count = 0; //对输出顶点计数
while(!StackEmpty(S)) {
Pop(S, i); printf(i, G.vertices[i].data); ++count; //输出i号顶点并计数
for(p=G.vertices[i].firstarc; p; p=p->nextarc) {
k = p->adjvex; //k为p顶点的编号域,目的是为了令其入度数组对应下标的值减1
if(!(--indegree[k])) Push(S, k); //若入度减为0,则入栈
}
}
if(count<G.vexnum)
return ERROR; //该有向图有回路
else return OK;
}
分析该算法,采用邻接表存储,对有 ∣ V ∣ |V| ∣V∣个顶点和 ∣ E ∣ |E| ∣E∣条弧的有向图而言,建立求各顶点的入度的时间复杂度为 O ( ∣ E ∣ ) O(|E|) O(∣E∣);建立零入度顶点栈的时间复杂度为 O ( ∣ V ∣ ) O(|V|) O(∣V∣);在拓扑排序过程中,若有向图无环,则每个顶点进一次栈,出一次栈,入度减1的操作在 W H I L E WHILE WHILE语句中总共执行 ∣ E ∣ |E| ∣E∣次,所以,总的时间复杂度为 O ( ∣ V ∣ + ∣ E ∣ ) O(|V|+|E|) O(∣V∣+∣E∣)。
采用邻接矩阵存储时拓扑排序的时间复杂度为 O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2)。
用拓扑排序算法处理AOV网时,应注意以下问题:
1)入度为0的顶点,即没有前驱活动的或前驱活动都已完成的顶点,工程可以从这个顶点所代表的活动开始或继续;
2)若一个顶点有多个直接后继,则拓扑排序的结果通常不唯一;但若各个顶点已经排在一个线性有序的序列中,每个顶点有唯一的前驱后继关系,则拓扑排序的结果是唯一的;
3)由于AOV网中各顶点的地位平等,每个编号是人为的,因此可以按拓扑排序的结果重新编号,生成AOV网的新的邻接存储矩阵,这种邻接矩阵可以是三角矩阵;但对于一般的图来说,若其邻接矩阵是三角矩阵,则存在拓扑序列,反之则不一定成立。
上述拓扑排序的算法亦是下节讨论的求关键路径的基础。
当有向图无环时,也可利用深度优先遍历进行拓扑排序,因为图中无环,则由图中某点出发进行深度优先搜索遍历时,最先退出 D F S DFS DFS函数的顶点即出度为零的顶点,是拓扑有序序列中最后一个顶点。由此,按退出 D F S DFS DFS函数的先后记录下来的顶点序列(如同求强连通分量时finished数组中的顶点序列)即为逆向的拓扑有序序列。
对一个AOV网,如果采用下列步骤进行排序,则称之为逆拓扑排序:
1)从AOV网中选择一个没有后继(出度为0)的顶点并输出。
2)从网中删除该顶点和所有以它为终点的有向边。
3)重复上面两步,直到当前的AOV网为空。
与 A O V AOV AOV网对应的是 A O E AOE AOE网(Activity On Edge),即边表示活动的网。
A O E AOE AOE网是一个带权的有向无环图,其中,顶点表示事件(Event),弧表示活动,权表示活动持续的时间。通常, A O E AOE AOE网可用来估算工程的完成时间。
例如,下图是一个假想的有 11 11 11项活动的 A O E AOE AOE网。其中 9 9 9个事件 v 1 , v 2 , v 3 , . . . , v 9 v_1,v_2,v_3,...,v_9 v1,v2,v3,...,v9,每个事件表示在它之前的活动已经完成,在它之后的活动可以开始。如 v 1 v_1 v1表示整个工程开始, v 9 v_9 v9表示整个工程结束; v 5 v_5 v5表示 a 4 a_4 a4和 a 5 a_5 a5已经完成, a 7 a_7 a7和 a 8 a_8 a8可以开始。
与每个活动相联系的数是执行该活动所需的时间。比如,活动 a 1 a_1 a1需要 6 6 6天, a 2 a_2 a2需要 4 4 4天等。
由于整个工程只有一个开始点和一个完成点,故在正常的情况(无环)下,网中只有一个入度为零的点(称作源点)和一个出度为零的点(称作汇点)。
和 A O V AOV AOV网不同,对 A O E AOE AOE网有待研究的问题是:
(1)完成整项工程至少需要多少时间?
(2)哪些活动是影响工程进度的关键?
由于在 A O E AOE AOE网中有些活动可以并行地进行,所以完成工程的最短时间是从开始点到完成点的最长路径的长度(这里所说的路径长度是指路径上各活动持续时间之和,不是路径上弧的数目)。路径长度最长的路径叫做关键路径(Critical Path)。
假设开始点是 v 1 v_1 v1,从 v 1 v_1 v1到 v i v_i vi的最长路径长度叫做事件 v i v_i vi的最早发生时间。
这里注意一个反直觉性的问题,为啥路径长度最长,反而是他的最早发生时间?——这个结合一个具体例子很容易就能想清楚,因为,如果从 v 1 v_1 v1到 v i v_i vi有 k k k条路径,这 k k k条活动并行地执行,那么什么时候 v i v_i vi才能发生?当然是等这 k k k条活动全部干完了才可以啊。因为每个事件来说,在它之前的活动已经全部完成,它才能开始。——所以,这 k k k条活动当中,做的时间最长的那个、最慢做完的那个,大家都要等它做完, v i v_i vi事件才能够发生。
因此,要看 v i v_i vi最早什么时候能发生,就看 v 1 v_1 v1到 v i v_i vi的最长路径长度。
求活动的最早、最晚开始时间,e()、l()
(紧接上段话)这个时间决定了所有以 v i v_i vi为尾(就是起点)的弧所表示的活动的最早开始时间。我们用** e ( i ) e(i) e(i)表示活动 a i a_i ai的最早开始时间**。
还可以定义一个活动的最迟开始时间 l ( i ) l(i) l(i),这是在不推迟整个工程完成的前提下,活动 a i a_i ai最迟必须开始进行的时间。
一个活动 a i a_i ai,它对应于一条弧,所以这个活动最早什么时候开始,取决于其弧尾的那个结点最早什么时候开始,活动的最早开始时间不可能说它的起点事件还未发生、活动就开始了啊。
一个活动 a i a_i ai,它对应于一条弧,这个弧是有长度(权值)的。而刚才我们也说了,有时两个事件之间有多条路径,例如有 k k k条,那么它们并行地推进,自然所有做得快的就必须等待做的最慢的那个做完,才能进行下一事件(即弧头所指的事件)。因此,对于做得快的路径,它有两种选择:
1)最早能开始时,便开始,快速做完之后等待别人做完。——我这个活动最早什么时候能开始,即它弧尾的事件最早何时发生,对应于一个活动的最早开始时间 e ( i ) e(i) e(i)。
2)比如我做完之后要等别人5天,大家才能开始下一个事件,那么我先休息5天,再开始做,最后也是一样的效果。当然,如果我休息天数过多,比如休息6天,那势必会引起大家平白无故的还要再额外多等我1天,这就延误工期了。——这个最久能休息多长时间、从而最晚能什么时候开始的问题,对应于一个活动的最晚开始时间 l ( i ) l(i) l(i)。
当然,你如果说,我先休息2天,然后做完,最后等待别人3天,也无妨,也是一样的效果。——这种,它的开始时间就是介于最早和最晚开始时间之间了。
两者之差 l ( i ) − e ( i ) l(i)-e(i) l(i)−e(i)意味着完成活动 a i a_i ai的时间余量。
这就相当于刚才说的,我既然做得快,最快做完之后还要等别人5天,那么这5天时间就是我的时间余量。这5天,我可以先拿来休息,再开工;我也可以先休息2天,再开工,再等别人3天。这5天就是时间余量。
我们把 l ( i ) = e ( i ) l(i)=e(i) l(i)=e(i)的活动叫做关键活动。
就是那些做的最慢的活动,只有别人等它的份,没有说它做完了去等别人的份。或者说它的时间余量是0。
显然,关键路径上的所有活动都是关键活动。因此,提前完成非关键活动并不能加快工程的进度。
关键路径就是从整个工程的开始点到完成点,路径长度最长的路径呗。所以它上面的活动都是做的最慢的,都是关键活动。
分析关键路径的目的是辨别哪些是关键活动,以便争取提高关键活动的工效,缩短整个工期。
由上分析可知,辨别关键活动就是要找 e ( i ) = l ( i ) e(i)=l(i) e(i)=l(i)的活动。
为了求得 A O E AOE AOE网中的活动的 e ( i ) e(i) e(i)和 l ( i ) l(i) l(i),首先应求得事件的最早发生时间 v e ( j ) ve(j) ve(j)和最迟发生时间 v l ( j ) vl(j) vl(j)。
AOE网中,活动对应的是弧,完成活动所需时间对应的是弧的长度。
结点对应的是事件。通过前文可知,活动最早开始时间,是与其弧尾的结点的最早开始时间所一致的;活动最晚开始时间,也就是说它能够先休息的时间(时间余量),把先做后等待,转为先休息后做。那么先休息后做,此时开始时间就是最晚开始时间了。
如果活动 a i a_i ai由弧 < j , k >
e ( i ) = v e ( j ) l ( i ) = v l ( k ) − d u t ( < j , k > ) e(i)=ve(j)\\ l(i)=vl(k)-dut(
如果已经得知了一个事件(即图中顶点)的最早发生时间、最晚发生时间。
那么求与之相关的活动(即相关的弧)的最早开始时间、最晚开始时间,就简简单单、轻轻松松了。
那现在问题就是,一个事件的最早发生时间 v e ( j ) ve(j) ve(j)、最晚发生时间 v l ( j ) vl(j) vl(j),是咋求的?
求事件的最早、最晚开始时间,ve()、vl()
求 v e ( j ) ve(j) ve(j)和 v l ( j ) vl(j) vl(j)需分两步进行:
(1)从 v e ( 1 ) = 0 ve(1)=0 ve(1)=0,即源点,开始正向递推
v e ( j ) = max i { v e ( i ) + d u t ( < i , j > ) } < i , j > ∈ T , j = 2 , 3 , . . . , n ve(j)=\max_i\{ve(i)+dut()\}\\ ∈T,j=2,3,...,n ve(j)=imax{ve(i)+dut(<i,j>)}<i,j>∈T,j=2,3,...,n
其中, T T T是所有以第 j j j个顶点为头的弧的集合。
(2)从 v l ( n ) = v e ( n ) vl(n)=ve(n) vl(n)=ve(n),即汇点,开始反向倒推
v l ( i ) = min j { v l ( j ) − d u t ( < i , j > ) } < i , j > ∈ S , i = n − 1 , . . . , 2 , 1 vl(i)=\min_j\{vl(j)-dut()\} ∈S,i=n-1,...,2,1 vl(i)=jmin{vl(j)−dut(<i,j>)}<i,j>∈S,i=n−1,...,2,1
其中, S S S是所有以第 i i i个顶点为尾的弧的集合。
这两个递推公式的计算必须分别在拓扑有序和逆拓扑有序的前提下进行。也就是说, v e ( j ) ve(j) ve(j)必须在 v j v_j vj的所有前驱的最早发生时间求得之后才能确定,而 v l ( j ) vl(j) vl(j)则必须在 v j v_j vj的所有后继的最迟发生时间求得之后才能确定。因此,可以在拓扑排序的基础上计算 v e ( j ) ve(j) ve(j)和 v l ( j ) vl(j) vl(j)。
求关键路径
由上述内容,得到如下所述求关键路径的算法:
(1)输入 e e e条弧 < j , k >
(2)从源点 v 1 v_1 v1出发,令 v e [ 1 ] = 0 ve[1]=0 ve[1]=0,按拓扑有序求其余各顶点的最早发生时间 v e [ i ] ( 2 ≤ i ≤ n ) ve[i]\quad(2≤i≤n) ve[i](2≤i≤n)。如果得到的拓扑有序序列中顶点个数小于网中顶点数 n n n,则说明网中存在环,不能求关键路径,算法终止;否则执行步骤(3)。
(3)从汇点 v n v_n vn出发,令 v l [ n ] = v e [ n ] vl[n]=ve[n] vl[n]=ve[n],按逆拓扑有序求其余各顶点的最迟发生时间 v l [ i ] ( n − 1 ≥ i ≥ 1 ) vl[i]\quad(n-1≥i≥1) vl[i](n−1≥i≥1)。
(4)根据各顶点的 v e ve ve和 v l vl vl值,求每条弧 s s s的最早开始时间 e ( s ) e(s) e(s)和最迟开始时间 l ( s ) l(s) l(s)。若某条弧满足条件 e ( s ) = l ( s ) e(s)=l(s) e(s)=l(s),则为关键活动。
例子1
如下图(a)所示,是一个 A O E AOE AOE网,求其关键路径。
先求各个顶点(即事件)的 v e ( ) 和 v l ( ) ve()和vl() ve()和vl()。
方法:令 v e ( 1 ) = 0 ve(1)=0 ve(1)=0,正向往后推导各个顶点的 v e ( ) ve() ve();令 v l ( n ) = v e ( n ) vl(n)=ve(n) vl(n)=ve(n),然后逆向往前去推导各个顶点的 v l ( ) vl() vl()。
画出如下表格。
顶点 | v 1 v_1 v1 | v 2 v_2 v2 | v 3 v_3 v3 | v 4 v_4 v4 | v 5 v_5 v5 | v 6 v_6 v6 |
---|---|---|---|---|---|---|
v e ve ve | 0 | 3 | 2 | 6 | 6 | 8 |
v l vl vl | 0(注意,容易错写3) | 4 | 2(注意,容易错写5) | 6 | 7 | 8 |
【注意!什么是最晚开始时间?】
什么是最晚开始时间,不是说:哦,我从这条弧判断出,这个事件最晚可以第5天开始;从另一条弧判断出,这个事件可以最晚第2天开始。我从第5天开始,比我从第2天开始还要更晚一些,OK,我“最晚”开始时间是第5天开始。因为从第2天开始不是最晚么,没有第5天开始更晚么。——那这样你就错了。那你咋不第8天开始呢,第8天开始不是比第5天开始更更晚?
啥叫最晚开始时间,你是要在不耽误工期的前提下,最晚开始。你是要对(1)“这条路径得知,该事件允许最晚拖到第5天开始”——OK,这个事件的开始时间为区间 [ . . . , 5 ] [...,5] [...,5];(2)“另一条路径得知,该事件允许最晚拖到第2天开始”——OK,这个事件的开始时间为区间 [ . . . , 2 ] [...,2] [...,2]。你要对这些开始区间取交集,而非取并集。取交集后,得到一个综合所有考虑而言的最终允许的最晚开始时间,哦,该事件最晚开始时间是第2天。
而如果你觉得第5天开始是所谓的“最晚”开始时间,那么当你真正令这个事件在第5天才开始的时候,你会发现,“这条路径得知,这个事件最晚拖到第2天开始”,而这条路径上的工期已经被耽误了。总之,不能想当然。
不论是事件的最晚开始时间、还是活动的最晚开始时间,它这个“最晚”,都一定是要在确保整个工程不会被延误的情况下的最晚时间。而绝不是简简单单的,说 8 > 5 8>5 8>5,哦,我最晚就是第8天?可能是,也可能不是,得看是什么场景。
【同理也需要注意!什么是最早开始时间?】
只不过这个例子中我没算错,但是也是有算错的可能性的,若不那么清晰的话。
最早开始时间,如果晕了,可能也会理解为,这条路径最早第2天开始,那条路径最早第5天开始,OK我最早第二天开始。
这也是犯了一个直觉的错误。啥叫最早开始时间,不是说 2 < 5 2<5 2<5,我最早就第2天开始了。最早开始时间是说,我得等着你们所有人都干完了,我才能开始,等你们所有人一干完我立即开始,此时的开始时间,叫做我最早啥时候能开始,而再早于这个时间,我便无法开始,因为你们有些人还没干完呢,我咋开始?
接下来求各弧(即活动)的 e ( ) e() e()和 l ( ) l() l()。
方法:对于一个活动(即弧,包括弧头、弧尾;即一个有向的箭头,包括起点、终点)而言,这个活动什么时候能最早开始?即其弧尾(起点)对应顶点的那个事件最早什么时候开始,我这个活动最早就什么时候开始。
即,活动的最早开始时间,等于该活动弧的起点所表示事件的最早开始时间;即,若弧 < v k , v j >
<vk,vj>表示活动 a i a_i ai,则 e ( i ) = v e ( k ) e(i)=ve(k) e(i)=ve(k)。 这个活动最晚什么时候开始?看这个活动的弧所指向的重点,那个事件最晚啥时候能开始,以此为判断活动最晚什么时候开始的依据。因为,何谓“最晚”?——即,我这件事一直往后拖,拖到最晚再开始,只要不耽误后面的工程就行了。所以我看看下一步最晚啥时候要,把那个截止日期取过来,减去我做这件事需要花费的时间长度,即为我这件事最晚开始的时间点。
即,活动的最晚开始时间,等于该活动弧的终点所表示事件的最迟发生时间与该活动所需时间之差;即,若弧 < v k , v j >
<vk,vj>表示活动 a i a_i ai,则有 l ( i ) = v l ( j ) − d u t ( < v k , v j > ) l(i)=vl(j)-dut( ) l(i)=vl(j)−dut(<vk,vj>)。而我们的目的是什么,求关键路径啊。所以什么是关键路径呢,就是那些时间余量为0的活动所构成的工程总路径。
所以对于每个活动,还要计算一下 d ( i ) = l ( i ) − e ( i ) d(i)=l(i)-e(i) d(i)=l(i)−e(i)。 d ( i ) = 0 d(i)=0 d(i)=0的是关键活动。
画出如下表格。
活动 | a 1 a_1 a1 | a 2 a_2 a2 | a 3 a_3 a3 | a 4 a_4 a4 | a 5 a_5 a5 | a 6 a_6 a6 | a 7 a_7 a7 | a 8 a_8 a8 |
---|---|---|---|---|---|---|---|---|
e e e | 0 | 0 | 3 | 3 | 2 | 2 | 6 | 6 |
l l l | 1 | 0 | 4 | 4 | 2 | 5 | 6 | 7 |
l − e l-e l−e | 1 | 0 | 1 | 1 | 0 | 3 | 0 | 1 |
所以刚刚图(a)所示的 A O E AOE AOE网的关键路径,即为 ( v 1 , v 3 , v 4 , v 6 ) (v_1,v_3,v_4,v_6) (v1,v3,v4,v6)。
例子2
对于一个 A O E AOE AOE网,即下图。
具体求关键路径的过程略。我们可计算求得关键活动为 a 1 , a 4 , a 7 , a 8 , a 10 , a 11 a_1,a_4,a_7,a_8,a_{10},a_{11} a1,a4,a7,a8,a10,a11。它们构成两条关键路径: ( v 1 , v 2 , v 5 , v 7 , v 9 ) (v_1,v_2,v_5,v_7,v_9) (v1,v2,v5,v7,v9)和 ( v 1 , v 2 , v 5 , v 8 , v 9 ) (v_1,v_2,v_5,v_8,v_9) (v1,v2,v5,v8,v9)。如下图所示。
由这个两个例子所引发的说明
用 A O E AOE AOE网来估算某些工程完成的时间是非常有用的。实际上,求关键路径的方法本身最初就是与维修和建造工程一起发展的。但是,由于网中各项活动是互相牵涉的,因此,影响关键活动的因素亦是多方面的,任何一项活动持续时间的改变都会影响关键路径的改变。
例如,对例子1中图(a)所示的网来说,若 a 5 a_5 a5的持续时间改为3,则可发现,关键活动数量增加,关键路径也增加。若同时再将 a 4 a_4 a4的时间改为4,则 ( v 1 , v 3 , v 4 , v 6 ) (v_1,v_3,v_4,v_6) (v1,v3,v4,v6)不再是关键路径。
由此可见,关键活动的速度提高是有限度的,只有在不改变网的关键路径的情况下,提高关键活动的速度才有效。
另一方面,若网中有几条关键路径,那么,单是提高一条关键路径上的关键活动的速度,还不能导致整个工程缩短工期,而必须提高同时在几条关键路径上的活动的速度。
1、事件 v k v_k vk的最早发生时间 v e ( k ) ve(k) ve(k)
它指从源点 v 1 v_1 v1到顶点 v k v_k vk顶点最长路径长度。事件 v k v_k vk的最早发生时间决定了所有从 v k v_k vk开始的活动能够开工的最早时间。
可用递推公式来计算每个事件的 v e ( ) ve() ve():
v e ( 源点 ) = 0 v e ( k ) = M a x { v e ( j ) + W e i g h t ( v j , v k ) } ( W e i g h t ( v j , v k ) 表示 < v j , v k > 的权值) ve(源点)=0\\ ve(k)=Max\{ve(j)+Weight(v_j,v_k)\}\\(Weight(v_j,v_k)表示
注意,不要犯直觉上的错误,不要觉得 2 < 5 2<5 2<5,所以最早开始时间就是第2天。实际上最早要等到第5天开始。
2、事件 v k v_k vk的最迟发生时间 v l ( k ) vl(k) vl(k)
它是指在不推迟整个工程完成的前提下,即保证它的后继事件 v j v_j vj在其最迟发生时间 v l ( j ) vl(j) vl(j)能够发生时,该事件最迟必须发生的时间。
可用递推公式来计算每个事件的 v l ( ) vl() vl():
v l ( 汇点 ) = v e ( 汇点 ) v l ( k ) = M i n { v l ( j ) − W e i g h t ( v k , v j ) } ( v j 为 v k 的任意后继) vl(汇点)=ve(汇点)\\ vl(k)=Min\{vl(j)-Weight(v_k,v_j)\}\\ (v_j为v_k的任意后继) vl(汇点)=ve(汇点)vl(k)=Min{vl(j)−Weight(vk,vj)}(vj为vk的任意后继)
3、活动 a i a_i ai的最早开始时间 e ( i ) e(i) e(i)
它是指该活动弧的起点所表示的事件的最早发生时间。若弧 < v k , v j >
4、活动 a i a_i ai的最迟开始时间 l ( i ) l(i) l(i)
它是指该活动弧的终点所表示事件的最迟发生时间与该活动所需时间之差。若弧 < v k , v j >
5、一个活动 a i a_i ai的最迟开始时间 l ( i ) l(i) l(i)与其最早开始时间 e ( i ) e(i) e(i)的差额 d ( i ) = l ( i ) − e ( i ) d(i)=l(i)-e(i) d(i)=l(i)−e(i)
它是指该活动完成的时间余量,即在不增加完成整个工程所需总时间的情况下,活动 a i a_i ai可以拖延的时间。
若一个活动的时间余量为0,则说明该活动必须要如期完成,否则就会拖延整个工程的进度,所以称 l ( i ) − e ( i ) = 0 l(i)-e(i)=0 l(i)−e(i)=0即 l ( i ) = e ( i ) l(i)=e(i) l(i)=e(i)的活动 a i a_i ai是关键活动。
6、求关键路径的算法步骤
1)从源点出发, 令 v e ( 源点 ) = 0 ve(源点)=0 ve(源点)=0,之后按照上述所讲方法,求其余各顶点的最早发生时间 v e ( ) ve() ve()。
2)从汇点出发,令 v l ( 汇点 ) = v e ( 汇点 ) vl(汇点)=ve(汇点) vl(汇点)=ve(汇点),按上述所讲方法,求其余各顶点的最迟发生时间 v l ( ) vl() vl()。
3)根据各顶点的 v e ( ) ve() ve(),求所有弧的最早开始时间 e ( ) e() e()。
4)根据各顶点的 v l ( ) vl() vl(),求所有弧的最迟开始时间 l ( ) l() l()。
5)求 A O E AOE AOE网中所有活动的差额 d ( ) d() d(),找出所有 d ( ) = 0 d()=0 d()=0的活动构成关键路径。
7、对于加快关键路径上活动的两点说明
1)关键路径上的所有活动都是关键活动,决定着整个工程的工期。因此可以通过在一定程度上加快关键活动,来缩短整个工程的工期。但是,不能超过这个限度,即缩短到一定程度后,若还要再缩短,则该关键活动可能就变成了非关键活动。
2)网中的关键路径并不唯一,对于有多条关键路径的网而言,一般来说你只提高一条关键路径上的关键活动速度并不能缩短整个工程的工期;但是如果你加快的那个关键活动,它是所有关键路径上所共有的一项关键活动,也就意味着你同时缩短了这个网中所有关键路径,那么你才有可能达到缩短工期的目的。(当然,这个缩短一样也是要有一定限度的)
假若要在计算机上建立一个交通咨询系统则可以采用图的结构来表示实际的交通网络。如下图所示。
上图中,顶点表示城市,边表示城市间的交通联系。这个咨询系统可以回答旅客提出的各种问题。
例如,一位旅客要从A城到B城,他希望选择一条途中中转次数最少的路线。假设图中每一站都需要换车,则这个问题反映到图上就是要找一条从顶点A到B所含边的数目最少的路径。我们只需从顶点A出发对图作广度优先搜索,一旦遇到顶点B就终止。由此所得广度优先生成树上,从根顶点A到顶点B的路径就是中转次数最少的路径,路径上A与B之间的顶点就是途经的中转站数。
但是,这只是一类最简单的图的最短路径问题。
有时,对于旅客来说,可能更关心的是节省交通费用;而对于司机来说,里程和速度则是他们感兴趣的信息。为了在图上表示有关信息,可对边赋以权,权的值表示两城市间的距离,或途中所需的时间,或交通费用等等。此时路径长度的度量就不再是路径上边的数目,而是路径上边的权值之和。
考虑到交通图的有向性,此处将讨论带权有向图,并称路径上的第一个顶点为源点(Source),最后一个顶点为终点(Destination)。
【王道书上此处的说明】
前面学过的广度优先搜索查找最短路径只能是对无权图而言的。
当图是带权图时,把从一个顶点 v 0 v_0 v0到图中其余任意一个顶点 v i v_i vi的一条路径(可能不止一条)所经过边上的权值之和,定义为该路径的带权路径长度,把带权路径长度最短的那条路径称为最短路径。
求解最短路径的算法通常都依赖于一种性质:即两点之间的最短路径也包含了路径上其他顶点间的最短路径。
**带权有向图 G G G**的最短路径问题一般可分为两类:
一类是单源最短路径,即求图中某一顶点到其他各顶点的最短路径,可通过经典的 D i j k s t r a Dijkstra Dijkstra算法求解;
二是求每对顶点间的最短路径,可通过 F l o y d Floyd Floyd算法来求解。
我们先来讨论单源点的最短路径问题:给定带权有向图 G G G和源点 v v v,求从 v v v到 G G G中其余各顶点的最短路径。
例如,下图7.34所示带权有向图 G 6 G_6 G6中从 v 0 v_0 v0到其余各顶点之间的最短路径,如图7.35所示。
根据观察法以及最短路径的基本定义,可得右边的表格。
比如从 v 0 v_0 v0到 v 3 v_3 v3,有两条不同的路径: ( v 0 , v 2 , v 3 ) (v_0,v_2,v_3) (v0,v2,v3)和 ( v 0 , v 4 , v 3 ) (v_0,v_4,v_3) (v0,v4,v3),前者长度为 60 60 60,而后者长度为 50 50 50。因此,后者是从 v 0 v_0 v0到 v 3 v_3 v3的最短路径。
而从 v 0 v_0 v0到 v 1 v_1 v1是没有路径的。
如何求得这些路径? 迪杰斯特拉( D i j k s t r a ) 迪杰斯特拉(Dijkstra) 迪杰斯特拉(Dijkstra)提出了一个按路径长度递增的次序产生最短路径的算法。
首先,引进一个 辅助向量 D 辅助向量D 辅助向量D,它的每个分量 D [ i ] D[i] D[i]表示当前所找到的从始点 v v v到每个终点 v i v_i vi的最短路径的长度。
它的初态为:若从 v v v到 v i v_i vi有弧,则 D [ i ] D[i] D[i]为弧上的权值;否则置 D [ i ] D[i] D[i]为 ∞ ∞ ∞。
显然,长度为
D [ j ] = min i { D [ i ] ∣ v i ∈ V } D[j]=\min_i\{D[i]\ |\ v_i∈V\} D[j]=imin{D[i] ∣ vi∈V}
的路径就是从 v v v出发的长度最短的一条最短路径。此路径为 ( v , v j ) (v,v_j) (v,vj)。
那么,下一条长度次短的最短路径是哪一条呢?假设该次短路径的终点是 v k v_k vk,则可想而知,这条路径或者是 ( v , v k ) (v,v_k) (v,vk),或者是 ( v , v j , v k ) (v,v_j,v_k) (v,vj,vk)。它的长度或者是从 v v v到 v k v_k vk的弧上的权值,或者是 D [ j ] D[j] D[j]和从 v j v_j vj到 v k v_k vk的弧上的权值之和。
一般情况下,假设 S S S为已求得最短路径的终点的集合,则可证明:下一条最短路径(设其终点为 x x x)或者是弧 ( v , x ) (v,x) (v,x),或者是中间只经过 S S S中的顶点而最后到达顶点 x x x的路径。
这可用反证法来证明。假设此路径上有一个顶点不在 S S S中,则说明存在一条终点不在 S S S而长度比此路径短的路径。但是,这是不可能的。因为我们是按路径长度递增的次序来产生各最短路径的,故长度比此路径短的所有路径均已产生,它们的终点必定在 S S S中,即假设不成立。