1. 程式人生 > 實用技巧 >圖解:深度優先搜尋與廣度優先搜尋及其六大應用

圖解:深度優先搜尋與廣度優先搜尋及其六大應用

圖演算法第二篇 深度優先搜尋與廣度優先搜尋及其應用

約定:本文所有涉及的圖均為無向圖,有向圖會在之後的文章涉及

1.圖的儲存方式

我們首先來回顧一下圖的儲存方式:鄰接矩陣和鄰接表。為了實現更好的效能,我們在實際應用中一般使用鄰接表的方式來表示圖。


具體的實現程式碼為:

package Graph;

import java.util.LinkedList;

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

    public Graph(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的連結串列中
        adj[w].add(v);//將v新增到w的連結串列中
        E++;
    }

    public Iterable<Integer> adj(int v){
        //我們不必注意這個細節,可以直接把它忽視而不會影響任何關於圖的理解與實現
        return adj[v];
    }

}

接下來,我們會首先介紹深度優先搜尋廣度優先搜尋的原理和具體實現;然後根據這兩個基本的模型,我們會介紹六種典型的應用,這些應用只是在深搜和廣搜的程式碼的基礎上進行了一些加工,卻可以解決不同的問題!

注意:我們的思路:如何遍歷一張圖?>深搜與廣搜>能夠解決的問題。

2.深度優先搜尋

深度優先搜尋是利用遞迴方法實現的。我們只需要在訪問其中一個頂點時:

  • 將它標記為已經訪問
  • 遞迴地訪問它的所有沒有被標記過的鄰居頂點

我們來仔細地看一下這個過程:

深度優先搜素大致可以這樣描述:不撞南牆不回頭,它沿著一條路一直走下去,直到走不動了然後返回上一個岔路口選擇另一條路繼續前進,一直如此,直到走完所有能夠到達的地方!它的名字中深度

兩個字其實就說明了一切,每一次遍歷都是走到最深!

注意一個細節:在我們上面的最後一個圖中,頂點3仍然未被標記(綠色)。我們可以得到以下結論:

使用深度優先搜尋遍歷一個圖,我們可以遍歷的範圍是所有和起點連通的部分,通過這一個結論,下文我們可以實現一個判斷整個圖連通性的方法。

深度優先搜尋的程式碼實現,我是用Java實現的,其中,我定義了一個類,這樣做的目的是更加清晰(畢竟,一會後面還有很多演算法)

package Graph;

public class DepthFirthSearch {

    private boolean[] marked;//用來標記頂點

    public DepthFirthSearch(Graph G,int s){
        //s是起點
        marked = new boolean[G.V()];
        dfs(G,s);
    }

    private void dfs(Graph G,int v){
        marked[v]=true;//標記頂點,這是我們的第一條原則
        
        //對於所有沒有被標記的鄰居頂點,遞迴訪問,這時第二條原則
        for(int w:G.adj(v))
            if(!marked[w]) dfs(G, w);
    }

    public boolean marked(int w){
        //判斷一個頂點能否從起點到達;因為在深搜的過程中,只要被標記了就是能夠到達,反正就是不連通的
        return marked[w];
    }

    
}

3.深搜應用(一):查詢圖中的路徑

我們通過深度優先搜尋可以輕鬆地遍歷一個圖,如果我們在此基礎上增加一些程式碼就可以很方便地查詢圖中的路徑!

比如,題目給定頂點A頂點B,讓你求得從A能不能到達B?如果能,給出一個可行的路徑!

為了解決這個問題,我們添加了一個例項變數edgeTo[]整型陣列來記錄路徑。比如:我們從頂點A直接到達頂點B,那麼就令edgeTo[B]=A,也就是“edge to B is A”,其中A是距離B最近的頂點且從A可以到達B。我舉個簡單的例子:

具體的程式碼如下,其中為了實現這個功能,我定義了一個完整的類:

package Graph;

import java.util.Stack;

public class DepthFirstPaths {

    private boolean [] marked;//記錄是否已經訪問
    private int[] edgeTo;//從起點到一個頂點的已知路徑上的最後一個頂點
    private final int s;//查詢的起點

    public DepthFirstPaths(Graph G,int s){
        //在圖G中查詢,s是起點
        marked = new boolean[G.V()];
        edgeTo = new  int[G.V()];
        this.s=s;
        dfs(G,s);//遞迴呼叫dfs
    }

