算法之《圖》Java實現

 

 

上上篇博客中,咱們介紹了算法中中的查找算法,其中大部分是在介紹查找算法中須要用獲得的數據結構。在這一篇博客中,咱們未來開啓圖的新篇章。html

圖的源碼java

數據結構之圖

在前面咱們所介紹的樹的數據結構中,咱們能夠明顯的感受到,樹的表示是分層的,例如父子關係,而其餘關係只能間接的表示,例如同級關係。而圖卻不受這種限制。圖是由頂點(或結點)及頂點之間的關係組成的集合。一般,圖中的頂點數量或者一個頂點與其餘頂點之間的連線的個數不受限制。(C++數據結構與算法)git

定義(百度百科)

主要有如下兩種定義。
二元組的定義:
圖G是一個有序二元組(V,E),其中V稱爲頂集(Vertices Set),E稱爲邊集(Edges set),E與V不相交。它們亦可寫成V(G)和E(G)。
E的元素都是二元組,用(x,y)表示,其中x,y∈V。
三元組的定義:
圖G是指一個三元組(V,E,I),其中V稱爲頂集,E稱爲邊集,E與V不相交;I稱爲關聯函數,I將E中的每個元素映射到 。若是e被映射到(u,v),那麼稱邊e鏈接頂點u,v,而u,v則稱做e的端點,u,v此時關於e相鄰。同時,若兩條邊i,j有一個公共頂點u,則稱i,j關於u相鄰。github

在介紹圖以前,首先讓咱們來了解一下圖中的一個重要的分類。圖的術語特別的多,不過咱們能夠慢慢的瞭解,由於定義都比較簡單(我將在下面慢慢的介紹一些術語)。算法

  • 無向圖:圖是有一組頂點和一組可以將兩個頂點相連的邊組成的。可能概念有點模糊,可是能夠根下面的有向圖相比較就特別簡單了。數組

     


     

     

  • 有向圖:由一組頂點和一組有方向的邊組成,每條有方向的邊都鏈接着有序的一對頂點網絡

    (這張來自百度百科的圖片都快糊了)數據結構

     

    img

     

    圖的分類其實不少,可是咱們主要介紹的就是這兩種分類,還有一些分類可能會在接下來的博客中提到(我也不肯定會不會提到,還沒寫)負載均衡

圖的術語表

  1. 相鄰:若是兩個頂點經過一條邊相連, 則稱這兩個頂點是相鄰的,並稱這條邊依附於這兩個頂點ide

  2. 度數:某個頂點的度數即爲依附於它的邊的總數。

  3. 子圖:一幅圖的全部邊的一個子集以及他們所依附的全部頂點組成的圖。

  4. 路徑:由邊順序連接的一系列頂點

  5. 簡單路徑:一條沒有重複頂點的路徑。

  6. 環:一條至少包含一條邊且起點和終點相同的路徑。

  7. 簡單環:除了第一個頂點和最後一個頂點以外,其他頂點不重複出現的環。

  8. 連通圖:任意兩個頂點之間互通。一副非連通的圖由諾幹個連通的部分組成。

  9. 圖的密度:已鏈接的頂點對佔全部可能被鏈接的頂點對的比例。

  10. 平行邊:鏈接同一對頂點的兩條邊稱爲平行邊。

  11. 二分圖:圖中的每一條邊所鏈接的兩個頂點都分別屬於不一樣的部分,以下圖所示:

     

    img

在這一章博客中,我所講的內容會偏向於算法,並不會在數據結構上面說不少內容。

無向圖

OK,在前面說完這麼多,首先讓咱們來講下最簡單的圖:無向圖

不過在說在無向圖的操做以前,咱們至少得解決一個問題:咱們使用如何的結構去儲存圖。在前面咱們知道,圖不是像樹同樣(絕大部分的樹),只須要關心父子關係,而不須要關心兄弟關係。簡單點來講,就是樹的關係是縱向的(從上到下),而圖卻並非這樣,圖之間的關係是並列的。相信看過圖這種數據結構的人,應該對於圖的儲存結構的方式能夠說的信口拈來吧。下面是一些圖的儲存的方法:

圖源:geeksforgeeks

  • 鄰接矩陣表示法

    下圖一眼就能夠看懂,若是結點a與結點b之間相鏈接,則A(a,b) = A(b,a) = 1,不然爲0。

     


     

     

  • 鄰接表表示法

    在鄰接表表示法中,第一列表明的爲結點,如0,1,2……,然後面的則表明爲結點與其餘結點相鏈接的結點。(例如0結點後面爲1,4結點,則表明0結點與1結點和4結點相鏈接【在這裏咱們能夠發現,第5行的4結點的後面一樣有1結點】)

     


     

     

  • 關聯矩陣表示法

那麼咱們該選擇哪種的表示方式呢?兩種各有優缺點:

  • 若是咱們須要處理頂點V的鄰接頂點,咱們使用鄰接表只須要deg(v)步操做(deg:圖論中點連出的邊數)。而在鄰接矩陣中,咱們須要進行|V|步操做。可是在當咱們須要插入或者刪除一個鄰接與v的節點的時候,咱們須要對鄰接表進行調整,而對於鄰接矩陣,只須要進行0和1的變換便可。

  • 鄰接矩陣的空間複雜度是O(V*V),而鄰接表的複雜度爲O(V+E),V爲頂點數,E爲邊數。

咱們將會遇到的應用使用幾乎都是稀疏圖——《算法第四版》

​ 在這裏咱們能夠再想一下,在最稠密的狀況下(每個結點都與其餘結點相鏈接),鄰接矩陣的空間複雜度會遠遠的 小於鄰接表(n!和n^2不在一個數量級)。

  • 若是出現平行邊了,咱們只可以使用鄰接表去表示。

說了這麼多,在下面的數據結構中,除非特殊說明,咱們選擇使用鄰接表來進行數據儲存。咱們能夠上代碼了。

