1. 程式人生 > 實用技巧 >BFS與DFS套路總結

BFS與DFS套路總結

概述

深度優先遍歷和廣度優先搜尋和廣度優先搜尋是解決圖問題最常見的方式,並且在leetcode中有許多相關的變體,但萬變不離其宗,其本質結構或者演算法框架時固定的,因此本文BFS和DFS演算法的原理總結了對應的演算法框架,並提供了幾道例題來解決如何使用這些框架。

好,話不多少,我們下邊正式開始。

BFS

BFS演算法本質上就是從一個圖的起點出發開始搜尋找到目標終點完成搜尋。

當然在該演算法上會有許多變體比如:

比如迷宮,有些格子是圍牆不能走,找到從起點到終點的最短距離。

再比如說連連看遊戲,兩個方塊消除的條件不僅僅是圖案相同,還得保證兩個方塊之間的最短連線不能多於兩個拐點。你玩連連看,點選兩個座標,遊戲是如何判斷它倆的最短連線有幾個拐點的?

這些問題背景本質上都可以看成圖,都可以看做是從起點到終點尋找最短路徑的長度。

基於以上認識,我們可以將BFS的整個解決分成下邊幾個步驟:

  1. 起點入佇列
  2. 以佇列非空為迴圈條件,進行節點擴散(將所有佇列節點出隊(同時判斷出隊節點是否為目標節點),獲取其鄰接結點)
  3. 判斷獲取的節點是否已被遍歷,未被遍歷節點入隊。

進而我們可以整理出如下的BFS框架

/**
* 給定起始節點start和目標節點target,返回其最短路徑長度
**/
int BFS(Node start,Node target){
    Queue<Node> q; //核心資料結構
    Set<Node> visited: //某些情況下可以通過byte陣列來進行代替
    int step = 0; //記錄擴散步數
    //起始節點入佇列
    q.add(start);
    visited.offer(start);
    while(q not empty) {
        //必須要用sz來儲存q.size(),然後擴散sz不能直接使用q.size()
        int sz = q.size();
        //將佇列中的節點進行擴散
        for(int i =0 ; i < sz; i++) {
            Node cur = q.poll();
            // 目標節點判斷
            if(cur is target) {
                return step;
            }
            // 鄰接結點入佇列
            for(Node n:cur.adjs) {
                //未訪問節點入佇列
                if(n is not int visited) {
                    visitd.add(n);
                    q.offer(n);
                }
            }
        }
        // 更新步數
        step++;
    }
}

看到上邊的演算法框架可能有些同學會有些疑問,既然已經通過佇列判空來作為BFS條件,為何為何每次還要加一個sz來做一輪擴散??

其實這個不難理解,我們此處通過sz來擴散,保證當前節點的所有鄰接結點都訪問後,步數再加一,如果不進行擴散的話,每次從佇列中取出一個元素進行訪問後,都會對步長加1,造成結果偏差。也就是說如果我們在套用BFS時,如果不需要步長(step)的話,其實這一步的擴散也是可以不要的。

1. 克隆圖問題

首先我們先可以一下克隆圖問題

該問題,我們在使用BFS進行解決時,發現:在整個遍歷過程中,我們壓給不需要步長,因此該問題在套用BFS框架時,就無需進行擴散。

因此我們可以比較容易的寫出下邊的解決方案:

  public Node cloneGraph(Node node) {
    if (node == null) {
      return null;
    }
    Queue<Node> queue = new LinkedList<>();
    Map<Node, Node> map = new HashMap<>();
    queue.add(node);
    map.put(node, new Node(node.val));

    while (!queue.isEmpty()) {
        //無需擴散,亦可以解決
    //  int sz = queue.size();
     // for (int i=0;i<sz;i++){
        Node cur = queue.poll();
        for (Node n : cur.neighbors) {
          if (!map.containsKey(n)) {
            map.put(n, new Node(n.val));
            queue.add(n);
          }
          // 建立與鄰接節點關係
          map.get(cur).neighbors.add(map.get(n));
        }
      //}
    }
    return map.get(node);
  }