    private void dfs(Graph G,int v){
        //從起點v開始查詢
        marked[v]=true;
        for(int w:G.adj(v)){
            if(!marked[w]){
                edgeTo[w]=v;//w的前一個頂點是v
                dfs(G,w);//既然w沒有被標記,就遞迴地進行dfs遍歷它
            }
        }

    }

    public boolean hasPathTo(int v){
        //判斷是否有從起點到頂點v的路徑
        //如果頂點v被標記了,就說明它可以到達,否則,就不可以到達
        return marked[v];
    }

    //列印路徑
    public void pathTo(int v){
        if(!hasPathTo(v)) System.out.println("不存在路徑");;
        Stack<Integer> path=new Stack<Integer>();

        for(int x=v;x!=s;x=edgeTo[x]){
            path.push(x);
        }
        path.push(s);
        //列印棧中的元素
        while(path.empty()==false)
            System.out.print(path.pop()+"  ");
        System.out.println();
    }

    
}

最後一個列印函式pathTo地思想就是通過一個for迴圈,將路徑壓到一個棧裡,通過棧地先進後出地性質實現反序輸出。理解了上面的dfs地過程就好,這裡可以單獨拿出來去理解。

4.深搜應用(二):尋找連通分量

還記得我們上文講到的dfs的一條性質嗎?一個dfs搜尋能夠遍歷與起點相連通的所有頂點。我們可以這樣思考:申請一個整型陣列id[0]用來將頂點分類——“聯通的頂點的id相同,不連通的頂點的id不同”。首先,我對頂點adj[0]進行dfs,把所有能夠遍歷到的頂點的id設定為0,然後把這些頂點都標記;接下來對所有沒有被標記的頂點進行dfs,執行同樣的操作,比如將id設為1,這樣依次類推,直到把所有的頂點標記。最後我們我們得到的id[]就可以完整的反映這個圖的連通情況。

如果我們需要判斷兩個頂點之間是否連通,只需要比較它們的id即可;同時,我們還可以根據有多少個不同的id來獲得一個圖中連通分量的個數,一舉兩得!

具體的程式碼實現如下:

package Graph;

public class CC {

    private boolean[] marked;
    private int [] id;//用於記錄連通訊息,相當於身份id
    private int count;//用來判斷最終一共有多少個不同的id值

    public CC(Graph G){
        marked = new boolean[G.V()];
        id=new int[G.V()];
        
        for(int s=0;s<G.V();s++){
            if(!marked[s]){
                dfs(G,s);
                count++;
            }
        }
    }
    
    //以下就是一次完整的dfs搜尋,它所遍歷的頂點都是連通的,對應了同一個count
    private void dfs(Graph G,int v){
        marked[v]=true;
        id[v]=count;
        for(int w:G.adj(v)){
            if(!marked[w])
                dfs(G,w);
        }
    }
    
    //判斷兩個頂點是否聯通
    public boolean connected(int v,int w){
        return id[v]==id[w];
    }
    
    //返回身份id
    public int id(int v){
        return id[v];
    }

    //返回一個圖中連通分量的個數,也就是一共有多少個身份id
    public int count(){
        return count;
    }
}

5.深搜應用(三):判斷是否有環

約定:假設不存在自環和平行邊

具體思想:我們在每次進入dfs的時候,都把父節點傳入。比如,我們要對頂點A進行bfs,則將A的父親和A一起傳入bfs函式,然後在函式內部,如果A的鄰居頂點沒有被標記,就遞迴地進行dfs,如果鄰居頂點被標記了,並且這個鄰居頂點不是頂點A的父節點(我們有辦法判斷,因為在每次dfs的時候都將父頂點傳入),那麼就判定存在環

說起來不是很好理解,我畫了一幅圖,你仔細跟著每個步驟就肯定沒問題:

我希望你自己畫一下結果為無環的情況,我相信你的理解會更深刻!具體的程式碼如下:

package Graph;

public class Cycle {
    private boolean []marked;
    private boolean hasCycle;
    
    public Cycle(Graph G){
        marked = new boolean[G.V()];
        for(int s=0;s<G.V();s++){
            if(!marked[s])
                dfs(G,s,s);
        }
        
    }

