1. 程式人生 > 實用技巧 >圖解:有向環、拓撲排序與Kosaraju演算法

圖解:有向環、拓撲排序與Kosaraju演算法

圖演算法第三篇 圖解:有向環、拓撲排序與Kosaraju演算法

首先來看一下今天的內容大綱,內容非常多,主要是對演算法思路與來源的講解,圖文並茂,希望對你有幫助~

1.有向圖的概念和表示

概念

有向圖與上一篇文章中的無向圖相對,邊是有方向的,每條邊所連線的兩個頂點都是一個有序對,它們的鄰接性都是單向的。

一幅有方向的圖(或有向圖)是由一組頂點和一組有方向的邊組成的,每條有方向的邊都連線著一對有序的頂點。

其實在有向圖的定義這裡,我們沒有很多要說明的,因為大家會覺得這種定義都是很自然的,但是我們要始終記得有方向這件事!

資料表示

我們依然使用鄰接表儲存有向圖,其中v-->w表示為頂點v

的鄰接連結串列中包含一個頂點w。注意因為方向性,這裡每條邊只出現一次!

我們來看一下有向圖的資料結構如何實現,下面給出了一份Digraph類(Directed Graph)

package Graph.Digraph;
import java.util.LinkedList;

public class Digraph{
    private final int V;//頂點數目
    private int E;//邊的數目
    private LinkedList<Integer> adj[];//鄰接表

    public Digraph(int V){
        //建立鄰接表
        //將所有連結串列初始化為空
        this.V=V;this.E=0;
        adj=new LinkedList[V];
        for(int v=0;v<V;++v){
            adj[v]=new LinkedList<>();
        }
    }

    public int V(){ return V;}//獲取頂點數目
    public int E(){ return E;}//獲取邊的數目

    //注意,只有這裡與無向圖不同
    public void addEdge(int v,int w){
        adj[v].add(w);//將w新增到v的連結串列中
        E++;
    }

    public Iterable<Integer> adj(int v){
        return adj[v];
    }

    //獲取有向圖的取反
    public Digraph reverse(){
        Digraph R=new Digraph(V);
        for(int v=0;v<V;v++){
            for(int w:adj(V))
                R.addEdge(w, v);//改變加入的順序
        }
        return R;
    }
}

如果你已經掌握了無向圖的資料表示,你會發現有向圖只是改了個名字而已,只有兩處需要注意的地方:addEdge(v,w)方法reverse()方法。在新增一條邊時因為有了方向,我們只需要在鄰接表中增加一次;reverse()方法能夠返回一幅圖的取反(即每個方向都顛倒過來),它會在以後的應用中發揮作用,現在我們只要有個印象就行。

2.有向圖的可達性

在無向圖(上一篇文章)中,我們使用深度優先搜尋可以找到一條路徑,使用廣度優先搜尋可以找到兩點間的最短路徑。仔細想一下,它們是否對有向圖適用呢?是的,同樣的程式碼就可以完成這個任務,我們不需要做任何的改動(除了Graph換成Digraph)

因為這些內容在上篇文章中都已經詳細介紹過,所以就不展開了,有興趣的話可以翻一下上篇文章,有詳細的圖示講解。

3.環和有向無環圖

我們在實際生活中可能會面臨這樣一個問題:優先順序限制下的排程問題。說人話就是你需要做一些事情,比如A,B,C,但是做這三件事情有一定的順序限制,做B之前必須完成A,做C之前必須完成B…………你的任務就是給出一個解決方案(如何安排各種事情的順序),使得限制都不衝突。

如上圖,第一種和第二種情況都比較好辦,但是第三種?是不是哪裡出了問題!!!

對於上面的排程問題,我們可以通過有向圖來抽象,頂點表示任務,箭頭的方向表示優先順序。不難發現,只要有向圖中存在有向環,任務排程問題就不可能實現!所以,我們下面要解決兩個問題:

  • 如何檢測有向環(只檢查存在性,不考慮有多少個)
  • 對於一個不存在有向環的有向圖,如何排序找到解決方案(任務排程問題)

1.尋找有向環

我們的解決方案是採用深度優先搜尋。因為由系統維護的遞迴呼叫棧表示的正是“當前”正在遍歷的有向路徑。一旦我們找到了一條有向邊v-->w,並且w已經存在於棧中,就找到了一個環。因為棧表示的是一條由w指向v的有向路徑,而v-->w正好補全了這個環。同時,如果沒有找到這樣的邊,則意味著這幅有向邊是無環的。

