數據結構與算法--圖的搜索(深度優先和廣度優先)

數據結構與算法--圖的搜索(深度優先和廣度優先)

有時候我們需要系統地檢查每一個頂點或者每一條邊來獲取圖的各種性質,為此需要從圖的某個頂點出發,訪遍圖中其余頂點,且使得每一個頂點只被訪問一次,這個過程就稱為圖的搜索或者圖的遍歷。如果限制某個頂點只被訪問一次?我們可以建立一個布爾數組,在某個頂點第一次被訪問時,將該頂點在數組中對應的下標設置為true。圖的搜索通常由兩種方案——深度優先搜索和廣度優先搜索。

深度優先搜索

深度優先搜索(Depth First Search),簡稱DFS,該方法主要思想是:

  • 從某一個頂點開始,選擇一條沒有到達過的頂點(布爾數組中對應的值為false)
  • 標記剛選擇的頂點為“訪問過”(布爾數組中對應的值設置為true)
  • 來到某個頂點,如果該頂點周圍的頂點都訪問過了,返回到上個頂點
  • 當回退后的頂點依然是上述情況,繼續返回

這聽起來像是遞歸。沒錯,代碼確實是遞歸實現的,并且實現起來特別簡單。

package Chap7;

import java.util.Arrays;
import java.util.List;

public class DepthFirstSearch {
    // 用來標記已經訪問過的頂點,保證每個頂點值訪問一次
    private boolean[] marked;
      // s為搜索的起點
    public DepthFirstSearch(UndiGraph<?> graph, int s) {
        marked = new boolean[graph.vertexNum()];
        dfs(graph, s);
    }

    private void dfs(UndiGraph<?> graph, int v) {
        // 將剛訪問到的頂點設置標志
        marked[v] = true;
          // 打印剛訪問的頂點,可換成其他操作
        System.out.println(v);
        // 從v的所有鄰接點中選擇一個沒有被訪問過的頂點
        for (int w : graph.adj(v)) {
            if (!marked[w]) {
                dfs(graph, w);
            }
        }
    }

    public static void main(String[] args) {
        List<String> vertexInfo = Arrays.asList("v0", "v2", "v3", "v4", "v5");
        int[][] edges = {{0, 1}, {0, 2}, {0, 3},
                {1, 3}, {1, 4},
                {2, 4}};

        UndiGraph<String> graph = new UndiGraph<>(vertexInfo, edges);
        DepthFirstSearch search = new DepthFirstSearch(graph, 0);

    }
}

從代碼中看出,深度優先搜索其實就兩步:

  • 標記訪問過的頂點
  • 遞歸地訪問當前頂點所有沒有被標記過的鄰居頂點。

在上面的實現中,我們對訪問的每個頂點執行了打印操作。打印只是告訴我們搜索的順序。不過我們很想知道從某個起點開始到另一個頂點的路徑。

為此我們用到了一個edgeTo[]的整型數組,這個數組可以記住每個頂點到起點的路徑,而不是記錄當前頂點到起點的路徑。為了做到這一點在由邊v-w第一次訪問任意w時,將edgeTo[w]設為v,表示v-w是起點s到w的路徑上的最后一條已知的邊。比如0-2-3-5,表示從0到5的路徑,那么edgeTo[5] = 3。同理如果只是到3的路徑,那么edgeTo[3] =2, 到2的路徑是edgeTo[2] = 0。這樣,我們得到的edgeTo[]其實是一棵根結點為起點的樹,而且數組里存的是下標的父結點。就像下圖一樣。

image

edgeTo[1]= 2,而結點1的父結點就是結點2;edgeTo[2] = 0,而頂點2的父結點就是結點0,這和樹的雙親表示法有點類似。不存在給edge[0](根結點)賦值的情況,因為此例中我們的起點是頂點0,所以edgeTo[0]保持默認值0。從樹中可以看出,起點到頂點5的路徑是0-2-3-5。如果我們寫一個方法pathTo(),若傳入5,只能先獲取到edgetTo[5],得到父結點為3,然后根據edgeTo[3]得到父結點2...一直到獲取到根結點,可以看到獲取的順序是從葉子結點到根結點,但是真正輸出路徑的時候是從根結點到葉子結點,所以利用棧(Stack)可以實現這一過程。稍微想一下,先入棧的5被排在了底下,最后入棧的0排在了最頂上,確實是這樣的。

