Python演算法:遍歷

發表於2015-05-20

本節主要介紹圖的遍歷演算法BFS和DFS,以及尋找圖的(強)連通分量的演算法

Traversal就是遍歷,主要是對圖的遍歷,也就是遍歷圖中的每個節點。對一個節點的遍歷有兩個階段,首先是發現(discover),然後是訪問(visit)。遍歷的重要性自然不必說,圖中有幾個演算法和遍歷沒有關係?!

[演算法導論對於發現和訪問區別的非常明顯,對圖的演算法講解地特別好,在遍歷節點的時候給節點標註它的發現節點時間d[v]和結束訪問時間f[v],然後由這些時間的一些規律得到了不少實用的定理,本節後面介紹了部分內容,感興趣不妨閱讀下演算法導論原書]

圖的連通分量是圖的一個最大子圖,在這個子圖中任何兩個節點之間都是相互可達的(忽略邊的方向)。我們本節的重點就是想想怎麼找到一個圖的連通分量呢?

一個很明顯的想法是,我們從一個頂點出發,沿著邊一直走,慢慢地擴大子圖,直到子圖不能再擴大了停止,我們就得到了一個連通分量對吧,我們怎麼確定我們真的是找到了一個完整的連通分量呢?可以看下作者給出的解釋,類似上節的Induction,我們思考從 i-1 到 i 的過程,只要我們保證增加了這個節點後子圖仍然是連通的就對了。

Let’s look at the following related problem. Show that you can order the nodes in a connected graph, V1, V2, … Vn, so that for any i = 1…n, the subgraph over V1, … , Vi is connected. If we can show this and we can figure out how to do the ordering, we can go through all the nodes in a connected component and know when they’re all used up.

How do we do this? Thinking inductively, we need to get from i -1 to i. We know that the subgraph over the i -1 first nodes is connected. What next? Well, because there are paths between any pair of nodes, consider a node u in the first i -1 nodes and a node v in the remainder. On the path from u to v, consider the last node that is in the component we’ve built so far, as well as the first node outside it. Let’s call them x and y. Clearly there must be an edge between them, so adding y to the nodes of our growing component keeps it connected, and we’ve shown what we set out to show.

經過上面的一番思考,我們就知道了如何找連通分量:從一個頂點開始,沿著它的邊找到其他的節點(或者說站在這個節點上看,看能夠發現哪些節點),然後就是不斷地向已有的連通分量中新增節點,使得連通分量內部依然滿足連通性質。如果我們按照上面的思路一直做下去,我們就得到了一棵樹,一棵遍歷樹,它也是我們遍歷的分量的一棵生成樹。在具體實現這個演算法時,我們要記錄“邊緣節點”,也就是那些和已得到的連通分量中的節點相連的節點,它們就像是一個個待辦事項(to-do list)一樣,而前面加入的節點就是標記為已完成的(checked off)待辦事項。

這裡作者舉了一個很有意思的例子,一個角色扮演的遊戲,如下圖所示,我們可以將房間看作是節點,將房間的門看作是節點之間的邊,走過的軌跡就是遍歷樹。這麼看的話,房間就分成了三種:(1)我們已經經過的房間;(2)我們已經經過的房間附近的房間,也就是馬上可以進入的房間;(3)“黑屋”,我們甚至都不知道它們是否存在,存在的話也不知道在哪裡。

根據上面的分析可以寫出下面的遍歷函式walk,其中引數S暫時沒有用,它在後面求強連通分量時需要,表示的是一個“禁區”(forbidden zone),也就是不要去訪問這些節點。

注意下面的difference函式的使用,引數可以是多個,也就是說呼叫後返回的集合中的元素在各個引數中都不存在,此外,引數也不一定是set,也可以是dict或者list,只要是可迭代的(iterables)即可。可以看下python docs

我們可以用下面程式碼來測試下,得到的結果沒有問題

上面的walk函式只適用於無向圖,而且只能找到一個從引數s出發的連通分量,要想得到全部的連通分量需要修改下

用下面的程式碼來測試下,得到的結果沒有問題

至此我們就完成了一個時間複雜度為Θ(E+V)的求無向圖的連通分量的演算法,因為每條邊和每個頂點都要訪問一次。[這個時間複雜度會經常看到,例如拓撲排序,強連通分量都是它]

[接下來作者作為擴充套件介紹了尤拉回路和哈密頓迴路:前者是經過圖中的所有邊一次,然後回到起點;後者是經過圖中的所有頂點一次,然後回到起點。網上資料甚多,感興趣自行了解]

下面我們看下迷宮問題,如下圖所示,原始問題是一個人在公園中走路,結果走不出來了,即使是按照“左手準則”(也就是但凡遇到交叉口一直向左轉)走下去,如果走著走著回到了原來的起點,那麼就會陷入無限的迴圈中!有意思的是,左邊的迷宮可以通過“左手準則”轉換成右邊的樹型結構。

