BFS 算法框架套路詳解

2021-03-02 算法與數據結構

後臺有很多人問起 BFS 和 DFS 的框架,今天就來說說吧。

首先,你要說 labuladong 沒寫過 BFS 框架,這話沒錯,今天寫個框架你背住就完事兒了。但要是說沒寫過 DFS 框架,那你還真是說錯了,其實 DFS 算法就是回溯算法,我們前文 回溯算法框架套路詳解 就寫過了,而且寫得不是一般得好,建議好好複習。

BFS 的核心思想應該不難理解的,就是把一些問題抽象成圖,從一個點開始,向四周開始擴散。一般來說,我們寫 BFS 算法都是用「隊列」這種數據結構,每次將一個節點周圍的所有節點加入隊列。

BFS 相對 DFS 的最主要的區別是:BFS 找到的路徑一定是最短的,但代價就是空間複雜度比 DFS 大很多,至於為什麼,我們後面介紹了框架就很容易看出來了。

本文就由淺入深寫兩道 BFS 的典型題目,分別是「二叉樹的最小高度」和「打開密碼鎖的最少步數」,手把手教你怎麼寫 BFS 算法。

一、算法框架

要說框架的話,我們先舉例一下 BFS 出現的常見場景好吧,問題的本質就是讓你在一幅「圖」中找到從起點start到終點target的最近距離,這個例子聽起來很枯燥,但是 BFS 算法問題其實都是在幹這個事兒。

把枯燥的本質搞清楚了,再去欣賞各種問題的包裝才能胸有成竹嘛。

這個廣義的描述可以有各種變體,比如走迷宮,有的格子是圍牆不能走,從起點到終點的最短距離是多少?如果這個迷宮帶「傳送門」可以瞬間傳送呢?

再比如說兩個單詞,要求你通過某些替換,把其中一個變成另一個,每次只能替換一個字符,最少要替換幾次?

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

再比如……

淨整些花裡胡哨的,這些問題都沒啥奇技淫巧,本質上就是一幅「圖」,讓你從一個起點,走到終點,問最短路徑。這就是 BFS 的本質,框架搞清楚了直接默寫就好。

記住下面這個框架就 OK 了:

// 計算從起點 start 到終點 target 的最近距離
int BFS(Node start, Node target) {
    Queue<Node> q; // 核心數據結構
    Set<Node> visited; // 避免走回頭路

    q.offer(start); // 將起點加入隊列
    visited.add(start);
    int step = 0; // 記錄擴散的步數

    while (q not empty) {
        int sz = q.size();
        /* 將當前隊列中的所有節點向四周擴散 */
        for (int i = 0; i < sz; i++) {
            Node cur = q.poll();
            /* 劃重點:這裡判斷是否到達終點 */
            if (cur is target)
                return step;
            /* 將 cur 的相鄰節點加入隊列 */
            for (Node x : cur.adj())
                if (x not in visited) {
                    q.offer(x);
                    visited.add(x);
                }
        }
        /* 劃重點:更新步數在這裡 */
        step++;
    }
}

隊列q就不說了,BFS 的核心數據結構;cur.adj()泛指cur相鄰的節點,比如說二維數組中,cur上下左右四面的位置就是相鄰節點;visited的主要作用是防止走回頭路,大部分時候都是必須的,但是像一般的二叉樹結構,沒有子節點到父節點的指針,不會走回頭路就不需要visited。

二、二叉樹的最小高度

先來個簡單的問題實踐一下 BFS 框架吧,判斷一棵二叉樹的最小高度,這也是 LeetCode 第 111 題,看一下題目:

怎麼套到 BFS 的框架裡呢?首先明確一下起點start和終點target是什麼,怎麼判斷到達了終點?

顯然起點就是root根節點,終點就是最靠近根節點的那個「葉子節點」嘛,葉子節點就是兩個子節點都是null的節點:

if (cur.left == null && cur.right == null) 
    // 到達葉子節點

那麼,按照我們上述的框架稍加改造來寫解法即可:

int minDepth(TreeNode root) {
    if (root == null) return 0;
    Queue<TreeNode> q = new LinkedList<>();
    q.offer(root);
    // root 本身就是一層,depth 初始化為 1
    int depth = 1;

    while (!q.isEmpty()) {
        int sz = q.size();
        /* 將當前隊列中的所有節點向四周擴散 */
        for (int i = 0; i < sz; i++) {
            TreeNode cur = q.poll();
            /* 判斷是否到達終點 */
            if (cur.left == null && cur.right == null) 
                return depth;
            /* 將 cur 的相鄰節點加入隊列 */
            if (cur.left != null)
                q.offer(cur.left);
            if (cur.right != null) 
                q.offer(cur.right);
        }
        /* 這裡增加步數 */
        depth++;
    }
    return depth;
}

