BFS 算法框架
BFS 算法框架
文章目錄
- BFS 算法框架
- 一、BFS算法框架
- 二、二叉樹的最小高度
- 三、解開密碼鎖的最少次數
- 四、雙向 BFS
BFS 的核心思想應該不難理解的,就是 把一些問題抽象成圖,從一個點開始,向四周開始擴散。一般來說,我們寫 BFS 算法都是用「隊列」這種數據結構,每次將一個節點周圍的所有節點加入隊列。
BFS 相對 DFS 的最主要的區別是:BFS 找到的路徑一定是最短的,但代價就是空間復雜度比 DFS 大很多,至于為什么,我們后面介紹了框架就很容易看出來了。
一、BFS算法框架
要說框架的話,我們先舉例一下 BFS 出現的常見場景好吧,問題的本質就是讓你在一幅「圖」中找到從起點start到終點target的最近距離。
// 計算從起點 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。
二、二叉樹的最小高度
給定一個二叉樹,找出其最小深度。
最小深度是從根節點到最近葉子節點的最短路徑上的節點數量。
說明: 葉子節點是指沒有子節點的節點。
示例:
給定二叉樹 [3,9,20,null,null,15,7],3/ \9 20/ \15 7 返回它的最小深度 2.怎么套到 BFS 的框架里呢?首先明確一下起點start和終點target是什么,怎么判斷到達了終點?
顯然 起點start就是root根節點 ,終點target就是最靠近根節點的那個「葉子節點」
葉子節點就是兩個子節點都是null的節點:
if (cur.left == null && cur.right == null) // 到達葉子節點那么,按照我們上述的框架稍加改造來寫解法即可:
/*** Definition for a binary tree node.* struct TreeNode {* int val;* TreeNode *left;* TreeNode *right;* TreeNode(int x) : val(x), left(NULL), right(NULL) {}* };*/ class Solution { public:int minDepth(TreeNode* root) {if (root == nullptr) return 0;queue<TreeNode*> q ;q.push(root);// root 本身就是一層,depth 初始化為 1int depth = 1;while (!q.empty()) {int sz = q.size();/* 將當前隊列中的所有節點向四周擴散 */for (int i = 0; i < sz; i++) {TreeNode* cur = q.front();q.pop();/* 判斷是否到達終點 */if (cur->left == nullptr && cur->right == nullptr) return depth;/* 將 cur 的相鄰節點加入隊列 */if (cur->left != nullptr)q.push(cur->left);if (cur->right != nullptr) q.push(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,也就是O(N)。
由此觀之,BFS 還是有代價的,一般來說在找最短路徑的時候使用 BFS,其他時候還是 DFS 使用得多一些(主要是遞歸代碼好寫)。
三、解開密碼鎖的最少次數
你有一個帶有四個圓形撥輪的轉盤鎖。每個撥輪都有10個數字: ‘0’, ‘1’, ‘2’, ‘3’, ‘4’, ‘5’, ‘6’, ‘7’, ‘8’, ‘9’ 。每個撥輪可以自由旋轉:例如把 ‘9’ 變為 ‘0’,‘0’ 變為 ‘9’ 。每次旋轉都只能旋轉一個撥輪的一位數字。
鎖的初始數字為 ‘0000’ ,一個代表四個撥輪的數字的字符串。
列表 deadends 包含了一組死亡數字,一旦撥輪的數字和列表里的任何一個元素相同,這個鎖將會被永久鎖定,無法再被旋轉。
字符串 target 代表可以解鎖的數字,你需要給出最小的旋轉次數,如果無論如何不能解鎖,返回 -1。
示例 1:
輸入:deadends = ["0201","0101","0102","1212","2002"], target = "0202" 輸出:6 解釋: 可能的移動序列為 "0000" -> "1000" -> "1100" -> "1200" -> "1201" -> "1202" -> "0202"。 注意 "0000" -> "0001" -> "0002" -> "0102" -> "0202" 這樣的序列是不能解鎖的, 因為當撥動到 "0102" 時這個鎖就會被鎖定。示例 2:
輸入: deadends = ["8888"], target = "0009" 輸出:1 解釋: 把最后一位反向旋轉一次即可 "0000" -> "0009"。示例 3:
輸入: deadends = ["8887","8889","8878","8898","8788","8988","7888","9888"],target = "8888" 輸出:-1 解釋: 無法旋轉到目標數字且不被鎖定。示例 4:
輸入: deadends = ["0000"], target = "8888" 輸出:-1提示:
1.死亡列表 deadends 的長度范圍為 [1, 500]。
2.目標數字 target 不會在 deadends 之中。
3.每個deadends 和 target 中的字符串的數字會在 10,000 個可能的情況 ‘0000’ 到 ‘9999’ 中產生。
第一步,
我們不管所有的限制條件,不管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';elsech[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';elsech[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; }這段 BFS 代碼已經能夠窮舉所有可能的密碼組合了,但是顯然不能完成題目,有如下問題需要解決:
1、會走回頭路。比如說我們從"0000"撥到"1000",但是等從隊列拿出"1000"時,還會撥出一個"0000",這樣的話會產生死循環。
2、沒有終止條件,按照題目要求,我們找到target就應該結束并返回撥動的次數。
3、沒有對deadends的處理,按道理這些「死亡密碼」是不能出現的,也就是說你遇到這些密碼的時候需要跳過。
完整代碼
class Solution { public:// 將 s[j] 向上撥動一次 string plusOne(string ch, int j) {if (ch[j] == '9')ch[j] = '0';elsech[j] += 1;return ch; } // 將 s[i] 向下撥動一次 string minusOne(string ch, int j) {if (ch[j] == '0')ch[j] = '9';elsech[j] -= 1;return ch; }int openLock(vector<string>& deadends, string target) {// 記錄需要跳過的死亡密碼set<string> deads;for (string s : deadends) deads.insert(s);// 記錄已經窮舉過的密碼,防止走回頭路set<string> visited;queue<string> q;// 從起點開始啟動廣度優先搜索int step = 0;q.push("0000");visited.insert("0000");while (!q.empty()) {int sz = q.size();/* 將當前隊列中的所有節點向周圍擴散 */for (int i = 0; i < sz; i++) {string cur = q.front();q.pop();/* 判斷是否到達終點 */if (deads.count(cur))continue;if (cur == target)return step;/* 將一個節點的未遍歷相鄰節點加入隊列 */for (int j = 0; j < 4; j++) {string up = plusOne(cur, j);if (!visited.count(up)) {q.push(up);visited.insert(up);}string down = minusOne(cur, j);if (!visited.count(down)) {q.push(down);visited.insert(down);}}}/* 在這里增加步數 */step++;}// 如果窮舉完都沒找到目標密碼,那就是找不到了return -1;} };至此,我們就解決這道題目了。有一個比較小的優化:可以不需要dead這個哈希集合,可以直接將這些元素初始化到visited集合中,效果是一樣的
四、雙向 BFS
你以為到這里 BFS 算法就結束了?恰恰相反。BFS 算法還有一種稍微高級一點的優化思路:雙向 BFS,可以進一步提高算法的效率。
這里就提一下區別:傳統的 BFS 框架就是從起點開始向四周擴散,遇到終點時停止;而雙向 BFS 則是從起點和終點同時開始擴散,當兩邊有交集的時候停止。
為什么這樣能夠能夠提升效率呢?其實分析算法復雜度的話,它倆的最壞復雜度都是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 就是擴散 q2q1 = 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 和 q2temp = q1;q1 = q2;q2 = temp;}// ...為什么這是一個優化呢?
因為按照 BFS 的邏輯,隊列(集合)中的元素越多,擴散之后新的隊列(集合)中的元素就越多;在雙向 BFS 算法中,如果我們每次都選擇一個較小的集合進行擴散,那么占用的空間增長速度就會慢一些,效率就會高一些。
不過話說回來,無論傳統 BFS 還是雙向 BFS,無論做不做優化,空間復雜度都是一樣的,只能說雙向 BFS 是一種 trick 吧。
總結
- 上一篇: Qt文件系统
- 下一篇: Qt的Socket通信