与其他数据结构一样,图也需要进行遍历操作,来访问各个数据点,以及后续对顶点和边进行操作。相对于树来说,图的结构更为复杂。
图的遍历,可以理解为将非线性结构转化为半线性结构的过程。我们知道,树就是一种半线性结构,经遍历而确定的边类型中,最为重要的类型就是树边,所有的树边与顶点一起构成了原始图的一颗支撑树(森林),称作遍历树(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)。