首先是抽象類的代碼:

package graph;


import java.awt.*;
import java.util.ArrayList;
import java.util.List;

/** * 圖的抽象數據結構 * @author xiaohui */
public abstract class Graph {
    // 頂點數量
    int V;
    // 邊的數量
    int E;
    // 鄰接表
    List[] adj;

    // 構造一個含有V個頂點的圖,可是不含邊
    Graph(int V) {
        adj = new ArrayList[V];
        for (int i = 0; i < V; i++) {
            adj[i] = new ArrayList<Integer>();
        }
        this.V = V;
    }

    /** * @return 返回頂點的數量 */
    int V(){
        return V;
    }

    /** * @return 返回邊的數量 */
    int E(){
        return E;
    }

    /** * 在圖中添加一條邊v-w * @param v * @param w */
    abstract void addEdge(int v, int w);

    /** * 得到與v相鄰的全部頂點 * @param v * @return */
    abstract Iterable<Integer> adj(int v);

    /** * 與結點s相連通的全部結點 * @param s * @return */
    abstract Iterable<Integer>search(int s);

    /** * 是否存在S結點到V結點的路徑 * @param s * @param v * @return */
    abstract boolean hasPathTo(int s,int v);

    /** * 找出s到v結點的路徑 * @param s * @param v * @return */
    abstract Iterable<Integer> pathTo(int s,int v);


    /** * 便於進行打印 * @return */
    @Override
    public String toString() {
        String s = "Graph{" +
                "V=" + V +
                ", E=" + E +
                '}';
        for (int v=0;v<V;v++){
            s += (v+":");
            for (int w :this.adj(v)) {
                s += w+" ";
            }
            s+= "\n";
        }
        return s;
    }
}

你們可能發現,上面的數據結構設計的不是很嚴謹,好比說結點都是使用了Int數據類型,而沒有使用泛型。一樣,這些方法不必定所有在一個類中實現,可能會進行分離。

首先讓咱們來實現較爲簡單的幾個函數。

@Override
void addEdge(int v, int w) {
    adj[v].add(w);
    adj[w].add(v);
    this.E ++;
}

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

接下來咱們須要實現的就是衆所周知的搜索函數了(由於深度優先搜索和廣度有限搜索應該算大名鼎鼎的算法了吧)。咱們想知道途中有哪一些的點,使用不一樣的算法會產生不一樣的做用效果。

深度優先搜索

深度優先搜索相似i走迷宮,一條路走到黑,若是發現這條路走不通,就在前一個路口繼續向前走。就像下面這樣(圖片節選自《算法第四版》)

 


 

 

那麼算法中,咱們須要解決什麼問題呢?咱們能夠經過adj函數獲得結點的相鄰結點,可是若是咱們如何保證結點已經被咱們訪問過了,咱們就須要一個標誌mark,這個標誌表明着這個結點是否已經被訪問過。(HashSet這種數據結構也能夠作到這種事情)。步驟以下:

  • 將被訪問的結點標記爲已訪問
  • 遞歸地訪問它的全部沒有被標記過的鄰居結點
/** * 無向圖的深度優先搜索 * @author xiaohui */
public class DepthFirstSearch {
    private boolean[] marked;
    private int count;

    public DepthFirstSearch(UndirGraph graph,int s){
        marked = new boolean[graph.V()];
        dfs(graph,s);
    }

    private void dfs(UndirGraph graph, int s) {
        marked[s] = true;
        count++;
        for (int v:graph.adj(s)){
            if (!marked[v]){
                dfs(graph,v);
            }
        }
    }

    public boolean getMarked(int w) {
        return marked[w];
    }

    public int getCount() {
        return count;
    }
}

你們能夠有上面的代碼能夠i很簡單的知道,得到與s相同的結點,只須要對dfs進行遞歸便可,並將結點的marked標誌設置爲true便可。如今咱們就能夠完善search函數了。

Iterable<Integer> search(int s) {
    DepthFirstSearch dfs = new DepthFirstSearch(this,s);
    List list = new ArrayList(dfs.getCount());
    for (int i=0;i<this.V();i++) {
        if (dfs.getMarked(i)){
            list.add(i);
        }
    }
    return list;
}

在上面的深度優先搜索的算法,其實還有一個應用,那就是尋找路徑的問題,也就是說,經過深度優先算法,咱們能夠知道A結點和X結點是否存在一條路徑,若是有,則輸出路徑。

/** * @author xiaohui * 經過深度優先搜索尋找路徑 */
public class DepthFirstSearchPath {

    private boolean[] marked;
    /** * 從起點到一個頂點的已知路徑上面的最後一個頂點,例如: * 0-3-4-5-6 則 edgeTo[6] = 5 */
    private int[] edgeTo;
    /** * 起點 */
    private final int s;

    /** * 在graph中找出起點爲s的路徑 * @param graph * @param s */
    public DepthFirstSearchPath(Graph graph,int s) {
        marked = new boolean[graph.V()];
        this.s = s;
        edgeTo = new int[graph.V()];
        dfs(graph,s);
    }

    private void dfs(Graph graph, int s) {
        marked[s] = true;

        for (int v:graph.adj(s)){
            if (!marked[v]){
                edgeTo[v] = s;
                dfs(graph,v);
            }
        }
    }

    /** * v的頂點是否可達,也就是說是否存在s到v的路徑 * @param v * @return */
    public boolean hasPathTo(int v){
        return marked[v];
    }

