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


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

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

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,我們一起交流,一起進步!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM