【資料結構與演算法筆記04】對圖搜尋策略的一些思考(包括DFS和BFS)

神奇少女祝XiXi發表於2021-04-10

圖搜尋策略

這裡的“圖搜尋策略”應該怎麼理解呢?

首先,是“圖搜尋”,所謂圖無非就是由節點和邊組成的,那麼圖搜尋也就是將這個圖中所有的節點和邊都訪問一遍。

其次是“策略”:

==> 如果就直接給你一個圖,要怎麼樣才能將所有的節點和邊都訪問一遍呢?

這裡可以考慮一個非常非常大並且結構複雜的圖,那麼當拿到這個圖的時候資訊龐雜無比,你不知道里面有多少個節點,有多少條邊,不知道節點和邊之間是怎樣錯綜複雜的關係,不知道有多少連通子圖......

對這樣的圖進行搜尋會讓人一下子摸不著頭緒,這時候肯定會想到,得有一個入手點才行!這是一個很通用的解決問題的策略,當整體過於複雜的時候不如試著從一個很小的區域性開始一點點梳理脈絡。

這個入手點一般是某個節點。

==> 有了一個入手點之後,要怎樣繼續去搜尋其他的節點和邊呢?

這時候會考慮,從我已有的知識來說,我目前只知道一個搜尋的起點(記為source),以及關於這個起點的一些資訊:與source相連的邊的集合,與source相鄰的節點集合。不過這兩個資訊其實是重合的,如果我現在從source出發去訪問一條與source相連的邊,其實也就是訪問了這條邊另一頭的節點(source的一個鄰居)。

當我走了這一條邊之後,到達source的這個鄰居節點之後,又會得到與這個鄰居節點相鄰的邊以及它的鄰居......

也就是說,在每一個時間截面(或者說選擇節點)上,我都有很多選擇——我有一個可以訪問但還沒有訪問的節點的集合,此時我會從這個節點集合中選出一個節點,想前走一步,每探索一步,我對這個圖的瞭解就更深一步(如果說有一個已經訪問過的節點的集合,那麼這個集合中節點的個數就可以理解為深入的程度),但這樣的深入並非永無止境,這個圖總會遍歷完(總會到達這樣一個選擇節點:此時沒有任何可以繼續訪問的節點了),所以這個探索會像一個先膨脹再收縮的過程(或者說像一個回力鏢?),總會回到已經訪問過的節點上來,這時候就不必再繼續下去了,再繼續下去對 “遍歷更多的節點和邊” 這一目標沒有任何價值。

於是我得到了一個初步的搜尋思路

我有一個可以訪問但還沒有訪問的節點的集合(記為Package),初始時這個集合中只有我一個節點。

每訪問一個節點,都有可能會向這個Package中裝入一些新的節點(當前訪問的節點的鄰居中,還沒有訪問過的那些節點),當然也可能不會。

我每次從這個Package中取一個節點,然後更新一下Package的內容,然後再取一個節點......就這樣一直重複直到Package為空,這時就已經將從source所在的那個連通子圖全部遍歷完了。

==> 每次取出一個節點?

對於剛剛得出的搜尋策略,還有一個值得思考的地方:每次取一個節點?每次取哪個節點?

如何決定每一次取出哪一個節點這個問題就是所謂的“策略”。

當確定了“策略”之後,還需要考慮Package用什麼資料結構來儲存,要選擇一個最適合於搜尋策略的資料結構。


畫外音:不能狹隘地認為一說到圖搜尋或者遍歷圖,不是DFS就是BFS,雖然確實這兩種是最最常用而且能高效地解決很多問題的搜尋策略。但是我也可以簡單地每次就隨機取一個節點進行訪問,或者說每次先把剩餘未訪問鄰居最多的節點拿出來訪問,或者說每次把距離source最近的節點拿出來訪問等等。我這裡只是隨便舉一些例子,我想說的是,圖搜尋的策略有千千萬萬種,我們不能侷限自己的思路,當拿到一個問題時,應該想著,怎麼樣為這個問題服務,而不是直接就開始想“這題用DFS能不能做?不能就用BFS”。

 

深度優先搜素(DFS)& 廣度優先搜尋(BFS)