    /** * 返回s到v的路徑 * @param v * @return */
    public Iterable<Integer> pathTo(int v){

        if (!hasPathTo(v)){
            return null;
        }
        Stack<Integer> path = new Stack<>();
        for (int x = v;x!=s;x = edgeTo[x]){
            path.push(x);
        }
        path.push(s);
        return path;

}

在上面的算法中, 咱們首先進行深度優先遍歷將每一個結點是否被遍歷保存到marked[]數組中,而後,在edgeTo[]數組咱們保存了進行深度遍歷中被遍歷結點的上一個結點,示意圖以下圖所示(圖片節選自《算法》):

 


 

 

如今咱們能夠補全上文中的一些函數了。

/** * 是否存在S結點到V結點的路徑 * @param s * @param v * @return */
@Override
boolean hasPathTo(int s, int v) {
    DepthFirstSearchPath dfsPath = new DepthFirstSearchPath(this,s);
    return dfsPath.hasPathTo(v);
}
/** * 找出s到v結點的路徑 * @param s * @param v * @return */
@Override
Iterable<Integer> pathTo(int s, int v) {
    DepthFirstSearchPath dfsPath = new DepthFirstSearchPath(this,s);
    return dfsPath.pathTo(v);
}

經過深度優先搜索,咱們能夠獲得s結點的路徑,那麼深度優先搜索還有什麼用法呢?其中有一個用法就是尋找出一幅圖的全部連通份量。

public class CC {
    private boolean[] marked;
    /** * id表明結點所屬的連通份量爲哪個,例如: * id[1] =0,id[3]=1 * 表明1結點屬於0連通份量,3結點屬於1連通份量 */
    private int[] id;
    /** * count表明連通份量的表示,0,1…… */
    private int count;

    public CC(Graph graph) {
        marked = new boolean[graph.V()];
        id = new int[graph.V()];
        for (int s=0;s<graph.V();s++){
            if (!marked[s]){
                count++;
                dfs(graph,s);
            }
        }
    }

    private void dfs(Graph graph,int v) {
        marked[v] = true;
        id[v] = count;
        for (int w:graph.adj(v)) {
            if (!marked[w]){
                dfs(graph,w);
            }
        }
    }

    /** * v和w是否屬於同一連通份量 * @param v * @param w * @return */
    public boolean connected(int v,int w){
        return id[v]==id[w];
    }

    /** * 得到連通份量的數量 * @return */
    public int getCount() {
        return count;
    }

    /** * 結點屬於哪個連通份量 * @param w * @return */
    public int id(int w){
        return id[w];
    }

}

在下圖中,有三個連通份量。

 


 

 

說完深度優先搜索,咱們能夠來講一說廣度優先搜索算法了。在前面的深度優先搜索中,咱們將深度優先搜索算法比喻成迷宮,它能夠帶咱們從一個結點走到另一個結點(也就是尋找路徑問題),可是若是咱們須要去解決最短路徑的問題,使用深度優先搜索能不能解決呢?答案是不能,咱們能夠想想,使用深度優先搜索,咱們是一條道走到「黑」,有可能離開始結點最近的結點反而還有可能最後遍歷。可是廣度優先遍歷卻能夠解決這個問題。

廣度優先遍歷

廣度優先的算法在迷宮中相似這樣:咱們先遍歷開始結點的相鄰結點並將結點,而後按照與起點的距離的順序來遍歷全部的頂點。在前面的深度優先遍歷中,咱們使用了隱式的棧【LIFO】(遞歸)來進行保存結點,而在廣度優先遍歷中,咱們將使用顯式的隊列(FIFO)來保存結點。

 


 

 

進行廣度優先遍歷的算法步驟以下:

先將起點加入隊列,而後重複如下步驟:

  • 取隊列中的下一個頂點v並標記它
  • 將與v相鄰的全部未被標記過的結點加入隊列
package graph.undir;

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

/** * @author xiaohui * 廣度優先遍歷 */
public class BreadthFirstSearch {
    private boolean[] marked;
    private final int s;
    private int[] edgeTo;

    public BreadthFirstSearch(Graph graph,int s) {
        this.s = s;
        this.marked = new boolean[graph.V()];
        this.edgeTo = new int[graph.V()];
        bfs(graph,s);
    }

    private void bfs(Graph graph, int s) {
        Queue<Integer> queue = new LinkedList<>();
        marked[s] = true;
        // 將s加入隊列中
        queue.offer(s);
        while(!queue.isEmpty()){
            // 從隊列中刪除結點
            int v = queue.poll();
            for (int w: graph.adj(v)) {
                if (!marked[w]){
                    edgeTo[w] = v;
                    marked[w] = true;
                    queue.offer(w);
                }
            }
        }
    }

    public boolean hasPathTo(int v){
        return marked[v];
    }

    public Iterable<Integer> pathTo(int v){
        if (hasPathTo(v)){
            return null;
        }
        Stack<Integer> path = new Stack<>();
        for (int i = v; i != s; i = edgeTo[i]) {
            path.push(i);
        }
        path.push(s);
        return path;
    }
}

對於從s可達的任意頂點v,廣度優先搜索都能找到一條從s到v的最短路徑。下面是進行廣度優先遍歷的狀況圖:

 


 

 

在這裏咱們能夠思考一下如何使用廣度優先搜索或者深度優先搜索解決這兩個問題:

  • 圖G是無環圖嗎?(假設不存在自環或者平行邊)
  • 圖G是二分圖嗎?

在上面兩個問題的解決方法很簡單。

第一個問題中,咱們能夠這樣思考:在進行搜索的時候,若是A結點的鄰居結點B已經被被標記了,可是若是在B結點中,它的鄰居結點C已經被標記了,可是若是鄰居結點C並非結點A,那麼這幅圖就是一個有環圖。道理很簡單,在前面咱們知道,經過一個已經被標記的結點,咱們確定能夠經過該節點回到起點s,那麼C結點有一條路徑回到起點,A結點也有一條路徑回到起點,而B結點將A和C結點鏈接起來了,造成了一個環。

第二個問題中,和第一個問題很相似,在C結點中,若是C結點的顏色不和A結點同樣(則和B結點同樣),那麼該圖必定不會是一個二分圖。

有向圖

在有向圖中,邊是單邊的,也就是說,邊是由一個結點指向另一個結點, 兩個結點的鄰接性是單向的。在一幅有向圖中,一個頂點的出度爲該頂點指出的邊的總數,入度爲指向該頂點的邊的總數。在一幅有向圖中間存在4種關係:

A->B,A<-B,A B(沒有邊相鏈接),A->B A<-B