現在來實現。

package Chap7;

import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;

public class DepthFirstSearch {
    // 用來標記已經訪問過的頂點,保證每個頂點值訪問一次
    private boolean[] marked;
    // 起點
    private final int s;
    // 到該頂點的路徑上的最后一條邊
    private int[] edgeTo;

    public DepthFirstSearch(UndiGraph<?> graph, int s) {
        this.s = s;
        marked = new boolean[graph.vertexNum()];
        edgeTo = new int[graph.vertexNum()];
        dfs(graph, s);
    }

    private void dfs(UndiGraph<?> graph, int v) {
        // 將剛訪問到的頂點設置標志
        marked[v] = true;
//        System.out.println(v);
        // 從v的所有鄰接點中選擇一個沒有被訪問過的頂點
        for (int w : graph.adj(v)) {
            if (!marked[w]) {
                edgeTo[w] = v;
                dfs(graph, w);
            }
        }
    }

    // 連通圖的任意一個頂點都有某條路徑能到達任意一個頂點,如果v在這個連通圖中,必然存在起點到v的路徑
    // 現在marked數組中的值都是true,所以數組中若有這個v(在這個連通圖中), 返回true就表示路徑存在
    public boolean hasPathTo(int v) {
        return marked[v];
    }

    public Iterable<Integer> pathTo(int v) {
        if (hasPathTo(v)) {
            LinkedList<Integer> path = new LinkedList<>();
            for (int i = v; i != s; i = edgeTo[i]) {
                path.push(i);
            }
            // 最后將根結點壓入
            path.push(s);
            return path;
        }
        // 到v不存在路徑,就返回null
        return null;
    }

    public void printPathTo(int v) {
        System.out.print(s+" to "+ v+": ");

        if (hasPathTo(v)) {
            for (int i : pathTo(v)) {
                if (i == s) {
                    System.out.print(i);
                } else {
                    System.out.print("-" + i);
                }
            }
            System.out.println();
        } else {
            System.out.println("不存在路徑!");
        }
    }

    public static void main(String[] args) {
        List<String> vertexInfo = Arrays.asList("v0", "v1", "v2", "v3", "v4", "v5");
        int[][] edges = {{3, 5},{0, 2}, {0, 1}, {0, 5},
                {1, 2}, {3, 4}, {2, 3}, {2, 4}};

        UndiGraph<String> graph = new UndiGraph<>(vertexInfo, edges);

        DepthFirstSearch search = new DepthFirstSearch(graph, 0);
        search.printPathTo(4);
    }
}


/* Output

0 to 5: 0-2-3-5

*/

只是在深度優先搜索的實現中新加了一些東西,最重要的是在dfs遞歸方法中插入了一行edgeTo[w] = v;,我們知道w是v的一個鄰接點,那么這行的字面意思就是到w的頂點是v,即路徑v-w。上面提到過這是起點s到w的最后一條邊。

擴展的方法hasPathTo用來判斷是否有到某頂點的路徑,由于這是連通圖,任意一個頂點(包括起點s)都有某條路徑能到達任意一個頂點,在初始化該類時,已經調用過深度優先搜索,所以marked數組里都是true,這意味著只要某個頂點能在marked數組中找到對應的下標,那么返回true,表示肯定存在到它的路徑。

我們的pathTo用來確定一條從起點到指定頂點的路徑,注意這條路徑不一定是最短的,也可能并非是唯一路徑。必須先判斷是否存在到該指定頂點的路徑,如果不存在則返回null;若存在,則從查找的頂點開始入棧,i = edgeTo[i]表示樹向上一層,更新當前值為結點i的父結點,直到根結點停止,由條件i != s可知,根結點并沒有入棧,所以在循環之后要將根結點入棧。

printPathTo就是將pathTo返回的內容格式化輸出,就像這樣。表示頂點0到頂點5的路徑是0-2-3-5。

0 to 5: 0-2-3-5

可見這路徑并不是最短路徑,0-5直接可達才是最短的。

