概述
深度優先遍歷和廣度優先搜索和廣度優先搜索是解決圖問題最常見的方式,並且在leetcode中有許多相關的變體,但萬變不離其宗,其本質結構或者算法框架時固定的,因此本文BFS和DFS算法的原理總結了對應的算法框架,並提供了幾道例題來解決如何使用這些框架。
好,話不多少,我們下邊正式開始。
BFS
BFS算法本質上就是從一個圖的起點出發開始搜索找到目標終點完成搜索。
當然在該算法上會有許多變體比如:
比如迷宮,有些格子是圍牆不能走,找到從起點到終點的最短距離。
再比如說連連看游戲,兩個方塊消除的條件不僅僅是圖案相同,還得保證兩個方塊之間的最短連線不能多於兩個拐點。你玩連連看,點擊兩個坐標,游戲是如何判斷它倆的最短連線有幾個拐點的?
這些問題背景本質上都可以看成圖,都可以看做是從起點到終點尋找最短路徑的長度。
基於以上認識,我們可以將BFS的整個解決分成下邊幾個步驟:
- 起點入隊列
- 以隊列非空為循環條件,進行節點擴散(將所有隊列節點出隊(同時判斷出隊節點是否為目標節點),獲取其鄰接結點)
- 判斷獲取的節點是否已被遍歷,未被遍歷節點入隊。
進而我們可以整理出如下的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)類似於樹的先序遍歷。在搜索時會盡可能的沿着一條所有路徑進行搜索,直到該條路徑上所有節點搜索完成,然后切換到另一條路徑上進行搜索,直到圖的所有節點全部都被遍歷。
因此廣度優先搜索整個過程可以分成如下步驟:
- 判斷終止條件
- 對節點進行訪問並加入到訪問鏈表中
- 以當前節點的鄰接結點為起點,通過遞歸向更深層次進行搜索。
即可以簡單總結出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)要小得多。兩者各有側重。