  • 有向路徑:由一系列頂點組成,對於其中的每個頂點都存在一條有向邊從它指向序列中的下一個頂點。
  • 有向環: 爲一條至少含有一條邊且起點和終點相同的有向路徑。

有向圖詳解

 

在有向圖中,對代碼須要進行一些改變,在addEdgeo函數中,咱們再也不是添加2條邊,而是隻是添加一條邊,同時咱們添加了一個reserve函數,目的是將邊的方向進行翻轉。

/** * 在圖中添加一條邊v-w * @param v * @param w */
@Override
void addEdge(int v, int w) {
    adj[v].add(w);
    E++;
}

/** * 遍歷每個結點,而後進行翻轉 * @return 返回翻轉後的圖 */
public DiGraph reverse(){
    DiGraph diGraph = new DiGraph(V);
    for (int i = 0; i < V; i++) {
        for (int w:adj(i)){
            diGraph.addEdge(w,i);
        }
    }
    return diGraph;
}

/** * 得到與v相鄰的全部頂點 * * @param v * @return */
@Override
Iterable<Integer>adj(int v) {
    return adj[v];
}

路徑問題

上面的代碼仍是比較簡單的。在無向圖中,咱們研究告終點的可達性,使用深度優先算法來探究兩個結點是否可達,而在有向圖中,單點可達性:是否存在一條從s到達給定頂點v的有向路徑。

/** * @author xiaohui * 有向圖的深度優先算法 */
public class DirectGraphDFS {
    private boolean[] marked;

    /** * 有向圖的深度優先算法構造函數 * @param diGraph * @param s 起點 */
    public DirectGraphDFS(DiGraph diGraph,int s) {
        marked = new boolean[diGraph.V()];
        dfs(diGraph,s);
    }

    /** * 深度遞歸算法 * @param diGraph * @param v */
    private void dfs(DiGraph diGraph, int v) {
        marked[v] = true;
        for (int w:diGraph.adj(v)) {
            if (!marked[w]){
                dfs(diGraph,v);
            }
        }
    }


    /** * 起點s可達到v嗎 * @param v * @return */
    public boolean pathTo(int v){
        return marked[v];
    }
}

一文看懂javaGC這篇博客中,咱們討論了在Java虛擬機中,咱們使用了可達性分析算法來判斷一個對象是否已經死亡。在下圖中灰色的方塊表明的是能夠被回收的對象。

一樣,在無向圖中,咱們能夠經過搜索來找出結點之間的路徑,以及經過廣度優先搜索來找出最短路徑,一樣,在有向圖中咱們一樣可以作到這樣。一樣,在算法中,和前面的無向圖之間的算法一毛同樣,沒什麼改變。

調度問題

調度問題提及來很簡單,就是先有雞仍是先有蛋的問題。一種應用普遍的模型就是給定一組任務並安排它們的執行順序,其中順序會有限制條件去限制(例如任務的執行的開始時間,也多是任務的時耗)。其中最重要的一種條件叫優先級限制

優先級限制中,明確的指明瞭哪些任務必須在哪些任務以前完成,在有向圖中,優先級限制下的調度問題等價於下面的問題:

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

在下面的圖是一個有向圖進行拓撲排序後的結果。

 


 

 

在前面咱們說了,必須明確任務的前後關係,那麼若是若是任務關係造成了環狀,好比說A要在B以前完成,B要在C以前完成,可是C要在A以前完成, 那麼這個問題確定是無解的。so,咱們在進行拓撲排序以前得先判斷有向圖中間是否有環。(也就是說優先級限制下的調度問題等價於計算有向無環圖的全部a丁丁的拓撲排序)

/** * 查找有向圖中是否存在環 * @author xiaohui */
public class DirectedCycle {
    private boolean[] marked;
    private int[] edgeTo;
    /** * 有向環中全部頂點 */
    private Stack<Integer> cycle;
    /** * 頂點是否在遞歸調用棧上 */
    private boolean[] onStack;

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


    private void dfs(Graph graph, int v) {
        onStack[v] = true;
        marked[v] = true;
        for (int w:graph.adj(v)){
            if (this.hasCycle()){
                return;
            }
            else if(!marked[w]){
                edgeTo[w] = v;
                dfs(graph,w);
            }
            // 當它的鄰居結點已經被標記時,且在同一個調用棧中。
            else if (onStack[w]){
                cycle = new Stack<>();
                for (int x= v;x != w;x = edgeTo[x]){
                    cycle.push(x);
                }
                cycle.push(w);
                cycle.push(v);
            }
            onStack[v] = false;
        }
    }

    /** * 有向圖中是否含有環 * @return */
    public boolean hasCycle(){
        return cycle == null;
    }

    /** * 得到有向環中的頂點 * @return */
    public Iterable cycle(){
        return this.cycle;
    }
}

在這裏我將着重解釋下onStack這個數組的做用。咱們能夠回想一下咱們在無向圖中若是查找一個圖中是否存在一個:咱們經過查看結點的下一個結點是否是被標記的來判斷的。之因此這樣由於無向圖是雙向導通的,咱們必然能夠根據被標記的點回去,可是咱們想一想,有向圖能夠嗎?顯然是不行的,由於有向圖是單向導通的。咱們並不能經過已經被標記的結點又回到起點。所以,onStack的做用就在與這個地方。當某結點A鄰居結點的onStack爲true的時候,說明該鄰居結點結點正處於遞歸的過程當中,則該鄰居結點可以經過遞歸獲得結點A。而當onStack爲false的時候則說明改鄰居結點不能經過遞歸回到回到結點A

說完有向圖中間的環的檢測方法,咱們就能夠來討論一下如何對有向圖的頂點進行拓撲排序了。

實際上深度優先搜索也算得上是一種拓撲排序。在深度優先搜索中,咱們可以保證每一個頂點的訪問順序一定會符合拓撲排序的規律。根據遞歸的狀況,下面有3中排序的規律:

