1. 程式人生 > >有向圖的幾個演算法分析總結

有向圖的幾個演算法分析總結

簡介

    前面討論的很多文章裡,都是針對無向圖進行的分析。無向圖的一個特性就是其中一旦兩個節點a和b是相連的,這就意味著有路徑從a到b,同時也有從b到a的。它具體對應的矩陣表達方式對應著一個對稱矩陣。而這裡重點是考察有向圖。和無向圖比起來,有向圖更加多了一種出入度的概念。因為方向的有向性,很多以前在無向圖裡看起來比較簡單的問題在這裡會變得更加有意思。

有向圖定義

  一個常用的有向圖會如下圖這樣:

  因為每個節點和節點之間對應著一定的方向關係,所以這裡用一個箭頭來表示從一個節點到另外一個節點的有向關係。在具體儲存有向圖定義的資料結構裡,我們還是可以採用一樣的連結串列組結構。只是因為是有向圖,所以加入元素的時候不用考慮對稱節點的問題。在實現上其實也更加簡化。

  下面是一個關於有向圖的簡單定義實現:

Java程式碼  收藏程式碼
  1. public class Digraph {  
  2.     private final int vertices;  
  3.     private int edges;  
  4.     private List<LinkedList<Integer>> adj;  
  5.     public Digraph(int vertices) {  
  6.         if(vertices < 0throw new IllegalArgumentException(  
  7.                 "Number of vertices in a Digraph must be nonnegative"
    );  
  8.         this.vertices = vertices;  
  9.         this.edges = 0;  
  10.         adj = new ArrayList<LinkedList<Integer>>();  
  11.         for(int i = 0; i < vertices; i++) {  
  12.             adj.add(new LinkedList<Integer>());  
  13.         }  
  14.     }  
  15.     public void addEdge(int v, int w) {  
  16.         if
    (v < 0 || v >= vertices)   
  17.             throw new IndexOutOfBoundsException(  
  18.                     "vertex " + v + " not in bound.");  
  19.         if(w < 0 || w >= vertices)   
  20.             throw new IndexOutOfBoundsException(  
  21.                     "vertex " + w + " not in bound.");  
  22.         adj.get(v).add(w);  
  23.         edges++;  
  24.     }  
  25. }  

 這部分程式碼很簡單,無需解釋。

 有了這部分定義之後,我們再來考慮後面的幾個典型的問題。

環的存在和檢測

 和前面無向圖的過程有點類似,我們要檢測一個圖中間是否存在環,肯定也需要通過某種遍歷的方式,然後對訪問過的元素做標記。如果再次碰到前面訪問過的元素,則說明可能存在環。這裡,如何來檢測環和如果這個環存在的話,我們要返回這個環。在無向圖的時候,這個方法確實是很簡單可行,我們可以通過廣度或者深度優先遍歷來解決。在有向圖的情況下,前面的辦法照搬過來就一定可行嗎?

 我們來看下面的一個示例:

 

    在該圖中,假設我們從節點1開始去遍歷,當按照前面的僅僅是修改marked[] 陣列的辦法,可能先通過節點2到達節點6,於是就設定了marked[6] = true。如下圖:

 

  當再次遍歷到節點6的時候,則如下圖所示:

  這個時候,如果去看marked[6]的話,它已經被標記為true了。可是,如果按照這個條件,我們就確定這種情況存在環,肯定不行。因為現在的這個情況實際上並不是一個環,它僅僅是訪問到了一個前面訪問過的節點。在這種情況下,要判斷一個環的存在,和取得環所在元素的問題根源在於哪裡呢?

  在前面的示例中,我們從節點1到2,然後到6,整個的過程裡,這幾個點被遍歷了,但是光到這一步還沒有構成一個環。按照深度優先遍歷的過程,這個時候相當於2和6已經遍歷完了,要去遍歷節點1的另外一個邊。實際上,這個時候就算從另外一個邊可以遍歷到前面的節點2或者6,因為這個時候能訪問到2和6的是另外一組有向邊了,它們和前面經過的那些有向邊是不一定構成環的。

  另外,從環的構成來說。如果我們按照深度優先的順序訪問到了一個環,必然是在逐步遞迴推進的過程中能訪問到自己前面訪問過的節點。這裡的差別就在於遞迴推進所重複訪問的節點和前面圖深度遍歷所訪問的節點還又所差別。我們以下圖來說明一下它們的詳細差別:

  假定我們從節點1出發,先訪問2這邊的邊,一直到節點6,這個時候按照深度優先遍歷是首先一步步遞迴進去到節點6。因為節點6沒有別的出邊,所以就要一步步的按照前面的過程返回。這個過程如下圖:

  在前面2,6節點都返回後,這個時候就算後面的節點比如5訪問到6了,它們是不構成環的,如下圖:

   這個時候的節點2和6,它們和節點3,4, 5之間的差別是,2和6已經不在函式遞迴的棧裡了,因為它們已經從前面的遞迴裡返回了,而3,4,5節點還是在裡面。所以到後面遍歷到節點7,8之後,我們再次碰到了節點4,就可以確認它們是構成了一個環。如下圖:

 所以,這裡問題的關鍵點就是,我們再次碰到的節點4它還沒有從前面向前遞迴的函式返回回來,結果又被遍歷的時候給碰上了。這樣,按照前面的分析,我們的環檢測要點就是,找到一個還在遍歷中的節點,同時在遍歷的時候它如果再次被訪問到了,則表示找到了環。而如果它被訪問完了之後返回,則再次碰到它的時候就不是環了。

 從實現的角度來說,相當於對這個節點遞迴訪問前要設定一個對應的值,表示它在一個遞迴順序裡。然後在它訪問退出這個遞迴後,要將這個值設定回來。一種簡單的方式就是設定一個boolean[] onstack這樣的陣列,每個元素對應這裡面的一個值。然後,因為要記錄訪問過的節點,肯定要用一個boolean[] marked陣列。另外,既然還要記錄環的結果,肯定還要一個記錄前向訪問的陣列int[] edgeTo。

  按照前面的討論,可以得到一個檢驗環並儲存環的程式碼實現:

Java程式碼  收藏程式碼
  1. public class DirectedCycle {  
  2.     private boolean[] marked;  
  3.     private int[] edgeTo;  
  4.     private Stack<Integer> cycle;  
  5.     private boolean[] onStack;  
  6.     public DirectedCycle(Digraph g) {  
  7.         onStack = new boolean[g.getVertices()];  
  8.         edgeTo = new int[g.getVertices()];  
  9.         marked = new boolean[g.getVertices()];  
  10.         for(int v = 0; v < g.getVertices(); v++) {  
  11.             if(!marked[v]) dfs(g, v);  
  12.         }  
  13.     }  
  14.     private void dfs(Digraph g, int v) {  
  15.         onStack[v] = true;  
  16.         marked[v] = true;  
  17.         for(int w : g.adj(v)) {  
  18.             if(hasCycle()) return;  
  19.             if(!marked[w]) {  
  20.                 edgeTo[w] = v;  
  21.                 dfs(g, w);  
  22.             } else if(onStack[w]) {  
  23.                 cycle = new Stack<Integer>();  
  24.                 for(int x = v; x != w; x = edgeTo[x])  
  25.                     cycle.push(x);  
  26.                 cycle.push(w);  
  27.                 cycle.push(v);  
  28.             }  
  29.         }  
  30.         onStack[v] = false;  
  31.     }  
  32. }  

  前面程式碼的要點在於dfs方法。當我們要訪問某個節點v的時候,首先設定它對應的onStack[v] = true。而這個節點被訪問結束後,肯定是在它後面遍歷的遞迴都結束了,所以要在for迴圈之後重新將onStack[v] = false。在找到環的時候,我們根據當前節點v不斷回退到某個節點,這個節點剛好就是w。因為這是屬於函式遞迴呼叫裡面的,如果檢測到w被遍歷過,必然也能夠找到w。於是講這些節點壓入棧中。這樣整個環就找到了。

 前面程式碼裡引用到的方法hasCycle就很簡單,它和返回整個環的程式碼如下:

Java程式碼  收藏程式碼
  1. public boolean hasCycle() {  
  2.         return cycle != null;  
  3.     }  
  4.     public Iterable<Integer> cycle {  
  5.         return cycle;  
  6.     }  

  查詢和返回有向圖裡的環需要遍歷所有的節點,同時根據每次遞迴推進的過程中保證覆蓋到在同一個遞迴序列裡的元素。這是實現的兩個要點。

  這裡我們討論了有向圖環的檢測和引用,它在後續的問題應用裡有很重要的作用。在後續的小節裡會繼續說明。這裡先埋下一個伏筆。

深度優先遍歷排序

 我們在對圖做一些遍歷的時候,有的時候會發現一個和訪問樹很類似的規律。比如說,訪問一棵二叉樹的時候,我們訪問它的序列關係不同,會產生不同的序列。比如說前序,中序和後序。在訪問圖的時候,假定以深度優先遍歷為例。當我們每次遍歷到一個節點的時候就訪問它,也可以稱其訪問序為前序,而如果等它遍歷遞迴後返回的時候再訪問它,這就相當於一個後序。以前面的圖為例:

  這裡,我們如果按照前序的過程來遍歷的話,首先就是1, 2, 6。然後是3, 4, 5, 7, 8。這就是典型的深度優先遞迴訪問的步驟。而按照後序的過程來考慮呢,它訪問的順序則如下:6, 2, 8, 7, 5, 4, 3, 1。這裡的序列相當於是將每個遍歷訪問的節點先入棧,然後當遍歷到一個沒有出邊的節點時,這將作為一個返回條件。遞迴再逐步返回。

 要實現這兩種遍歷的方法其實很簡單,無非就是在深度優先遍歷的時候在訪問某個節點前或者訪問結束後將節點加入到佇列裡。具體的實現如下:

Java程式碼  收藏程式碼
  1. public class DepthFirstOrder {  
  2.     private boolean[] marked;  
  3.     private Queue<Integer> pre;  
  4.     private Queue<Integer> post;  
  5.     public DepthFirstOrder(Digraph g) {  
  6.         pre = new LinkedList<Integer>();  
  7.         post = new LinkedList<Integer>();  
  8.         marked = new boolean[g.getVertices()];  
  9.         for(int v = 0; v < g.getVertices(); v++)  
  10.             if(!marked[v]) dfs(g, v);  
  11.     }  
  12.     private void dfs(Digraph g, int v) {  
  13.         pre.add(v);  
  14.         marked[v] = true;  
  15.         for(int w : g.adj(v)) {  
  16.             if(!marked[w])  
  17.                 dfs(g, w);  
  18.         }  
  19.         post.add(v);  
  20.     }  
  21.     public Iterable<Integer> pre() {  
  22.         return pre;  
  23.     }  
  24.     public Iterable<Integer> post() {  
  25.         return post;  
  26.     }  
  27. }  
  重點就是在dfs方法裡。在pre裡面新增元素的時候是每次剛第一次訪問某個節點時。而在post裡面新增元素的時候則表示通過該節點以及它所關聯的節點都已經遍歷結束了。這兩個序列有什麼作用呢?在後序一些計算裡,還是很有用的。

 比如說後序的序列,因為每次我們放入佇列的是已經被遍歷過的節點,而且通過這個節點已經不可能再訪問到別的節點了。這就意味著從這個節點要麼是出度為0的節點,要麼是通過它所能訪問到的節點都已經被訪問過了。因為這些節點將作為遞迴結束的條件返回。所以說它們是優先返回的。也說明這些被返回的節點是可以被其他節點所遍歷訪問到的。而如果圖裡面有孤立的節點或者入度為0的節點呢?對於它們,因為沒有辦法通過其他節點所遍歷到,它們被返回的可能性就越晚。這種特性在後面的討論裡有一個很重要的作用。這裡先不詳細的闡述。

拓撲排序和DAG

 拓撲排序和DAG如果每接觸過看起來會覺得很新奇。它們的定義是很緊密相連的。 該怎麼來理解它們呢?我們先看看它們的定義。DAG(Directed acyclic graph),表示有向無環圖。就是對應一個有向圖,但是它裡面卻沒有環。像下面的這些個圖,都可以稱為DAG:


    對於這個定義,當我們要檢測一個有向圖是不是DAG的時候就很簡單了。有了前面檢測環的方法,直接用那個辦法就可以了。現在,拓撲排序又是什麼意思呢?

 這個概念結合一些具體的問題來說可能更加好理解一些。在一些任務安排和排程的問題裡。不同的問題或者任務之間又一些依賴的關係,有的任務需要在某些任務完成之後才能做。就像一些學校的教學課程安排。設定某一門課程需要依賴於一個前置的課程,只有學生學習了前置課程之後才能取學習該課程。如果將一門課程當做一個節點,從它引出一個指標指向後序依賴它的課程。就可能有一個類似這樣的圖:

    如果將上圖中每個課程用一個數位元組點表示,這不正是前面的一個圖嗎?對於這種圖來說,最大的特點就是它們肯定就不能存在環。不然就有邏輯上的錯誤。因此,前面檢測一個圖是否為DAG的方法就是看圖中是否有環。而拓撲排序則是在確定沒有環的情況下,輸出一個正常的序列。這個序列表示從一個不依賴任何元素的節點到後序的節點。這些序列正好符合課程安排或者任務排程的邏輯順序。

  我們已經知道了,對於一個有向圖來說,如果它不存在環,則它應該為DAG。現在的問題是怎麼找出這個拓撲序列來呢?前面一節裡講到的深度優先排序的過程在這裡就起到作用了。實際上,對於深度優先遍歷的後序序列,如果我們將它們的順序完全倒過來,得到的序列就是滿足我們要求的序列。對於這部分的證明書上有詳細的說明,這裡就不再贅述,只是直接搬過來這個結論。

  有了前面這兩個結論的支援,要實現拓撲排序就已經比較簡單了。就是我們首先判斷一下圖中間是否存在環,然後如果沒有存在的話,則取其中後序遍歷序列的相反就可以了。具體的實現如下:

Java程式碼  收藏程式碼
  1. public class Topological {  
  2.     private Iterable<Integer> order;  
  3.     public Topological(Digraph g) {  
  4.         DIrectedCycle cycleFinder = new DirectedCycle(g);  
  5.         if(!cycleFinder.hasCycle()) {  
  6.             DepthFirstOrder dfs = new DepthFirstOrder(g);  
  7.             order = dfs.reversePost();  
  8.         }  
  9.     }  
  10. }  

 因為這部分程式碼就是糅合前面幾個部分的程式碼到一塊,所以就很簡單了。 

另外一種思路

 針對前面的判斷DAG以及求拓撲序列的問題。我們如果仔細觀察的話,會發現一個這麼有意思的現象。就是拓撲序列要求的序列必然是開始於一系列入度為0的節點。如果沒有入度為0的節點,則表示這個圖不是DAG,這樣連遍歷都沒有必要了。當然,如果只是因為這個圖裡有入度為0的節點,並不代表這個圖就一定是DAG。只是有了這麼一個特徵之後有一個好處,我們判斷圖是否為DAG時還是要檢查是否存在環。

 但是,一旦判斷出圖裡沒有存在環,剩下的給出拓撲排序序列可以更加簡化。我們只要去取這些入度為0的節點,然後從這些節點遍歷圖。然後給出的序列就是拓撲排序的序列。

  現在還要一個問題就是,我們怎麼來求這些節點的入度呢?一個簡單的辦法就是在定義Digraph的時候增加一個數組int[] inDegree。每次我們新增一個邊u->v到圖裡時,inDegree[v]++。剩下的事,你懂的。

總結

 有向圖雖然看起來在定義的很多方面和無向圖很近似,但是當考慮到它的一些具體特性時。很多原來在無向圖裡比較簡單的問題就變得更加複雜化了。比如說判斷環和求圖中的環時,需要利用深度優先遞迴的序列來判斷。另外,有向圖裡前序、後序遍歷在判斷圖是否為DAG以及求圖的拓撲排序時很有幫助。裡面的詳細實現細節值得好好琢磨。這篇文章相對比較長,不過在討論這些問題的時候能夠有點小小的收穫和一些想法,也算是挺不錯的。

參考材料

http://algs4.cs.princeton.edu/42directed/Digraph.java.html

http://algs4.cs.princeton.edu/42directed/DepthFirstOrder.java.html

http://algs4.cs.princeton.edu/44sp/DirectedCycle.java.html