[注:具體的轉換方式我還未明白,下面是作者給出的構造說明]

Here the “keep one hand on the wall” strategy will work nicely. One way of seeing why it works is to observe that the maze really has only one inner wall (or, to put it another way, if you put wallpaper inside it, you could use one continuous strip). Look at the outer square. As long as you’re not allowed to create cycles, any obstacles you draw have to be connected to the it in exactly one place, and this doesn’t create any problems for the left-hand rule. Following this traversal strategy, you’ll discover all nodes and walk every passage twice (once in either direction).

上面的迷宮實際上就是為了引出深度優先搜尋(DFS),每次到了一個交叉口的時候,可能我們可以向左走,也可以向右走,選擇是有不少,但是我們要向一直走下去的話就只能選擇其中的一個方向,如果我們發現這個方向走不出去的話,我們就回溯回來,選擇一個剛才沒選過的方向繼續嘗試下去。

基於上面的想法可以寫出下面遞迴版本的DFS

很自然的我們想到要將遞迴版本改成迭代版本的,下面的程式碼中使用了Python中的yield關鍵字,具體的用法可以看下這裡IBM Developer Works

 

上面迭代版本經過一點點的修改可以得到更加通用的遍歷函式

函式traverse中的引數qtype表示佇列型別,例如棧stack,下面的程式碼給出瞭如何自定義一個stack,以及測試traverse函式

如果還不清楚的話可以看下演算法導論中的這幅DFS示例圖,節點的顏色後面有介紹

上圖在DFS時給節點加上了時間戳,這有什麼作用呢?

前面提到過,在遍歷節點的時候如果給節點標註它的發現節點時間d[v]和結束訪問時間f[v]的話,從這些時間我們就能夠發現一些資訊,比如下圖,(a)是圖的一個DFS遍歷加上時間戳後的結果;(b)是如果給每個節點的d[v]到f[v]區間加上一個括號的話,可以看出在DFS遍歷中(也就是後來的深度優先樹/森林)中所有的節點 u 的後繼節點 v 的區間都在節點 u 的區間內部,如果節點 v 不是節點 u 的後繼,那麼兩個節點的區間不相交,這就是“括號定理”。

加上時間戳的DFS遍歷還算比較好寫對吧

除了給節點加上時間戳之外,演算法導論在介紹DFS的時候還給節點進行著色,在節點被發現之前是白色的,在發現之後先是灰色的,在結束訪問之後才是黑色的,詳細的流程可以參考上面給出的演算法導論中的那幅DFS示例圖。有了顏色有什麼用呢?作用大著呢!根據節點的顏色,我們可以對邊進行分類!大致可以分為下面四種:

使用DFS對圖進行遍歷時,對於每條邊(u,v),當該邊第一次被發現時,根據到達節點 v 的顏色來對邊進行分類(正向邊和交叉邊不做細分):

(1)白色表示該邊是一條樹邊;

(2)灰色表示該邊是一條反向邊;

(3)黑色表示該邊是一條正向邊或者交叉邊。

下圖顯示了上面介紹括號定理用時的那個圖的深度優先樹中的所有邊的型別,灰色標記的邊是深度優先樹的樹邊

那對邊進行分類有什麼作用呢?作用多著呢!最常見的作用的是判斷一個有向圖是否存在環,如果對有向圖進行DFS遍歷發現了反向邊,那麼一定存在環,反之沒有環。此外,對於無向圖,如果對它進行DFS遍歷,肯定不會出現正向邊或者交叉邊。

那對節點標註時間戳有什麼用呢?其實,除了可以發現上面提到的那些很重要的性質之外,時間戳對於接下來要介紹的拓撲排序的另一種解法和強連通分量很重要!

我們先看下摘自演算法導論的這幅拓撲排序示例圖,這是某個教授早上起來後要做的事情,嘿嘿

不難發現,最終得到的拓撲排序剛好是節點的完成時間f[v]降序排列的!結合前面的括號定理以及依賴關係不難理解,如果我們按照節點的f[v]降序排列,我們就得到了我們想要的拓撲排序了!這就是拓撲排序的另一個解法![在演算法導論中該解法是主要介紹的解法,而我們前面提到的那個解法是在演算法導論的習題中出現的]

基於上面的想法就能夠得到下面的實現程式碼,函式recurse是一個內部函式,這樣它就可以訪問到G和res等變數

 

[接下來作者介紹了一個Iterative Deepening Depth-First Search,沒看懂,貌似和BFS類似]

如果我們在遍歷圖時“一層一層”式地遍歷,先發現的節點先訪問,那麼我們就得到了廣度優先搜尋(BFS)。下面是作者給出的一個有意思的區別BFS和DFS的例子,遍歷過程就像我們上網一樣,DFS是順著網頁上的連結一個個點下去,當訪問完了這個網頁時就點選Back回退到上一個網頁繼續訪問。而BFS是先在後臺開啟當前網頁上的所有連結,然後按照開啟的順序一個個訪問,訪問完了一個網頁就把它的視窗關閉。