  • 前序:在遞歸調用以前將頂點加入隊列
  • 後序:在遞歸調用以後將頂點加入隊列
  • 逆後序:在遞歸調用以後將頂點壓入棧

有向圖中基於深度優先搜索的頂點排序:

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

/** * 深度遞歸頂點排序 * @author xiaohui */
public class DfsOrder {
    private boolean[] marked;
    /** * 前序 */
    private Queue<Integer> pre;
    /** * 後序 */
    private Queue<Integer> post;
    /** * 逆後序 */
    private Stack<Integer> reversePost;

    public DfsOrder(Graph graph) {
        this.marked = new boolean[graph.V()];
        this.pre = new LinkedList<>();
        this.post = new LinkedList<>();
        this.reversePost = new Stack<>();
        for (int i = 0; i < graph.V(); i++) {
            if(!marked[i]){
                dfs(graph,i);
            }
        }
    }

    private void dfs(Graph graph, int v) {
        pre.offer(v);
        marked[v] = true;
        for (int w:graph.adj(v)) {
            if (!marked[w]){
                dfs(graph,w);
            }
        }
        post.offer(v);
        reversePost.push(v);
    }
    
    public Iterable<Integer> reversePost(){
        return this.reversePost;
    }
}

而在其中逆後序排序即是拓撲排序了。

強連通性

咱們已經知道在有向圖中,邊是單向的。可是若是兩個頂點是互相可達(相似無向圖)的,就稱他們爲強連通的。若是一幅圖中的任意兩個頂點都是強連通的,就稱這幅圖是強連通的。

兩個頂點是強連通的當且盡當它們都在一個普通的有向環中。:很簡單的解釋,在環中,兩個結點都是互相可達的。

連通性有下面3個性質:

  1. 自反性:任意頂點和本身是強連通的
  2. 傳遞性:v和w是強連通,w和x是強連通的,則v和x是強連通的
  3. 對稱性:v和w是強連通,則w和v也是強連通的。

強連通份量:

下面是一張有向圖和它的強連通份量。每個陰影塊就是就是一個強連通份量。

 


 

 

以高中的生物知識來講,上面就是一個生態系統的能量流通圖,在某些生物之間能量能夠相互流通,這樣就是一個強連通份量了,可是對於某些來講,只有對於生態系統只有輸出並不會獲得輸入(好比說陽光),而有些只有輸入沒有輸出(消費者? 不肯定對不對,高中知識有點忘了)。

 

img

接下來咱們須要去尋找強連通份量了。在無向圖中,咱們計算連通份量僅僅是在dfs算法中加了區區幾行代碼便完美地解決了連通份量的問題。那麼,咱們在有向圖中應該怎麼作呢?

在這裏咱們能夠思考一下咱們前面所說的強連通性的規律,以及咱們在調度問題中如何檢測環的算法來解決這個問題。

在這裏有一個比較暴力的解決方法,對於某個結點V,咱們獲得在有向圖中V能夠到達的頂點,而後進行遍歷,獲得可到達V的頂點。而後呢,咱們取他們的交集。這樣就能夠獲得連通份量了。可是顯而易見,這樣的時間複雜度是O(n2)。找出可到達V的頂點的時間複雜度是O(n2),取並集的時間複雜度視數據結構而定,使用數組的話時間複雜度是O(n^2)。

總所周知,通常咱們是不接受平方級別的時間複雜度的(好比說排序),而在無向圖中,得到連通份量的時間複雜度僅僅爲O(n),那麼在有向圖中間咱們的解法是否能夠像無向圖同樣美妙呢?

有一個算法叫作Kosaraju,很是的簡潔,讓咱們來講一說這個算法的步驟,而後再來討論它爲何要這樣作?

  1. 將一幅圖G進行反向也就是調用reverse()函數獲得G2
  2. 將G2進行拓撲排序獲得它的逆後序排序(也就是一個序列)。
  3. 而後對圖進行深度優先搜索,進行深度搜索的順序就是第2個步驟中的逆後序序列。
  4. 在構造函數中,使用同一個dfs()函數調用被訪問的頂點都在同一個強連通份量中間。

接下來是代碼,你們會發現,代碼特別的少

/** * 使用Kosaraju算法的獲得強通份量 * @author xiaohui */
public class DfsSCC {

    private boolean[] marked;
    private int[] id;
    private int count = 0;

    public DfsSCC(DiGraph graph) {
        marked = new boolean[graph.V()];
        id = new int[graph.V()];
        DfsOrder order = new DfsOrder(graph.reverse());
        for (int s:order.reversePost()){
            dfs(graph,s);
            count++;
        }
    }

    private void dfs(DiGraph graph, int v) {
        marked[v] = true;
        id[v] = count;
        for (int w:graph.adj(v)){
            if (!marked[v]){
                dfs(graph,w);
            }
        }
    }

    /** * 返回某結點強連通的id * @param v * @return */
    public int id(int v){
        return id[v];
    }

    /** * 判斷v和w是否屬於強連通 * @param v * @param w * @return */
    public boolean stronglyConnected(int v,int w){
        return id[v]==id[w];
    }

