本章阐述图的表示方法和图的搜索方法。图的搜索指的是系统化的沿着图的边访问所有顶点。图的搜索算法可以用来发现图的结构。许多图的算法在开始时,都是通过搜索获取图结构信息。另外还有一些图的算法实际上是由基本的图搜索算法经过简单扩充而成的。因此。图的搜索技术是图算法领域的核心。
一:图的表示
对于图G=(V, E),可以有两种表示方法:邻接链表和邻接矩阵。两种表示方法既可以表示无向图,也可以表示有向图。邻接链表适合表示稀疏图(边的条数|E|远远小于 ),本书给出的多数图算法都假定图以邻接链表的方式表示。邻接矩阵适合表示稠密图(|E|接近 ),而且,如果需要快速判断两个节点之间是否有边相连,也可以用邻接矩阵表示法。如下图:
1:邻接链表
Adj为一个包含|V|条链表的数组,每个节点有一条链表。对于每个节点u∈V,邻接链表Adj[u]包含所有与节点u之间有边相连的节点v。
如果G是一个有向图,则对于边(u, v)来说,节点v将出现在链表Adj[u]里,所以,所有邻接链表的长度之和等于|E|。如果G是一个无向图,对于边(u, v)来说,节点v将出现在链表Adj[u]里,而且节点u将出现在链表Adj[v]里,所以,所有邻接链表的长度之和等于2|E|。所以,不管是有向图还是无向图,邻接链表表示法的存储空间均为O(V+E)。
邻接链表的表示法也有着潜在的不足之处是无法快速判断一条边(u, v)是否是图中的一条边。邻接矩阵则客服的了这个缺陷,但是付出的代价是更大的存储空间。
2:邻接矩阵
对于邻接矩阵表示法,将G中的节点编为1,2,...,|V|,编号可以是任意的。进行编号之后,图G的邻接矩阵表示由一个|V|*|V|的矩阵A表示,矩阵满足下面的条件:
邻接矩阵的空间需求为O( )。无向图的邻接矩阵是一个对称矩阵。在某些应用中,只存储邻接矩阵的对角线及对角线以上的部分,这样一来,图所占用的存储空间几乎可以减少一半。
邻接链表表示和邻接矩阵表示在渐近意义下至少是一样有效的,但由于邻接矩阵简单明了,因而当图较小时,更多地采用邻接矩阵来表示。
在伪代码中,v.d表示节点v的属性d。(u,v).f表示边(u,v)的属性。
二:广度优先搜索
广度优先搜索是最简单的图搜索算法之一,给定图G=(V, E)和一个可以识别的源节点s,广度优先搜索系统地探索G中的边,来发现可从s到达的所有顶点。该算法能够计算从源节点s到每个可到达的节点的最短距离。该搜索算法同时还能生成一棵根为s,且包括所有s的可达顶点的“广度优先搜索树”。对于每个源节点s可以到达的节点v,在广度优先搜索树里从节点s到节点v的简单路径所对应的就是图G中从节点s到节点v的最短路径,该算法既可以用于有向图,也可用于无向图。
该搜索算法之所以称为广度优先搜素,是因为它始终是将已发现和未发现顶点之间的边界,沿其广度方向向外扩展。亦即,算法首先会发现和s距离为k的所有顶点,然后才会发现和s距离为k+1的其他顶点。
广度优先搜索在概念上将每个节点涂上白色,灰色或黑色。所有节点一开始均为白色,在算法推进过程中,这些节点可能会变为灰色或黑色。灰色节点代表的是已知和未知两个集合之间的边界。
广度优先搜索算法BFS中,假定输入图G=(V,E)是以邻接链表所表示的,u.color表示节点u的颜色,u.π表示在广度优先树中u的前驱节点。u.d表示从源节点s到节点u的距离。该算法使用先进先出的队列来管理灰色节点集合。算法如下:
BFS(G, s)
for each vertex u G.V-{s}
u.color = WHITE
u.d = ∞
u.π = NIL
s.color = GRAY
s.d = 0
s.π = NIL
Q =
ENQUEUE(Q, s)
while Q !=
u = DEQUEUE(Q)
for each v ∈ G.Adj[u]
if v.color == WHITE
v.color = GRAY
v.d = u.d + 1
v.π = u
ENQUEUE(Q, v)
u.color = BLACK
广度优先搜索的结果可能依赖于对每个节点的邻接节点的访问顺序,广度优先树可能会不一样,但本算法所计算出来的距离d都是一样的。在该算法中,队列操作的总时间为O(V),扫描邻接链表的总时间为O(E)。因此,广度优先搜索的总运行时间为O(V+E)。
广度优先搜索算法可以得到从源顶点s到所有可达顶点的最短距离v.d。同时,该算法能够构造出广度优先树。下面的代码为打印一颗广度优先树中,源节点s到任意结点v的路径:
PRINT-PATH(G, s, v)
if v == s
print s
else if v.π == NIL
print “nopath from” s “to” v “exists”
else PRINT-PATH(G, s, v.π)
print v
三:深度优先搜索
深度优先搜索:只要可能,就在图中尽量“深入”。这个过程一直持续到从源节点可以达到的所有节点都被发现为止。如果还存在尚未发现的节点,则深度优先搜索将从这些未被发现的节点中任选一个作为新的源节点。该算法重复整个过程,直到图中的所有节点都被发现为止。
在讨论广度优先搜索时,源节点的数量限制为一个,而深度优先搜索则可以有多个源节点。这主要是因为广度优先搜索和深度优先搜索有不同的用途,BFS通常用来寻找从特定源节点出发的最短路径距离,而DFS则常常作为另一个算法里的一个子程序。因此,BFS形成一棵树,而DFS形成一个森林,称为深度优先森林。
与广度优先搜索类似,在深度优先搜索过程中,也通过对顶点进行着色来表示顶点的状态。开始时,每个顶点均为白色,搜索中被发现时即置为灰色,结束时又被置成黑色(即当其邻接表被完全检索之后)。这一技巧可以保证每一个顶点在搜索结束时,只存在于一棵深度优先树中,因此,这些树是不相交的。
除了创建一个深度优先森林外,深度优先搜索同时为每个顶点加盖时间戮。每个节点v有两个时间戮:第一个时间戳v.d记录当节点第一次被发现(置成灰色)的时间。第二个时间戳v.f记录的是搜索完成对v的邻接链表扫描的时间(涂上黑色的时候)。这些时间戳提供了图结构的重要信息,通常能够帮助推断深度优先搜索算法的行为。
因为|V|个节点中的每一个节点,都只能有一个发现事件和一个完成事件。所以这些时间戳都是处于1和2|V|之间的整数,而且对于每个节点u,有u.d
下面为伪代码,输入图G既可以是有向图,也可以是无向图:
DFS(G)
for each vertes u∈ G.V
u.color= WHITE
u.π = NIL
time = 0
for each vertes u∈ G.V
if u.color == WHITE
DFS-VISIT(G,u)
DFS-VISIT(G, u) //recursiverealization
time = time + 1
u.d = time
u.color = GRAY
for each v∈ G.Adj[u]
if v.color == WHITE
v.π = u
DFS-VISIT(G,v)
u.color = BLACK
time = time + 1
u.f = time
-------------------------------------------------------------------------------------------------------------------------------------
DFS-VISIT(G, u) //stackrealization
time = time + 1
u.d = time
u.color = GRAY
s.push(u)
while(s.isempty() == false)
u = s.top()
flag = false
for each v ∈ G.Adj[u]
if v.color == WHITE
v.π = u
time = time + 1
v.d = time
v.color = GRAY
s.push(v)
flag = true
break
if flag == false
u = s.pop()
u.color = BLACK
time = time +1
u.f = time
对于每个节点v ∈G.V来说,DFS-VISIT被调用的次数刚好为一次。该算法的运行时间为O(V+E)。注意深度优先搜索的结果可能依赖于算法DFS的第5行中各节点被访问的顺序,也依赖于在DFS-VISIT的第4行中一个节点的邻接链表被访问的顺序。在实践中,这些不同的访问顺序往往不会引起什么问题。因为任何深度优先搜索结果通常都可以被有效地利用,最终结果基本上都是等价的。
深度优先搜索的一个重要特性是发现和完成时间具有括号结构。如果以用左括号”(u”表示节点u的发现时间,用右括号”u)”表示节点u的完成时间,那么,发现和完成的历史记载形成一个规整的表达式,所有的括号都适当的嵌套在一起,如下图:
性质1:在对有向图或无向图G = (V, E)进行的任意DFS过程中,对于任意两个节点u和v来说,下面三种情况只有一种成立:
a:区间[u.d, u.f]和区间[v.d, v.f]完全分离,在深度优先森林中,节点u不是节点v的后代,节点v也不是节点u的后代。
b:区间[u.d, u.f]包含在区间[v.d, v.f]中,在深度优先树中,节点u是节点v的后代。
c:区间[v.d, v.f]包含在区间[u.d, u.f]中,在深度优先树中,节点v是节点u的后代。
性质二:在有向或无向图G的深度优先森林中,节点v是节点u的真后代当且仅当u.d < v.d
性质三:在有向或无向图G = (V, E)的深度优先森林中,节点v是节点u的后代,当且仅当在时间u.d,存在一条从节点u到节点v的全部由白色节点所构成的路径。
通过DFS可以对图G的边进行分类:
1:树边:深度优先森林中的边。
2:后向边:后向边(u, v)是将节点u连接到其在深度优先树中祖先节点v的边。
3:前向边:是将节点u连接到其在深度优先树中一个后代节点v的边(u, v)。
4:横向边:其他所有的边。
如下图,B表示后向边,F表示前向边,C表示横向边,阴影边为树边:
性质四:在对无向图G进行DFS时,每条边要么是树边,要么是后向边。
四:拓扑排序
深度优先搜索算法可以对有向无环图进行拓扑排序。对于有向无环图G,拓扑排序是指G中所有节点的一种线性排序。该次序满足下面的条件:如果图G中包含边(u, v),则节点u在拓扑排序中处于节点v的前面。可以将拓扑排序看做是将图的所有节点在一条水平线上排开,图的所有有向边都是从左指向右。
许多实际应用需要使用有向无环图来指明事件的优先顺序。比如下图为某人起床后的穿衣顺序,有向边(u, v)表示必须先穿u,然后才能穿v:
利用DFS进行拓扑排序的算法如下:
TOPOLOGICAL-SORT(G)
call DFS(G) to compute finishing times v.f for each vertex v
as each vertex is finished, insert it into the front of linked list
return the linked list of vertices
拓扑排序的次序与节点的完成时间正好相反。该算法的时间复杂度为O(V+E)。
一个有向图G=(V, E)是无环的当且仅当对其进行DFS时,不产生后向边。之所以说TOPOLOGICAL-SORT能够生成有向无环图的拓扑排序,是因为对于不同的节点u,v,如果存在边(u, v),则v.f < u.f。证明过程见P356
五:强连通分量
利用DFS可以将有向图分解为强连通分量。有向图G=(V, E)的强连通分量是一个最大节点集合C V,对于该集合中的任意一对节点u和v来说,路径u->v和路径v->u同时存在,即节点u和v可以相互抵达,如下图:
利用DFS寻找强连通分量时,需要使用到图G=(V, E)的转置 = (V, ),这里 ={(u, v): (v, u) ∈E}。也就是 由对图G中的边进行反向而获得。给定G的邻接链表,得到 的时间为O(V+E)。G和 的强连通分量完全相同:u和v在G中可以相互抵达,当且仅当它们在 能相互抵达。
下面的线性时间算法(O(V+E))使用两次深度优先搜索来计算有向图G=(V, E)的强连通分量,第一次运行在G上,第二次运行在 上:
STRONGLY-CONNECTED-COMPONENETS(G)
call DFS(G) to compute finishing times u.f for each vertex u
compute
call DFS( ), but in the main loop of DFS, consider the vertices in order of decreasing u.f(as computed in line1)
output the vertices of each tree in thedepth-first forest formed in line 3 as a seperate strongly connected component.
该算法的正确性证明参见P358。