1. 程式人生 > >無向圖的幾個基本演算法應用

無向圖的幾個基本演算法應用

簡介

    最近在看一些圖相關的問題。實際上關於圖相關的研究和問題已經非常多了。在前面的幾篇文章裡,我也談到過圖的定義、遍歷法,擴充套件樹生成和最短路徑等問題。 除了這些問題及應用以外,還有一些比較常見的問題,雖然難度不大,不過經常會在一些情況下碰到。不仔細去考慮的話還是比較難解決的。這篇文章裡重點要討論解決的幾個問題分別是檢測圖的連通性、圖中間環的檢測和二分圖的檢測。

圖的連通性

    判斷一個圖的連通性,從概念上來說,就是如果一個圖是連通的,那麼對於圖上面的任意兩個節點i, j來說,它們相互之間可以通過某個路徑連線到對方。比如下圖:

    在這個圖裡,任意的兩個節點都可以通過一個路徑到達對方。而對於非連通的圖來說,它相當於將一個圖分割成多個獨立的部分,每個部分之間沒有任何聯絡,一個典型的示例如下圖:

    在這個圖裡,7,8組成的部分以及9到12所組成的部分它們都是互相隔離的。那麼如果要檢查和判斷一個圖是否為連通的,該用什麼辦法呢?

判斷圖是否連通

    如果僅僅是判斷一個圖是否為連通的,結合前面討論圖的基礎遍歷方法,可以有如下的方法。在前面圖遍歷的方法過程中,我們是從一個指定的點開始,通過不同的策略去遍歷這個圖,有深度遍歷和廣度遍歷。每次經過一個節點的時候,首先判斷一下這個節點是否已經訪問過了,如果沒有訪問過,則這個節點可以作為下一次繼續遍歷的候選。因為如果這個圖是連通的話,這種方法最終會覆蓋到整個圖。所以可以採用一種計數統計的方式來實現。比如說每次訪問一個以前沒有遍歷的節點,則將對應的計數加一。這樣當最後遍歷結束後,如果統計的節點和圖本身的節點一樣的話,表示這個圖是連通的,否則表示不連通。在前面的圖定義裡有相關實現,這裡把部分程式碼給轉貼過來。

深度優先遍歷:

Java程式碼  收藏程式碼
  1. public class DepthFirstSearch {  
  2.     private boolean[] marked;  
  3.     private int count;  
  4.     private final int s;  
  5.     public DepthFirstSearch(Graph g, int s) {  
  6.         marked = new boolean[g.getVertices()];  
  7.         this.s = s;  
  8.         dfs(g, s);  
  9.     }  
  10.     private
     void dfs(Graph g, int v) {  
  11.         marked[v] = true;  
  12.         count++;  //計數,統計訪問過的節點  
  13.         for(int w : g.adj(v))  
  14.             if(!marked[w]) {  
  15.                 dfs(g, w);  
  16.             }  
  17.     }  
  18.     public boolean marked(int w) {  
  19.         return hasPathTo(w);  
  20.     }  
  21.     public int count() {  
  22.         return count;  
  23.     }  
  24. }  

廣度優先遍歷:

Java程式碼  收藏程式碼
  1. private void bfs(Graph g, int s) {  
  2.         Queue<Integer> queue = new LinkedList<Integer>();  
  3.         marked[s] = true;  
  4.         queue.enqueue(s);  
  5.         while(q.size() > 0) {  
  6.             int v = queue.remove();  
  7.             for(int w : g.adj(v))  
  8.                 if(!marked[w]) {  
  9.                     marked[w] = true;  
  10.                     queue.add(w);  
  11.                     count++;  //統計計數  
  12.                 }  
  13.         }  
  14.     }  

    這種方法用來判斷整個圖是否為連通的時候,實際上只要給定一個點,然後按照給定的步驟可以把改點所連線的所有點都涵蓋到。如果有其它分隔的部分則沒有再處理了。所以,通過這種辦法我們在圖不是連通的情況下,它只需要涵蓋圖的一部分就執行結束了。最壞的情況時間複雜度也就是O(V+E)。

    這種辦法如果用來單純判斷一個圖是否連通確實很有效。但是,在某些情況下,我們需要考慮的不僅僅是判斷整個圖是否為連通這麼簡單。比如說,有時候我們需要考慮,給定兩個節點i, j,需要判斷它們是否相互連線的。這就是我們接著需要考慮的問題。