    /** * 返回強連通份量的數目 * @return */
    public int cout(){
        return count;
    }
}

上面即是尋找強連通份量的代碼,接下來咱們要好好的思考一下爲何可以達到這種效果。

首先咱們能夠很很簡單的知道,每一個和s強連通的頂點v都會在構造函數dfs(graph,s)被訪問到。接下來咱們須要思考的是,爲何構造函數中dfs(graph,s)函數所到達的任意頂點v都必然是和s強連通的。

v是dfs(graph,s)達到的某個頂點,那麼原圖G中必然會有一條s到v的路徑,如今咱們只須要找到v到s的路徑便可。等價於證實G2(G經過reverse()函數獲得)有一條s到v的路徑。

在這裏咱們能夠想想,v結點在拓撲排序中會不會出如今s結點的前面?固然不會!!(若是出如今前面,在dfs(graph,s)中就不會調用dfs(graph,v),由於v結點已經被標記了。)

所以如今咱們已經肯定了v結點在s結點的後面, 那麼表明着什麼呢?表明着在G2的深度優先遍歷中,dfs(graph,v)調用結束絕逼在dfs(graph,s)以前調用(棧是先進後出),那麼在圖G2中就分爲兩種狀況:

  1. dfs(graph,v)在dfs(graph,s)調用以前結束
  2. dfs(graph,v)在dfs(graph,s)調用結束以前結束

由於在圖G中有一條s->v的路徑,在圖G2中有一條v->s的路徑,則第一種狀況不可能出現。則第二種狀況說明了G2中有一條s->v的路線。則圖G中有一條v->s的路徑。

下面是一張過程示意圖(左邊是對G2進行逆後序排序,右邊是根據排序的結果進行深度遞歸)

 

1565337476466

最小生成樹(無向圖)

在說最小生成樹以前,咱們得先來講說加權圖。下圖中即是一副加權無向圖。加權圖和圖的區別在於它的每一條邊都有一個權值,那麼它有什麼用呢?舉個栗子:圖咱們能夠應用在網絡流量方面,那麼每一條邊的h權值能夠表明某一時刻的網絡質量,那麼當流量進行選擇的時候,確定會選擇質量好的那一路。(實際上網絡流量選擇要比這還複雜,由於還要考慮到負載均衡的狀況。)

 

1565340262377

那麼什麼是最小生成樹呢?

圖的生成樹是它的一棵含有其全部頂點的無環連通子圖

一幅加權圖的**最小生成樹(MST)**是它的一棵權值(樹的全部邊的權值之和)最小的生成樹。如上圖的黑色邊構成的無環連通子圖。在這裏咱們規定:

  • 只考慮連通圖:試想一下,若是不連通,咱們又如何知道兩個頂點之間的權值呢?
  • 邊的權重:邊的權值能夠爲正數,負數,0
  • 全部邊的權只能都各不相同:相同的話,最小生成樹就不惟一了

下面是生成樹的一些性質:

  1. 用一條邊鏈接樹中的任意兩個頂點都會產生一個新的環。
  2. 從樹中任意刪除一條邊都將會獲得兩棵獨立的樹。

以下圖:

 


 

 

根據上面的兩個性質,咱們能夠將圖中全部的頂點切分爲兩個非空且不重疊的兩個集合。而其中橫切邊是一條鏈接兩個屬於不一樣集合的頂點的邊。

切分定理:把加權圖中的全部頂點分爲集合、檢查橫跨兩個集合的全部邊並識別哪條邊應屬於圖的最小生成樹。

 


 

 

固然,在切分中,咱們會獲得一條權重最小的邊(這條邊必然屬於最小生成樹的邊),可是並不表明着其它的邊就不屬於最小生成樹。

最小生成樹的貪心算法

切分定理是解決最小生成樹問題的全部算法的基礎。而這些算法都是貪心算法的特殊狀況:使用切分定理找到一條邊,而後不斷的切分,直到找出全部的最小生成樹的全部邊。

最小生成樹的貪心算法:咱們將含有V個頂點的加權連通圖的最小生成樹的邊標記爲黑色(初始狀態邊是灰色的),找到一種切分,它產生的橫切邊均不爲黑色,而後權重最小的橫切變標記爲黑色。反覆,直到標記了V-1條黑色邊爲止。

下面是一個貪心最小生成樹算法的圖:

 


 

 

由於有權無向圖的邊發生了改變,因此定義數據結構的代碼也得發生改變。

加權無向圖的數據結構

帶權重的邊的數據類型

/** * 定義一條邊的數據類型結構 */
public class Edge implements Comparable<Edge> {
    /** * 一條邊的某一個頂點 */
    private final int v;
    /** * 一條邊的另一個頂點 */
    private final int w;
    /** * 邊的權重 */
    private final double weight;

    public Edge(int v, int w, double weight) {
        this.v = v;
        this.w = w;
        this.weight = weight;
    }
    public double weight(){
        return weight;
    }

    /** * 獲得邊的某一個頂點 * @return */
    public int either(){
        return v;
    }

    /** * 經過某一個頂點獲得邊的另一個頂點 * @param vertex * @return */
    public int other(int vertex){
        if(vertex == w){
            return v;
        }else if(vertex==v){
            return w;
        }else{
            throw new RuntimeException("沒有這一條邊");
        }
    }

    /** * 邊進行比較 * @param o * @return */
    @Override
    public int compareTo(Edge o) {
        if (this.weight() > o.weight()){
            return 1;
        }else if (this.weight() < o.weight()){
            return -1;
        }
        return 0;
    }

    @Override
    public String toString() {
        return "Edge{" +
                "v=" + v +
                ", w=" + w +
                ", weight=" + weight +
                '}';
    }
}

加權無向圖的數據類型:

import java.util.ArrayList;
import java.util.List;
/** * 加權無向圖的數據結構 */
public class EdgeWeightedGraph {
    /** * 頂點總數 */
    private final int V;
    /** * 邊的總數 */
    private int E;
    /** * 邊 */
    private List<Edge>[] adj;

    public EdgeWeightedGraph(int V) {
        this.V = V;
        this.E = 0;

        adj = new ArrayList[V];
        for (int i = 0; i < V; i++) {
            adj[i] = new ArrayList<Edge>();
        }
    }

