前兩天接到了螞蟻金服的面試電話,面試官很直接,上來就丟擲了三道演算法題。。。
其中有一道關於二叉樹實現中序遍歷的,當時沒回答好,所以特意學習了一把二叉樹的知識,行文記錄總結。
二叉樹&二叉查詢樹
樹相關術語:
節點: 樹中的每個元素稱為一個節點,
根節點: 位於整棵樹頂點的節點,它沒有父節點, 如上圖 5
子節點: 其他節點的後代
葉子節點: 沒有子節點的元素稱為葉子節點, 如上圖 3 8 24
二叉樹:二叉樹就是一種資料結構, 它的組織關係就像是自然界中的樹一樣。官方語言的定義是:是一個有限元素的集合,該集合或者為空、或者由一個稱為根的元素及兩個不相交的、被分別稱為左子樹和右子樹的二叉樹組成。
二叉查詢樹: 二叉查詢樹也叫二叉搜尋樹(BST),它只允許我們在左節點儲存比父節點更小的值,右節點儲存比父節點更大的值,上圖展示的就是一顆二叉查詢樹。
程式碼實現
首先建立一個類來表示二叉查詢樹,它的內部應該有一個Node類,用來建立節點
function BinarySearchTree () {
var Node = function(key) {
this.key = key,
this.left = null,
this.right = null
}
var root = null
}
複製程式碼
它還應該有一些方法:
- insert(key) 插入一個新的鍵
- inOrderTraverse() 對樹進行中序遍歷,並列印結果
- preOrderTraverse() 對樹進行先序遍歷,並列印結果
- postOrderTraverse() 對樹進行後序遍歷,並列印結果
- search(key) 查詢樹中的鍵,如果存在返回true,不存在返回fasle
- findMin() 返回樹中的最小值
- findMax() 返回樹中的最大值
- remove(key) 刪除樹中的某個鍵
向樹中插入一個鍵
向樹中插入一個新的鍵,首頁應該建立一個用來表示新節點的Node類例項,因此需要new一下Node類並傳入需要插入的key值,它會自動初始化為左右節點為null的一個新節點
然後,需要做一些判斷,先判斷樹是否為空,若為空,新插入的節點就作為根節點,如不為空,呼叫一個輔助方法insertNode()方法,將根節點和新節點傳入
this.insert = function(key) {
var newNode = new Node(key)
if(root === null) {
root = newNode
} else {
insertNode(root, newNode)
}
}
複製程式碼
定義一下insertNode() 方法,這個方法會通過遞迴得呼叫自身,來找到新新增節點的合適位置
var insertNode = function(node, newNode) {
if (newNode.key <= node.key) {
if (node.left === null) {
node.left = newNode
}else {
insertNode(node.left, newNode)
}
}else {
if (node.right === null) {
node.right = newNode
}else {
insertNode(node.right, newNode)
}
}
}
複製程式碼
完成中序遍歷方法
要實現中序遍歷,我們需要一個inOrderTraverseNode(node)方法,它可以遞迴呼叫自身來遍歷每個節點
this.inOrderTraverse = function() {
inOrderTraverseNode(root)
}
複製程式碼
這個方法會列印每個節點的key值,它需要一個遞迴終止條件————檢查傳入的node是否為null,如果不為空,就繼續遞迴呼叫自身檢查node的left、right節點 實現起來也很簡單:
var inOrderTraverseNode = function(node) {
if (node !== null) {
inOrderTraverseNode(node.left)
console.log(node.key)
inOrderTraverseNode(node.right)
}
}
複製程式碼
先序遍歷、後序遍歷
有了中序遍歷的方法,只需要稍作改動,就可以實現先序遍歷和後序遍歷了 上程式碼:
這樣就可以對整棵樹進行中序遍歷了
// 實現先序遍歷
this.preOrderTraverse = function() {
preOrderTraverseNode(root)
}
var preOrderTraverseNode = function(node) {
if (node !== null) {
console.log(node.key)
preOrderTraverseNode(node.left)
preOrderTraverseNode(node.right)
}
}
// 實現後序遍歷
this.postOrderTraverse = function() {
postOrderTraverseNode(root)
}
var postOrderTraverseNode = function(node) {
if (node !== null) {
postOrderTraverseNode(node.left)
postOrderTraverseNode(node.right)
console.log(node.key)
}
}
複製程式碼
發現了吧,其實就是內部語句更換了前後位置,這也剛好符合三種遍歷規則:先序遍歷(根-左-右)、中序遍歷(左-根-右)、中序遍歷(左-右-根)
先來做個測試吧
現在的完整程式碼如下:
function BinarySearchTree () {
var Node = function(key) {
this.key = key,
this.left = null,
this.right = null
}
var root = null
//插入節點
this.insert = function(key) {
var newNode = new Node(key)
if(root === null) {
root = newNode
} else {
insertNode(root, newNode)
}
}
var insertNode = function(node, newNode) {
if (newNode.key <= node.key) {
if (node.left === null) {
node.left = newNode
}else {
insertNode(node.left, newNode)
}
}else {
if (node.right === null) {
node.right = newNode
}else {
insertNode(node.right, newNode)
}
}
}
//實現中序遍歷
this.inOrderTraverse = function() {
inOrderTraverseNode(root)
}
var inOrderTraverseNode = function(node) {
if (node !== null) {
inOrderTraverseNode(node.left)
console.log(node.key)
inOrderTraverseNode(node.right)
}
}
// 實現先序遍歷
this.preOrderTraverse = function() {
preOrderTraverseNode(root)
}
var preOrderTraverseNode = function(node) {
if (node !== null) {
console.log(node.key)
preOrderTraverseNode(node.left)
preOrderTraverseNode(node.right)
}
}
// 實現後序遍歷
this.postOrderTraverse = function() {
postOrderTraverseNode(root)
}
var postOrderTraverseNode = function(node) {
if (node !== null) {
postOrderTraverseNode(node.left)
postOrderTraverseNode(node.right)
console.log(node.key)
}
}
}
複製程式碼
竟然已經完成了新增新節點和遍歷的方式,我們來測試一下吧:
定義一個陣列,裡面有一些元素
var arr = [9,6,3,8,12,15]
我們將arr中的每個元素依此插入到二叉搜尋樹中,然後列印結果
var tree = new BinarySearchTree()
arr.map(item => {
tree.insert(item)
})
tree.inOrderTraverse()
tree.preOrderTraverse()
tree.postOrderTraverse()
複製程式碼
執行程式碼後,我們先來看看插入節點後整顆樹的情況:
輸出結果
中序遍歷:
3
6
8
9
12
15
先序遍歷:
9
6
3
8
12
15
後序遍歷:
3
8
6
15
12
9
很明顯,結果是符合預期的,所以,我們用上面的JavaScript程式碼,實現了對樹的節點插入,和三種遍歷方法,同時,很明顯可以看到,在二叉查詢樹樹種,最左側的節點的值是最小的,而最右側的節點的值是最大的,所以二叉查詢樹可以很方便的拿到其中的最大值和最小值
查詢最小、最大值
怎麼做呢?其實只需要將根節點傳入minNode/或maxNode方法,然後通過迴圈判斷node為左側(minNode)/右側(maxNode)的節點為null
實現程式碼:
// 查詢最小值
this.findMin = function() {
return minNode(root)
}
var minNode = function(node) {
if (node) {
while (node && node.left !== null) {
node = node.left
}
return node.key
}
return null
}
// 查詢最大值
this.findMax = function() {
return maxNode(root)
}
var maxNode = function (node) {
if(node) {
while (node && node.right !== null) {
node =node.right
}
return node.key
}
return null
}
複製程式碼
所搜特定值
this.search = function(key) {
return searchNode(root, key)
}
複製程式碼
同樣,實現它需要定義一個輔助方法,這個方法首先會檢驗node的合法性,如果為null,直接退出,並返回fasle。如果傳入的key比當前傳入node的key值小,它會繼續遞迴查詢node的左側節點,反之,查詢右側節點。如果找到相等節點,直接退出,並返回true
var searchNode = function(node, key) {
if (node === null) {
return false
}
if (key < node.key) {
return searchNode(node.left, key)
}else if (key > node.key) {
return searchNode(node.right, key)
}else {
return true
}
}
複製程式碼
移除節點
移除節點的實現情況比較複雜,它會有三種不同的情況:
-
需要移除的節點是一個葉子節點
-
需要移除的節點包含一個子節點
-
需要移除的節點包含兩個子節點
和實現搜尋指定節點一元,要移除某個節點,必須先找到它所在的位置,因此移除方法的實現中部分程式碼和上面相同:
// 移除節點
this.remove = function(key) {
removeNode(root,key)
}
var removeNode = function(node, key) {
if (node === null) {
return null
}
if (key < node.key) {
node.left = removeNode(node.left, key)
return node
}else if(key > node.key) {
node.right = removeNode(node.right,key)
return node
}else{
//需要移除的節點是一個葉子節點
if (node.left === null && node.right === null) {
node = null
return node
}
//需要移除的節點包含一個子節點
if (node.letf === null) {
node = node.right
return node
}else if (node.right === null) {
node = node.left
return node
}
//需要移除的節點包含兩個子節點
var aux = findMinNode(node.right)
node.key = aux.key
node.right = removeNode(node.right, axu.key)
return node
}
}
var findMinNode = function(node) {
if (node) {
while (node && node.left !== null) {
node = node.left
}
return node
}
return null
}
複製程式碼
其中,移除包含兩個子節點的節點是最複雜的情況,它包含左側節點和右側節點,對它進行移除主要需要三個步驟:
- 需要找到它右側子樹中的最小節點來代替它的位置
- 將它右側子樹中的最小節點移除
- 將更新後的節點的引用指向原節點的父節點
有點繞兒,但必須這樣,因為刪除元素後的二叉搜尋樹必須保持它的排序性質
測試刪除節點
tree.remove(8)
tree.inOrderTraverse()
複製程式碼
列印結果:
3
6
9
12
15
8 這個節點被成功刪除了,但是對二叉查詢樹進行中序遍歷依然是保持排序性質的
到這裡,一個簡單的二叉查詢樹就基本上完成了,我們為它實現了,新增、查詢、刪除以及先中後三種遍歷方法
存在的問題
但是實際上這樣的二叉查詢樹是存在一些問題的,當我們不斷的新增更大/更小的元素的時候,會出現如下情況:
tree.insert(16)
tree.insert(17)
tree.insert(18)
複製程式碼
來看看現在整顆樹的情況:
很容易發現,它是不平衡的,這又會引出平衡樹的概念,要解決這個問題,還需要更復雜的實現,例如:AVL樹,紅黑樹 哎,之後再慢慢去學習吧
關於實現二叉排序樹,我也找到慕課網的一系列的視訊:Javascript實現二叉樹演算法, 內容和上述實現基本一致