    private void dfs(Graph G,int v,int u){
        marked[v]=true;
        for(int w:G.adj(v)){
            if(!marked[w])
                dfs(G,w,v);
            else if(w!=u) hasCycle=true;
        }
    }

    public boolean hasCycle(){
        return hasCycle;
    }
    
}

6.深搜應用(四):判斷是否為二分圖

二分圖定義是:能夠用兩種顏色將圖的所有頂點著色,使得任意一條邊的兩個端點的顏色都不相同。

對於這個問題,我們依然只需要在dfs中增加很少的程式碼就可以實現。

具體思路:我們在進行dfs搜尋的時候,凡是碰到沒有被標記的頂點時就將它依據二分圖的定義標記(使相鄰頂點的顏色不同),凡是碰到已經標記過的頂點,就檢查相鄰頂點是否不同色。在這個過程中,如果發現存在相鄰頂點同色,則不是二分圖;如果直到遍歷完也沒有發現上述同色情況,則是二分圖,且上述根據dfs遍歷所染的顏色就是二分圖的一種。

具體的程式碼實現:

package Graph;

public class TwoColor{
    private boolean[] marked;
    private boolean[] color;//用布林值代表顏色
    private boolean isTwoColorable = true;
    
    public TwoColor(Graph G){
        marked=new boolean[G.V()];
        color=new boolean[G.V()];
        for(int s=0;s<G.V();s++){
            if(!marked[s]){
                dfs(G,s);

            }
        }
    }

    private void dfs(Graph G,int v){
        marked[v]=true;
        for(int w:G.adj(v)){
            if(!marked[w]){
                color[w]=!color[v];//如果沒有被標記,就按照二分圖規則標記
                dfs(G,w);
            }
            else if(color[w]==color[v]) isTwoColorable=false;//如果被標記就檢查
        }
    }

    public boolean isBipartite(){
        return isTwoColorable;
    }
}

7.廣度優先搜尋

在很多情境下,我們不是希望單純地找到一條連通的路徑,而是希望找到最短的那條,這個時候深搜就不再發揮作用了,我們接下來介紹另一種圖的遍歷方式:廣度優先搜尋(bfs)。我們先介紹它的實現,然後再介紹如何尋找最短路徑。

廣度優先搜尋使用一個佇列來儲存所有已經被標記過但其鄰接表還未被檢查過的頂點。它先將起點加入佇列,然後重複以下步驟直到佇列為空。

  • 取佇列中的第一個頂點v出隊
  • 將與v相鄰的所有未被標記過的頂點先標記後加入佇列

注意:在廣度優先搜尋中,我們並沒有使用遞迴,在深搜中我們隱式地使用棧,而在廣搜中我們顯式地使用佇列

一起來看一下具體的過程吧~

直觀地講,它其實就是一種“地毯式”層層推進的搜尋策略,即先查詢離起始頂點最近的,然後是次近的,依次往外搜尋。

我相信你在仔細追蹤了上圖後對廣度優先搜尋有了一個完整的認識,那麼接下來我就附上具體的程式碼實現:

package Graph;

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

public class BreadthFirstSearch {

    private boolean[] marked;
    private final int s;//起點

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