現在我們來看下深度優先搜索的詳細軌跡,注意對照著上圖的鄰接表:先是從起點0開始

  • 因為2是0的鄰接表第一個元素且沒有被標記訪問,則遞歸調用它并標記。edgeTo[2] = 0表示0-2這條路徑。
  • 現在頂點0是頂點2的鄰接表第一個元素,但是0已經被標記了,所以跳過它,看下一個。1沒有被標記,所以遞歸調用它,并標記。edgeTo[1] = 2,表示0-2-1這條路徑。
  • 頂點1的鄰接表元素都被標記過了,所以不再遞歸,方法從dfs(1)中返回到上一個頂點2,現在檢查2的下一個鄰接頂點,3沒被標記所以遞歸它并標記,edgeTo[3] = 2表示0-2-3這條路徑。
  • 頂點5是3的鄰接表第一個元素沒被標記,遞歸調用它并標記,edgeTo[5] = 3表示0-2-3-5這條路徑。
  • 頂點5的鄰接表元素都被標記過了,方法從dfs (5)中返回到帶上一個頂點3,檢查3的鄰接表下一個元素,4沒有被標記,所以遞歸它并標記,edgeTo[4] = 3表示0-2-3-4。至此,所有頂點都被標記過。搜索算是完成了。

DFS的非遞歸版本

DFS也可以自己設一個棧模擬系統棧,下面是非遞歸版本。


/**
* 非遞歸實現DFS
*
* @param graph 圖
* @param s    起點
*/
public void dfs(UndiGraph<?> graph, int s) {
      boolean[] marked = new boolean[graph.vertexNum()];
      // 模擬系統棧
      LinkedList<Integer> stack = new LinkedList<>();
      // 起點先入棧
      stack.push(s);
      // 標記訪問
      marked[s] = true;
      System.out.print(s);

      while (!stack.isEmpty()) {
          // 取出剛訪問的頂點
        int v = stack.peek();
        for (int w : graph.adj(v)) {
              if (!marked[w]) {
                marked[w] = true;
                System.out.print(w);
                stack.push(w);
                  // 模擬DFS只存入一個就好,一定要break
                break;
              }
        }
        // 所有鄰接點都被訪問過了,模擬遞歸的返回
        stack.pop();
      }
}

廣度優先搜索

深度優先搜索得到的路徑上面已經看到,并不是最短路徑,很自然地我們對下面的問題感到興趣:單點最短路徑,即給定一個圖和一個起點s,是否存在到給定頂點v的一條路徑,如果有找出最短的那條。

解決這個問題方法是廣度優先搜索(Breadth First Search),簡稱BFS。

這個算法的思想大體是:

  • 從起點開始,標記之并加入隊列。
  • 起點出列,其所有未被標記的鄰接點在被標記后,入列。
  • 隊列頭的元素出列,將該元素的所有未被標記的鄰接點標記后,入列。

如此反復,當隊列為空時,所有頂點也都被標記過了。不像DFS的遞歸那樣隱式地使用棧(系統管理的,以支持遞歸),BFS使用了隊列。

package Chap7;

import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;
import java.util.Queue;

public class BreadthFirstSearch {
    // 用來標記已經訪問過的頂點,保證每個頂點值訪問一次
    private boolean[] marked;
    // 起點
    private final int s;
    // 到該頂點的路徑上的最后一條邊
    private int[] edgeTo;


    public BreadthFirstSearch(UndiGraph<?> graph, int s) {
        this.s = s;
        marked = new boolean[graph.vertexNum()];
        edgeTo = new int[graph.vertexNum()];
        bfs(graph, s);
    }

    public void bfs(UndiGraph<?> graph, int s) {
        marked[s] = true;
        // offer入列, poll出列
        Queue<Integer> queue = new LinkedList<>();
        queue.offer(s);
        while (!queue.isEmpty()) {
            int v = queue.poll();
//            System.out.print(v+" ");
            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)) {
            LinkedList<Integer> path = new LinkedList<>();
            for (int i = v; i != s; i = edgeTo[i]) {
                path.push(i);
            }
            // 最后將根結點壓入
            path.push(s);
            return path;
        }
        // 到v不存在路徑,就返回null
        return null;
    }

    public void printPathTo(int v) {
        System.out.print(s+" to "+ v+": ");

        if (hasPathTo(v)) {
            for (int i : pathTo(v)) {
                if (i == s) {
                    System.out.print(i);
                } else {
                    System.out.print("-" + i);
                }
            }
            System.out.println();
        } else {
            System.out.println("不存在路徑!");
        }
    }

    public static void main(String[] args) {
        List<String> vertexInfo = Arrays.asList("v0", "v1", "v2", "v3", "v4", "v5");
        int[][] edges = {{3, 5},{0, 2}, {0, 1}, {0, 5},
                {1, 2}, {3, 4}, {2, 3}, {2, 4}};

        UndiGraph<String> graph = new UndiGraph<>(vertexInfo, edges);
        BreadthFirstSearch search = new BreadthFirstSearch(graph, 0);
        search.printPathTo(5);
    }
}