    public int V() {
        return V;
    }
    public int E() {
        return E;
    }

    public void addEdge(Edge e) {
        int v = e.either(), w = e.other(v);
        adj[v].add(e);
        adj[w].add(e);
        E++;
    }
    public Iterable<Edge> adj(int v) {
        return adj[v];
    }

    /** * 獲取圖中的全部邊 * @return */
    public Iterable<Edge> edges(){
        List<Edge> list = new ArrayList<>();
        for (int i = 0; i < V; i++) {
            for (Edge e:adj[i]){
                /** * 若是i和j爲一條邊e,那麼adj[i] = e;adj[j] = e;這兩條邊是同樣的,因此咱們須要去除一條邊 */
                if (e.other(i)>i){
                    list.add(e);
                }
            }
        }
        return list;
    }
}

在定義好數據結構後,咱們就能夠開始來講一下生成最小樹的算法了

最小生成樹的算法

對於最小生成樹有兩種經常使用的算法,普里姆算法(Prim算法)和克魯斯卡爾算法(Kruskal算法)。這兩種算法都是基於貪心算法的算法。首先讓咱們來講一下Kruskal算法,這個比較簡單。

Kruskal算法

Kruskal算法很簡單,首先咱們獲得全部的邊,而後根據邊的權重對邊進行排序(從小到大)。而後咱們將邊根據順序加入最小生成樹中(必須保證加入的邊不會與已經加入的邊構成環)

 


 

 

如今這個問題就分紅了兩個部分:

  1. 如何排序——使用排序算法便可(使用堆排序),使用優先隊列
  2. 如何檢測迴路。

咱們來着重討論第二點,如何檢測迴路

如何檢測迴路,咱們可使用union-find算法。首先咱們說一下這個的原理:

首先咱們有N個獨立分散的點,若是咱們將點用線段進行鏈接,如何避免成環。咱們能夠這樣想,,像樹同樣,有根節點,若是兩個結點的根節點是同樣的,那麼毋庸置疑,將兩個結點進行鏈接確定會成環。

其中,這個算法有3種寫法:

  • quick-find算法。
  • quick-union算法。
  • 加權quick-union算法

我將介紹加權quick-union算法,由於這個在最最壞的狀況下時間複雜度也只有lgN。

quick-union算法

/** * 加權quick-union算法 */
public class WeightQuickUnionUF {

    /** * 結點的父節點 */
    private int[] id;
    /** * (由結點索引的)各個根節點所對應的根節點的大小 */
    private int[] sz;
    /** * 連通份量的數量 */
    private int count;

    /** * 進行初始化,初始化後其中每個結點都是一個連通份量 * 其中結點的父節點爲本身自己 * @param N */
    public WeightQuickUnionUF(int N) {
        count = N;
        id = new int[N];
        for (int i = 0; i < N; i++) {
            id[i] = i;
        }
        sz = new int[N];
        for (int i = 0; i < N; i++) {
            sz[i] = 1;
        }
    }

    /** * p和q是否相連接,若相鏈接,則在同一個連通份量裏面 * @param p * @param q * @return */
    public boolean connected(int p,int q){
       return find(p) == find(q); 
    }

    /** * 找到根節點 * @param v * @return */
    private int find(int v) {
        // 在根節點中id[v]= v(在初始化的時候定義的)
        while(v != id[v]){
            v = id[v];
        }
        return v;
    }

    /** * 在p和q之間添加一條連接 * @param p * @param q */
    public void union(int p,int q){
        int i = find(p);
        int j = find(q);
        // 若是是同一條連通份量,則返回,不必添加
        if (i==j){
            return ;
        }
        // 這一步的目的是將小樹加入大樹
        if (sz[i]<sz[j]){
            id[i] = j;
            sz[j] += sz[i];
        }else{
            id[j] = i;
            sz[i] += sz[j];
        }
        count --;
    }
}

上面的算法在union中,是將小樹加入大樹。目的是減小在最壞狀況下的時間複雜度。

下面即是Kruskal算法的實現部分

package graph.weight;

import java.util.ArrayList;
import java.util.LinkedList;
import java.util.PriorityQueue;
import java.util.Queue;
public class KruskalMST {
    /** * 優先隊列 */
    private PriorityQueue<Edge> pq;

    /** * 最小生成樹 */
    private Queue<Edge> mst;

    public KruskalMST(EdgeWeightedGraph graph) {
        pq = new PriorityQueue<>();
        mst =  new LinkedList<>();
        // 將邊加入優先隊列
        for (Edge edge:graph.edges()){
            pq.add(edge);
        }
        mst(graph);
    }

    private void mst(EdgeWeightedGraph graph) {
        WeightQuickUnionUF uf = new WeightQuickUnionUF(graph.V());
        while(!pq.isEmpty()){
            // 從裏面取出最小的元素
            Edge e = pq.poll();
            int v = e.either();
            int w = e.other(v);
            // 防止成環
            if (uf.connected(v,w)){
                continue ;
            }
            uf.union(v,w);
            mst.offer(e);
        }
    }

    public Queue getMst() {
        return mst;
    }
}

ok,說完Kruskal算法,讓咱們來講一說Prim算法。

Prim算法

prim算法和kruskal算法同樣,一樣使用的是貪心算法。它的原理很簡單,就是每一步爲都會爲一棵生長中的樹添加一條邊(老是將鏈接樹中的頂點與不在樹中的頂點切權重最小的邊加入樹中)。

下面是一個示例:

 


 

 

其中prim算法有兩種實現方式:

  • 延時實現

    1. 把一個頂點加入最小生成樹中。
    2. 再把它的全部的鏈接着未訪問頂點的鄰接邊都放入優先隊列(做爲橫切邊)。
    3. 而後從橫切邊中彈出權重最小的一條,檢查它兩邊的頂點是否在最小生成樹中,若是在,則忽略該邊,從頭開始步驟3。若是不在,則把它和它所鏈接的頂點加入最小生成樹中。
    4. 再對它所鏈接的頂點做和以上相同的操做。直到優先隊列爲空。

