5.1 图的基本概念
5.1.1 图的定义
图(Graph)记为 (G=(V, E)),由顶点集 (V={v_1, v_2, cdots, v_n}) 和边集 (E={(u,v)| u,vin V}) 组成,
其中 V(G) 表示图 G 中顶点(Vertex)的有限非空集,E(G)表示图 G 中顶点之间关系边(Edge)的集合。
用 (n=|V|) 表示图 G 中顶点的个数,也称为图 G 的阶,
用 (e=|E|) 表示图 G 中边的条数。
注意:
线性表可以是空表,树可以是空树,但图不可以是空图。
就是说,图中不能一个顶点也没有,图的顶点集 V—定非空,但边集 E 可以为空,此时图中只有顶点而没有边。
下面是图的一些基本概念及术语。
(1)有向图
若 E 是有向边(也称为弧)的有限集合时,则图 G 为有向图。
弧是顶点的有序对,记为 <v, w>
,其中 v、w 是顶点,v 称为弧尾,w 称为弧头,称为从顶点 v 到顶点 w 的弧,也称 v 邻接到 w, 或 w 邻接自 v。
图 5-1(a) 所示的有向图 (G_1) 可表示为
(G_1 = (V_1, E_1))
(V_1 = {1, 2, 3})
(E_1 = {<1,2>, <2,1>, <2,3>})
(2)无向图
若 E 是无向边(简称边)的有限集合时,则图 G 为无向图。
边是顶点的无序对,记为 (v, w)
或 (w, v)
,因为 ((v,w) = (w,v)),其中 v、w 是顶点,故可以称:
顶点 w 和顶点 v 互为邻接点、边 (v,w) 依附于顶点 w 和 v,或者说边 (v,w) 和顶点 v、w 相关联。
图 5-1(b) 所示的无向图 (G_2) 可表示为
(G_2 = (V_2, E_2))
(V_2 = {1,2,3,4})
(E_2 = {(1,2), (1,3), (1,4), (2,3), (2,4), (3,4)})
(3)简单图与多重图
多重图的定义和简单图是相对的。
-
图 G 如果满足不存在重复边和不存在顶点到自身的边两个条件,则称 G 为简单图。
图 5-1 中 (G_1) 和 (G_2) 均为简单图。
数据结构中仅讨论简单图。 -
若图 G 中某两个结点之间的边数多于一条,又允许顶点通过同一条边和自己关联,则 G 为多重图。
(4)完全图
-
在无向图中,如果任意两个顶点之间都存在边,则称该图为无向完全图。
含有 n 个顶点的无向完全图有 n(n-1)/2 条边。 -
在有向图中,如果任意两个顶点之间都存在方向相反的两条弧,则称该图为有向完全图。
含有 n 个顶点的有向完全图有 n(n-1) 条有向边。
图 5-1 中 (G_2) 为无向完全图,而 (G_3) 为有向完全图。
(5)子图、生成子图
设有两个图 (G=(V,E)) 和 (G'=(V',E')),
- 若 (V') 是 (V) 的子集,且 (E') 是 (E) 的子集,则称 (G') 是 (G) 的子图。
- 若还满足顶点数 (V(G')=V(G)),则 (G') 为 G 的生成子图。
图 5-1 中 (G_3) 为 (G_1) 的子图。
注意:
并非 V 和 E 的任何子集都能构成 G 的子图,因为这样的子集可能不是图,也就是说,E 的子集中的某些边关联的顶点可能不在这个 V 的子集中。
(6)连通、连通图和连通分量
- 连通:在无向图中,若从顶点 v 到顶点 w 有路径存在,则称 v 和 w 是连通的。
- 连通图:若图 G 中任意两个顶点都是连通的,则称图 G 为连通图,否则称为非连通图。
- 连通分量:无向图中的极大连通子图称为连通分量。
如果一个图有 n 个顶点,并且有小于 n-1 条边,则此图必是非连通图。
如图 5-2(a) 所示,图 (G_4) 有 3 个连通分量,如图 5-2(b) 所示。
注意:
弄清连通、连通图、连通分量的概念非常重要。
首先要区分极大连通子图和极小连通子图:
- 极大连通子图是无向图的连通分量,极大即要求该连通子图包含其所有的边;
- 极小连通子图是既要保持图连通,又要使得边数最少的子图。
(7)强连通图、强连通分量
- 强连通:在有向图中,若从顶点 v 到顶点 w 和从顶点 w 到顶点 v 之间都有路径,则称这两个顶点是强连通的。
- 强连通图:若图中任何一对顶点都是强连通的,则称此图为强连通图。
- 强连通分量:有向图中的极大强连通子图称为有向图的强连通分量,图 (G_1) 的强连通分量如图 5-3 所示。
注意:
强连通图、强连通分量只是针对有向图而言。
一般在无向图中讨论连通性,在有向图中考虑强连通性。
(8)生成树、生成森林
- 生成树:连通图的生成树是包含图中全部顶点的一个极小连通子图。
- 若图中顶点数为 n,则它的生成树含有 n-1 条边。
- 对于生成树而言,若去掉一条边会变成非连通图;若加上一条边则会形成一个回路。
- 生成森林:在非连通图中,所有连通分量的生成树构成了非连通图的生成森林。
图 (G_2) 的一个生成树如图 5-4 所示。
注意:
包含无向图中全部顶点的极小连通子图,只有生成树满足条件,因为砍去生成树的任一条边,图将不再连通。
(9)顶点的度、入度和出度
度:图中每个顶点的度(Degree)定义为以该顶点为一个端点的边的数目。
-
对于无向图,顶点 v 的度是指依附于该顶点的边的条数,记为 (TD(v))。
在具有 n 个顶点 e 条边的无向图中,有 $$sum_{i=1}^n TD(v_i) = 2e$$
即无向图的全部顶点的度之和等于边数的两倍,这是因为每条边和两个顶点相关联。 -
对于有向图,顶点 v 的度分为入度和出度。
- 入度是以顶点 v 为终点的有向边的数目,记为 ID(v);
- 出度是以顶点 v 为起点的有向边的数目,记为 OD(v)。
顶点 v 的度等于其入度和出度之和,即 $$TD(v)=ID(v)+OD(v)$$
在具有 n 个顶点 e 条边的有向图中,(sum_{i=1}^n ID(v_i) = sum_{i=1}^n OD(v_i) = e),即有向图的全部顶点的入度之和与出度之和相等并且等于边数,这是因为每条有向边都有一个起点和终点。
(10)有向树
其中一个顶点的入度为 0,其余顶点的入度均为 1 的有向图构成有向树。
(11)边的权和网
在一个图中,每条边都可以标上具有某种含义的数值,该数值称为该边的权值。
这种边上带有权值的图称为带权图,也称作网。
(12)路径、路径长度和回路
- 路径:顶点 (v_p) 到顶点 (v_q) 之间的一条路径是指顶点序列 (v_p, v_{i1}, v_{i2},cdots,v_{im},v_q)。
- 路径长度:路径上边的数目。
- 回路(或环):第一个顶点和最后一个顶点相同的路径。
如果一个图有 n 个顶点,并且有大于 n-1 条边,则此图一定有环。
(13)简单路径、简单回路
- 简单路径:在顶点序列中顶点不重复出现的路径称为简单路径。
- 简单回路:除第一个顶点和最后一个顶点之外,其余顶点不重复出现的回路称为简单回路。
(14)距离
从顶点 u 出发到顶点 v 的最短路径长度称作从 u 到 v 的距离。
若从 u 到 v 根本不存在路径,则记该距离为无穷((infty))。
(15)稠密图、稀疏图
边数很少的图称为稀疏图;反之,称为稠密图。
稀疏和稠密本身是模糊的概念,稀疏图和稠密图常常是相对而言的。
一般当图 G 满足 (|E|lt |V| imes log|V|) 时,可以将 G 看成是稀疏图。
5.2 图的存储及基本操作
图的存储结构相比线性表和树更为复杂:
- 图中顶点没有次序之分
- 图中的边和顶点的数量任意
图主要的存储方式:
- 顺序存储:
- 邻接矩阵
- 边集数组
- 链式存储
- 邻接表
- 十字链表(有向图)
- 邻接多重表(无向图)
5.2.1 邻接矩阵法
所谓邻接矩阵存储,就是用一个一维数组存储图中顶点的信息,用一个二维数组存储图中边的信息(即各顶点之间的邻接关系),存储顶点之间邻接关系的二维数组称为邻接矩阵。
结点数为 n 的图 (G=(V,E)) 的邻接矩阵 A 是 (n imes n) 的。
将 G 的顶点编号为 (v_1,v_2,cdots,v_n),若 ((v_i,v_j)in E),则 (A[i][j]=1),否则 (A[i][j]=0)。
对于带权图而言,若顶点 (v_i) 和 (v_j) 之间有边相连,则邻接矩阵中对应项存放着该边对应的权值;若顶点 (v_i) 和 (v_j) 不相连,则用 (infty) 来代表这两个顶点之间不存在边。
有向图、 无向图和网对应的邻接矩阵示例如图 5-5 所示。
图的邻接矩阵存储结构定义如下:
#define MaxVertexNum 100 //顶点数目的最大值
typedef char VertexType; //顶点的数据类型
typedef int EdgeType; //带权图中边上权值的数据类型
typedef struct {
VertexType Vex[MaxVertexNum]; //顶点表
EdgeType Edge [MaxVertexNum][MaxVertexNum]; //邻接矩阵,边表
int vexnum, arcnum; //图的当前顶点数和弧数
} MGraph;
图的邻接矩阵存储表示法具有以下特点:
- 无向图的邻接矩阵是一个对称矩阵。
因此,在实际存储邻接矩阵时只需存储上(或下)三角矩阵的元素即可。 - 对于无向图,邻接矩阵的第 i 行(或第 i 列)非零元素(或非 (infty) 元素)的个数正好是第 i 个顶点的度 (TD(v_i))。
- 对于有向图,邻接矩阵的第 i 行(或第 i 列)非零元素(或非 (infty) 元素)的个数正好是第 i 个顶点的出度 (OD(v_i))(或入度 (ID(v_i)))。
- 用邻接矩阵法存储图,很容易确定图中任意两个顶点之间是否有边相连。
但是,要确定图中有多少条边,则必须按行、按列对每个元素进行检测,所花费的时间代价很大。
这是用邻接矩阵存储图的局限性。 - 邻接矩阵的存储方式适用于稠密图。
5.2.2 邻接表法
当一个图为稀疏图时,使用邻接矩阵表示法显然要浪费大量的存储空间。
而图的邻接表法结合了顺序存储和链式存储方法,大大减少了这种不必要的浪费。
在邻接表中存在两种结点:顶点表结点和边表结点。
顶点表结点由顶点域(data)和指向第一条邻接边的指针(firstarc)构成,
边表(邻接表)结点由邻接点域(adjvex)和指向下一条邻接边的指针域(nextarc)构成。
如图 5-6 所示。
无向图和有向图的邻接表的实例分别如图 5-7 和图 5-8 所示。
图的邻接表存储结构定义如下:
#define MaxVertexNum 100 //图中顶点数目的最大值
typedef struct ArcNode{ //边表结点
int adjvex; //该弧所指向的顶点的位置
struct ArcNode *next; //指向下一条弧的指针
//InfoType info; //网的边权值
} ArcNode;
typedef struct VNode { //顶点表结点
VertexType data; //顶点信息
ArcNode *first; //指向第一条依附该顶点的弧的指针
} VNode, AdjList[MaxVertexNum];
typedef struct {
AdjList vertices; //邻接表
int vexnum,arcnum; //图的顶点数和弧数
} ALGraph; //ALGraph 是以邻接表存储的图类型
图的邻接表存储方法具有以下特点:
- 如果 G 为无向图,则所需的存储空间为 (mathcal{O}(|V|+2|E|));
如果 G 为有向图,则所需的存储空间为 (mathcal{O}(|V|+|E|))。
前者的倍数 2 是由于无向图中,每条边在邻接表中出现了两次。 - 邻接表的存储方式适用于稀疏图
- 在邻接表中,给定一顶点,能很容易地找出它的所有邻边,因为只需要读取它的邻接表就可以了。
在邻接矩阵中,相同的操作则需要扫描一行,花费的时间为 (mathcal{O}(n))。
但是,如果要确定给定的两个顶点间是否存在边,则在邻接矩阵里可以立刻査到,在邻接表中则需要在相应结点对应的边表中査找另一结点,效率较低。 - 在有向图的邻接表表示中,求一个给定顶点的出度只需计算其邻接表中的结点个数即可;
但求其顶点的入度,则需要遍历全部的邻接表,此时可采用逆邻接表的存储方式来加速求解给定顶点的入度。 - 图的邻接表表示并不唯一
这是因为在每个顶点对应的单链表中,各边结点的链接次序可以是任意的,取决于建立邻接表的算法以及边的输入次序。
5.2.3 十字链表
十字链表是有向图的一种链式存储结构。
在十字链表中,对应于有向图中的每条弧有一个结点,对应于每个顶点也有一个结点。
这些结点的结构如下:
弧结点中有 5 个域:其中尾域 (tailvex) 和头域(headvex)分别指示弧尾和弧头这两个顶点在图中的位罝,链域 hlink 指向弧头相同的下一条弧,链域 tlink 指向弧尾相同的下一条弧,info域指向该弧的相关信息。
这样,弧头相同的弧在同一个链表上,弧尾相同的弧也在同一个链表上。
顶点结点中有 3 个域:data 域存放顶点相关的数据信息,如顶点名称,firstin 和 firstout 两个域分别指向以该顶点为弧头或弧尾的第一个弧结点。
图 5-9 为有向图的十字链表表示法。
注意,顶点结点之间是顺序存储。
图的十字链表存储结构定义如下:
#define MaxVertexNum 100 //图中顶点数目的最大值
typedef struct ArcNode{ //边表结点
int tailvex, headvex; //该弧的头尾结点
struct ArcNode *hlink, *tlink; //分别指向弧头相同和弧尾相同的结点
//InfoType info; //相关信息指针
} ArcNode;
typedef struct VNode { //顶点表结点
VertexType data; //顶点信息
ArcNode *firstin, *firstout; //指向第一条入弧和出弧
} VNode;
typedef struct{
VNode xlist[MaxVertexNum]; //邻接表
int vexnum, arcnum; //图的顶点数和弧数
} GLGraph; //GLGraph 是以十字邻接存储的图类型
在十字链表中,既容易找到 (v_i) 为尾的弧,也容易找到 (v_i) 为头的弧,因而容易求得顶点的出度和入度。
图的十字链表表示是不唯一的,但一个十字链表表示确定一个图。
5.2.4 邻接多重表
邻接多重表是无向图的另一种链式存储结构。
在邻接表中,容易求得顶点和边的各种信息,但在邻接表中求两个顶点之间是否存在边,或需要对边执行删除等操作时,需要分别在两个顶点的边表中遍历,效率较低。
与十字链表类似,在邻接多重表中,每一条边用一个结点表示,其结构如下图。
其中,mark 为标志域,可用以标记该条边是否被搜索过;
ivex 和 jvex 为该边依附的两个顶点在图中的位置;
ilink 指向下一条依附于顶点 ivex 的边;
jlink 指向下一条依附于顶点 jvex 的边;
info为指向和边相关的各种信息的指针域。
每一个顶点也用一个结点表示,它由如下所示的两个域组成。
其中,data 域存储该顶点的相关信息,firstedge 域指示第一条依附于该顶点的边。
在邻接多重表中,所有依附于同一顶点的边串联在同一链表中,由于每条边依附于两个顶点,则每个边结点同时链接在两个链表中。
图 5-10 为无向图的邻接多重表表示法。注意,每条边只有一个结点。
图的邻接多重表存储结构定义如下:
#define MaxVertexNum 100 //图中顶点数目的最大值
typedef struct ArcNode { //边表结点
bool mark; //访问标记
int ivex, jvex; //分别指向该弧的两个结点
struct ArcNode *ilink, *jlink; //分别指向两个顶点的下一条边
//InfoType info; //相关信息指针
} ArcNode;
typedef struct VNode { //顶点表结点
VertexType data; //顶点信息
ArcNode *firstedge; //指向第一条依附该顶点的边
} VNode;
typedef struct {
VNode adjmulist[MaxVertexNum]; //邻接表
int vexnum, arcnum; //图的顶点数和弧数
} AMLGraph; //AMLGraph 是以邻接多重表存储的图类型
5.2.5 图的基本操作
图的基本操作是独立于图的存储结构的。
而对于不同的存储方式,操作算法的具体实现会有着不同的性能。
请读者根据上述的存储方式,考虑如下具体算法如何实现,以及采用何种存储方式的算法效率会更高。
图的基本操作主要包括(仅抽象地考虑,故忽略掉各变量的类型):
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 (Qx, y, v)
:设置图 G 中边(x, y)
或<x, y>
对应的权值为 v。
此外还有图的遍历算法:按照某一种方式访问图中每一个顶点且仅访问一次。
图的遍历算法包括深度优先遍历和广度优先遍历,具体见下一节内容。
5.3 图的遍历
图的遍历是指从图中的某一顶点出发,按照某种搜索方法沿着图中的边对图中的所有顶点访问且仅访问一次。
- 注意到树是一种特殊的图,所以树的遍历实际上也可以看作是一种特殊的图的遍历。
- 图的遍历是图的一种最基本的操作,其他许多操作都建立在图的遍历操作基础之上。
图的遍历主要有两种算法:广度优先搜索和深度优先搜索(包括广度优先搜索和深度优先搜索在内的几乎所有图的搜索算法都可以抽象为优先级搜索或最佳优先搜索)。
- 广度优先搜索会优先考虑最早被发现的顶点,也就说离起点越近的顶点优先级越高。
- 深度优先搜索会优先考虑最后被发现的顶点。
广度优先搜索算法由 Edward F. Moore 在研宂迷宫路径问题时发现;
深度优先搜索在 20 世纪 50 年代晚期获得广泛使用,尤其是在人工智能方面。
5.3.1 广度优先搜索(Breadth-First-Search,BFS)
广度优先搜索(BFS)类似于二叉树的层序遍历算法,它的基本思想是:
- 首先访问起始顶点 v,接着由 v 出发,依次访问 v 的各个未访问过的邻接顶点 (w_1,w_2,cdots,w_i),
- 然后再依次访问 (w_1,w_2,cdots,w_i) 的所有未被访问过的邻接顶点;
- 再从这些访问过的顶点出发,再访问它们所有未被访问过的邻接顶点……依次类推,直到图中所有顶点都被访问过为止。
类似的思想还将应用于Dijkstra 单源最短路径算法和 Prim 最小生成树算法。
- 广度优先搜索是一种分层的査找过程,每向前走一步可能访问一批顶点,不像深度优先搜索那样有往回退的情况,因此它不是一个递归的算法。
- 为了实现逐层的访问,算法必须借助一个辅助队列,以记忆正在访问的顶点的下一层顶点。
广度优先搜索算法的伪代码如下:
#define MaxSize 100
bool visited[MaxSize]; //访问标记数组
void BFS(Graph G, int v) {
InitQuene(Q);
visit(v); //访问初始顶点 v
visited[v] = TRUE; //对 v 做已访问标记
Enqueue(Q, v); //顶点 v 入队列
while(!isEmpty(Q)){
DeQueue(Q, v); //顶点 v 出队列
for(w=FirstNeighbor(G,v); w>=0; w=NextNeighbor(G,v,w))
//检测 v 所有邻接点
if(!visited[w]){ //w 为 v 的尚未访问的邻接顶点
visit(w); //访问顶点 w
visited[w]=TRUE; //对 w 做己访问标记
EnQueue(Q, w); //顶点 w 入队列
}
}//while
}
void BFSTraverse(Graph 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
}
辅助数组 visited[] 标志顶点是否被访问过,它的初始状态为 FALSE。
在图的遍历过程中,一旦某一个顶点 v,被访问,就立即置 visited[i] 为 TRUE, 防止它被多次访问。
下面通过实例,演示广度优先搜索的过程,给定图 G 如图 5-13 所示。
假设从 a 结点开始访问,a 先入队。
此时队列非空,取出队头元素 a,由于 b、c 与 a 邻接且未被访问过,于是依次访问 b、 c, 并将 b、c 依次入队。
队列非空,取出队头元素 b, 依次访问与 b 邻接且未被访问的顶点 d、e,并将 d、e 入队(注意:a 与 b 也邻接,但 a 己置访问标记,故不再重复访问)。
此时队列非空,取出队头元素 c,访问与 c 邻接且未被访问的顶点 f、g,并将 f、g 入队。
此时,取出队头元素 d,但与 d 邻接且未被访问的顶点为空,故而不进行任何操作。
继续取出队头元素 e,将 h 入队列……当最后取出队头元素 h 后,队列为空,从而循环自动跳出。
遍历结果为 abcdefgh
。
从上例不难看出,图的广度优先搜索的过程与二叉树的层序遍历是完全一致的,这也说明了图的广度优先搜索遍历算法是二叉树的层次遍历算法的扩展。
图的广度优先遍历还可以用在求一些问题的最优解上,不过初试方面很难涉及,有兴趣的同学可以参考《 王道考研系列:计算机考研一机试指南》。
1. BFS 算法的性能分析
无论是邻接表还是邻接矩阵的存储方式,BFS 算法都需要借助一个辅助队列 Q,
n 个顶点均需入队一次,在最坏的情况下,空间复杂度为 (mathcal{O}(|V|))。
- 当采用邻接表存储方式时,每个顶点均需搜索一次(或入队一次),故时间复杂度为 (mathcal{O}(|V|)),
在搜索任一顶点的邻接点时,每条边至少访问一次,故时间复杂度为 (mathcal{O}(|E|)),
算法总的时间复杂度为 (mathcal{O}(|V|+|E|))。 - 当采用邻接矩阵存储方式时,査找每个顶点的邻接点所需时间为 (mathcal{O}(|V|)),故算法总的时间复杂度为 (mathcal{O}(|V|^2))。
2. BFS 算法求解单源最短路径问題
- 最短路径:如果图 (G=(V,E)) 为非带权图,定义从顶点 u 到顶点 v 的最短路径 (d(u,v)) 为从 u 到 v 的任何路径中最少的边数;
如果从 u 到 v 没有通路,则 (d(u,v)=infty)。
使用 BFS, 我们可以求解一个满足上述定义的非带权图的单源最短路径问题,这是由广度优先搜索总是按照距离由近到远来遍历图中每个顶点的性质决定的。
BFS 算法求解单源最短路径问题的算法如下:
void BFS_MIN_Distance(Graph G, int u) {
//d[i】表系从 u 一到 i 结点的最短路径
for(i=0; i<G.vexnum; ++i)
d[i]= infty;
visited[u] = TRUE;
d[u] = 0;
EnQueue(Q, u);
while(!isEmpty(Q)){ //BFS 算法主过程
DeQueue(Q,u); //队头元素 u 出队
for(w=FirstNeighbor(G,u); w>=0; w=NextNeighbor(G,u,w))
if(!visited[w]){ //w 为 u 的尚未访问的邻接顶点
visited[w] = TRUE; //设己访问标记
d[w] = d[u]+1; //路径长度加 1
EnQueue(Q,w); //顶点 W 入队
}//if
}//while
}
3. 广度优先生成树
在广度遍历的过程中,我们可以得到一棵遍历树,称为广度优先生成树,如图 5-14 所示。
需要注意的是,一给定图的邻接矩阵存储表示是唯一的,故其广度优先生成树也是唯一的,但由于邻接表存储表示不唯一,故其广度优先生成树也是不唯一的。
5.3.2 深度优先搜索(Depth-First-Search,DFS)
与广度优先搜索不同,深度优先搜索(DFS)类似于树的先序遍历。
正如其名称中所暗含的意思一样,这种搜索算法所进循的搜索策略是尽可能“深”地搜索一个图。
它的基本思想如下:
首先访问图中某一起始顶点 v,然后由 v 出发,访问与 v 邻接且未被访问的任一顶点 (w_1),再访问与 (w_1) 邻接且未被访问的任一顶点 (w_2, cdots),重复上述过程。
当不能再继续向下访问时,依次退回年到最近被访问的顶点,若它还有邻接顶点未被访问过,则从该点开始继续上述搜索过程,直到图数中所有顶点均被访问过为止。
一般情况下,其递归形式的算法十分简洁。
下面描述其算法过程。
bool visited[MAX__VERTEX_NUM]; //访问标记数组
void DFSTraverse(Graph G){
//对图 G 进行深度优先遍历,访问函数为 visit ㈠
for(v=0; v<G.vexnum; ++v)
visited[v] = FALSE; //初始化己访问标记数据
for(v=0; v<G.vexnum; ++v) //本代码中是从 v_0 开始遍历
if(!visited[v])
DFS(G, v);
}
void DFS(Graph G, int v){
//从顶点 v 出发,采用递归思想,深度优先遍历图 G
visit(v); //访问顶点 v
visited[v] = TRUE; // 设己访问标记
for(w=FirstNeighbor(G,v); w>=0; w=NextNeighor(G,v,w))
if(!visited[w]) { //w 为 u 的尚未访问的邻接顶点
DFS(G,w);
}
}
以图 5-13 所示的无向图为例,演示深度优先搜索的过程:
首先访问 a,并置 a 己访问标记;
然后访问与 a 邻接且未被访问的顶点 b,置 b 己访问标记;
然后访问与 b 邻接且未被访问的顶点 d,置 d 己访问标记。
此时 d 己没有未被访问过的邻接点,故返回上一个访问过的顶点 b,访问与其邻接且未被访问的顶点 e,置 e 访问标记……
依次类推,直到图中所有的顶点访问一次且仅访问一次。
遍历结果为 abdehcfg
。
注意:
图的邻接矩阵表示是唯一的,但对于邻接表来说,如果边的输入次序不同,生成的邻接表也不同。
因此,对于同样一个图,基于邻接矩阵的遍历所得到的 DFS 序列和 BFS 序列是唯一的,基于邻接表的遍历所得到的 DFS 序列和 BFS 序列是不唯一的。
1. DFS 算法的性能分析
DFS 算法是一个递归算法,需要借助一个递归工作栈,故它的空间复杂度为 (mathcal{O}(|V|))。
遍历图的过程实质上是对每个顶点査找其邻接点的过程,其耗费的时间取决于所采用的存储结构。
- 当以邻接矩阵表示时,査找每个顶点的邻接点所需时间为 (mathcal{O}(|V|)),
故总的时间复杂度为 (mathcal{O}(|V|^2))。 - 当以邻接表表示时,査找所有顶点的邻接点所需时间为 (mathcal{O}(|E|)),访问顶点所需时间为 (mathcal{O}(|V|)),
此时总的时间复杂度为 (mathcal{O}(|V|+|E|))。
2. 深度优先的生成树和生成森林
与广度优先搜索一样,深度优先搜索也会产生一棵深度优先生成树。
当然,这是有条件的,即对连通图调用 DFS 才可以产生深度优先生成树,否则产生的将是深度优先生成森林,如图 5-15 所示。
和 BFS 类似,基于邻接表存储的深度优先生成树是不唯一的。
5.3.3 图的遍历与图的连通性
图的遍历算法可以用来判断图的连通性。
-
对于无向图来说,如果无向图是连通的,则从任一结点出发,仅需一次遍历就能够访问图中所有顶点;
如果无向图是非连通的,则从某一个顶点出发,一次遍历只能访问到该顶点所在连通分量的所有顶点,而对于图中其他连通分量的顶点,则无法通过这次遍历访问。 -
对于有向图来说,若从初始点到图中的每个顶点都有路径,则能够访问到图中的所有顶点,否则不能访问到所有顶点。
故而在 BFSTraverse() 或 DFSTraverse() 中添加了第二个 for 循环,再选取初始点,继续进行遍历,以防止一次无法谝历图的所有顶点。 -
对于无向图,上述两个函数调用
BFS(G,i)
或DFS(G,i)
的次数等于该图的连通分量数;
而对于有向图,则不是这样,因为一个连通的有向图分为强连通的和非强连通的,
它的连通子图也分为强连通分量和非强连通分量,非强连通分量一次调用 BFS(G, i) 或 DFS(G,i) 无法访问到该连通分量的所有顶点。
如图 5-16 所示。
5.4 图的应用
本节是历年考査的重点。
图的应用主要包括:最小生成(代价)树、最短路径、拓扑排序和关键路径。
一般而言,这部分内容直接以算法设计题形式考査的可能性很小,而更多的是结合图的实例来考查算法的具体执行过程,读者必须学会对给定的图手工模拟各个算法的执行过程。
此外,还需掌握对于给定的模型建立相应的图去解决问题的方法。
5.4.1 最小生成树(Minimum-Spanning-Tree,MST)
- 一个连通图的生成树是图的极小连通子图,它包含图中的所有顶点,并且只含尽可能少的边(n-1 条)。
- 对于生成树来说,若砍去它的一条边,就会使生成树变成非连通图;
若给它增加一条边,就会形成图中的一条回路。
对于一个带权连通无向图 (G=(V,E)),生成树不同,每棵树的权( 即树中所有边上的权值之和)也可能不同。
设 R 为 G 的所有生成树的集合,若 T 为 W 中边的权值之和最小的那棵生成树,则 T 称为 G 的最小生成树。
不难看出,最小生成树具有如下性质:
- 最小生成树不是唯一的,即最小生成树的树形不唯一,R 中可能有多个最小生成树。
当图 G 中的各边权值互不相等时,G 的最小生成树是唯一的;
若无向连通图 G 的边比顶点数少 1,即G 本身就是一棵树时,G 的最小生成树就是它本身。 - 最小生成树的边的权值之和总是唯一的,虽然最小生成树不唯一,但其对应的边的权值之和总是唯一的,而且是最小的。
- 最小生成树的边数为顶点数减 1。
- 构造最小生成树有多种算法,但大多数算法都利用了最小生成树的下列性质:
假设 (G=(V,E)) 是一个带权连通无向图,U 是顶点集 V 的一个非空子集,
若 ((u,v)) 是一条具有最小权值的边,其中 (uin U),(vin V-U), 则必存在一棵包含边 ((u,v)) 的最小生成树。
基于该性质的最小生成树算法主要有:Prim 算法和 Kruskal 算法,它们都基于贪心算法的策略。
对这两种算法的掌握不应拘泥于其代码实现,而应掌握算法的本质含义和基本思想,并能够手工模拟算法的实现步骤。
下面介绍一个通用的最小生成树算法:
GENERIC_MST(G){
T = NULL;
while(T 未形成一棵生成树) {
找到一条最小代价边 (u, v) 并且加入 T 后不会产生回路;
T = T cup (u,v)
}
}
通用的算法采用每次加入一条边来逐渐形成一棵生成树。
下面介绍两种实现上述通用算法的途径:
1. 普里姆(Prim)算法
Prim 算法的执行非常类似于寻找图的最短路径的 Dijkstra 算法(见下一节)。
假设 (N={V,E}) 是连通网,(E_r) 是 N 上最小生成树中边的集合。
算法从 (V_T ={u_0} (u_0in V), E_T={}) 开始,重复执行下述操作:
在所有 (uin V_T, vin V-V_T) 的边 ((u,v)in E) 中找一条代价最小的边 ((u_0,v_0)) 并入集合 (E_T),同时将 (v_0) 并入 (V_T),直至 (V_T=V) 为止。
此时 (E_r) 中必有 n-1 条边,则 (T={V_T,E_T}) 为 N 的最小生成树。
图 5-20 所示为 Prim 算法构造最小生成树的过程。
Prim 算法的步骤如下:
- 初始化:向空树 (T= (V_T, E_T)) 中添加图 (G=(V,E)) 的任一顶点 (u_0),使 (V_T={u_0}),(E_T= emptyset)。
- 循环(重复下列操作至 (V_T=V)):从图 G 中选择满足 ({(u,v)|uin V_T,vin V-V_T}) 且具有最小权值的边 ((u,v)),并置 (V_T=V_Tcup{v}, E_T=E_Tcup{(u,v)})。
Prim 算法的简单实现如下:
void Prim(G, T){
T = emptyset //初始化空树
U = {w}; //添加任一顶点w
while((V-U) != emptyset){ 若树中不含全部顶点
设 (u, v) 是使 uin U与 vin (V-U), 且权值最小的边;
T = Tcup {(u,v)}; //边归入树
U = Ucup {v}; //顶点归入树
}
}
Prim 算法的时间复杂度为 (mathcal{O}(|V|^2)),不依赖于 (|E|),因此它适用于求解边稠密的图的最小生成树。
虽然采用其他方法可以改进 Prim 算法的时间复杂度,但增加了实现的复杂性。
2. 克鲁斯卡尔(Kruskal)算法
与 Prim 算法从顶点开始扩展最小生成树不同,Kruskal 算法是一种按权值的递增次序选择合适的边来构造最小生成树的方法。
假设 (N=(V,E)) 是连通网,对应的最小生成树 (T=(V_T,E_T)),Kruskal 算法的步骤如下:
- 初始化:(V_T=V, E_T=emptyset),即每个顶点构成一棵独立的树,T 此时是一个仅含 (|V|) 个顶点的森林;
- 循环(重复下列操作至 T 是一棵树):按 G 的边的权值递增顺序依次从 (E-E_T) 中选择一条边,如果这条边加入 T 后不构成回路,则将其加入 (E_T),否则舍弃,直到 (E_T) 中含有 n-1 条边。
Kruskal 算法的简单实现如下:
void Kruskal(V,T){
T = V; //初始化树T,仅含顶点
numS = n; //连通分量数
while(numS>1) { //如果连通分量数大于 1
从 E 中取出权值最小的边 (v,u);
if(v 和 u 属于 T 中不同的连通分量) {
T = Tcup {(v,u)}; //将此边加入生成树中
numS--; ///连通分量数减 1
}
}
}
根据图的相关性质,若一条边连接了两棵不同树中的顶点时,则对这两棵树来说,它必定是连通的,将这条边加入森林中,完成两棵树的合并,直到整个森林合并成一棵树。
对于图 5-21(a) 所示的连通网,按照 Kruskal 算法构造最小生成树的过程如图 5-21 所示。
在构造过程中,按照网中边的权值由小到大的顺序,不断选取当前未被选取的边集中权值最小的边。
依据生成树的概念,n 个结点的生成树,有 n-1 条边,故反复上述过程,直到选取了 n-1 条边为止,就构成了一棵最小生成树。
通常在 Kruskal 算法中,采用堆来存放边的集合,则每次选择最小权值的边只需 (mathcal{O}(log|E|)) 的时间。
又生成树 T 中所有边可以看作一个等价类,每次添加新的边的过程类似于求解等价类的过程,由此可以采用并査集的数据结构来描述 T,从而构造 T 的时间复杂度为 (mathcal{O}(|E|log|E|)),因此,Kruskal 算法适合于边稀疏而顶点较多的图。
5.4.2 最短路径
在 5.3 节所述的广度优先搜索査找最短路径只是对无权图而言,
若图是带权图,则把从一个顶点 (v_0) 到图中其余任一个顶点 (v_i) 的一条路径(可能不止一条)上所经过边上的权值之和定义为该路径的带权路径长度,把带权路径长度最短的那条路径也称作最短路径。
求解最短路径的算法通常都依赖于一种性质,也就是两点之间的最短路径也包含了路径上其他顶点间的最短路径。
带权有向图 G 的最短路径问题,一般可分为两类:
- 一是单源最短路径,即求图中某一顶点到其他各顶点的最短路径,可通过经典的 Dijkstra 算法求解;
- 二是求每一对顶点间的最短路径,可通过 Floyd-Warshall 算法来求解。
1. Dijkstra 算法求单源最短径问题
求带权有向图中某个源点到其余各顶点的最短路径,最常用的是 Dijkstra 算法。
该算法设置一个集合 S 记录己求得的最短路径的顶点,可用一个数组 s[] 来实现,初始化为 0,当 (s[v_i]=1) 时表示将顶点 (v_i) 放入 S 中,初始时把源点 (v_0) 放入 S 中。
此外,在构造过程中还设置了两个辅助数组:
dist[]
:记录了从源点 (v_0) 到其他各顶点当前的最短路径长度,(dist[i]) 初值为 (arcs[v_0][i])。
path[]
:path[i]
表示从源点到顶点 i 之间的最短路径的前驱结点,在算法结束时,可根据其值追溯得到源点 (v_0) 到顶点 (v_i) 的最短路径。
假设从顶点 0 出发,即 (v_0=0),集合 S 最初只包含顶点 0,邻接矩阵 arcs 表示带权有向图,(arcs[i][j])
表示有向边 (<i,j>) 的权值,若不存在有向边 (<i,j>),则 (arcs[i][j]) 为 (infty),Dijkstra 算法的步骤如下(不考虑对 path[] 的操作):
- 初始化:集合 S 初始为 {0},dist[] 的初始值 (dist[i]=arcs[0][i], i=1,2,cdots,n-1)。
- 从顶点集合 V-S 中选出 (v_j),满足 (dist[j]=Min{dist[i]|v_iin V-S}),(v_j) 就是当前求得的一条从 (v_0) 出发的最短路径的终点,令 (S=Scup{j})。
- 修改从 (v_0) 出发到集合 V-S 上任一顶点 (v_k) 可达的最短路径长度:
如果 dist[j]+arcs[j][k]<dist[k],则令 dist[k]=dist[j]+arcs[j][k]。 - 重复 2、3 操作共 n-1 次,直到所有的顶点都包含在 S 中。
例如,表 5-1 所示为应用 Dijkatra 算法对图 5-22 中的图从顶点 1 出发,求其到其余顶点的最短路径。
思考:Dijkatra 算法与 Dijkstra Prim 算法的相似之处?
显然,Dijkstra 算法也是基于贪心策略的。
算法的主要部分为一个双重循环,外层循环内有两个并列的单层循环,任取一个循环内的操作为基本操作,则基本操作执行的总次数为双重循环执行的次数。
若使用邻接矩阵表示,它的时间复杂度为 (mathcal{O}(|V|^2))。
若使用带权的邻接表表示,虽然修改 dist[]的时间可以减少,但由于在 dist[] 中选择最小分量的时间不变,其时间复杂度仍为 (mathcal{O}(|V|^2))。
人们可能只希望找到从源点到某一个特定顶点的最短路径,但是,这个问题和求解源点到其他所有顶点的最短路径一样复杂,其时间复杂度也为 (mathcal{O}(|V|^2))。
而如果要找出所有结点对之间的最短距离,则需要对每个结点运行一次 Dijstra 算法,即时间复杂度为 (mathcal{O}(|V|^3))。
值得注意的是,如果边上带有负权值,Dijkstra 算法并不适用。
若允许边上带有负权值,有可能出现当与 S(己求得最短路径的顶点集,归入 S 内的结点的最短路径不再变更)内某点(记为 a)以负边相连的点(记为 b)确定其最短路径时,
它的最短路径长度加上这条负边的权值结果小于 a 原先确定的最短路径长度,而此时 a 在 Dijkstra 算法下是无法更新的。
例如,对于图 5-23 所示的带权有向图,利用 Dijkstra 算法不一定能得到正确的结果。
2. Floyd 算法求各顶点之间最短路径问题
求所有顶点之间的最短路径问题描述如下:
已知一个各边权值均大于 0 的带权有向图,对每一对顶点 (v_i
e v_j),要求求出 (v_i) 与 (v_j) 之间的最短路径和最短路径长度。
Floyd 算法的基本思想是:
递推产生一个 n 阶方阵序列 (A^{(-1)},A^{(0)},cdots,A^{(k)},cdots,A^{(n-1)}),其中 (A^{(k)}[i][j]) 表示从顶点 (v_i) 到顶点 (v_j) 的路径长度,k 表示绕行第 k 个顶点的运算步骤。
初始时,对于任意两个顶点 (v_i) 和 (v_j),若它们之间存在边,则以此边上的权值作为它们之间的最短路径长度;
若它们之间不存在有向边,则以 (infty) 作为它们之间的最短路径长度。
以后逐步尝试在原路径中加入顶点 k((k=0,1,cdots,n-1))作为中间顶点。
如果增加中间顶点后,得到的路径比原来的路径长度减少了,则以此新路径代替原路径。
算法的描述如下:
定义一个 n 阶方阵序列:(A^{(-1)},A^{(0)},cdots,A^{(n-1)}),其中