图的遍历(搜索)

与其他数据结构一样,图也需要进行遍历操作,来访问各个数据点,以及后续对顶点和边进行操作。相对于树来说,图的结构更为复杂。

图的遍历,可以理解为将非线性结构转化为半线性结构的过程。我们知道,树就是一种半线性结构,经遍历而确定的边类型中,最为重要的类型就是树边,所有的树边与顶点一起构成了原始图的一颗支撑树(森林),称作遍历树(traversal tree)。

因为图中的顶点间,可能存在多条通路,所以不仅要对边设置各种状态,对于顶点也需要动态地设置多种状态,以避免重复访问同一个顶点多次。图的遍历更加强调对于处于特定状态顶点的甄别和查找,所以也称为图搜索。大部分的图搜索算法都可以在O(n+e)的时间内完成,因为每个顶点和每条边都必须访问,这已经是最优结果。

图的搜索策略主要有三种,广度优先搜索(bfs),深度优先搜索(dfs),优先级搜索(pfs)。不同搜索策略的区别,可以体现为边分类的结果不同,以及所得遍历树的结构差异。决定因素在于,每一步迭代按照何种策略来选取下一个访问的顶点。通常,下一步访问都选取某个已经访问到的顶点的邻接顶点,同一顶点所有邻接顶点的优先级可以根据实际情况确定。各种搜索策略的不同在于,存在多个顶点的时候,下一步选择哪个顶点的邻接顶点。下面分别介绍这三种搜索方法的策略以及简单应用。

广度优先搜索

策略:越早被访问到的节点,其邻居越被优先的访问。

引入波峰集的概念,在所有已访问到的顶点中,仍有邻居尚未访问的,构成波峰集。搜索过程也可以理解为,反复从波峰集中寻找最早被访问到的顶点v,若它的邻居已经全部被访问到,将其逐出波峰集;否则,随意选出一个尚未访问到的邻居,并将它加入波峰集。

仔细回想,广度优先的策略,与二叉树的层次遍历是相同的。所以可以借鉴二叉树层次遍历的方法,用一个辅助队列来实现图的广度优先搜索:

 1 template<typename Tv, typename Te> void Graph<Tv, Te>::bfs(int s)//以s为起点的广度优先搜索
 2 {
 3     reset(); int clock = 0; int v = s;
 4     do
 5         if (stasus(v) == UNDISCOVERED)//遇到未发现的顶点
 6             BFS(v, clock);//执行一次BFS
 7     while (s != (v = (++v%n)));
 8 }
 9 template<typename Tv, typename Te> void Graph<Tv, Te>::BFS(int v)
10 {
11     queue<int> Q;
12     status(v) = DISCOVERED; Q.push(v);
13     while (!Q.empty())
14     {
15         int v = Q.front(); Q.pop(); //取出最前方的点
16         for(int u = firstNbr(v); u > -1; u = nextNbr(v, u))//枚举v的所有邻居u(按点编号从后向前)
17             if (status(u) == UNDISCOVERED)
18             {
19                 status(u) = DISCOVERED;
20                 Q.push(u);
21                 type(v, u) = TREE; parent(u) = v;//引入树边拓展支撑树并确定父子关系
22             }
23             else
24             {
25                 type(v, u) = CROSS;//跨边
26             }
27         status(v) = VISITED;
28     }
29 }

可以看到,把边简单地分成了两类:树边和跨边。若当前节点的邻居为UNDISCOVERED,则将边加入到支撑树中,并将点的状态设置为DISCOVERED,存入辅助队列之中,改写父子关系。否则,将边归为跨边(CROSS)。当前节点的所有邻居都已经被检查状态后,该节点的访问完成,并取出队列中最前面的点,继续这一过程,直到辅助队列中没有顶点,即全部顶点均已被访问完毕,算法结束。

深度优先搜索

策略:优先选取最后一个访问到的顶点的邻居。

因此,各顶点被访问到的次序,类似于树中的先序遍历,但完成访问的次序,类似于后序遍历。实现代码如下:

 1 template<typename Tv, typename Te> void Graph<Tv, Te>::dfs(int s)//以s为起点的深度优先搜索
 2 {
 3     reset(); int clock = 0; int v = s;
 4     do
 5         if (stasus(v) == UNDISCOVERED)//遇到未发现的顶点
 6             DFS(v, clock);//执行一次BFS
 7     while (s != (v = (++v%n)));//做到不重不漏
 8 }
 9 template<typename Tv, typename Te> void Graph<Tv, Te>::DFS(int v, int& clock)//递归实现