    private void bfs(Graph G,int v){

        Queue<Integer> queue = new LinkedList<>();
        marked[v]=true;//標記queue起點
        queue.add(v);//將起點加入佇列
        while(!queue.isEmpty()){
            int t=queue.poll();//從佇列中刪去下一個頂點
            for(int w:G.adj(t)){
                if(!marked(w)){
                    //對於每個沒有被標記的相鄰頂點
                    marked[w]=true;//標記它
                    queue.add(w);//並將它新增到佇列
                }
            }
        }

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

8.廣搜應用(一):查詢最短路徑

其實只要在廣度優先搜尋的過程中新增一個整型陣列edgeTo[]用來儲存走過的路徑就可以輕鬆實現查詢最短路徑,因為其原理和廣搜中的edgeTo[]完全一致,所以這裡我就不多說了。

以下是具體的程式碼實現:

package Graph;

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

public class BreadthFirstPaths {

    private boolean[] marked;
    private int[] edgeTo;//到達該頂點的已知路徑上的最後一個頂點
    private final int s;//起點

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

    private void bfs(Graph G,int v){

        Queue<Integer> queue = new LinkedList<>();
        marked[v]=true;//標記queue起點
        queue.add(v);//將起點加入佇列
        while(!queue.isEmpty()){
            int t=queue.poll();//從佇列中刪去下一個頂點
            for(int w:G.adj(t)){
                if(!marked(w)){
                    edgeTo[w]=t;//儲存最短路徑的最後一條邊
                    //對於每個沒有被標記的相鄰頂點
                    marked[w]=true;//標記它
                    queue.add(w);//並將它新增到佇列
                }
            }
        }

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

    
    public boolean hasPathTo(int v){
        //判斷是否有從起點到頂點v的路徑
        //如果頂點v被標記了,就說明它可以到達,否則,就不可以到達
        return marked[v];
    }

    public void pathTo(int v){
        if(!hasPathTo(v)) System.out.println("不存在路徑");;
        Stack<Integer> path=new Stack<Integer>();

        for(int x=v;x!=s;x=edgeTo[x]){
            path.push(x);
        }
        path.push(s);
        //列印棧中的元素
        while(path.empty()==false)
            System.out.print(path.pop()+"  ");
        System.out.println();
        
    }
}

9.廣搜應用(二):求任意兩頂點間最小距離

設想這樣一個問題:給定圖中任意兩點(u,v),求解它們之間間隔的最小邊數。

我們的想法是這樣的:以其中一個頂點(比如u)為起點,執行bfs,同時申請一個整型陣列distance[]用來記錄bfs遍歷到的每一個頂點到起點u的最小距離。

關鍵:假設在bfs期間,頂點x從佇列中彈出,並且此時我們會將所有相鄰的未訪問頂點i{i1,i2……}推回到佇列中,同時我們應該更新distance [i] = distance [x] + 1;。它們之間的距離差為1。我們只需要在每一次執行上述進出佇列的時候執行這個遞推關係式,就能保證distance[]中記錄的距離值是正確的!!

請務必仔細思考這個過程,我們很高興只要保證這一點,就可以達到我們計算最短距離的目的。

以下是我們的程式碼實現:

package Graph;

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

public class getDistance {

    private boolean[] marked;
    private int [] distance;//用來記錄各個頂點到起點的距離
    private final int s;//起點

    public getDistance(Graph G,int s){
        marked = new boolean[G.V()];
        distance= new int[G.V()];
        this.s=s;
        bfs(G,s);
    }

    private void bfs(Graph G,int v){

        Queue<Integer> queue = new LinkedList<>();
        marked[v]=true;//標記queue起點
        queue.add(v);//將起點加入佇列
        while(!queue.isEmpty()){
            int t=queue.poll();//從佇列中刪去下一個頂點
            for(int w:G.adj(t)){
                if(!marked(w)){
                    //對於每個沒有被標記的相鄰頂點
                    marked[w]=true;//標記它
                    queue.add(w);//並將它新增到佇列
                    distance[w]=distance[t]+1;//這裡就是需要新增遞推關係的地方!
                }
            }
        }

    }
    public boolean marked(int w){
        return marked[w];
    
    //列印,對於一個給定的頂點,我們可以獲得距離它特定長度的頂點
    public void PrintVertexOfDistance(Graph G,int x){
        for(int i=0;i<G.V();i++){
            if(distance[i]==x){
                System.out.print(i+" ");
            }
        }
        System.out.println();

    }

    
}

通過上面的方法,我們可以很容易的實現求一個人的三度好友之類的問題,我專門寫了一個列印的函式(在上面程式碼片段最後),它接收一個整型變數int v,可以打印出所有到起點距離為v的頂點。

10.後記

好了,關於圖的內容就到這裡了,我希望通過這篇文章你對於圖的深搜和廣搜有了一個深刻的認識!記得動手寫程式碼哦~下一篇文章,小超與你不見不散!

碼字和繪製原理圖很不容易,如果覺得本文對你有幫助,關注作者就是最大的支援!順手點個在看更感激不盡!因為,這將是小超繼續創作的動力,畢竟,做任何事情都是要有反饋的~

最後歡迎大家關注我的公眾號:小超說,之後我會繼續創作演算法與資料結構以及計算機基礎知識的文章。也可以加我微信 chao_hey,我們一起交流,一起進步!