6.5 图遍历算法概述
图算法是个庞大的家族,其中大部分成员的主体框架,都可归结于图的遍历。
实际上,无论采用何种策略和算法,图的遍历都可理解为,将非线性结构转化为半线性结构的过程。经遍历而确定的边类型中,最重要的一类即所谓的树边,它们与所有顶点共同构成了原图的一棵支撑树(森林),称作遍历树(traversal tree)。以遍历树为背景,其余各种类型的边,也能提供关于原图的重要信息,比如其中所含的环路等。
图中顶点之间可能存在多条通路,故为避免对顶点的重复访问,在遍历的过程中,通常还要动态地设置各顶点不同的状态,并随着遍历的进程不断地转换状态,直至最后的“访问完毕”。图的遍历更加强调对处于特定状态顶点的甄别与查找,故也称作图搜索(graph search)。
与树遍历一样,作为图算法基石的图搜索,本身也必须能够高效地实现。幸运的是,正如我们马上就会看到的,诸如深度优先、广度优先、最佳优先等基本而典型的图搜索,都可以在线性时间内完成。准确地,若顶点数和边数分别为n和e,则这些算法自身仅需O(n + e)时间。既然图搜索需要访问所有的顶点和边,故这已经是我们所能期望的最优的结果。
6.6 广度优先搜索
各种图搜索之间的区别,体现为边分类结果的不同,以及所得遍历树(森林)的结构差异。其决定因素在于,搜索过程中的每一步迭代,将依照何种策略来选取下一接受访问的顶点。
通常,都是选取某个已访问到的顶点的邻居。同一顶点所有邻居之间的优先级,在多数遍历中不必讲究。因此,实质的差异应体现在,当有多个顶点已被访问到,应该优先从谁的邻居中选取下一顶点。
策略:
广度优先搜索(breadth-first search, BFS)采用的策略可概括为:越早被访问到的顶点,其邻居越优先被选用。
在所有已访问到的顶点中,仍有邻居尚未访问者,构成所谓的波峰集(frontier)。
BFS搜索过程也可等效地理解为:反复从波峰集中找到最早被访问到顶点v,若其邻居均已访问到,则将其逐出波峰集;否则,随意选出一个尚未访问到的邻居,并将其加入到波峰集中。
不难发现,若将上述BFS策略应用于树结构,则效果等同于层次遍历——波峰集内顶点的深度始终相差不超过一,且波峰集总是优先在更浅的层次沿广度方向拓展。实际上,树层次遍历的这些特性,在一定程度上也适用于图的BFS搜索。
由于每一步迭代都有一个顶点被访问,故至多迭代O(n)步。
实现:
使用Queue模板类
enqueue 入队
dequeue 出队
0001 template <typename Tv, typename Te> //广度优先搜索BFS算法(全图)
0002 void Graph<Tv, Te>::bfs ( Rank s ) { //s < n
0003 reset(); int clock = 0; Rank v = s; //初始化
0004 do //逐一检查所有顶点
0005 if ( UNDISCOVERED == status ( v ) ) //一旦遇到尚未发现的顶点
0006 BFS ( v, clock ); //即从该顶点出发启动一次BFS
0007 while ( s != ( v = ( ( v+1 ) % n ) ) ); //按序号检查,故不漏不重
0008 }
0009
0010 template <typename Tv, typename Te> //广度优先搜索BFS算法(单个连通域)
0011 void Graph<Tv, Te>::BFS ( Rank v, int& clock ) { //v < n
0012 Queue<Rank> Q; //引入辅助队列
0013 status( v ) = DISCOVERED; Q.enqueue ( v ); //初始化起点
0014 dTime( v ) = clock; clock = 0; //dTime继承自前一联通/可达分量
0015 while ( !Q.empty() ) { //在Q变空之前,不断
0016 Rank v = Q.dequeue(); //轮到队首顶点v接受访问
0017 for ( Rank u = firstNbr( v ); -1 < u; u = nextNbr( v, u ) ) //枚举v的所有邻居u
0018 if ( UNDISCOVERED == status ( u ) ) { //若u尚未被发现,则
0019 status( u ) = DISCOVERED; Q.enqueue ( u ); dTime( u ) = dTime( v ) + 1; //发现该顶点
0020 type( v, u ) = TREE; parent( u ) = v; //引入树边拓展支撑树
0021 } else { //若u已被发现,或者甚至已访问完毕,则
0022 type( v, u ) = CROSS; //将(v, u)归类于跨边
0023 }
0024 status( v ) = VISITED; fTime( v ) = clock++; //v访问完毕
0025 if ( Q.empty() )
0026 clock = dTime(v) + 1; //为可能的下一连通/可达分量,预备好起始顶点的dTime
0027 else if ( dTime( v ) < dTime( Q.front() ) )
0028 clock = 0; //dTime的增加,意味着开启新的一代
0029 }
0030 }
仿照树的层次遍历,这里也借助队列Q,来保存已被发现,但尚未访问完毕的顶点。因此,任何顶点在进入该队列的同时,都被随即标记为DISCOVERED(已发现)状态。
BFS()的每一步迭代,都先从Q中取出当前的首顶点v;再逐一核对其各邻居u的状态并做相应处理;最后将顶点v置为VISITED(访问完毕)状态,即可进入下一步迭代。
若顶点u尚处于UNDISCOVERED(未发现)状态,则令其转为DISCOVERED状态,并随即加入队列Q。实际上,每次发现一个这样的顶点u,都意味着遍历树可从v到u拓展一条边。于是,将边(v, u)标记为树边(tree edge),并按照遍历树中的承袭关系,将v记作u的父节点。
若顶点u已处于DISCOVERED状态(无向图),或者甚至处于VISITED状态(有向图),则意味着边(v, u)不属于遍历树,于是将该边归类为跨边(cross edge)。
BFS()遍历结束后,所有访问过的顶点通过parent[]指针依次联接,从整体上给出了原图某一连通或可达域的一棵遍历树,称作广度优先搜索树,或简称BFS树(BFS tree)。
实例:
上例最终形成了一颗BFS树。
森林的情况:在逐个检查顶点的过程中,只要发现某一顶点尚未被发现,则意味着其所属的连通分量或可达分量尚未触及,故可从该顶点出发再次启动BFS(),以遍历其所属的连通分量或可达分量。如此,各次BFS()调用所得的BFS树构成一个森林,称作BFS森林(BFS forest)。
复杂度:
空间:除作为输入的图本身外,BFS搜索所使用的空间,主要消耗在用于维护顶点访问次序的辅助队列、用于记录顶点和边状态的标识位向量,累计O(n) + O(n) + O(e) = O(n + e)。
时间:首先需花费O(n + e)时间复位所有顶点和边的状态。不计对子函数BFS()的调用,bfs()本身对所有顶点的枚举共需O(n)时间。而在对BFS()的所有调用中,每个顶点、每条边均只耗费O(1)时间,累计O(n + e)。综合起来,BFS搜索总体仅需O(n + e)时间。
“开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 10 天,点击查看活动详情”