10 {
11     dTime(v) = ++clock; status(v) = DISCOVERED;//发现的时间
12     for (int u = firstNbr(v); u > -1; u = nextNbr(v, u))
13         switch (status(u))
14         {
15         case UNDISCOVERED:type(u, v) = TREE; parent(u) = v; DFS(u, clock); break;
16         case DISCOVERED:type(u, v) = BACKWARD; break;//有向环路,u必为v的祖先,故为后向边
17         default://u已经访问完毕(visited,有向图),通过比较承袭关系区分前向边和跨边
18             type(u, v) = (dTime(v) < dTime(u)) ? FORWARD : CROSS; break;//u的发现时间晚,为前向边
19         }
20     status(v) = VISITED; fTime(v) = ++clock;//访问结束的时间
21 }

把边分为四类:树边,前向边,后向边,跨边。如果u发现了但是还没有访问完毕,说明u是v的祖先,因此定义为后向边;如果u已经是被访问完毕的,就要分开讨论:如果u的发现时间比v还要晚,说明u的层次比v要低,定义为前向边,如果u的发现时间比v早,说明u和v属于不同的分支,定义为跨边。几类边的定义,对于处理一些问题是很有帮助的,比如双连通域分解、拓扑排序等。

深度优先搜索的策略体现在,发现了一个UNDISCOVERED状态的邻居,就以这个邻居为起点继续递归地进行搜索。一个重要之处在于,用dTime和fTime来表示一个顶点被发现和被访问完毕的时间,一个顶点的活跃期即为dTime----fTime,这可以给我们判断两个顶点是否有血缘关系提供方便。可以证明,两个顶点存在“”祖先-后代”关系,当且仅当两个顶点的活跃期为包含关系。

算法运行过后,通过parent指针可以给出起始顶点可达域的遍历树,这些树构成了DFS森林。

这里用了递归的方法,实际上很容易改成迭代方法。与BFS类似,这里使用一个辅助堆栈,需要添加的一些操作是,发现未访问的顶点,就把这个顶点入栈,每次循环检查栈顶的顶点,如果邻居均访问完成,就出栈,取出下一个顶点,直到栈中已经没有顶点。

 

深度优先搜索的应用(一)  拓扑排序

如果一个线性序列,每一个顶点都不会通过边,指向其在此序列中的前驱顶点,那么这个线性序列,称作原有向图的一个拓扑排序(topological sorting)。

可以证明,有向无环图必然存在拓扑排序,且拓扑排序未必唯一。任一有向无环图必然存在入度为0的顶点,否则这个图将包含环路。这样就产生了得到一个拓扑排序的方法:只要将入度为0的顶点m以及相关联的边从图G中取出,则剩余的G'依然是一个有向无环图,递归下去,直到所有的点都被去掉,则按照次序,即可组成原图的一个拓扑排序。

另一种思路,可以通过深度优先搜索的方法。对应上面的方法,图中也必然存在出度为0的顶点,而这个顶点在深度优先搜索中会被首先转换为VISITED。与第一种方法类似,将访问完毕的顶点m以及关联边去掉,递归下去,下一个出度为0的顶点应当为m的前驱。由此可见,DFS中各顶点被标记为VISITED的次序,正好逆序地给出了一个原图的拓扑排序,实现代码如下:

 1 template<typename Tv, typename Te> stack<Tv>* Graph<Tv, Te>::tSort(int s)
 2 {
 3     reset(); int clock = 0; int v = s;
 4     stack<Tv>* S = new Stack<Tv>;
 5     do {
 6         if(status(v)==UNDISCOVERED)
 7             if (!TSort(v, clock, S))
 8             {
 9                 while (!S->empty())
10                     S->pop(); break;//任一连通域非DAG,直接返回
11             }
12     } while (s != (v = ( ++v % n ) ) );
13     return S;
14 }
15 template<typename Tv, typename Te> bool Graph<Tv, Te>::TSort(int v, int& clock, stack<Tv>* S)
16 {                                                                  //基于DFS的拓扑排序(单次)
17     dTime(v) = ++clock; status(v) = DISCOVERED;
18     for (int u = firstNbr(v); u > -1; u = nextNbr(v, u))
19         switch (status(u))
20         {
21         case UNDISCOVERED:parent(u) = v; type(v, u) = TREE;
22             if (!TSort(u, clock, S)) return false;//若从u出发,u及其后代不能拓扑排序,返回
23             break;
24         case DISCOVERED:type(v, u) = BACKWARD; return false;//出现后向边直接退出
25         default:
26             type(v, u) = (dTime(v) < dTime(u)) ? FORWARD : CROSS;
27             break;
28         }
29     status(v) = VISITED; S->push(vertex(v));//返回时,顶点按照被访问的次序也就是拓扑排序的次序,在栈中自顶向下
30     return true;
31 }

