圖論(7):圖的遍歷 - 廣度優先和深度優先算法

定義

從圖中某一頂點出發訪遍圖中其余頂點,且使每一個頂點僅被訪問一次,這個訪問的過程叫做圖的遍歷(Traversing Graph)。且圖的遍歷算法是一個比較基礎的算法,前面我們介紹的有向無環圖的依賴排序(拓撲排序)、關鍵路徑等算法都需要基于該算法。
通常,有兩條遍歷圖的路徑:廣度優先搜索深度優先搜索,且對無向圖和有向圖都適用。

另外,由于Guava中的Graph模塊已對這兩種圖的遍歷算法進行了實現,且其代碼是我所見過最完美的實現了。因此,本篇文章不打算重新實現一個遍歷算法,而是以Guava的實現為基礎進行算法分析和驗證。它的具體實現代碼文件為:https://github.com/google/guava/blob/master/android/guava/src/com/google/common/graph/Traverser.java

廣度優先遍歷

也稱為廣度優先搜索(Breadth First Search),它類似于樹的分層遍歷算法(按樹的深度來遍歷,深度為1的節點、深度為2的節點...)。其定義如下:
假設從圖中某個頂點v出發,在訪問了v之后依次訪問v的各個未曾訪問過的鄰接點,然后分別從這些鄰接點出發并依次訪問它們的鄰接點,并使“先被訪問的頂點鄰接點”先于“后被訪問的頂點的鄰接點”被訪問,直到圖中所有所有已被訪問的頂點的鄰接點都被訪問到。若此時圖中尚有頂點未被訪問,則另選圖中一個未曾被訪問的頂點作起始點重復上述過程,直至圖中所有頂點均被訪問到為止。

換句話說,廣度優先遍歷的過程是以v為起始點,由近至遠,依次訪問和v有路徑相通且路徑長度為1,2,...的頂點的過程。

下面演示對示例圖的廣度優先遍歷:假設從起始點v1開始遍歷,首先訪問v1和v1的鄰接點v2和v3,然后依次訪問v2的鄰接點v4和v5,及v3的鄰接點v6和v7,最后訪問v4的鄰接點v8。于是得到節點的線性遍歷順序為:v1 -> v2 -> v3 -> v4 -> v5 -> v6 -> v7 -> v8,即示例圖中紅色箭頭線為其廣度優先遍歷順序。

廣度優先遍歷(紅色箭頭線及序號為遍歷順序)

算法分析

從演示示例的推演中,我們可以發現其訪問順序恰好是一個隊列的出隊和入隊的過程:出隊的節點即為當前訪問的節點,緊接著將其所有的鄰接點入隊,為下次迭代時將要訪問的預備節點。

Guava的實現思路基本也是如此,下面我們看看它的實現方式:
首先,它定義了一個抽象的廣度優先遍歷接口,以便有多種實現方式(下面它還有一個樹狀圖的遍歷優化實現),更重要的是它將遍歷的過程封裝在一個迭代器(Iterable)中返回了,僅在調用方通過next()訪問返回結果時才動態輸出其遍歷順序,個人覺得這是它實現的一大亮點。這樣做的好處是,實際的調用過程并不消耗cpu時間(僅僅是new了一個特定類型的Iterator),而是在調用完成后在使用其結果做其他運算時(比如輸出或者將其作為其他功能的輸入等)才耗費計算時間。一般,我們通常的做法是函數完成遍歷運算然后返回運算結果(List<N>),如果后續需要利用該結果進行其他運算時再循環遍歷結果集,亦或者直接在在遍歷算法的迭代中直接處理其結果,而迭代器明顯優于這兩種做法。

廣度優先遍歷接口定義為:

/**
 * 
 * @param startNode: 遍歷的起始節點
 * @return 返回一個Iterable作為節點的順序
 */
public abstract Iterable<N> breadthFirst(N startNode);

由于接口返回的是一個Iterable,那么它的實現肯定就是創建了一個Iterable了,然后實現它的iterator()接口:

@Override
public Iterable<N> breadthFirst(final N startNode) {
    /**
     * 創建一個匿名的Iterable,并實現iterator()接口
     */
    return new Iterable<N>() {
        @Override
        public Iterator<N> iterator() {
            /**
             * 返回一個自定義的Iterator
             */
            return new BreadthFirstIterator(startNode);
        }
    };
}

