4、BFS演算法套路框架——Go語言版

輕荇發表於2021-12-19

前情提示:Go語言學習者。本文參考https://labuladong.gitee.io/algo,程式碼自己參考抒寫,若有不妥之處,感謝指正

關於golang演算法文章,為了便於下載和整理,都已開源放在:

涉及題目

Leetcode 111. 二叉樹的最小深度(簡單)

Leetcode 752. 開啟轉盤鎖(中等)

BFS廣度有限搜尋和DFS深度優先搜尋演算法是特別常用的兩種演算法,其實 DFS 演算法就是回溯演算法,上篇已經講過。

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

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

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

一、演算法框架

要說框架的話,我們先舉例一下 BFS 出現的常見場景好吧,問題的本質就是讓你在一幅「圖」中找到從起點 start 到終點 target 的最近距離,這個例子聽起來很枯燥,但是 BFS 演算法問題其實都是在幹這個事兒,把枯燥的本質搞清楚了,再去欣賞各種問題的包裝才能胸有成竹嘛。

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

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

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

再比如……

這些問題都沒啥神奇的,本質上就是一幅「圖」,讓你從一個起點,走到終點,問最短路徑。這就是 BFS 的本質。

框架搞清楚了直接默寫就好,記住下面程式碼就 OK 了:

// 偽碼
// 計算從起點start到終點target的最近距離
func BFS(start *Node, target *Node){
    var queue []*Node  //  核心資料結構
    var visited map[*Node]bool // 避免走回頭路,在go中使用map來實現set,map中的key為唯一值,這與set的特性一致。
    queue = append(queue, start)  // 將起點加入佇列
    visited[start] = true
    step := 0  // 記錄擴散的步數
    
    for{
        if len(queue)==0{ // 在go中可以使用fo-if來實現while
			break
        }
        size := len(queue)
        // 將當前佇列中的所有節點向四周擴散
        for i:=0; i < size; i++{
            cur := queue[0]  // 獲取佇列頭部當前節點
            // 劃重點:這裡判斷是否達到終點
            if cur is target{ // 偽碼
                return step
            }
            // 將cur的相鄰節點加入佇列
            for cur.adj{ // 偽碼
                if visited[cur.adj] != true{
                    queue = append(queue, cur.adj)
                    visited[cur.adj] = true
                }
            }
            // 劃重點,刪除頭部在這裡
            queue = queue[1:]
        }
        // 劃重點,更步數在這裡
        step ++
    }
}

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

二、二叉樹的最小高度

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

img

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

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

if cur.Left == nil && cur.Right == nil{
	// 到達葉子節點
}

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

/** 
 * Definition for a binary tree node.
 * type TreeNode struct {
 *     Val int
 *     Left *TreeNode
 *     Right *TreeNode
 * }
 */