这里可以看到,主函数里面执行了一个判别操作,当结束执行函数的时候,栈中仍然有顶点,说明拓扑排序不存在。单次搜索过程中,一旦存在后向边,这个连通域必然存在一个环路,那么也就不存在拓扑排序,可以直接退出本次执行。当主函数执行完毕时,栈中的次序即为一个拓扑排序。

 

深度优先搜索的应用(二)  双连通域分解

对于一个无向图,如果删除一个顶点v后,原图中包含的连通域增多,称v是一个切割节点或关节点。不含任何关节点的图,称为双连通图。任一无向图都可以视为若干个极大的双连通子图组合而成,每一个这样的子图都称为原图的一个双连通域(bi-connected component)。

讨论什么样的节点可能是关节点。DFS树中的叶节点不可能是关节点,因为删除它不会造成任何影响。如果根节点包含两个分支,那么根节点必然是关节点。对于内部节点,如果删除这个节点后,导致一颗真子树与其真祖先无法连通,那么该节点必然是关节点,反之则不是关节点。

考虑前面DFS算法中定义的边,后向边是与其祖先相联的,因此,在DFS过程中,只要随时更新每个顶点所能连通的最高祖先(highest connected ancestor,hca),就能判断关节点,并获得双连通域,实现代码如下:

 1 template<typename Tv, typename Te> void Graph<Tv, Te>::bcc(int s)
 2 {
 3     reset(); int clock = 0; int v = s; stack<int> S;
 4     do {
 5         if (status(v) == UNDISCOVERED)
 6         {
 7             BCC(v, clock, S);
 8             S.pop(); break;//任一连通域非DAG,直接返回
 9         }
10     } while (s != (v = (++v % n)));
11 }
12 template<typename Tv, typename Te> void Graph<Tv, Te>::BCC(int v, int& clock, stack<int>& S)
13 {
14     hca(v) = dTime(v) = ++clock; status(v) = DISCOVERED; S.push(v);
15     for (int u = firstNbr(v); u > -1; u = next(v, u))
16         switch (status(u))
17         {
18         case UNDISCOVERED:parent(u) = v; type(v, u) = TREE; BCC(u, clock, S); 
19             if (hca(u) < dTime(v))//u可以通过后向边指向v的真祖先
20                 hca(v) = min(hca(v), hca(u));
21             else//否则,u无法通过后向边与v的祖先相连,v为关节点,u以下即为一个bcc
22             {
23                 while (v != S.top()) S.pop();//依次弹出栈中当前bcc中的节点
24             }
25             break;
26         case DISCOVERED:type(v, u) = BACKWARD;
27             if (u != parent(v)) hca(v) = min(hca(v), dTime(u));//更新hca(v)
28             break;
29         //default://visited(仅对于有向图)
30         //    type(v, u) = (dTime(v) < dTime(u)) ? FORWARD : CROSS;
31         //    break;
32         }
33     status(v) = VISITED;
34 }

深度优先搜索的过程中,随时更新节点的最高连通祖先。如果节点UNDISCOVERED,那么遍历返回后,如果hca(u)比他父亲的发现时间小,那么更新父亲的hca;否则,说明无法通过后向边与祖先连接,弹出关节点v之前的节点。如果节点为DISCOVERED状态,此边为后向边,更新hca(v)。

posted @ 2017-07-20 18:28  luStar  阅读(3011)  评论(0编辑  收藏  举报