我們所使用的資料結構:

  • 基本的dfs演算法
  • 新增一個onStack[]陣列用來顯式地記錄棧上的頂點(即一個頂點是否在棧上)

我們還是以一個具體的過程為例講解

具體的程式碼我想已經難不倒你了,我們一起來看看吧

package Graph.Digraph;

import java.util.Stack;

public class DirectedCycle {
    private boolean [] marked;
    private int [] edgeTo;
    private Stack<Integer> cycle;//有向環中的所有頂點(如果存在)
    private boolean[] onStack; //遞迴呼叫的棧上的所有頂點

    public DirectedCycle(Digraph G){
        onStack=new boolean[G.V()];
        edgeTo=new int[G.V()];
        marked=new boolean[G.V()];
        
        for(int v=0;v<G.V();v++){
            if(!marked[v]) dfs(G,v);
        }
    }

    private void dfs(Digraph G,int v){
        onStack[v]=true;//進入dfs時,頂點v入棧
        marked[v]=true;
        for(int w:G.adj(v)){
            if(this.hasCycle()) return;
            else if(!marked[w]){
                edgeTo[w]=v;dfs(G,w);
            }
            else if(onStack[w]){
                //重點
                cycle=new Stack<Integer>();
                for(int x=v;x!=w;x=edgeTo[x])
                    cycle.push(x);

                cycle.push(w);
                cycle.push(v);
            }
        }
        //退出dfs時,將頂點v出棧
        onStack[v]=false;
    }

    public boolean hasCycle(){
        return cycle!=null;
    }

    public Iterable<Integer> cycle(){
        return cycle;
    }
}

該類為標準的遞迴 dfs() 方法添加了一個布林型別的陣列 onStack[] 來儲存遞迴呼叫期間棧上的
所有頂點。當它找到一條邊 v → ww 在棧中時,它就找到了一個有向環。環上的所有頂點可以通過
edgeTo[] 中的連結得到。

在執行 dfs(G,v) 時,查詢的是一條由起點到 v 的有向路徑。要儲存這條路徑, DirectedCycle維護了一個由頂點索引的陣列 onStack[],以標記遞迴呼叫的棧上的所有頂點(在呼叫
dfs(G,v) 時將 onStack[v] 設為 True,在呼叫結束時將其設為 false)。DirectedCycle 同時也
使用了一個 edgeTo[] 陣列,在找到有向環時返回環中的所有頂點,

2.拓撲排序

如何解決優先順序限制下的排程問題?其實這就是拓撲排序

拓撲排序的定義:給定一幅有向圖,將所有的頂點排序,使得所有的有向邊均從排在前面的元素指向排在後面的元素(或者說明無法做到這一點)

下面是一個典型的例子(排課問題)

它還有一些其他的典型應用,比如:

現在,準備工作已經差不多了,請集中注意力,這裡的思想可能不是很好理解。緊跟我的思路。

現在首先假設我們有一副有向無環圖,確保我們可以進行拓撲排序;通過拓撲排序,我們最終希望得到一組頂點的先後關係,排在前面的元素指向排在後面的元素,也就是對於任意的一條邊v——>w,我們得到的結果應該保證頂點v頂點w前面;

我們使用dfs解決這個問題,在呼叫dfs(v),以下三種情況必有其一:

  • dfs(w)已經被呼叫過且已經返回了(此時w已經被標記)
  • dfs(w)已經被呼叫過且還沒有返回(仔細想想這種情況,這是不可能存在的)
  • dfs(w)還沒有被呼叫(w還沒有被標記),此時情況並不複雜,接下來會呼叫dfs(w),然後返回dfs(w),然後呼叫dfs(v)

簡而言之,我們可以得到一個很重要的結論:dfs(w)始終會在dfs(v)之前完成。 換句話說,先完成dfs的頂點排在後面

請確保你完全理解了上面的思想,接下來其實就相對容易了。我們建立一個棧,每當一個頂點dfs完成時,就將這個頂點壓入棧。 最後,出棧就是我們需要的順序


其實到這裡拓撲排序基本上就已經被我們解決了,不過這裡我們拓展一下,給出一些常見的排序方式,其中我們剛才說到的其實叫做逆後序排序。它們都是基於dfs

  • 前序:在遞迴呼叫之前將頂點加入佇列
  • 後序:在遞迴呼叫之後將頂點加入佇列
  • 逆後序:在遞迴呼叫之後將頂點壓入棧

我們在這裡一併實現這三個排序方法,在遞迴中它們表現得十分簡單

package Graph.Digraph;

