0. 前言
廣度優先搜尋(BFS)和深度優先搜尋(DFS),大家可能在oj上見過,各種求路徑、最短路徑、最優方法、組合等等。於是,我們不妨動手試一下js版本怎麼玩。
1.佇列、棧
佇列是先進先出,後進後出,常用的操作是取第一個元素(shift)、尾部加入一個元素(push)。
棧是後進先出,就像一個垃圾桶,後入的垃圾先被倒出來。常用的操作是,尾部加入元素(push),尾部取出元素(pop)
2.BFS
BFS是靠一個佇列來輔助執行的。顧名思義,廣度搜尋,就是對於一個樹形結構,我們一層層節點去尋找目標節點。
按照這個順序進行廣度優先遍歷,明顯是佇列可以完美配合整個過程:- 1進佇列 【1】
- 取出佇列第一個元素1,將1的子節點234按順序加入佇列後面 【2,3,4】
- 取出隊首元素2,將他的子節點按順序加入佇列 【3,4,5,6】
- 取出3,將子節點7加入 【4,5,6,7】
- 取出4,將子節點89加入【5,6,7,8,9】
- 取出5,沒有子節點,沒有什麼幹
- 繼續一個個取出
到了最後,佇列清空,樹也遍歷了一次
1.1 矩陣形式的圖的遍歷
假設有幾個點,我們需要設計一個演算法,判定兩個點有沒有相通
假設點12345是這樣的結構:
問:1能不能到達5
顯然我們一眼看上去是不會到達的,如果是設計演算法的話,怎麼做呢?
我們把點之間的關係用一個矩陣表示,0表示不連線,n行m列的1表示點n通向點m
var map = [
[0,1,1,0,0],
[0,0,1,1,0],
[0,1,1,1,0],
[1,0,0,0,0],
[0,0,1,1,0]
]
複製程式碼
function bfs(arr,start,end){
var row = arr.length
var quene = []
var i = start
var visited = {}//記錄遍歷順序
var order = [] //記錄順序,給自己看的
quene.push(i) //先把根節點加入
while(quene.length){ //如果佇列沒有被清空,也就是還沒遍歷完畢
for(var j = 0;j<row;j++){
if(arr[i][j]){ //如果是1
if(!visited[j]){
quene.push(j)//佇列加入未訪問
}
}
}
quene.shift()//取出佇列第一個
visited[i] = true//記錄已經訪問
while(visited[quene[0]]){
quene.shift()
}
order.push(i)//記錄順序
i = quene[0]
}
return {visited:visited,result:!!visited[end],order:order}
}
bfs(map,0,4)
複製程式碼
1.2 樹的BFS舉例
舉個例子,3月24號今日頭條筆試題第二題的最少操作步數:
定義兩個字串變數:s和m,再定義兩種操作, 第一種操作: m = s; s = s + s; 第二種操作: s = s + m; 假設s, m初始化如下: s = "a"; m = s; 求最小的操作步驟數,可以將s拼接到長度等於n 輸入一個整數n,表明我們需要得到s字元長度,0<n<10000 案例: in: 6 out: 3
思路:利用廣度優先搜尋,假設左節點是操作1,右節點是操作2,這樣子就形成了操作樹。利用bfs的規則,把上層的父節點按順序加入佇列,然後從前面按順序移除,同時在佇列尾部加上移除的父節點的子節點。我這裡,先把父節點拿出來對比,他的子節點放在temp,對比完了再把子節點追加上去
每個節點分別用兩個數記錄s,m。發現第一次兩種操作是一樣的,所以我就略去右邊的了function bfs(n){
if(n<2||n!==parseInt(n)||typeof n !=='number') return
if(n==2) return 1
var quene = [[2,1]]//從2開始
var temp = []//存放父節點佇列的子節點
var count = 0
var state = false//判斷是否結束迴圈
while(!state){
while(quene.length){//如果佇列不是空,從前面一個個取,並把他的子節點放在temp
var arr = quene.pop()
if(arr[0]==n){//找到了直接結束
state = true
break
}
temp.push([arr[0]*2,arr[1]*2])
temp.push([arr[0]+arr[1],arr[1]])
}
count++//佇列已經空,說明這層的節點已經全部檢索完,而且子節點也儲存好了
quene = [...temp]//佇列是子節點所有的元素集合,重複前面操作
temp = []
}
return count
}
複製程式碼
3.DFS
DFS著重於這個搜尋的過程,一般以“染色”的形式配合棧來執行,也比較徹底。V8老生代的垃圾回收機制中的標記-清除也利用了DFS。我們定義三種顏色:黑白灰,白色是未處理過的,灰是已經經過了但沒有處理,黑色是已經處理過了 還是前面那幅圖
我們用兩個陣列,一個是棧,一個是儲存我們遍歷順序的,陣列的元素拿到的都是原物件樹的引用,是會改變原物件的節點顏色的
- 從根節點開始,把根節點1壓入棧,染成灰色 【1:灰】
- 發現1的白色子節點2,壓入棧染色【1:灰,2:灰】
- 發現2的白色子節點5,入棧染色【1:灰,2:灰,5:灰】
- 發現5沒有白色子節點,於是5已經確認是遍歷過的,5出棧染黑色【1:灰,2:灰】,【5:黑】
- 回溯2,發現2還有白色子節點6,6入棧染灰,發現6沒有子節點,6出棧染黑色,【1:灰,2:灰】,【5:黑,6:黑】;又發現2沒有白色子節點,2出棧染黑色【1:灰】,【5:黑,6:黑,2:黑】
- 2又回溯1,發現1還有白色子節點3,3入棧染灰【1:灰,3:灰】,【5:黑,6:黑,2:黑】
- 同樣的,7沒有白色子節點,7入棧直接出棧染黑,【1:灰,3:灰】,【5:黑,6:黑,2:黑,7:黑】;3沒有白色子節點【1:灰】出棧染黑,【5:黑,6:黑,2:黑,7:黑,3:黑】
- 到了4,【1:灰,4:灰】,他有白色子節點89,而89沒有白色子節點,所以89入棧又直接出棧了【1:灰,4:灰】,【5:黑,6:黑,2:黑,7:黑,3:黑,8:黑,9:黑】
- 4這次就沒有白色子節點了,到他出棧染黑,【1:灰】,【5:黑,6:黑,2:黑,7:黑,3:黑,8:黑,9:黑,4:黑】
- 回溯,發現1沒有白色子節點,最後1出棧染黑,【5:黑,6:黑,2:黑,7:黑,3:黑,8:黑,9:黑,4:黑,1:黑】
我們可以看到,入棧的時候,從白色染灰色,出棧的時候,從灰色到黑色。整個過程中,染黑的順序類似於二叉樹的後序遍歷
v8的垃圾回收,將持有引用的變數留下,沒有引用的變數清除。因為如果持有引用,他們必然在全域性的樹中被遍歷到。如果沒有引用,那這個變數必然永遠是白色,就會被清理
我們用物件來表示上面這棵樹:
var tree = {
val: 1,
children: [
{val: 2,children: [{val:5,children:null,color:'white'},{val: 6,children:null,color:'white'}],color:'white'},
{val: 3,children: [{val: 7,children:null,color:'white'}],color:'white'},
{val: 4,children: [{val:8,children:null,color:'white'},{val: 9,children:null,color:'white'}],color:'white'}
],
color: 'white'
}
複製程式碼
開始我們的DFS:
function dfs ( tree ) {
var stack = []//記錄棧
var order = []//記錄遍歷順序
!function travel (node) {
stack.push(node)//入棧
node.color = 'gray'
console.log(node)
if(!node.children) {//沒有子節點的說明已經遍歷到底
node.color = 'black'
console.log(node)
stack.pop()
order.push(node)
return
}
var children = node.children
children.forEach(child=>{
travel(child)
})
node.color = 'black'
stack.pop()//出棧
order.push(node)
console.log(node)
}(tree)
return order
}
複製程式碼
過程用遞迴比較簡單,上面大部分程式碼都是除錯程式碼,自己可以改一下測試其他的類似場景。遍歷完成後,tree上面每一個節點都是黑色了。遍歷中間過程,每一個節點入棧的時候是灰色的,出棧的時候是黑色的。
後續更新(2019-10)
寫此文章的時候,水平低了一些,有一個實戰的例子可以見另一篇文章,程式碼更加優雅簡潔