當然,即使加上擴散步驟也不影響問題的解決。

2.開啟轉盤鎖

接下來,我們看一個稍微困難的題目。

這個問題粗劣一看好像跟,沒有任何關係??

首先我們這樣想,如果改題目我們不考慮死亡數字這一限制條件,我們會怎麼做?

毫無疑問我們可以進行窮舉,先從“0000”開始,每次波動一次鎖,可以是["1000","9000","0100","0900"..."0009"]共八種情況。我們把每種情況都看做是圖的節點,我們會發現所有的情況組合在一起就構成了一個全連線無向圖,而對密碼的尋找也就變成在BFS中對target的尋找。很神奇有沒有??

接下來我們可以套用模板。寫出如下解決程式碼:

class Solution {
    public int openLock(String[] deadends, String target) {
    // 記錄需要跳過的deadends資訊
    Set<String> deadSet = new HashSet<>();
    for (String deadStr : deadends) {
      deadSet.add(deadStr);
    }
    int step = 0;
    // 標記已經訪問的字元
    Set<String> visited = new HashSet<>();
    Queue<String> queue = new LinkedList<>();
    queue.add("0000");
    visited.add("0000");

    while (!queue.isEmpty()) {
      int sz = queue.size();
      for (int i = 0; i < sz; i++) {
        String cur = queue.poll();
        // 遇到死亡數字結束此次搜尋
        if (deadSet.contains(cur)) {
          continue;
        }
        // 終止條件:找到target
        if (target.equals(cur)) {
          return step;
        }
        // 處理相鄰的八種情況
        for (int j = 0; j < 4; j++) {
          String up = plusUp(cur, j);
          if (!visited.contains(up)) {
            visited.add(up);
            queue.add(up);
          }
          String down = plusDown(cur,j);
          if (!visited.contains(down)){
            visited.add(down);
            queue.add(down);
          }
        }
      }
      step ++;
    }
    return -1;
  }
//向上撥動第j位鎖
  private String plusUp(String str, int j) {
    char[] strArray = str.toCharArray();
    if (strArray[j] == '9') {
      strArray[j] = '0';
    } else {
      strArray[j] += 1;
    }
    return new String(strArray);
  }
//向下波動第j位鎖
  private String plusDown(String str, int j) {
    char[] strArray = str.toCharArray();
    if (strArray[j] == '0') {
      strArray[j] = '9';
    } else {
      strArray[j] -= 1;
    }
    return new String(strArray);
  }
}

3.雙向BFS優化

上邊我們通過BFS已經能夠解決大部分問題,但是對於BFS的效能我們還是可以通過一些方法來進行優化。比如我們可以嘗試通過雙向BFS來進行優化

那什麼是雙向BFS??

傳統的BFS是從起點開始向四周進行擴散,而雙向BFS則是從起點和終點同時進行擴散,直到兩者相交在一起結束。

雖然理論上講兩者的最壞時間複雜度都是O(N),但實際在執行時,確實雙向BFS的效能會更好一點,這是為什麼那??

我們可以藉助下面兩張圖輔助進行理解。

圖示中的樹形結構,如果終點在最底部,按照傳統 BFS 演算法的策略,會把整棵樹的節點都搜尋一遍,最後找到target;而雙向 BFS 其實只遍歷了半棵樹就出現了交集,也就是找到了最短距離。從這個例子可以直觀地感受到,雙向 BFS 是要比傳統 BFS 高效的。

但是雙向BFS最大的侷限性就是,必須知道終點在哪裡,比如第一個克隆圖的問題,我們便不能通過雙向BFS來進行解決。而對二個問題,我們便可以採用。