圖中間任意兩個點的連通性

    因為有時候要考慮的是給定兩個點,看它們之間是否連通。所以可能有很多種情況。比如說當整個圖是連通的,則它們必然是連通的。而如果整個圖不是連通的,但是這兩個點是在一個連通的塊,它們也是相互連通的。

    對於這個問題,該怎麼來分析呢?從前面判斷圖是否連通的過程裡,我們可以借鑑到一點思路。首先,對於一個連通的塊,按照給定的遍歷方法,肯定可以把這一塊給覆蓋。可是,假設把某一塊覆蓋了,對於這個被覆蓋的區域內的點,隨意給定兩個,我們怎麼知道它們就是連通的呢?這就是這個問題的關鍵點。在前面的圖遍歷演算法裡,當我們每經過一個節點的時候,就將一個boolean陣列marked裡對應的元素設定為true。那麼這裡是不是也可以這樣來做呢?

    比如下圖中的0到6節點部分,假設這部分被涵蓋之後。他們對應的marked部分為true。可是對於7,8節點呢?它們也要在後面的部分裡遍歷覆蓋,至少保證7和8是連通的,只是它們和外面其他點沒有關係。

    所以,從前面的討論裡可以看出來。光遍歷一個連通的塊是不夠的,肯定要遍歷完所有的塊。另外,如果遍歷完一個塊僅僅用boolean陣列來標誌的話還是不夠的,比如說當我們遍歷完0到6這個部分,它們對應的makred被設定為true。而後面又遍歷了節點7,8。對於它們該怎麼處理呢?如果也標識為true,我們怎麼來表示0到6是互通的,但是它們卻和節點7,8沒關係呢?所以,問題的關鍵在於對於每個不同的連通區域,要進行不同的標識。

    概括起來,前面要處理的問題主要是兩個:1. 遍歷圖中間所有節點。 2. 所有相通的塊必須標識為相同。

    對於第二個問題從前面的遍歷方法我們已經知道,不管是dfs還是bfs,只要給定一個節點遍歷完,這一塊地方我們一路做同樣的標記就可以了,只要它們相通那麼標記也肯定是一樣的。而對於要遍歷所有節點的問題,這個也好辦。無非就是遍歷一遍所有的節點,對每個節點都呼叫遍歷方法,不過對於已經訪問過的節點則直接跳過。所以在實現的細節上,我們可以考慮用一個計數器和一個數組,對於某個塊它設定一個值,然後對應的這個值也放到對應陣列的索引的位置裡。下一次再遇到一個遍歷的塊時,對這個計數器加一。這樣每次遍歷的塊的計數器值不同。給定任意兩個節點,只要判斷一下數組裡對應的計數器值是否相同就可以了。

    按照前面的這些討論,詳細的實現程式碼如下:

Java程式碼  收藏程式碼
  1. public class CC {  
  2.     private boolean[] marked;  
  3.     private int[] id;  // 記錄每個節點所屬的連通塊計數  
  4.     private int count;  //用來標記不同連通塊  
  5.     public CC(Graph g) {  
  6.         marked = new boolean[g.v()];  
  7.         id = new int[g.v()];  
  8.         for(int i = 0; i < g.v(); i++) { //遍歷所有節點   
  9.             if(!makred[i]) {  
  10.                 dfs(g, i);  
  11.                 count++  
  12.             }  
  13.         }  
  14.     }  
  15.     private void dfs(Graph g, int v) {  
  16.         marked[v] = true;  
  17.         id[v] = count;  
  18.         for(int w : g.adj(v))  
  19.             if(!marked[w])  
  20.                 dfs(g, w);  
  21.     }  
  22.     public boolean connected(int v, int w) {  
  23.         return id[v] == id[w];  
  24.     }  
  25.     public int id(int v) {  
  26.         return id[v];  
  27.     }  
  28.     public int count() {  
  29.         return count;  
  30.     }  
  31. }  

    前面程式碼實現的細節點在於我們定義了一個int[] id陣列,它儲存不同節點的統計值。而count這個統計值則表示相通的塊裡這個值是一樣的。

    另外,前面這幾個方法用的是深度優先遍歷的方法。要改用廣度優先遍歷的方法也很方便。