/* Output

0 to 5: 0-5

*/

我把打印操作注釋了,實際上它會輸出如下內容

0 2 1 5 3 4

先是打印了起點,然后依次打印了0的所有鄰接點2、1、5,之后按照隊列的出列順序,打印2的所有未被標記的鄰接點,實際上這已經打印完了所有頂點。而且從代碼里也能看出,不像DFS那樣每次只標記一個頂點,BFS每次都標記了若干頂點。

上面的代碼中,除了bfs的實現代碼,其余有關path的方法可以直接使用DFS中的實現。還是來看下詳細的搜索軌跡。

  • 首先頂點0入列
  • 頂點0出列,將它所有鄰接點2、1、5(參考DFS中的鄰接表圖片),標記他們。且edgeTo[2]、edgeTo[1]、edgeTo[5]的值都設為0
  • 頂點2出列,檢查它的鄰接點,0、1已經被標記,于是將3、4入列,并標記它們。edgeTo[3]、edgeTo[4]的值都設為2
  • 頂點1出列,其鄰接點均已被標記
  • 頂點5出列,其鄰接點均已被標記
  • 頂點3出列,其鄰接點均已被標記
  • 頂點4出列,其鄰接點均已被標記

可以發現,實際上標記工作和edgeTo數組在第三步之后就已經完成,之后的工作只是檢查出列的頂點的鄰接點是否被標記過而已。

我們不妨打印下起點到其余各個頂點的路徑

0 to 5: 0-5
0 to 4: 0-2-4
0 to 3: 0-2-3
0 to 2: 0-2
0 to 1: 0-1

不難發現,這些路徑都是最短路徑。實際上有這么一個命題:對于從s可達的任意頂點v,廣度優先搜索都能找到一條從s到v的最短路徑。

廣度優先搜索是先覆蓋起點附近的頂點,只在鄰近的所有頂點都被訪問后才向前進,其搜索路徑短而直接;而深度優先搜索是尋找離起點更遠的頂點,只有在碰到周圍的鄰接點都被訪問過了才往回退,選一個近處的頂點,繼續深入到更遠的地方,其路徑長而曲折。

以上深度優先和廣度優先的實現對于有向圖也是適用的,把接收的參數的換成有向圖即可。


by @sunhaiyu

2017.9.19

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,345評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,494評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,283評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,953評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,714評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,186評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,255評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,410評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,940評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,776評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,976評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,518評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,210評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,642評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,878評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,654評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,958評論 2 373

推薦閱讀更多精彩內容

  • 1)這本書為什么值得看: Python語言描述,如果學的Python用這本書學數據結構更合適 2016年出版,內容...
    孫懷闊閱讀 12,549評論 0 15
  • 大部分內容來自于《大話數據結構》,代碼全部使用Swift實現。至于為什么抽風寫這個???你懂的。 1.線性表 線性表...
    一劍孤城閱讀 81,882評論 12 111
  • 圖是一種比線性表和樹更復雜的數據結構,在圖中,結點之間的關系是任意的,任意兩個數據元素之間都可能相關。圖是一種多對...
    Alent閱讀 2,324評論 1 22
  • 課程介紹 先修課:概率統計,程序設計實習,集合論與圖論 后續課:算法分析與設計,編譯原理,操作系統,數據庫概論,人...
    ShellyWhen閱讀 2,329評論 0 3
  • 電影看完了。可能有人會問我,講了一個什么故事,是悲劇還是喜劇,我可能會講不出來。電影看的是一種感覺,一種期待,當你...
    4e9f0f5e6b31閱讀 163評論 0 0