1. 程式人生 > >深度優先搜尋原理與實踐(java)

深度優先搜尋原理與實踐(java)

概論

深度優先搜尋屬於圖演算法的一種,是一個針對圖和樹的遍歷演算法,英文縮寫為 DFS 即 Depth First Search。深度優先搜尋是圖論中的經典演算法,利用深度優先搜尋演算法可以產生目標圖的相應拓撲排序表,利用拓撲排序表可以方便的解決很多相關的圖論問題,如最大路徑問題等等。一般用堆資料結構來輔助實現 DFS 演算法。其過程簡要來說是對每一個可能的分支路徑深入到不能再深入為止,而且每個節點只能訪問一次。

基本步奏

(1)對於下面的樹而言,DFS 方法首先從根節點1開始,其搜尋節點順序是 1,2,3,4,5,6,7,8(假定左分枝和右分枝中優先選擇左分枝)。

   

(2)從 stack 中訪問棧頂的點;

   

(3)找出與此點鄰接的且尚未遍歷的點,進行標記,然後放入 stack 中,依次進行;

   

(4)如果此點沒有尚未遍歷的鄰接點,則將此點從 stack 中彈出,再按照(3)依次進行;

   

 

(5) 由於與節點 5 相連的的節點都被訪問過了,於是5被彈出,查詢與 4 相鄰但沒有被訪問過的節點:

   

(6)直到遍歷完整個樹,stack 裡的元素都將彈出,最後棧為空,DFS 遍歷完成。

  (7) 針對上面的過程,可以用程式碼表示如下:
    // 用於記錄某個節點是否訪問過
   private Map<String, Boolean> status = new HashMap<String, Boolean>();
  // 用於儲存訪問過程中的節點 private Stack<String> stack = new Stack<String>();
  // 入口,這裡選擇 1 為入口 public void DFSSearch(String startPoint) { stack.push(startPoint); status.put(startPoint, true); dfsLoop(); } private void dfsLoop() {
     // 到達終點,結束迴圈 if(stack.empty()){ return; } // 檢視棧頂元素,但並不出棧 String stackTopPoint = stack.peek(); // 找出與此點鄰接的且尚未遍歷的點,進行標記,然後全部放入list中。 List<String> neighborPoints = graph.get(stackTopPoint); for (String point : neighborPoints) { if (!status.getOrDefault(point, false)) { //未被遍歷 stack.push(point);
          // 加上已訪問標記 status.put(point, true); dfsLoop(); } }
     // 如果鄰接點都被訪問了,那麼就彈出,相當於是恢復操作,也就是在遞迴後面做的。 String popPoint = stack.pop(); System.out.println(popPoint); }

通過上面的示例,基本瞭解 dfs 使用。

通用框架

其一般框架原理如下:

void dfs()
{
    if(到達終點狀態)
    {
        ... //根據題意新增 
        return; 
    }
    if(越界或不合法狀態) return; 
    if(特殊狀態) // 剪枝
         return;
    for(擴充套件方式)
    {
        if(擴張方式所到達狀態合法)
        {
            修改操作; // 根據題意新增
            標記;
            dfs();
            (還原標記);
            //是否加上還原標記根據題意
            //如果加上還原標記就是回溯法 
        }
    }
}

通過這個 dfs 框架可以看出該方法主要有以下幾個規律:

  1. 訪問路徑的確定。根據不同的題目思考怎麼才算是一條訪問路徑,如何去實現遍歷。

  2. 起點條件。從哪個點開始訪問?是否每個點都需要當作起點?第一次 dfs 呼叫至關重要。

  3. 遞迴引數。也就是 dfs 遞迴怎麼在上一個節點的基礎上繼續遞迴,實現遞迴依賴什麼引數?需要知道一條路徑上各個節點之間的關係,當前訪問節點。

  4. 終結條件。訪問的終結條件是什麼?比如到達邊界點,所有點已經都訪問過了。終結條件需要在下一次遞迴前進行判斷。

  5. 訪問標誌。當一條路走不通的時候,會返回上一個節點,嘗試另一個節點。為了避免重複訪問,需要對已經訪問過的節點加上標記,避免重複訪問。

  6. 剪枝。屬於演算法優化。比如已經知道沿著當前路徑再走下去也不會滿足條件的時候,提前終止遞迴。

下面將結合幾道演算法題來加深對深度優先搜尋演算法的理解。

1、全排列

問題:給定大於0的數字n,輸出數字 1 ~ n 之間的全排列。

對於這道題目,有些人可能會好奇為啥這到題目可以使用 dfs 演算法。對於全排列,其實可以通過樹的形式來進行理解:

 可以發現就是一個 n 叉樹,總共是 n 層,下面採用前面總結的規律來看看演算法實現原理:

  1. 訪問路徑:從起始位置到葉節點就是一個排列,也就是一條路徑

  2. 起點條件:start 下面有 n 個節點,每個點都可以被當作起始點,說明需要採用 for 迴圈方式,。

  3. 遞迴引數:當前訪問的節點位置,定位下一個遞迴節點。需要一個變數記錄數字的排列,需要輸出。節點總數 n,便於知道何時遞迴結束。

  4. 終結條件:遞迴訪問到節點數到達 n 層的時候停止遞迴。

  5. 訪問標誌:不需要,可重複訪問;

  6. 剪枝:不需要,沒有其他需要提前終止遞迴的條件。

下面就是演算法實現:

     // 呼叫入口,起始點
   dfs(total, 0, "");
   // 遞迴引數:tatal 表示數字n, index 當前訪問節點,s 記錄排列方式 public void dfs(int total, int index, String s) {
     // 終結條件 if (index == total) { System.out.println(s); return; }
     // 對於每個節點,當前有 total 種選擇 for (int i= 1;i<=total;i++) { dfs(total, index+1, s+i); } }