圖中間環的檢測

    除了前面測試圖連通的問題,還有一個常見的問題就是檢測圖中間是否存在環。這也是一個很有意思的問題,因為在大多數圖的結構中確實是存在環的。而對於一個連通的圖來說,如果它不存在環,則可以稱其為樹了。想到這一步,我們才發現,這個環檢測的問題在判斷一個圖形是否為樹的問題這邊有很重要的應用。關於判斷一個結構是否為樹的問題這裡不贅述,先把這個圖中間環檢測的問題給處理清楚。

   對於一個存在有環的圖,一些常用的形式如下圖這樣:

    從這些圖的結構裡,我們可以看到一個這樣的規律。就是從圖中間構成環的任意一個節點開始,如果按照某個方向遍歷,最終它某個可以訪問的點是它前面已經遍歷過的。對於一些特殊的情況,比如兩個相鄰的節點之間的連線,它們不能定義為環,需要被排除。這也是後面具體實現的細節裡需要考慮的。

    現在結合後面的這個圖,我們來進一步細化前面考慮的步驟。假設我們僅僅用原來遍歷圖的資料結構,我們只是需要有一個boolean[] marked陣列就可以了。那麼,剛開始的時候,假設從節點1開始去遍歷。第一步之後的情況應該如下:

    按照前面的標記,makred[1]被標記為true。然後繼續考慮1節點所鄰接的節點2:

    這裡有一個問題,就是前面從1遍歷到2的時候,設定了marked[2] = true。但是從2為節點再一次進行遍歷的時候,可能首先又碰到從2到1的這個關係。這個時候,按照環存在的判斷條件,相當於從某個節點出發的時候碰到了一個前面遍歷過的節點了。但是2就是從1過來的,如果這種情況判斷為真的話則1和2被判斷為一個環了。所以需要避免這種情況。要避免這種情況的話,可以增加一個引數,表示訪問的當前節點的前一個節點。如果從當前節點所能連線到的節點去遍歷的時候,碰到的節點是已經訪問過的節點,但是這個節點是它的前一個節點的話,這種情況我們應該忽略。這樣,按照前面深度優先遍歷的規則,下一步訪問的情況如下圖:

    同樣,下一步可以訪問的節點假設為4,這個時候,對於節點4來說,它的前一個節點是3, 但是它可以遍歷到節點2, 而2已經是前面訪問過的了,所以這個時候可以判斷說確實有環存在。

    這是我們考慮到圖的一種情況。如果對於圖並不是完全連通的情況呢?為了避免遺漏,肯定要嘗試去遍歷所有的節點,和前面檢測圖連通性類似。所以,根據前面的討論,我們後面實現的詳細程式碼如下:

Java程式碼  收藏程式碼
  1. public class Cycle {  
  2.     private boolean[] marked;  
  3.     private boolean hasCycle;  
  4.     public Cycle(Graph g) {  
  5.         marked = new boolean[g.v()];  
  6.         for(int i = 0; i < g.v(); i+) {  
  7.             if(!makred[i])  
  8.                 dfs(g, i, i);  
  9.         }  
  10.     }  
  11.     private void dfs(Graph g, int v, int u) {  
  12.         marked[v] = true;  
  13.         for(int w : g.adj(v)) {  
  14.             if(!marked[w])  
  15.                 dfs(g, w, v);  
  16.             else if(w != u)  
  17.                 hasCycle = true;  
  18.         }  
  19.     }  
  20.     public boolean hasCycle() {  
  21.         return hasCycle;  
  22.     }  
  23. }  

    前面實現的程式碼並不多,重點在於幾個地方。一個是前面開始呼叫dfs方法的時候,傳入的節點引數是一個起始節點和它本身作為訪問過的前節點。這裡是通過一個for迴圈遍歷所有的節點,並通過marked陣列來過濾。另外就是dfs方法裡,每次取得給定節點v的所有連線點時,我們要判斷一下如果這個被訪問的節點是前面被訪問過而且不是前置節點的話,設定hasCycle為true。表達這個關係的程式碼是:

Java程式碼  收藏程式碼
  1. if(!marked[w])  
  2.     dfs(g, w, v);  
  3. else if(w != u)  
  4.     hasCycle = true;  

    這裡實現的細節值得仔細去體會。當前,前面遍歷的方法是用的深度優先遍歷,所以每次當要去遍歷下一個節點的時候,只要排除這個節點的前一個節點就可以了。反正深度優先遍歷就是這麼一個節點一直向前推進到沒有了才後退的。所以用一個引數作為前置節點的方式是可行的。我們也可以通過廣度優先便利的方式來實現。不過會稍微麻煩點。因為需要記錄每個節點的前置節點,需要再額外定義一個數組來表示它們的關係,在後面的判斷裡結合陣列的值來處理。這裡只是提出一個這樣的思路,具體的實現可以很容易得到。