// 迭代實現,廣度優先遍歷
func minDepth(root *TreeNode) int {
	if root == nil {
		return 0
	}
	var queue []*TreeNode       // 查詢佇列
	queue = append(queue, root) // 將起點加入佇列
	depth := 1                  // root 本身就是一層,depth初始化為1
	for {
		if len(queue) == 0 { // 佇列為空時,退出迴圈
			break
		}
		size := len(queue)
		// 將當前佇列中的所有結點向四周擴散
		for i := 0; i < size; i++ {
			cur := queue[0]
			// 判斷是否到終點
			if cur.Left == nil && cur.Right == nil {
				return depth
			}
			// 將 cur的相鄰節點加入佇列
			if cur.Left != nil {
				queue = append(queue, cur.Left)
			}
			if cur.Right != nil {
				queue = append(queue, cur.Right)
			}
			// 去掉當前節點
			queue = queue[1:]
		}
		// 這裡增加步數
		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 題,比較有意思:

img

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

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

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

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

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

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

// 將s[j]向上波動一次
func plusOne(s string, j int) string{
    ch := []byte(s)  // go中string禁止修改
    if ch[j] == '9'{
        ch[j] = '0'
    }else{
		ch[j] += 1
    }
    return string(ch)
}
// 將s[j]向下波動一次
func minusOne(s string, j int) string{
    ch := []byte(s)  // go中string禁止修改
    if ch[j] == '0'{
        ch[j] = '9'
    }else{
		ch[j] -= 1
    }
    return string(ch)
}

// BFS框架偽碼,列印出所有可能的密碼
func BFS(target string){
    var queue []string  //  核心資料結構
    queue = append(queue, "0000")  // 將起點加入佇列
    step := 0  // 記錄擴散的步數
    
    for{
        if len(queue)==0{ // 在go中可以使用fo-if來實現while
			break
        }
        size := len(queue)
        // 將當前佇列中的所有節點向四周擴散
        for i:=0; i < size; i++{
            cur := queue[0]  // 獲取佇列頭部當前節點
            // 劃重點:這裡判斷是否達到終點
            if cur is target{ // 偽碼
                fmt.println(cur)
            }
            // 將cur的相鄰節點加入佇列
            for j:=0; j < 4; j++{
                up := plusOne(cur,j) 
                down := minusOne(cur, j)
                queue = append(queue, up)
                queue = append(queue, down)
            }
            // 劃重點,刪除頭部在這裡
            queue = queue[1:]
        }
        // 劃重點,更步數在這裡
        step ++
    }
}

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

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

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

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

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

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

func openLock(deadends []string, target string) int {
	// 記錄需要跳過的死亡密碼
	deads := map[string]bool{}
	for _, s := range deadends {
		deads[s] = true
	}
	// 記錄已經窮舉過的密碼,防止走回頭路
	visited := map[string]bool{}
	var queue []string //  核心資料結構
	queue = append(queue, "0000") // 將起點加入佇列
	step := 0                     // 記錄擴散的步數

	for {
		if len(queue) == 0 { // 在go中可以使用fo-if來實現while
			break
		}
		size := len(queue)
		// 將當前佇列中的所有節點向四周擴散
		for i := 0; i < size; i++ {
			cur := queue[0] // 獲取佇列頭部當前節點
			queue = queue[1:] // 劃重點,刪除頭部在這裡
			// 劃重點:這裡判斷密碼是否合法是否訪問過,是否達到終點
			if deads[cur] == true {
				continue
			}
            if visited[cur] == true{
                continue
            }
			if cur == target {
				return step
			}
            visited[cur] = true  // 注意,這裡很重要
			// 將cur的相鄰節點加入佇列
			for j := 0; j < 4; j++ {
				up := plusOne(cur, j)
				if visited[up] != true {
					queue = append(queue, up)
				}
				down := minusOne(cur, j)
				if visited[down] != true {
					queue = append(queue, down)
				}
			}
		}
		// 劃重點,更步數在這裡
		step++
	}
	// 如果窮舉完都沒有找到目標密碼,那就是找不到了
	return -1
}

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

注意,這裡將相鄰節點加入佇列部分也可以進行程式碼簡化,不使用兩個函式,而是如下簡單的程式碼:

// 將cur的相鄰節點加入佇列
for j := 0; j < 4; j++ {
    num := int(cur[j]-'0')
    one := cur[:j]+strconv.Itoa((num+1)%10)+cur[j+1:]
    two := cur[:j]+strconv.Itoa((num+9)%10)+cur[j+1:]
    queue = append(queue, one)
    queue = append(queue, two)
}

四、雙向 BFS 優化

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

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

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

img

img

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

不過,雙向 BFS 也有侷限,因為你必須知道終點在哪裡。比如我們剛才討論的二叉樹最小高度的問題,你一開始根本就不知道終點在哪裡,也就無法使用雙向 BFS;但是第二個密碼鎖的問題,是可以使用雙向 BFS 演算法來提高效率的,程式碼稍加修改即可:

func openLock(deadends []string, target string) int {
	// 記錄需要跳過的死亡密碼
	deads := map[string]bool{}
	for _, s := range deadends {
		deads[s] = true
	}
	// 記錄已經窮舉過的密碼,防止走回頭路
	visited := map[string]bool{}
    // 用集合思想不用佇列,可以快速判斷元素是否存在
    queue1 := map[string]bool{}
    queue2 := map[string]bool{}
	// 初始化起點和終點
	queue1["0000"] = true
    queue2[target] = true
	step := 0                     // 記錄擴散的步數

    for len(queue1) > 0 && len(queue2) > 0 {
		// 在遍歷的過程中不能修改雜湊集合,用temp儲存queue1的擴散結果
        temp := map[string]bool{}
        // 將queue1中的所有節點向周圍擴散
        for cur, _ := range queue1{
            // 判斷是否到達終點
            if deads[cur] == true{
				continue
            }
            if queue2[cur] == true{
				return step
            }
            visited[cur] = true
            // 將一個節點的相鄰節點加入結合
            for j := 0; j < 4; j++ {
				up := plusOne(cur, j)
				if visited[up] != true {
					temp[up] = true
				}
				down := minusOne(cur, j)
				if visited[down] != true {
					temp[down] = true
				}
			}
        }
		// 劃重點,更步數在這裡
		step++
        // temp 相當於queue1
        // 在這裡交換queue1和queue2, 下一輪for迴圈會擴散queue2
        queue1 = queue2
        queue2 = temp
	}
	// 如果窮舉完都沒有找到目標密碼,那就是找不到了
	return -1
}

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

另外的一個技巧點就是 For 迴圈的最後交換 queue1queue2 的內容,所以只要預設擴散 queue1 就相當於輪流擴散 queue1queue2

其實雙向 BFS 還有一個優化,就是在 For 迴圈開始時做一個判斷:

//...
for len(queue1) > 0 && len(queue2) > 0 {
    if len(queue1) > len(queue2){
        tempForSwap := map[string]bool{}
        tempForSwap = queue1
        queue1 = queue2
        queue2 = tempForSwap
    }
}
//....

為什麼這是一個優化呢?

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

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

相關文章