跟著上面的思路來考慮DFS和BFS的搜尋策略:

  • DFS:每次從Package中選出距離source最遠的節點進行訪問。
  • BFS:每次從Package中選出距離source最近的節點進行訪問。

如果在某一個選擇節點有多個距離source最近/遠的節點,選哪一個都可以,具體選哪個現在暫時無法決定,這個問題先留著之後再考慮。

 

在前面的“初步搜尋思路”中有一個很重要的細節,回顧一下我們是如何更新Package的?

每訪問一個節點,都有可能會向這個Package中裝入一些新的節點(當前訪問的節點的鄰居中,還沒有訪問過的那些節點)...

如果在訪問某個節點A時對Package進行了更新:

  • DFS:A離source的距離(記為k)大於等於當前Package中的所有節點

                     ==> A的鄰居中,還沒有被訪問過的那些節點離source的距離(k+1)一定大於當前Package中的所有節點

      ==> 下一次訪問的節點一定是這次剛剛加入Package的節點中的某一個

      ==> 典型的“後入先出” 

      ==> Package用棧來存

      ==> 每次從棧頂取出一個節點,對其進行訪問,並將它的 鄰居中還沒有被訪問過的節點 壓棧

  • BFS: 假設當前Package中距離source最近的節點構成一個集合S_k,假設它們離source為k

                      ==> 每次從這個集合中選出一個節點,並將該節點的鄰居中還沒有被訪問過的那些節點(離source的距離為k+1)放入Package中

      ==> 在訪問S_k時加入到Package中的節點構成集合 S_k+1,以此類推

      ==> 這就意味著節點是按照他們和source的距離加入Package的,也是按照他們和source的距離離開Package的

      ==> 加入的順序 = 離開的順序

      ==> 典型的“先入先出”

      ==> Package用佇列來存

      ==> 每次從隊首取出一個節點,對其進行訪問,並將它的 鄰居中還沒有被訪問過的節點 放入隊尾

 

再回頭看剛才遺留的那個問題:如果在某一個選擇節點有多個距離source最近/遠的節點時,應該選哪一個?

在DFS中,顯然是選最後加入的那一個;在BFS中,是選最先加入的那一個。因為選哪一個其實都可以,按照這樣的方式取是最方便的,所以這個問題也就解決了。

到這裡就可以開始編碼實現了。

 

編碼實現

這裡採用《演算法(第4版)》中的圖處理演算法的設計模式:

1. DFS

因為DFS是用 “棧” 來控制順序的,所以有兩種寫法:一種是顯示地使用一個棧,用迭代的方式來實現;另一種是隱式地使用棧,用遞迴的方式實現。

 1 #pragma once
 2 #include <stack>
 3 #include "Graph.h"
 4 using namespace std;
 5 
 6 class DepthFirstSearch {
 7 private:
 8     bool* mymarked; //在遞迴寫法中:某一個時間截面記錄已經訪問過的節點
 9                     //在迭代寫法中:某一個時間截面記錄已經壓過棧的節點
10                     //在dfs結束後記錄與s節點連通的所有節點
11 
12 public:
13     DepthFirstSearch(Graph G, int s) {
14         //初始化輔助陣列mymarked
15         mymarked = new bool[G.v()];
16         for (int i = 0; i < G.v(); i++)
17             mymarked[i] = false;
18 
19         //深度優先搜尋
20         //dfs_recursion(G, s);
21         dfs_iteration(G, s);
22     }
23 
24     void dfs_recursion(Graph G, int s) {
25         //Step1: 訪問當前節點
26         mymarked[s] = true;
27         
28         //Step2: 對於S的所有鄰居,如果沒有訪問過的話,呼叫dfs自身來訪問它
29         for (int i = 0; i < G.adj(s).size(); i++) {
30             if (mymarked[G.adj(s)[i]] == false)
31                 dfs_recursion(G, G.adj(s)[i]);
32         }
33     }
34 
35     void dfs_iteration(Graph G, int s) {
36         stack<int> S;
37 
38         //Step0: 初始化棧:放入source節點
39         S.push(s);
40         mymarked[s] = true;
41 
42         while (!S.empty()) {
43             //Step1: 取出棧頂元素進行訪問
44             int cur = S.top();
45             S.pop();
46             
47             //Step2: 將cur節點的鄰居節點中,尚未被訪問過的節點壓棧
48             for (int i = 0; i < G.adj(cur).size(); i++) {
49                 if (mymarked[G.adj(cur)[i]] == false) {
50                     mymarked[G.adj(cur)[i]] = true;
51                     S.push(G.adj(cur)[i]);
52                 }
53             }
54         }
55     }
56 
57     bool marked(int w) {
58         return mymarked[w];
59     }
60 
61 };