二分圖

    還有一個比較重要的問題就是二分圖(bipartite)。這個問題有很多的變種,其本質上和著色問題也有很密切的聯絡。具體的定義就是,假設我們有一個圖,對於一個開始的節點,我們嘗試用如下的方式去給它著色。總共所有的節點只能著兩種色中間的一種。假設為紅色或者藍色。對於一個節點來說,假設它著的是某一種顏色,那麼和它相鄰的節點只能著和它不同的顏色。那麼,給定一個圖,如果這個圖滿足上述的特性的話,則這個圖可以稱之為二分圖。

    這樣的描述顯得比較空洞,我們來看一個具體的示例:

    圖中的這些圖形則都可以表示為二分圖。因為它們滿足給定一個節點,所有和它相鄰節點都和它顏色不同的特性。

    有了前面幾個問題討論的經驗,再來解決它就相對有一點思路了。肯定要判斷這個圖是否為二分圖必然會遍歷這個圖。然後每次在判斷的時候假定一個節點的顏色為某個值,那麼再將它相鄰的節點顏色都設定成不同的。因為只是兩種顏色,可以直接用布林值型別來處理。另外,對於不屬於二分圖的情況,肯定是某個節點訪問到一個它可以連線到的節點,而這個節點已經被訪問過了。但是這個被訪問過的節點和當前節點顏色是一樣的。這樣才能表明它和前面二分圖的定義有衝突。所以,我們遍歷整個圖就是為了過濾提到的這種情況。

    那麼,在實際實現中可以這樣考慮。對於所有節點對應的顏色需要定義一個boolean[] color陣列。然後最開始訪問一個節點的時候,將其對應color位設定為true,每次訪問一個關聯的節點時,將關聯節點設定成原來節點的相反值。也就是說,比如節點v它的顏色為color[v],那麼下一個它被關聯的節點的顏色則可以設定成color[w] = !color[v]。正好通過取反實現了顏色的變換。詳細實現的程式碼如下:

Java程式碼  收藏程式碼
  1. public class TwoColor {  
  2.     private boolean[] marked;  
  3.     private boolean[] color;  
  4.     private boolean isTwoColorable = true;  
  5.     public TwoColor(Graph g) {  
  6.         marked = new boolean[g.v()];  
  7.         color = new boolean[g.v()];  
  8.         for(int i = 0; i < g.v(); i++)  
  9.             if(!marked[i])  
  10.                 dfs(g, i);  
  11.     }  
  12.     private void dfs(Graph g, int v) {  
  13.         marked[v] = true;  
  14.         for(int w : g.adj(v)) {  
  15.             if(!marked[w]) {  
  16.                 color[w] = !color[v];  
  17.                 dfs(g, w);  
  18.             } else if(color[w] == color[v])  
  19.                 isTwoColorable = false;  
  20.         }  
  21.     }  
  22.     public boolean isBipartite() {  
  23.         return isTwoColorable;  
  24.     }  
  25. }  

    這裡實現的要點還是通過dfs方法,每次碰到一個節點的時候就要判斷一下是否已經訪問過,已經訪問過的話,要判斷顏色是否相同。沒有的話,則將新節點設定成當前節點的相反值。然後就是要遍歷所有節點,防止遺漏未連線的節點情況。程式碼看起來並不複雜, 細節還是需要慎重考慮。

總結

    這是一篇相對來說比較長的部落格。之所以寫這些主要是圖的連通性,圖中間環的檢測和二分圖的檢測等問題在很多圖應用中都是一個基礎。而且因為之前學習這部分的時候遺漏了這幾個重要的點,結果導致在一次重要的面試中碰到了這個問題。後來處理的不好,讓人非常的悔恨。實際上不管是圖的連通性,環也好或者劃分也好。它們基本上都是基於一個圖遍歷的過程和方法。我們常用的圖遍歷方法比如深度優先和廣度優先,它們結合一些其他的資料結構就能夠解決這些問題。在解決這些問題的時候,還有一個容易遺漏的地方就是我們很容易忽略圖的連通性情況。有的問題只有在圖是完全連通的情況下才可以,所以為了避免在圖不是全連通情況下的問題,我們必須儘量去遍歷所有的節點。

參考材料