與其他數據結構一樣,圖也需要進行遍歷操作,來訪問各個數據點,以及后續對頂點和邊進行操作。相對於樹來說,圖的結構更為復雜。
圖的遍歷,可以理解為將非線性結構轉化為半線性結構的過程。我們知道,樹就是一種半線性結構,經遍歷而確定的邊類型中,最為重要的類型就是樹邊,所有的樹邊與頂點一起構成了原始圖的一顆支撐樹(森林),稱作遍歷樹(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)。