    下面是延時實現的代碼:

    import java.util.LinkedList;
    import java.util.PriorityQueue;
    import java.util.Queue;
    
    /** * prim算法延時實現 */
    public class LazyPrimMST {
    
        private boolean[] marked;
        private Queue<Edge> mst;
        /** * 橫切邊 */
        private PriorityQueue<Edge> pq;
    
        public LazyPrimMST(EdgeWeightedGraph graph){
            marked = new boolean[graph.V()];
            pq = new PriorityQueue<>();
            mst = new LinkedList<>();
            mst(graph);
        }
    
        /** * 生成最小樹 * @param graph */
        public void mst(EdgeWeightedGraph graph){
            visit(graph,0);
            while (!pq.isEmpty()){
                // 從優先隊列中獲得最小的邊
                Edge e= pq.poll();
                int v = e.either();
                int w = e.other(v);
                // 若是兩個頂點都被標記了,則看下一條邊
                if (marked[v] && marked[w]){
                    continue;
                }
                // 將邊加入mst
                mst.add(e);
                if (!marked[v]){
                    visit(graph,v);
                }
                if (!marked[w]){
                    visit(graph,w);
                }
    
            }
        }
    
        /** * 標記頂點v並將其(全部)邊(邊所相鏈接的另一個頂點未被標記)加入隊列中 * @param graph * @param v */
        public void visit(EdgeWeightedGraph graph,int v){
            marked[v] = true;
            for (Edge e:graph.adj(v)){
                // 另一個頂點
                if (!marked[e.other(v)]){
                    // 將頂點假如優先隊列
                    pq.add(e);
                }
            }
        }
    
        /** * 返回最小生成樹 * @return */
        public Queue<Edge> getMst() {
            return this.mst;
        }
    }

當時你們可能發現了一個問題,那就是在優先隊列中保存了不少沒有用的邊,無疑,這些邊是須要佔用空間的,那麼若是咱們去除這些失效的邊,是否是就能夠節約空間了呢? 因而便又有了下面的算法。

  • 即時實現

    1. 先把一個頂點放入最小生成樹中。
    2. 遍歷該頂點的鄰接邊結點(結點A),若是邊(邊W)所鏈接的某個頂點(結點B)不在最小生成樹中,且它(結點B)到該頂點(結點A)的距離大於該邊(邊W)的權重,則該結點(結點B)到頂點(結點A)的距離變成該邊的權重,而且更新索引優先隊列中的邊。
    3. 從索引優先隊列彈出權重最小的邊,而後對它所鏈接的頂點做以上操做,直到棧空。
    import java.util.ArrayList;
    import java.util.HashMap;
    import java.util.PriorityQueue;
    
    /** * 即時版本的prim算法 */
    public class PrimMST {
    
        /** * 某結點距離樹最近的邊 */
        private Edge[] edgeTo;
        /** * 某結點距離樹的權重距離 */
        private double[] distTo;
    
        /** * 結點是否在樹中 */
        private boolean[] marked;
    
        /** * 有效的橫切邊(也就是被保存在優先隊列中有效的邊) * key: 結點的id * value:權重 */
        private IndexMinPQ<Double> pq;
    
        public PrimMST(EdgeWeightedGraph graph) {
            edgeTo = new Edge[graph.V()];
            distTo = new double[graph.V()];
            marked = new boolean[graph.V()];
            // IndexMinPQ是索引優先隊列,並非Java自己的庫,在個人github庫中有這個IndexMinPQ
            pq = new IndexMinPQ<>(graph.V());
            // 初始化結點到樹的距離爲無限大
            for (int i = 0; i < graph.V(); i++) {
                distTo[i] = Double.POSITIVE_INFINITY;
            }
    
            pq.insert(0,0.0);
            while(!pq.isEmpty()){
                visit(graph,pq.delMin());
            }
    
        }
    
        private void visit(EdgeWeightedGraph graph, int v) {
    
            marked[v] = true;
            // 遍歷與v相連的結點
            for (Edge e:graph.adj(v)){
                int w = e.other(v);
                // 假如w已經爲最小樹的結點,則不進行處理
                if (marked[w]){
                    continue;
                }
                // 假如w結點的權重小於w結點到樹的距離
                if (e.weight()<distTo[w]){
                    // w結點到最小樹的邊爲e
                    edgeTo[w] = e;
                    // 距離爲e邊的權重
                    distTo[w] = e.weight();
                    // 將結點放入優先隊列
                    if (pq.contains(w)){
                        pq.changeKey(w,distTo[w]);
                    }
                    else{
                        pq.insert(w,distTo[w]);
                    }
                }
            }
        }
     /** * 返回最小生成樹 * @return */
       public ArrayList<Edge> mst(){
           ArrayList<Edge> list = new ArrayList<>();
           for (Edge e:edgeTo) {
               if (e!=null){
                   list.add(e);
               }
    
           }
           return list;
       }
    }

    即時的prim算法和延時的prim算法很類似,原理上面並無什麼發生改變,只不過在即時的prim算法中,咱們只將有用的邊假如優先隊列,而沒有用的邊就無論它了。這樣咱們就節約了空間,以及遍歷邊的時間。

    性能特色:(V個頂點E條邊)

    算法(最壞狀況下) 空間 時間
    Kruskal算法 E ElogE
    延時的Prim算法 E ElogE
    即時的Prim算法 V ElogV

最短路徑

最短路徑問題是一個很常見的問題,由於導航地圖,流量負載互動都會遇到它。這個咱們等幾天更新後再討論……

參考書籍:

《算法》——聖書

再如何難以想象的事情,一旦作的次數多了,便會習慣直至麻木甚至開始樂在其中。 ——將夜

相關文章
相關標籤/搜索