二叉樹是很簡單的數據結構,我想上述代碼你應該可以理解的吧,其實其他複雜問題都是這個框架的變形,在探討複雜問題之前,我們解答兩個問題:

1、為什麼 BFS 可以找到最短距離,DFS 不行嗎

首先,你看 BFS 的邏輯,depth每增加一次,隊列中的所有節點都向前邁一步,這保證了第一次到達終點的時候,走的步數是最少的。

DFS 不能找最短路徑嗎?其實也是可以的,但是時間複雜度相對高很多。

你想啊,DFS 實際上是靠遞歸的堆棧記錄走過的路徑,你要找到最短路徑,肯定得把二叉樹中所有樹杈都探索完才能對比出最短的路徑有多長對不對?

而 BFS 藉助隊列做到一次一步「齊頭並進」,是可以在不遍歷完整棵樹的條件下找到最短距離的。

形象點說,DFS 是線,BFS 是面;DFS 是單打獨鬥,BFS 是集體行動。這個應該比較容易理解吧。

2、既然 BFS 那麼好,為啥 DFS 還要存在

BFS 可以找到最短距離,但是空間複雜度高,而 DFS 的空間複雜度較低。

還是拿剛才我們處理二叉樹問題的例子,假設給你的這個二叉樹是滿二叉樹,節點總數為N,對於 DFS 算法來說,空間複雜度無非就是遞歸堆棧,最壞情況下頂多就是樹的高度,也就是O(logN)。

但是你想想 BFS 算法,隊列中每次都會儲存著二叉樹一層的節點,這樣的話最壞情況下空間複雜度應該是樹的最底層節點的數量,也就是N/2,用 Big O 表示的話也就是O(N)。

由此觀之,BFS 還是有代價的,一般來說在找最短路徑的時候使用 BFS,其他時候還是 DFS 使用得多一些(主要是遞歸代碼好寫)。

好了,現在你對 BFS 了解得足夠多了,下面來一道難一點的題目,深化一下框架的理解吧。

三、解開密碼鎖的最少次數

這道 LeetCode 題目是第 752 題,比較有意思:

題目中描述的就是我們生活中常見的那種密碼鎖,若果沒有任何約束,最少的撥動次數很好算,就像我們平時開密碼鎖那樣直奔密碼撥就行了。

但現在的難點就在於,不能出現deadends,應該如何計算出最少的轉動次數呢?

第一步,我們不管所有的限制條件,不管deadends和target的限制,就思考一個問題:如果讓你設計一個算法,窮舉所有可能的密碼組合,你怎麼做

窮舉唄,再簡單一點,如果你只轉一下鎖,有幾種可能?總共有 4 個位置,每個位置可以向上轉,也可以向下轉,也就是有 8 種可能對吧。

比如說從"0000"開始,轉一次,可以窮舉出"1000", "9000", "0100", "0900"...共 8 種密碼。然後,再以這 8 種密碼作為基礎,對每個密碼再轉一下,窮舉出所有可能…

仔細想想,這就可以抽象成一幅圖,每個節點有 8 個相鄰的節點,又讓你求最短距離,這不就是典型的 BFS 嘛,框架就可以派上用場了,先寫出一個「簡陋」的 BFS 框架代碼再說別的:

// 將 s[j] 向上撥動一次
String plusOne(String s, int j) {
    char[] ch = s.toCharArray();
    if (ch[j] == '9')
        ch[j] = '0';
    else
        ch[j] += 1;
    return new String(ch);
}
// 將 s[i] 向下撥動一次
String minusOne(String s, int j) {
    char[] ch = s.toCharArray();
    if (ch[j] == '0')
        ch[j] = '9';
    else
        ch[j] -= 1;
    return new String(ch);
}

// BFS 框架,列印出所有可能的密碼
void BFS(String target) {
    Queue<String> q = new LinkedList<>();
    q.offer("0000");

    while (!q.isEmpty()) {
        int sz = q.size();
        /* 將當前隊列中的所有節點向周圍擴散 */
        for (int i = 0; i < sz; i++) {
            String cur = q.poll();
            /* 判斷是否到達終點 */
            System.out.println(cur);

            /* 將一個節點的相鄰節點加入隊列 */
            for (int j = 0; j < 4; j++) {
                String up = plusOne(cur, j);
                String down = minusOne(cur, j);
                q.offer(up);
                q.offer(down);
            }
        }
        /* 在這裡增加步數 */
    }
    return;
}