該自定義迭代器BreadthFirstIterator中,其主體實現在迭代函數next()中,每迭代一次(調用next()函數一次)返回一個以廣度優先遍歷為順序的一個節點:

/**
 * 聲明為private私有, 可對外隱藏其內部實現,調用者僅需要
 * 知道是一個Iterator即可
 */
private final class BreadthFirstIterator extends Iterator<N> {
    //構建節點順序的隊列:執行入隊和出隊操作
    private final Queue<N> queue = new ArrayDeque<>();

    //用于記錄節點是否訪問標記,避免重復訪問
    private final Set<N> visited = new HashSet<>();

    /**
     * 構造函數時,首先從起始點入隊開始
     * @param root
     */
    BreadthFirstIterator(N root) {
        queue.add(root);
        visited.add(root);
    }

    @Override
    public boolean hasNext() {
        /**
         * 當隊列為空時,則遍歷完成,即沒有后續節點了。
         */
        return !queue.isEmpty();
    }

    @Override
    public N next() {
        //隊頭節點出隊,作為當前迭代的訪問節點
        N current = queue.remove();
        /**
         * 依次將當前節點的后繼節點入隊,為下一次迭代做準備
         */
        for (N neighbor : graph.successors(current)) {
            //add()返回true時(表示第一次加入,即是第一次訪問),則入隊
            if (visited.add(neighbor)) {
                queue.add(neighbor);
            }
        }
        return current;
    }
}

上面給出了廣度優先遍歷算法的實現,下面寫一個簡單demo驗證其實現結果:

//構建示例圖所示的圖結構(無向圖)
MutableGraph<String> graph = GraphBuilder.undirected()
    .nodeOrder(ElementOrder.<String>natural()).build();

graph.putEdge(V1, V2);
graph.putEdge(V1, V3);
graph.putEdge(V2, V4);
graph.putEdge(V2, V5);
graph.putEdge(V3, V6);
graph.putEdge(V3, V7);
graph.putEdge(V4, V8);
graph.putEdge(V5, V8);
graph.putEdge(V6, V7);

//測試breadthFirst()接口,從V1開始遍歷
Iterable<String> bfs = Traverser.forGraph(graph).breadthFirst(V1);

//輸出遍歷結果: for循環(forEach)默認實現iterator的遍歷next()
for (String node : iterable) {
    print(node);
}

輸出順序為:

bfs graph: {V1,V3,V2,V6,V7,V5,V4,V8}

注:雖然該順序與示例圖的紅色箭頭線標識的順序有所不同,但仍滿足廣度優先遍歷的順序,其差異主要是在選擇當前節點的后繼節點時 遍歷后繼節點的順序不同。
而該順序不同的主要原因是由于無向圖鄰接點(successors()返回的Set<T>結果)的存儲結構UndirectedGraphConnections采用的是HashMap,然后通過HashMapKeySet()(對應的是HashSet<T>)返回的節點集,但由于HashSet是不是有序的,所以導致最終的結果并沒有按預先的順序,但結果整體上滿足遍歷順序的要求。

關于HashMap的順序問題,我做了如下測試:

//返回V1的后繼節點集
Set<String> successors = graph.successors(V1);
print(success);

//測試HashMap的節點順序
Map<String, Integer> testMap = new HashMap<>();
testMap.put(V1, 0);
testMap.put(V2, 0);
testMap.put(V3, 0);
print(testMap.keySet());

測試結果如下,剛好重現了遍歷順序的差異:

successor: {V3,V2}
Map KeySet() order: {V3,V2,V1}

下面我繼續來看深度優先遍歷:

深度優先遍歷

也稱為深度優先搜索(Depth First Search),它類似于樹的先根遍歷(先訪問樹的根節點)。其定義如下:
假設從圖中的某個頂點v出發,訪問此節點后,然后依次從v的未被訪問的鄰接點出發深度優先遍歷圖,直到圖中所有和v有路徑相通的頂點都被訪問到;若此時圖中尚有頂點未被訪問,則選另選一個未曾訪問的頂點作為起始點重復上述過程,直至圖中的所有節點都被訪問到為止。