2. BFS

 1 #pragma once
 2 #include <queue>
 3 #include "Graph.h"
 4 using namespace std;
 5 
 6 class BreadFirstSearch {
 7 private:
 8     bool* mymarked; //某一個時間截面記錄已經進入過佇列的節點
 9                     //在bfs結束後記錄與s節點連通的所有節點
10 
11 public:
12     BreadFirstSearch(Graph G, int s) {
13         //初始化輔助陣列mymarked
14         mymarked = new bool[G.v()];
15         for (int i = 0; i < G.v(); i++) {
16             mymarked[i] = false;
17         }
18 
19         //廣度優先搜尋
20         bfs(G, s);
21     }
22 
23     void bfs(Graph G, int s) {
24         queue<int> Q;
25 
26         //Step0: 初始化佇列:放入source節點
27         Q.push(s);
28         mymarked[s] = true;
29 
30         while (!Q.empty()) {
31             //Step1: 取出隊首元素進行訪問
32             int cur = Q.front();
33             Q.pop();
34 
35             //Step2: 將cur節點的鄰居節點中,尚未被訪問過的節點放入隊尾
36             for (int i = 0; i < G.adj(cur).size(); i++) {
37                 if (mymarked[G.adj(cur)[i]] == false) {
38                     mymarked[G.adj(cur)[i]] = true;
39                     Q.push(G.adj(cur)[i]);
40                 }
41             }
42 
43         }
44 
45     }
46 
47     bool marked(int w) {
48         return mymarked[w];
49     }
50 
51 };

 

值得注意的是

在兩個迭代演算法中,mymarked[i] == true的含義是:將該節點放入Package這個操作已經執行過了。

將節點i放入Package意味著它之後一定會被訪問,而不是現在就被訪問了。

從Package中取出節點i才意味著它被訪問了。

所以將一個節點對應的mymarked[i]設為True的時機應該是將它放入Package時,而不是從Package中取出時。

 

DFS和BFS的對比

  “深度優先搜尋和廣度優先搜尋是我們首先學習的幾種通用的圖搜尋的演算法之一。

  在搜尋中我們都會先將起點存入資料結構中,然後重複以下步驟直到資料結構被清空:

  1. 取其中的下一個頂點並標記它;
  2. 將 v 的所有相鄰而又未被標記的頂點加入資料結構。
       這兩個演算法的不同之處僅在於從資料結構中獲取下一個頂點的規則(對於廣度優先搜尋來說是最早加入的頂點,對於深度優先搜尋來說是最晚加入的頂點)。這種差異得到了處理圖的兩種完全不同的視角,儘管無論使用哪種規則,所有與起點連通的頂點和邊都會被檢查到。圖 4.1.19 和 圖 4.1.20 顯 示了深度優先搜尋和廣度優先搜尋處理樣圖 mediumG.txt 的過程,它們清晰地展示了兩種方法中搜尋路徑的不同。
  1. 深度優先搜尋不斷深入圖中並在棧中儲存了所有分叉的頂點;廣度優先搜尋則像扇面一般掃描圖,用一個佇列儲存訪問過的最前端的頂點。
  2. 深度優先搜尋探索一幅圖的方式是尋找離起點更遠的頂點,只在碰到死衚衕時才訪問近處的頂點;廣度優先搜尋則會首先覆蓋起點附近的頂點,只在臨近的所有頂點都被訪問了之後才向前進。
  3. 深度優先搜尋的路徑通常較長而且曲折,廣度優先搜尋的路徑則短而直接。
                                                               —— 《演算法(第4版)》


 

畫外音:書中在介紹DFS之前還介紹了一個走迷宮的例子,這個例子對於理解DFS也很有價值。

相關文章