PS:這段代碼當然有很多問題,但是我們做算法題肯定不是一蹴而就的,而是從簡陋到完美的。不要完美主義,咱要慢慢來,好不。

這段 BFS 代碼已經能夠窮舉所有可能的密碼組合了,但是顯然不能完成題目,有如下問題需要解決

1、會走回頭路。比如說我們從"0000"撥到"1000",但是等從隊列拿出"1000"時,還會撥出一個"0000",這樣的話會產生死循環。

2、沒有終止條件,按照題目要求,我們找到target就應該結束並返回撥動的次數。

3、沒有對deadends的處理,按道理這些「死亡密碼」是不能出現的,也就是說你遇到這些密碼的時候需要跳過。

如果你能夠看懂上面那段代碼,真得給你鼓掌,只要按照 BFS 框架在對應的位置稍作修改即可修復這些問題:

int openLock(String[] deadends, String target) {
    // 記錄需要跳過的死亡密碼
    Set<String> deads = new HashSet<>();
    for (String s : deadends) deads.add(s);
    // 記錄已經窮舉過的密碼,防止走回頭路
    Set<String> visited = new HashSet<>();
    Queue<String> q = new LinkedList<>();
    // 從起點開始啟動廣度優先搜索
    int step = 0;
    q.offer("0000");
    visited.add("0000");

    while (!q.isEmpty()) {
        int sz = q.size();
        /* 將當前隊列中的所有節點向周圍擴散 */
        for (int i = 0; i < sz; i++) {
            String cur = q.poll();

            /* 判斷是否到達終點 */
            if (deads.contains(cur))
                continue;
            if (cur.equals(target))
                return step;

            /* 將一個節點的未遍歷相鄰節點加入隊列 */
            for (int j = 0; j < 4; j++) {
                String up = plusOne(cur, j);
                if (!visited.contains(up)) {
                    q.offer(up);
                    visited.add(up);
                }
                String down = minusOne(cur, j);
                if (!visited.contains(down)) {
                    q.offer(down);
                    visited.add(down);
                }
            }
        }
        /* 在這裡增加步數 */
        step++;
    }
    // 如果窮舉完都沒找到目標密碼,那就是找不到了
    return -1;
}

至此,我們就解決這道題目了。有一個比較小的優化:可以不需要dead這個哈希集合,可以直接將這些元素初始化到visited集合中,效果是一樣的,可能更加優雅一些。

四、雙向 BFS 優化

你以為到這裡 BFS 算法就結束了?恰恰相反。BFS 算法還有一種稍微高級一點的優化思路:雙向 BFS,可以進一步提高算法的效率。

篇幅所限,這裡就提一下區別:傳統的 BFS 框架就是從起點開始向四周擴散,遇到終點時停止;而雙向 BFS 則是從起點和終點同時開始擴散,當兩邊有交集的時候停止

為什麼這樣能夠能夠提升效率呢?其實從 Big O 表示法分析算法複雜度的話,它倆的最壞複雜度都是O(N),但是實際上雙向 BFS 確實會快一些,我給你畫兩張圖看一眼就明白了:

圖示中的樹形結構,如果終點在最底部,按照傳統 BFS 算法的策略,會把整棵樹的節點都搜索一遍,最後找到target;而雙向 BFS 其實只遍歷了半棵樹就出現了交集,也就是找到了最短距離。從這個例子可以直觀地感受到,雙向 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 方便快速判斷兩個集合是否有交集

另外的一個技巧點就是 while 循環的最後交換q1和q2的內容,所以只要默認擴散q1就相當於輪流擴散q1和q2。

其實雙向 BFS 還有一個優化,就是在 while 循環開始時做一個判斷:

// ...
while (!q1.isEmpty() && !q2.isEmpty()) {
    if (q1.size() > q2.size()) {
        // 交換 q1 和 q2
        temp = q1;
        q1 = q2;
        q2 = temp;
    }
    // ...

為什麼這是一個優化呢?

因為按照 BFS 的邏輯,隊列(集合)中的元素越多,擴散之後新的隊列(集合)中的元素就越多;在雙向 BFS 算法中,如果我們每次都選擇一個較小的集合進行擴散,那麼佔用的空間增長速度就會慢一些,效率就會高一些。

不過話說回來,無論傳統 BFS 還是雙向 BFS,無論做不做優化,從 Big O 衡量標準來看,空間複雜度都是一樣的,只能說雙向 BFS 是一種 trick 吧,掌握不掌握其實都無所謂。最關鍵的是把 BFS 通用框架記下來,反正所有 BFS 算法都可以用它套出解法。

●編號1215,輸入編號直達本文

●輸入m獲取文章目錄

程式設計師數學學習

鍛鍊數學邏輯思維

相關焦點

  • 圖文詳解 DFS 算法 和 BFS 算法
    前言 深度優先遍歷(Depth First Search, 簡稱 DFS) 與廣度優先遍歷(Breath First Search)是圖論中兩種非常重要的算法,生產上廣泛用於拓撲排序,尋路(走迷宮),搜尋引擎,爬蟲等,也頻繁出現在 leetcode,高頻面試題中。
  • 圖文詳解 DFS 和 BFS
    來源:碼海作者:碼海深度優先遍歷(Depth First Search, 簡稱 DFS) 與廣度優先遍歷(Breath First Search)是圖論中兩種非常重要的算法,生產上廣泛用於拓撲排序,尋路(走迷宮),搜尋引擎,爬蟲等,也頻繁出現在 leetcode,高頻面試題中。
  • 這份GitHub上爆火的算法面試筆記,助你圓滿大廠夢
    算法面試筆記和算法小抄文檔兩份資料完整版在文末,有需要的朋友可以自取必讀章系列本章全是各算法的套路,後的算法套路都是基於這些框架構建的,建議全背誦【滑稽】。學習算法和刷題的思路指南學習數據結構和算法讀什麼書動態規劃解題套路框架動態規劃答疑篇
  • Edmond-Karp 最大流算法詳解
    int max_flow(int s, int t) {  // 最大流結果    int ret = 0;    while (1) {    // 從 S -> T 使用 bfs 查詢一條增廣路        bfs(s, t);    // 如果發現容量最小是 0 ,說明查不到了
  • 不愧是字節跳動技術官,算法精髓全寫這本666頁筆記裡了,服了
    學習數據結構和算法讀什麼書動態規劃解題套路框架動態規劃答疑篇回溯算法解題套路框架二分查找解題套路框架滑動窗口解題套路框架雙指針技巧總結BFS算法套路框架Linux的進程、線程、文件描述符是什麼Git/SQL/正則表達式的在線練習平臺
  • 目標檢測算法YOLO-V2詳解
    ❝上期我們一起學習了YOLO-V1算法的框架原來和損失函數等知識,如下:目標檢測算法YOLO-V1算法詳解目標檢測模型YOLO-V1損失函數詳解【文末領福利】❞今天,我們一起學習下YOLO-V2跟YOLO-V1比起來都做了哪些改進?
  • 從頭開始複習算法之讓你徹底搞清楚BFS和DFS
    來源:https://www.imooc.com/article/285772最近又有點學不進去了,不知道是不是天氣熱的緣故哈,沒辦法只好寫一點算法來保持學習的路線不間斷咯。關於BFS和DFS,這是我們在面試的時候經常會遇到的兩個基礎算法,為什麼說基礎呢?因為它理解了之後才10行左右的代碼,你說基礎不基礎?
  • 一道題看透DFS和BFS
    作者丨小小算法來源丨小小算法(xiaoxiaosuanfa)一道題看透DFS和BFS你知道 DFS 和 BFS 是哪幾個英文單詞的縮寫嗎?(手動滑稽)。DFS: Depth First Search (深度優先搜索)BFS: Breadth First Search (廣度優先搜索)DFS 和 BFS 都是常用來遍歷搜索樹或圖的算法。
  • 算法治理的參與框架
    於是這篇文章運用傳統的參與性決策框架來解決算法的問題。一、在算法中嵌入社會道德價值的困難首先,已有的研究試圖通過計算方式的設計,將道德和社會價值編碼進算法之中,但這依賴人類的定義方法和算法的目標函數。其次,社會價值的多元性使得目標無法在同一程度上得到滿足,這需要做出權衡決策。
  • 目標檢測算法YOLO-V1算法詳解
    ❝前面我們一起學了SSD算法的相關知識,如下:SSD目標檢測算法必須知道的幾個關鍵點目標檢測算法SSD結構詳解❞
  • 數據結構和算法學習指南
    首先,這裡講的都是普通的數據結構和算法,咱不是搞競賽的,野路子出生,只解決常規的問題,以面試為最終目標。另外,以下是我個人的經驗的總結,沒有哪本算法書會寫這些東西,所以請讀者試著理解我的角度,別糾結於細節問題,因為這篇文章就是對數據結構和算法建立一個框架性的認識。
  • EM算法詳解
    EM算法簡介3. 預備知識    3.1 極大似然估計    3.2 Jensen不等式4. EM算法詳解    4.1 問題描述    4.2 EM算法推導流程    4.3 EM算法流程5.EM算法若干思考    5.1 對EM算法的初始化研究    5.2 EM算法是否一定收斂?    5.3 如果EM算法收斂,能否保證收斂到全局最大值?6. EM算法實例7. 對EM算法總結1.
  • 常用算法詳解——列印楊輝三角形
    在中國南宋數學家楊輝1261年所著的《詳解九章算法》一書中出現。在歐洲,這個表叫做帕斯卡三角形。帕斯卡(1623----1662)是在1654年發現這一規律的,比楊輝要遲393年,比賈憲遲600年。楊輝三角是中國古代數學的傑出研究成果之一,它把二項式係數圖形化,把組合數內在的一些代數性質直觀地從圖形中體現出來,是一種離散型的數與形的結合。
  • 谷歌開源了量子算法框架Cirq,「軟硬兼施」擁抱量子霸權時代
    開源框架,為NISQ而生於是,谷歌開源了Cirq框架,這是專為NISQ算法打造的框架。這個框架,經Apache 2.0協議許可,可以修改,可以嵌入任何開源/付費的軟體包。框架安裝好之後,開發者就可以給特定的量子處理器,編寫它的量子算法了,據說很友好——用戶可以精確控制量子電路 (Quantum Circuits) ;為了編寫和編譯量子電路,數據結構是專門優化過的,讓開發者能更加充分地利用NISQ架構。
  • 去他丫的算法,文末送書,來
    fucking-algorithm 簡單粗暴,fuxx 算法,中文名稱「去他丫的算法」,這個翻譯也是非常精準了,透過名字都能感受到,作者手撕算法的氣勢。項目內容這是一個關於,算法刷題套路的項目,這幾年 IT 行業內卷嚴重,程式設計師面試必考算法,手撕算法已經是常規操作了,而其中的算法題,大部分是源自在線刷題網站 LeetCode (中文:立扣)直接引用作者原話:本倉庫總共 60 多篇原創文章,都是基於 LeetCode 的題目
  • 密碼學_RSA算法原理詳解
    RSA算法簡介:     RSA算法 是一種公鑰加密算法,RSA算法相比別的算法思路非常清晰,但是想要破解的難度非常大
  • 機器學習-聚類算法k-均值詳解
    對於含有n個數據的數據集D,以及簇數k,本文所講的劃分算法將基於距離函數,將對象組劃分成k個分區,每個分區代表一個簇,並儘量使簇中對象相似,不同簇中對象相異。使用簇內變差來衡量Ci的質量,它是Ci中所有對象和形心直接的誤差的平方和:定義E為數據集中所有對象的誤差平方和:算法原理為了得到k個簇,k-均值算法首先在D中隨機的選取k個對象,作為k個簇的中心。對於剩下的每個對象,根據其與各個簇中心的歐式距離,將其分配到最近的一個簇中。
  • RSA算法詳解
    RSA算法用到的數學知識特別多,所以在中間介紹這個算法生成私鑰和公鑰的過程中會穿插一些數學知識。生成步驟如下:1.尋找兩個不相同的質數隨意選擇兩個大的質數p和q,p不等於q,計算N=p*q;什麼是質數?
  • 限時刪除,《去你丫的算法》開放電子書下載!
    前不久在 GitHub 出現了一個手把手帶你刷 算法 的項目:fucking-algorithm
  • 「3步套路」快速掌握機器學習算法和模型 | 極客時間
    在學習這兩大類模型和算法的時候,有這麼一個技巧,就是要不斷地回歸到上面提到的基本思路上去,就是這個「三步套路」,反覆用這三個方面來審視當前的模型。另外,我們也可以慢慢地體會到,任何新的模型或者算法的誕生,往往都是基於舊有的模型算法,在以上三個方面中的某一個或幾個方向有所創新。