下面演示對示例圖的深度優先遍歷:假設從起始點v1開始遍歷,在訪問了v1后,選擇其鄰接點v2。因為v2未曾訪問過,則從v2出發進行深度優先遍歷。依次類推,接著從v4、v8、v5出發進行遍歷。在訪問了v5后,由于v5的鄰接點都已被訪問,則遍歷回退到v8。同樣的理由,繼續回退到v4、v2直至v1,此時v1的另一個鄰接點v3未被訪問,則遍歷又從v1到v3,再繼續進行下去。于是得到節點的線性順序為:v1 -> v2 -> v4 -> v8 -> v5 -> v3 -> v6 -> v7,即示例圖中紅色箭頭線為其深度優先遍歷順序。

深度優先遍歷-PreOrder順序(紅色箭頭線及序號為遍歷順序)

算法分析

從上述的描述可以看出,深度優先遍歷的定義就是一個遞歸的過程,每次都以當前節點的第一個未曾訪問過的鄰接點進行深度優先遍歷的過程。因此使用遞歸函數實現該算法是最直觀的實現方式,但由于遞歸的過程是函數棧累積的過程,如果節點數較多,容易造成函數棧的溢出而導致程序崩潰,因此正常生產環境一般會使用一個棧結構(Stack)來存放遍歷的節點以模擬函數棧的調用情況,以此避免遞歸的缺陷。

Guava的大體實現思路也是如此,使用棧結構來替代函數的遞歸實現。另外,Guava對深度優先遍歷還進行了擴展:區分節點的訪問順序是第一次訪問到節點的順序(PreOrder),還是訪問到底后回退時的節點順序(PostOrder)。

附上Guava的原文注釋:
"Pre-order" implies that nodes appear in the {@code Iterable} in the order in which they are first visited.
"Post-order" implies that nodes appear in the {@code Iterable} in the order in which they are visited for the last time.

因此,對于深度優先遍歷,Guava根據這兩個場景定義了下面兩個接口:

//第一次訪問到節點的順序(Pre-order)
public abstract Iterable<N> depthFirstPreOrder(N startNode);

//訪問到最后,然后回退訪問節點的順序(Post-order)
public abstract Iterable<N> depthFirstPostOrder(N startNode);

它的實現與上述描述相同,也是創建了一個Iterable,然后實現它的iterator()接口,由于這兩個接口相近,下面僅舉例說明其中一個(depthFirstPostOrder):

@Override
public Iterable<N> depthFirstPostOrder(final N startNode) {
    /**
     * 創建一個匿名的Iterable,并實現iterator()接口
     */
    return new Iterable<N>() {
        @Override
        public Iterator<N> iterator() {
            /**
             * 返回一個自定義的Iterator
             */
            return new DepthFirstIterator(startNode, Order.POSTORDER);

            /**
             * depthFirstPreOrder()遍歷時也是創建了DepthFirstIterator(),只是參數換成了Order.PREORDER
             */
            //return new DepthFirstIterator(startNode, Order.PREORDER);
        }
    };
}

該自定義迭代器DepthFirstIterator中,其主體實現在迭代函數next()中,每迭代一次(調用next()函數一次)返回一個以深度優先遍歷為順序的一個節點:


/**
 * 自定義深度優先遍歷的結果迭代器(AbstractIterator繼承自Iterator)
 */
private final class DepthFirstIterator extends AbstractIterator<N> {

    /**
     * 模擬函數遞歸遍歷節點的堆棧,每次入棧的數據是:節點-節點的鄰節點集
     */
    private final Deque<NodeAndSuccessors> stack = new ArrayDeque<>();

    /**
     * 標識節點是否已訪問過
     */
    private final Set<N> visited = new HashSet<>();

    /**
     * 指定深度優先遍歷的順序: PREORDER, POSTORDER
     */
    private final Order order;

    /**
     * 構造函數,首先將指定的起始節點-及鄰接點入棧
     * @param root:起始節點
     * @param order:指定遍歷的順序
     */
    DepthFirstIterator(N root, Order order) {
        stack.push(withSuccessors(root)); //節點-及鄰接點入棧
        this.order = order;
    }