int openLock(String[] deadends, String target) {
    Set<String> deads = new HashSet<>();
    for (String s : deadends) deads.add(s);
    // 用集合不用佇列,可以快速判斷元素是否存在
    Set<String> q1 = new HashSet<>();
    Set<String> q2 = new HashSet<>();
    Set<String> visited = new HashSet<>();

    int step = 0;
    q1.add("0000");
    q2.add(target);

    while (!q1.isEmpty() && !q2.isEmpty()) {
        // 雜湊集合在遍歷的過程中不能修改,用 temp 儲存擴散結果
        Set<String> temp = new HashSet<>();

        /* 將 q1 中的所有節點向周圍擴散 */
        for (String cur : q1) {
            /* 判斷是否到達終點 */
            if (deads.contains(cur))
                continue;
            if (q2.contains(cur))
                return step;
            visited.add(cur);

            /* 將一個節點的未遍歷相鄰節點加入集合 */
            for (int j = 0; j < 4; j++) {
                String up = plusOne(cur, j);
                if (!visited.contains(up))
                    temp.add(up);
                String down = minusOne(cur, j);
                if (!visited.contains(down))
                    temp.add(down);
            }
        }
        /* 在這裡增加步數 */
        step++;
        // temp 相當於 q1
        // 這裡交換 q1 q2,下一輪 while 就是擴散 q2
        q1 = q2;
        q2 = temp;
    }
    return -1;
}

簡單來看的話,雙向 BFS 還是遵循 BFS 演算法框架的,只是不再使用佇列,而是使用 HashSet 方便快速判斷兩個集合是否有交集

DFS

與廣度優先搜尋不同,深度優先搜尋(DFS)類似於樹的先序遍歷。在搜尋時會盡可能的沿著一條所有路徑進行搜尋,直到該條路徑上所有節點搜尋完成,然後切換到另一條路徑上進行搜尋,直到圖的所有節點全部都被遍歷

因此廣度優先搜尋整個過程可以分成如下步驟:

  1. 判斷終止條件
  2. 對節點進行訪問並加入到訪問連結串列中
  3. 以當前節點的鄰接結點為起點,通過遞迴更深層次進行搜尋。

即可以簡單總結出DFS的模板如下:

Set<Node> visited;
void DFS(Node start) {
    //結束條件
    if(shoud be end) {
        return;
    }
    visited.add(start);
    //遞歸向更深次進行遍歷
    for(Node n:start.adjs) {
        if(n is not visited){
         	DFS(n);   
        }
    }
}

1.克隆圖問題

在BFS章節,我們已經通過BFS的方法,解決了該問題,但由於問題本質上就是一個對圖進行遍歷的問題,只不過需要在遍歷的過程中進行復制。因而該問題我們也可以通過DFS來解決。

套用DFS模板可以寫出如下程式碼:

 Map<Node, Node> map = new HashMap<>();

  public Node DFS(Node node) {
    // 終止條件
    if (node == null) {
      return node;
    }
    //已經複製過的話,直接返回複製過的節點
    if(map.containsKey(node)) {
      return map.get(node);
    }
    // 標記訪問,並建立拷貝節點
    map.put(node, new Node(node.val));
    for (Node n : node.neighbors) {
      //此處不需要進行訪問判斷,因為即使被訪問過也需要加入到鄰接結點列表中
      map.get(node).neighbors.add(DFS(n));
    }
    return map.get(node);
  }

總結

從上述內容,我們不難看出BFS相對於DFS來說兩者本質區別在搜尋過程中擴散方式不同。BFS在搜尋時,“齊頭並進”從而使得在搜尋的時候,所有節點對位於同一個層級,進而可以幫助我們在不完全遍歷整個節點的情況下找到所有的最短的路徑。而DFS由於在搜尋時使用的是遞迴堆疊,最差的空間複雜度是O(logn),要比BFS的O(n)要小得多。兩者各有側重。

參考

  1. https://mp.weixin.qq.com/s/WH_XGm1-w5882PnenymZ7g