可以發現,程式碼還是很簡單的。


 

695. 島嶼的最大面積

給定一個包含了一些 0 和 1 的非空二維陣列 grid 。

一個 島嶼 是由一些相鄰的 1 (代表土地) 構成的組合,這裡的「相鄰」要求兩個 1 必須在水平或者豎直方向上相鄰。你可以假設 grid 的四個邊緣都被 0(代表水)包圍著。

找到給定的二維陣列中最大的島嶼面積。(如果沒有島嶼,則返回面積為 0 。)

示例 1:

[[0,0,1,0,0,0,0,1,0,0,0,0,0],
[0,0,0,0,0,0,0,1,1,1,0,0,0],
[0,1,1,0,1,0,0,0,0,0,0,0,0],
[0,1,0,0,1,1,0,0,1,0,1,0,0],
[0,1,0,0,1,1,0,0,1,1,1,0,0],
[0,0,0,0,0,0,0,0,0,0,1,0,0],
[0,0,0,0,0,0,0,1,1,1,0,0,0],
[0,0,0,0,0,0,0,1,1,0,0,0,0]]

對於上面這個給定矩陣應返回 6。注意答案不應該是 11 ,因為島嶼只能包含水平或垂直的四個方向的 1 。

示例 2:

[[0,0,0,0,0,0,0,0]]

對於上面這個給定的矩陣, 返回 0。

注意: 給定的矩陣grid 的長度和寬度都不超過 50。


 對於這道題目還是採用之前的分析方式:

  1. 訪問路徑:節點中相鄰的1構成一條路徑。0 直接無視。

  2. 起點條件:二維陣列的每個點都可以當作起點。所以兩個 for 迴圈來進行呼叫。

  3. 遞迴引數:當前訪問的節點位置(x,y),二維陣列表,從表中查詢下一個節點

  4. 終結條件:到達二維陣列的邊界,節點為0

  5. 訪問標誌:需要,不可重複訪問;可以將訪問過的節點置為0,避免再次訪問,重複計算。

  6. 剪枝:只有在節點等於1的時候,才呼叫dfs。這樣可以減少呼叫次數。

題目解答如下:

class Solution {
    public int maxAreaOfIsland(int[][] grid) {
        if (grid == null || grid.length <1 || grid[0].length<1) {
            return 0;
        }
        int rx = grid.length;
        int cy = grid[0].length;
        int max = 0;
        for (int x =0; x< rx; x++) {
            for (int y= 0;y<cy; y++) {
                if (grid[x][y]==1) { //只有節點等於1才呼叫,這裡就可以算作是剪枝,演算法的優化
                   int num = dfs(grid,x,y);
                   max = Math.max(max, num);
                }
            }
        }
        return max;

    }
   // 遞迴引數:節點位置x,y, 二維陣列
    private int  dfs (int[][] grid, int x, int y){
        int rx = grid.length;
        int cy = grid[0].length;
     // 邊界條件,節點為0 if (x >= rx || x < 0 || y>=cy || y<0 || grid[x][y]==0 ) { return 0; }
     // 直接修改原陣列來標記已訪問 grid[x][y]=0;
     // 每次遞迴就表示面積多了一塊 int num = 1;
     // 每個節點有四種不同的選擇方向 num += dfs(grid, x-1, y); num += dfs(grid, x, y-1); num += dfs(grid, x+1, y); num += dfs(grid, x, y+1); return num; } }

200. 島嶼數量

給你一個由 '1'(陸地)和 '0'(水)組成的的二維網格,請你計算網格中島嶼的數量。

島嶼總是被水包圍,並且每座島嶼只能由水平方向或豎直方向上相鄰的陸地連線形成。

此外,你可以假設該網格的四條邊均被水包圍。

示例 1:

// 輸入:
11110
11010
11000
00000
// 輸出: 1

示例 2:

// 輸入:
11000
11000
00100
00011
// 輸出: 3

解釋: 每座島嶼只能由水平和/或豎直方向上相鄰的陸地連線而成。


可以發現,這道題目與前面的題目很類似,關於 dfs 規則這裡就不在分析了,留給大家自己去分析。

題目解答如下:

class Solution {
    public int numIslands(char[][] grid) {
        if (grid == null || grid.length < 1 || grid[0].length<1) {
            return 0;
        }
        int num = 0;
        int rx = grid.length;
        int cy = grid[0].length;
     // 起始點 for (int x =0;x<rx;x++) { for (int y =0;y<cy;y++) {
          // 題目要求,'0'不符合路徑條件 if (grid[x][y]=='1') { dfs(grid,x,y); num++; } } } return num; }    // 遞迴條件 private void dfs(char[][] grid, int x, int y) { int rx = grid.length; int cy = grid[0].length;
     // 終結條件 if (x<0 || x>=rx || y<0 || y>= cy || grid[x][y] == '0') { return; }
     // 訪問方向實質是由訪問路徑來決定的,就是你得想清楚怎麼才算一條路徑 grid[x][y]='0'; dfs(grid,x-1,y); dfs(grid,x,y-1); dfs(grid,x+1,y); dfs(grid,x,y+1); return ; } }

到這裡,深度優先搜尋的理論和實踐就講完了,相信看到這裡的小夥伴應該也掌握了其演算法的原理,以及如何去書寫。

 

參考文章 

基本演算法——深度優先搜尋(DFS)和廣度優先搜尋(BF