    /**
     * 基類封裝的next()函數
     * @return
     */
    @Override
    protected N computeNext() {
        while (true) { //直到找到合適遍歷順序的節點
            /**
             * 堆棧為空時,遍歷結束,返回null
             */
            if (stack.isEmpty()) {
                return endOfData();
            }

            /**
             * 每次拿到棧頂的數據參與運算,且由于withSuccessors()
             * 對應的鄰接點也是Iterator,因此需要注意運算中是否所有
             * 的鄰接點都有遍歷完畢。(算法核心)
             */
            NodeAndSuccessors node = stack.getFirst();

            /**
             * Set<>.add()操作返回true時,表示為第一次加入到Set集合中
             * 對應到遍歷順序 -- PREORDER。因此firstVisit標識PREORDER順序的訪問
             */
            boolean firstVisit = visited.add(node.node);

            /**
             * 當successorIterator都遍歷完畢時(!node.successorIterator.hasNext()),
             * 表示該路徑已經遍歷完畢了,現在需要開始回退,則該節點是回退時的節點
             * 對應到遍歷順序 -- POSTORDER。因此lastVisit標識POSTORDER順序的訪問
             *
             */
            boolean lastVisit = !node.successorIterator.hasNext();

            /**
             * 當前步驟是否產生了一個可以返回的迭代節點(firstVisit | lastVisit)
             */
            boolean produceNode =
                (firstVisit && order == Order.PREORDER) || (lastVisit && order == Order.POSTORDER);

            /**
             * 如果當前節點的鄰接點集都已遍歷完畢時,則將該節點出棧。
             * 以致下一次stack.getFirst()時獲得的數據變成未訪問的新數據
             */
            if (lastVisit) {
                stack.pop();
            } else {
                /**
                 * 如果鄰接點還沒遍歷完畢,則每次將一個鄰接點及鄰接點的鄰接點集合壓入棧中
                 * 需注意該鄰接點是否已有訪問過
                 */
                N successor = node.successorIterator.next();
                if (!visited.contains(successor)) {
                    stack.push(withSuccessors(successor));
                }
            }
            /**
             * 若當前節點滿足遍歷順序(firstVisit | lastVisit),則返回該節點。
             * 否則,將繼續上述棧中數據的操作,直到找到滿足produceNode的節點或者棧中數據遍歷完畢
             */
            if (produceNode) {
                return node.node;
            }
        }
    }

    /**
     * 構建堆棧數據結構:節點-及鄰接點
     * @param node
     * @return
     */
    NodeAndSuccessors withSuccessors(N node) {
        return new NodeAndSuccessors(node, graph.successors(node));
    }
}

針對上面給出的深度度優先遍歷算法實現,下面還是寫一個簡單demo驗證其實現結果:

//構建示例圖所示的圖結構(無向圖)
MutableGraph<String> graph = GraphBuilder.undirected()
    .nodeOrder(ElementOrder.<String>natural()).build();

graph.putEdge(V1, V2);
graph.putEdge(V1, V3);
graph.putEdge(V2, V4);
graph.putEdge(V2, V5);
graph.putEdge(V3, V6);
graph.putEdge(V3, V7);
graph.putEdge(V4, V8);
graph.putEdge(V5, V8);
graph.putEdge(V6, V7);

//測試depthFirstPreOrder()接口,從V1開始遍歷
Iterable<String> dfsPre = Traverser.forGraph(graph).depthFirstPreOrder(V1);
for (String node : iterable) {
    print(node);
}


//測試depthFirstPostOrder()接口,從V1開始遍歷
Iterable<String> dfsPost = Traverser.forGraph(graph).depthFirstPostOrder(V1);
for (String node : iterable) {
    print(node);
}

輸出順序為:

dfs pre-order graph: {V1,V3,V6,V7,V2,V5,V8,V4}
dfs post-order graph: {V7,V6,V3,V4,V8,V5,V2,V1}

注:該順序與示例圖的紅色箭頭線標識的順序有所不同,但仍滿足深度優先遍歷的順序,其差異原因前面已經有說明過。

另外,由于圖節點連接屬性的復雜性(任何兩個節點都可能存在連接),所以在遍歷過程中需要另設一個Set<N>來記錄該節點的訪問標記(因為圖中如果存在回路時會存在重復訪問的問題);為此,Guava對于明確不存在回路的樹形圖(即有向無環圖)提供了另一種更優的訪問方法forTree(),不過它也是在前面介紹的三種遍歷方法的基礎上進行優化實現的,具體可以參考原文的前面的鏈接進行查看。

最后,文中示例代碼的詳細實現參見:https://github.com/Jarrywell/GH-Demo/blob/master/app/src/main/java/com/android/test/demo/graph/TestTraverser.java

參考文檔

《數據結構》-- 嚴蔚敏

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容