One way of visualizing BFS and DFS is as browsing the Web. DFS is what you get if you keep following links and then use the Back button once you’re done with a page. The backtracking is a bit like an “undo.” BFS is more like opening every link in a new window (or tab) behind those you already have and then closing the windows as you finish with each page.

BFS的程式碼很好實現,主要是使用佇列

 

Python的list可以很好地充當stack,但是充當queue則效能很差,函式bfs中使用的是collections模組中的deque,即雙端佇列(double-ended queue),它一般是使用連結串列來實現的,這個類有extend、append和pop等方法都是作用於佇列右端的,而方法extendleft、appendleft和popleft等方法都是作用於佇列左端的,它的內部實現是非常高效的。

Internally, the deque is implemented as a doubly linked list of blocks, each of which is an array of individual elements. Although asymptotically equivalent to using a linked list of individual elements, this reduces overhead and makes it more efficient in practice. For example, the expression d[k] would require traversing the first k elements of the deque d if it were a plain list. If each block contains b elements, you would only have to traverse k//b blocks.

最後我們看下強連通分量,前面的分量是不考慮邊的方向的,如果我們考慮邊的方向,而且得到的最大子圖中,任何兩個節點都能夠沿著邊可達,那麼這就是一個強連通分量。

下圖是演算法導論中的示例圖,(a)是對圖進行DFS遍歷帶時間戳的結果;(b)是上圖的的轉置,也就是將上圖中所有邊的指向反轉過來得到的圖;(c)是最終得到的強連通分支圖,每個節點內部顯示了該分支內的節點。

上面的示例圖自然不太好明白到底怎麼得到的,我們慢慢來分析三幅圖 [原書的分析太多了,我被繞暈了+_+,下面是我結合演算法導論的分析過程]

先看圖(a),每個灰色區域都是一個強連通分支,我們想想,如果強連通分支 X 內部有一條邊指向另一個強連通分支 Y,那麼強連通分支 Y 內部肯定不存在一條邊指向另一個強連通分支 Y,否則它們能夠整合在一起形成一個新的更大氣的強連通分支!這也就是說強連通分支圖肯定是一個有向無環圖!我們從圖(c)也可以看出來

再看看圖(c),強連通分支之間的指向,如果我們定義每個分支內的任何頂點的最晚的完成時間為對應分支的完成時間的話,那麼分支abe的完成時間是16,分支cd是10,分支fg是7,分支h是6,不難發現,分支之間邊的指向都是從完成時間大的指向完成時間小的,換句話說,總是由完成時間晚的強連通分支指向完成時間早的強連通分支!

最後再看看圖(b),該圖是原圖的轉置,但是得到強連通分支是一樣的(強連通分支圖是會變的,剛好又是原來分支圖的轉置),那為什麼要將邊反轉呢?結合前面兩個圖的分析,既然強連通分支圖是有向無環圖,而且總是由完成時間晚的強連通分支指向完成時間早的強連通分支,如果我們將邊反轉,雖然我們得到的強連通分支不變,但是分支之間的指向變了,完成時間晚的就不再指向完成時間早的了!這樣的話如果我們對它進行拓撲排序,即按照完成時間的降序再次進行DFS時,我們就能夠得到一個個的強連通分支了對不對?因為每次得到的強連通分支都沒有辦法指向其他分支了,也就是確定了一個強連通分支之後就停止了。[試試畫個圖得到圖(b)的強連通分支圖的拓撲排序結果就明白了]

經過上面略微複雜的分析之後我們知道強連通分支演算法的流程有下面四步:

1.對原圖G執行DFS,得到每個節點的完成時間f[v];

2.得到原圖的轉置圖GT;

3.對GT執行DFS,主迴圈按照節點的f[v]降序進行訪問;

4.輸出深度優先森林中的每棵樹,也就是一個強連通分支。

根據上面的思路可以得到下面的強連通分支演算法實現,其中的函式parse_graph是作者用來方便構造圖的函式

 

[最後作者提到了一點如何進行更加高效的搜尋,也就是通過分支限界來實現對搜尋樹的剪枝,具體使用可以看下這個問題頂點覆蓋問題Vertext Cover Problem]

問題5.17 強連通分支

In Kosaraju’s algorithm, we find starting nodes for the final traversal by descending finish times from an initial DFS, and we perform the traversal in the transposed graph (that is, with all edges reversed). Why couldn’t we just use ascending finish times in the original graph?

問題就是說,我們幹嘛要對轉置圖按照完成時間降序遍歷一次呢?幹嘛不直接在原圖上按照完成時間升序遍歷一次呢?

Try finding a simple example where this would give the wrong answer. (You can do it with a really small graph.)

相關文章