import java.util.ArrayList;
import java.util.LinkedList;
import java.util.Queue;
import java.util.Stack;

public class DepthFirstOrder {

    private boolean [] marked;
    private Queue<Integer> pre;//所有頂點的前序排列
    private Queue<Integer> post;//所有頂點的後序排列
    private Stack<Integer> reversePost;//所有頂點的逆後序排列

    public DepthFirstOrder(Digraph G){
        pre=new LinkedList<>();
        post=new LinkedList<>();
        reversePost = new Stack<>();

        marked=new boolean[G.V()];

        for(int v=0;v<G.V();v++){
            if(!marked[v]) dfs(G,v);
        }
    }

    private void dfs(Digraph G,int v){
        pre.offer(v);

        marked[v]=true;
        for(int w:G.adj(v))
            if(!marked[w])
                dfs(G, w);
        post.offer(v);
        reversePost.push(v);
    }

    //這裡可以不用管
    public Iterable<Integer> pre()  
    {  return pre;  } 
   public Iterable<Integer> post() 
   {  return post;  } 
   public Iterable<Integer> reversePost() 
   {  return reversePost;  }
}

恭喜你,到這兒我們已經完全可以實現拓撲排序,下面的Topological類實現了這個功能。在給定的有向圖包含環的時候,order()方法返回null,否則會返回一個能夠給出拓撲有序的所有頂點的迭代器(當然,你也可以很簡單的將排序頂點打印出來)。具體的程式碼如下:

package Graph.Digraph;

public class Topological {

    private Iterable<Integer> order;//頂點的拓撲順序

    public Topological(Digraph G){
        //判斷給定的圖G是否有環
        DirectedCycle cyclefinder=new DirectedCycle(G);
        if(!cyclefinder.hasCycle()){
            DepthFirstOrder dfs=new DepthFirstOrder(G);
            order = dfs.reversePost();
        }
    }

    public Iterable<Integer> order(){
        return order;
    }

    //判斷圖G是不是有向無環圖
    public boolean isDAG(){
        return order!=null;
    }
    
}

到這兒,有向環的檢測與拓撲排序的內容就結束了,接下來我們要考慮有向圖的強連通性問題

4.強連通分量

1.強連通的定義

回想一下我們在無向圖的時候,當時我們就利用深度優先搜尋解決了一幅無向圖的連通問題。根據深搜能夠到達所有連通的頂點,我們很容易解決這個問題。但是,問題變成有向圖,就沒有那麼簡單了!下面分別是無向圖和有向圖的兩個例子:

定義。如果兩個頂點vw是互相可達的,則稱它們為強連通的。也就是說,既存在一條從 vw的有向路徑,也存在一條從wv的有向路徑。如果一幅有向圖中的任意兩個頂點都是強
連通的,則稱這幅有向圖也是強連通的。

以下是另一些強連通的例子:

2.強連通分量

在有向圖中,強連通性其實是頂點之間的一種等價關係,因為它有以下性質

  • 自反性:任意頂點 v 和自己都是強連通的
  • 對稱性:如果 v 和 w 是強連通的,那麼 w 和 v 也是強連通的
  • 傳遞性:如果 v 和 w 是強連通的且 w 和 x 也是強連通的,那
    麼 v 和 x 也是強連通的

因為等價,所以和無向圖一樣,我們可以將一幅圖分為若干個強連通分量,每一個強連通分量中的所有頂點都是強連通的。這樣的話,任意給定兩個頂點判斷它們之間的強連通關係,我們就直接判斷它們是否在同一個強連通分量中就可以了!

接下來,我們需要設計一種演算法來實現我們的目標————將一幅圖分為若干個強連通分量。我們先來總結一下我們的目標:


3.Kosaraju演算法

Kosaraju演算法就是一種經典的解決強連通性問題的演算法,它實現很簡單,但是不好理解why,希望你打起精神,我希望我能夠把它講明白(也只是希望,我會盡量,如果不清楚的話,強烈建議結合演算法4一起食用)


回憶一下我們之前在無向圖的部分如何解決連通性問題的,一次dfs能夠恰好遍歷一個連通分量,所以我們可以通過dfs來計數,獲取每個頂點的id[];所以,我們在解決有向圖的強連通性問題時,也希望能夠利用一次dfs能夠恰好遍歷一個連通分量的性質;不過,在有向圖中,它失效了,來看一下圖一:

在圖一中,dfs遍歷會存在兩種情況:

第一種情況:如果dfs的起點時頂點A,那麼一次dfs遍歷會遍歷整個區域一和區域二,但是區域一與區域二並不是強連通的,這就是有向圖給我們帶來的困難!

