【知識點】深度優先搜尋 Depth First Search

Macw發表於2024-06-17

去年釋出的筆記,今年加以改編。
世界上只有兩種人,一種是討厭遞迴的人,另一種是討厭遞迴後又重新愛上遞迴的人...

搜尋演算法被廣泛的應用在計算機領域中。搜尋演算法的本質就是透過暴力列舉以及模擬的方式來求得最終的答案。但普通的暴力列舉侷限性太大,需要透過學習搜尋演算法來彌補暴力列舉演算法的不足。在學習深度優先搜尋演算法之前,請務必閱讀並掌握遞迴演算法,深度優先搜尋的操作是基於遞迴(函式自己掉用自己)來實現的。深度優先搜尋演算法常用於解決迷宮問題、圖形連通性問題以及在樹和圖形結構中查詢路徑等問題。

有一句老話叫做:不撞南牆不回頭。這非常形象的刻畫出了深度優先搜尋的演算法策略。以“左手原則”為例,“左手原則”指的是當人們在迷宮中尋找出口時,在每個路口都優先往自己左手邊的路口進行探索,如果探索到底都沒有找到出口,則回到前一個路口,走自己右手邊的路,以此類推,這個動作就被稱之為回溯操作。回溯操作即撤銷當前動作,繼續執行前一個動作。

深度優先搜尋演算法是透過遞迴演算法來實現的。具體的操作流程從一個節點開始,不斷的朝著某一個“路”進行走,直到走到底。如果走到底後沒有找的目標解,則回溯到上一個節點換一條“路”繼續直走到底。重複上述步驟,直到找到目標解為止。

本文將以下圖為例講述深度優先搜尋的一般操作:

image

要求:從節點A開始,透過深度優先搜尋的方式找到節點F。

首先,在對上圖進行遍歷之前,需要確定遍歷的順序:對每一個節點而言,該節點有兩個子節點,分別是左子節點和右子節點。因此可以將遍歷順序設定為先訪問左節點,如果左節點已經被訪問或左節點並不符合條件,則訪問右節點。紅色節點表示該節點正在被訪問,綠色節點表示即將要訪問的節點,黑色節點表示該節點已經訪問過,不需要再次訪問。具體的操作流程如下:

從 A 節點出發,先訪問 A 的左節點 B。

image

從 B 節點出發,先訪問 B 的左節點 D。

image

從 D 節點出發,因為 D 節點自身沒有左右節點,因此這條“路”已經走到了盡頭,標記點 D 已經被完全訪問過並進行回溯操作。先回溯到 D 的父節點 B,因為 B 的左節點已經被訪問過,因此再次嘗試訪問 B 的右節點 E。

image

從 E 節點出發,因為 E 節點自身沒有左右節點,因此這條“路”已經走到了盡頭,標記點 E 已經被完全訪問過並進行回溯操作。先回溯到 E 的父節點 B。

image

因為 B 的左右節點都被訪問過,則繼續回溯到 B 的父節點 A,因為 A 的左節點 B 已經被訪問過,因此再次嘗試訪問 A 的右節點 C。

image

從 C 節點出發,訪問 C 節點的左節點:F 節點。因為 F 節點就是目標查詢節點,因此直接返回結果並結束程式即可。

image

以上就是透過深度優先搜尋操作在上圖尋找某一個指定節點。對每一個節點而言,都去訪問這個節點的左子節點和右子節點,並對該節點的左子節點和右子節點繼續進行相同的訪問左右節點操作。如果到底都沒有找到結果,則進行回溯操作,再次向新的節點出發遍歷直到找到最終結果。這符合遞迴的特性,即一個問題的子問題相似但不相同,因此可以透過遞迴的方式來解決。

T255624 迷宮之判定

這道題就是一道典型的深度優先圖搜尋的模板題,題目的大意是從迷宮的左上角出發,判斷是否可以走到迷宮的右下角。與前文中的例題不同,這道題是一道地圖題,但也可以透過深度優先搜尋來實現。先定義地圖的任意一個座標 map[i][j] 為一個節點,那麼對於每個節點來說就有四種後繼選擇,分別是:往上走,往下走,往左走,往右走(在無障礙的情況下)。因此這可以透過遞迴的方式來模擬每一個節點。

難點一:如何存圖?

對於這個地圖而言,可以透過構建一個二維陣列 arr[50][50] 來儲存圖中的每一個節點。

int main(){
    int n, m;
    char arr[50][50];
    cin >> n >> m;
    for (int i=1; i<=n; i++){
        for (int j=1; j<=m; j++){
            cin >> arr[i][j];
        }
    }
    return 0;
}

難點二:如何構造搜尋函式?

深度優先搜尋的底層原理是透過遞迴來實現的,因此構造搜尋函式需要滿足遞迴的三大要求:

確定遞迴引數:

在深度優先搜尋中,遞迴的引數往往與節點相同,因此遞迴可以被等價的看作為每一個節點。在本例中,每一個節點有兩個引數,即這個節點的橫座標與縱座標,因此本遞迴函式有兩個引數,分別也是節點的橫座標與縱座標。

確定遞迴返回值:

在本次遞迴中,並不需要返回一個實際的結果。當程式訪問到目標節點,即地圖的右下角時,直接輸出答案"YES",並終止程式即可。

確定遞迴函式體:

遞迴的函式體就是深度優先搜尋中對每一個節點進行的操作。在本題中,每一個節點可以通向自己的上下左右節點(在有路的情況下),因此只需要遍歷這個節點所有的可通往節點即可。若一個節點之前已經被訪問過,則不需要再次訪問該節點,因此需要再開闢一個二維陣列 vis[50][50] 來記錄某一個節點是否被訪問過。當地圖中座標 (i, j) 的節點被訪問後,則將這個節點標記為 \(1\),表示已被訪問過。

綜上所述,本題的遞迴函式如下:

// 每一層遞迴都代表一個節點
void dfs(int x, int y){
    // 如果這個節點是終點,則終止程式並輸出結果。
    if (x == n && y == m){
        cout << "YES" << endl;
        exit(0);
    }
    // 訪問這個節點所通往的所有節點。
    // 如果上節點沒被訪問過且可以被訪問,則訪問上節點。
    if (x - 1 >= 1 && vis[x-1][y] == 0 && arr[x-1][y] == '.'){
        // 標記節點已經被訪問過。
        vis[x-1][y] = 1;
        dfs(x-1, y);  // 遞迴上節點。
    }
    // 如果下節點沒被訪問過且可以被訪問,則訪問下節點。
    if (x + 1 <= n && vis[x+1][y] == 0 && arr[x+1][y] == '.'){
        // 標記節點已經被訪問過。
        vis[x+1][y] = 1;
        dfs(x+1, y);  // 遞迴下節點。
    }
    // 如果左節點沒被訪問過且可以被訪問,則訪問左節點。
    if (y - 1 >= 1 && vis[x][y-1] == 0 && arr[x][y-1] == '.'){
        // 標記節點已經被訪問過。
        vis[x][y-1] = 1;
        dfs(x, y-1);  // 遞迴左節點。
    }
    // 如果右節點沒被訪問過且可以被訪問,則訪問右節點。
    if (y + 1 <= m && vis[x][y+1] == 0 && arr[x][y+1] == '.'){
        // 標記節點已經被訪問過。
        vis[x][y+1] = 1;
        dfs(x, y+1);  // 遞迴右節點。
    }
    return ;
}

本題完整的程式碼如下,具體講解見程式碼註釋:

#include <iostream>
#include <algorithm>
using namespace std;

int n, m;  // 地圖的長和寬。
char arr[50][50];  // 地圖。
int vis[50][50];  // 用來記錄一個點是否被存放過。

// 每一層遞迴都代表一個節點
void dfs(int x, int y){
    // 如果這個節點是終點,則終止程式並輸出結果。
    if (x == n && y == m){
        cout << "YES" << endl;
        exit(0);
    }
    // 訪問這個節點所通往的所有節點。
    // 如果上節點沒被訪問過且可以被訪問,則訪問上節點。
    if (x - 1 >= 1 && vis[x-1][y] == 0 && arr[x-1][y] == '.'){
        // 標記節點已經被訪問過。
        vis[x-1][y] = 1;
        dfs(x-1, y);  // 遞迴上節點。
    }
    // 如果下節點沒被訪問過且可以被訪問,則訪問下節點。
    if (x + 1 <= n && vis[x+1][y] == 0 && arr[x+1][y] == '.'){
        // 標記節點已經被訪問過。
        vis[x+1][y] = 1;
        dfs(x+1, y);  // 遞迴下節點。
    }
    // 如果左節點沒被訪問過且可以被訪問,則訪問左節點。
    if (y - 1 >= 1 && vis[x][y-1] == 0 && arr[x][y-1] == '.'){
        // 標記節點已經被訪問過。
        vis[x][y-1] = 1;
        dfs(x, y-1);  // 遞迴左節點。
    }
    // 如果右節點沒被訪問過且可以被訪問,則訪問右節點。
    if (y + 1 <= m && vis[x][y+1] == 0 && arr[x][y+1] == '.'){
        // 標記節點已經被訪問過。
        vis[x][y+1] = 1;
        dfs(x, y+1);  // 遞迴右節點。
    }
    return ;
}

int main(){
    cin >> n >> m;
    for (int i=1; i<=n; i++){
        for (int j=1; j<=m; j++){
            cin >> arr[i][j];
        }
    }
    dfs(1, 1);  // 遞迴的呼叫,從節點(1, 1)開始搜尋。
    // 如果遞迴程式結束了,則代表無法到達地圖的右下角,固輸出NO。
    cout << "NO" << endl; 
    return 0;
}

仔細觀察,遞迴函式的函式體非常的冗餘,遍歷上下左右四個方向的程式碼即為相似但不相同,主要的不同點在於需要對四個方向單獨進行檢測和判斷。可以透過用陣列記錄一個節點橫縱座標的位移來節省程式碼的空間以及加強程式碼的可讀性。四個方向的橫縱座標偏移量可以大致的寫成 dx[] = {-1, 1, 0, 0}; dy[] = {0, 0, -1, 1}。假設上下左右分別被記為 0, 1, 2, 3 。則 dx[i]dy[i],就分別代表當方向為i時,新節點x座標和y座標偏移量。因此,可以透過 for 迴圈來遍歷四個方向並對每一個方向進行統一的判斷。修改後的完整程式碼如下:

#include <iostream>
#include <algorithm>
using namespace std;

int n, m; 
char arr[50][50];  
int vis[50][50]; 
int dx[] = {-1, 1, 0, 0};  // 縱座標偏移量。
int dy[] = {0, 0, -1, 1};  // 橫座標偏移量。

void dfs(int x, int y){
    if (x == n && y == m){
        cout << "YES" << endl;
        exit(0);
    }
    // 遍歷四個方向,對每一個方向進行判斷即可。
    for (int i=0; i<4; i++){
        // 新的節點的橫縱座標。
        int cx = dx[i] + x;
        int cy = dy[i] + y;
        // 判斷新節點的橫縱座標是否合法。
        if (cx >= 1 && cy >= 1 && cx <= n && cy <= m){
            if (vis[cx][cy] == 0 && arr[cx][cy] == '.'){
                vis[cx][cy] = 1;
                dfs(cx, cy);
            }
        }
    }
    return ;
}

int main(){
    cin >> n >> m;
    for (int i=1; i<=n; i++){
        for (int j=1; j<=m; j++){
            cin >> arr[i][j];
        }
    }
    dfs(1, 1);  // 遞迴的呼叫,從節點(1, 1)開始搜尋。
    // 如果遞迴程式結束了,則代表無法到達地圖的右下角,固輸出NO。
    cout << "NO" << endl; 
    return 0;
}

P1605 迷宮

本題相較於前一題而言,不僅僅在於判斷是否可以從迷宮的起點走到迷宮的終點,而是給定起點座標和終點座標,每個方格最多經過一次,記錄起點座標到終點座標的路徑方案的數量。對於本題而言,也可以透過深度優先搜尋演算法來實現。在本章最開始的例子中(在二叉樹中尋找節點F),已經展示了深度優先搜尋的具體操作,可以看到,在執行深度優先搜尋中,計算機會按照深度優先的方式遍歷出所有的路徑。因此對於本題而言,只需要記錄在深度優先搜尋的過程中終點節點被訪問過的次數即可。

同時對於本題而言,因為需要統計出從起點到終點的所有路徑可能性,則需要進行回溯操作,表示在訪問節點之前需要把該節點標記為以訪問以防後續的遞迴繼續訪問當前節點。同時當該節點被訪問完成後,其後繼的所有搜尋都完成後,需要將節點標記為未訪問過以此使得其他的遞迴程式還可以繼續訪問該節點。這與 C++ 的遞迴機制有關,由於遞迴的底層原理是透過模擬資料結構棧來實現的,因此在彈出當前節點時需要保證後繼壓入棧的節點不能訪問當前被訪問過的節點。同時當該遞迴節點彈出棧後,後續壓入棧內的元素則不被該節點所限制。因此完整的程式碼如下,詳情請參照程式碼註釋。

#include <iostream>
#include <algorithm>
using namespace std;

// 分別表示迷宮的長寬、障礙總數以及最終累加結果。
int n, m, k, ans; 
// 記錄地圖,其中arr[i][j]為1表示座標(i, j)有障礙物。
int arr[50][50];  
int vis[50][50];  // 用來記錄一個節點是否被訪問過。
int dx[] = {-1, 1, 0, 0};  // 縱座標偏移量。
int dy[] = {0, 0, -1, 1};  // 橫座標偏移量。
int sx, sy, fx, fy;  // 起點座標以及終點座標。

void dfs(int x, int y){
    // 判斷是否到達了終點。
    if (x == fx && y == fy){
        ans += 1;  // 對結果進行累加操作。
        return ;
    }
    // 遍歷四個方向,對每一個方向進行判斷即可。
    for (int i=0; i<4; i++){
        // 新的節點的橫縱座標。
        int cx = dx[i] + x;
        int cy = dy[i] + y;
        // 判斷新節點的橫縱座標是否合法。
        if (cx >= 1 && cy >= 1 && cx <= n && cy <= m){
            // 判斷路是否被走過或是否被有障礙物。
            if (vis[cx][cy] == 0 && arr[cx][cy] == 0){
            	// 標記該節點為訪問過。
                vis[cx][cy] = 1;
                dfs(cx, cy);
                // 回溯,取消該節點的訪問。
                vis[cx][cy] = 0;
            }
        }
    }
    return ;
}

int main(){
    cin >> n >> m >> k;
    cin >> sx >> sy >> fx >> fy;
    while(k--){
        // 讀入障礙物座標。
        int t1, t2;
        cin >> t1 >> t2;
        arr[t1][t2] = 1;  // 表示地圖的在座標(t1, t2)處有障礙物。
    }
    vis[sx][sy] = 1;  // 標記起點被訪問過。
    dfs(sx, sy);  // 遞迴的呼叫,從節點(sx, sy)開始搜尋。
    // 輸出最終結果即可。
    cout << ans << endl;
    return 0;
}

相關文章