第二種情況:如果dfs的起點是頂點D,則第一次dfs會遍歷區域二,第二次dfs會遍歷區域一,這不就是我們想要的嗎?

所以,第二個情況給了我們一個努力的方向!也就是如果我們人為地,將所有的可能的情況都變成第二種情況,事情不就解決了!

有了方向,那麼接下來,我們來看一幅真實的有向圖案例,如圖二所示,這是一幅有向圖,它的各個強連通分量在圖中用灰色標記;我們的操作是將每個強連通分量看成一個頂點(比較大而已),那麼會產生什麼後果呢?我們的原始的有向圖就會變成一個有向無環圖!

ps:想一想為什麼不能存在環呢?因為前提我們把所有的強連通分量看成了一個個頂點,如果頂點A頂點B之間存在環,那AB就會構成一個更大的強連通分量!它們本應屬於一個頂點!

在得到一幅有向無環圖(DAG)之後,事情沒有那麼複雜了。現在,我們再回想一下我們的目的————在圖一中,我們希望區域二先進行dfs,也就是箭頭指向的區域先進行dfs。在將一個個區域抽象成點後,問題歸結於在一幅有向無環圖中,我們要找到一種順序,這種順序的規則是箭頭指向的頂點排在前

到這兒,我們稍微好好想想,我們的任務就是找到一種進行dfs的順序,這種順序,是不是和我們在前面講到的某種排序十分相似呢?我想你已經不難想到了,就是拓撲排序!但是和拓撲排序是完全相反的。

我們把箭頭理解為優先順序,對於頂點A指向頂點B,則A的優先順序高於B。那麼對於拓撲排序,優先順序高者在前;對於我們的任務,優先順序低者在前(我們想要的結果就是dfs不會從優先順序低的地方跑到優先順序高的地方)

對於圖二:我們想要的結果如圖三所示:

如果我們從頂點1開始進行dfs,依次向右,那麼永遠不會發生我們不希望的情況!因為箭頭是單向的!

我想,到這兒,你應該差不多理解我的意思了。我們還有最後一個小問題————如何獲取拓撲排序的反序?

其實解決方法很簡單:對於一個有向圖G,我們先取反(reverse方法),將圖G的所有邊的順序顛倒,然後獲取取反後的圖的逆後序排序(我們不能稱為拓撲排序,因為真實情況是有環的);最後,我們利用剛才獲得的頂點順序對原圖G進行dfs即可,這時它的原理與上一篇文章無向圖的完全一致!

最後,總結一下Kosaraju演算法的實現步驟:

  • 1.在給定的一幅有向圖 G 中,使用 DepthFirstOrder 來計算它的反向圖 GR 的逆後序排列。
  • 2.在 G 中進行標準的深度優先搜尋,但是要按照剛才計算得到的順序而非標準的順序來訪問
    所有未被標記的頂點。

具體的實現程式碼只在無向圖的實現CC類中增加了兩行程式碼(改變dfs的順序)

package Graph.Digraph;

public class KosarajuSCC 
{ 
   private boolean[] marked;   // 已訪問過的頂點 
   private int[] id;           // 強連通分量的識別符號 
   private int count;          // 強連通分量的數量
   public KosarajuSCC(Digraph G) 
   { 
      marked = new boolean[G.V()]; 
      id = new int[G.V()]; 
      DepthFirstOrder order = new DepthFirstOrder(G.reverse()); //重點
      for (int s : order.reversePost()) //重點
         if (!marked[s]) 
         {  dfs(G, s); count++;  }
    }
   private void dfs(Digraph G, int v) 
   { 
      marked[v] = true; 
      id[v] = count; 
      for (int w : G.adj(v)) 
         if (!marked[w]) 
             dfs(G, w); 
   }
   public boolean stronglyConnected(int v, int w) 
   {  return id[v] == id[w];  }
   public int id(int v) 
   {  return id[v];  }
   public int count()
   { return count;}
}

最後,附上一幅具體的操作過程:

有了Kosaraju演算法,我們很容易能夠判斷

  • 給定的兩個頂點的連通性(上文程式碼stronglyConnected)
  • 該圖中有多少個強連通分量(上文程式碼count)

後記

好了,關於有向圖的內容就到這裡了,我希望通過這篇文章你能夠徹底理解這三種演算法!,下一篇文章小超與你不見不散!

最後送你一幅圖演算法的思維導圖

後臺回覆【圖演算法】可獲得xmind格式,我只想說:真的好多內容!