之前的陣列, 棧, 連結串列, 佇列等都是順序資料結構, 這裡來介紹一個非順序資料結構, 樹. 樹在處理有層級相關的資料時非常有用, 還有在儲存資料如資料庫查詢實現等場景也是高頻使用.
作為一種分層資料的抽象模型, 在現實中最常見的例子是族譜, 公司組織架構圖等.
我個人覺得樹, 圖等非順序的資料結構是不太好直觀理解的, 它的實現都需要用到函式的遞迴. 就關於函式遞迴呼叫棧搞不清楚的話, 那也就很難理解程式碼了.
樹的基本內容包括:
- 樹相關的術語
- 建立二叉搜尋樹
- 樹的三種遍歷
- 新增和移除節點
- AVL平衡樹
樹的核心術語
- 節點: 樹的每個元素都稱為 "節點". 節點的祖先包括父節點, 祖父節點等; 節點的後代包括子節點, 孫節點等
- 根節點: 一顆樹頂部最上層有個唯一的根節點元素, 稱為根節點
- 內/外部節點: 至少有一個子節點的稱為 "內部節點", 否則稱 "外部節點 或 葉節點"
- 邊: 節點和節點之間的 "連線" 成為邊, 類似連結串列中的指標
- 子樹: 節點和其子節點的集合, 構成一顆子樹
- 節點深度: 節點深度指其祖先節點的數量
- 樹高度: 所有節點深度的最大值
搜尋二叉樹
二叉樹中的節點最多隻能有兩個子節點: 左子節點 和 右子節點.
這種只有2個路徑的結構可以讓我們寫出更高效的資料插入, 查詢, 刪除節點的演算法. 比如二分法, 關係型資料庫的查詢設計等, 就使用非常高頻.
二叉搜尋樹 (BST) 是二叉樹的一種, 強制要求:
- 比父節點小的值, 存左側子節點
- 比父節點大的值, 存右側子節點
// 節點類
class Node {
constructor(key) {
this.key = key // 節點值
this.left = null // 左節點指標
this.right = null // 右節點指標
}
}
// 二叉搜尋樹類
class BSTtree {
constructor() {
this.root = null // 根節點物件
}
}
主要實現的方法是有:
- insert(key): 向樹中插入一個新的鍵
- search(key): 搜尋 key, 如果存在返回 true, 否則返回 false
- inOrderTraverse(): 中序遍歷所有節點
- preOrderTraverse(): 先序遍歷所有節點
- postOrderTraverse(): 後序遍歷所有節點
- min() : 返回樹中最小值
- max(): 返回樹中最大值
- remove(key): 從樹中移除某個鍵
BST - 插入鍵
這裡我們先來實現一個輔助的方法 insertNode(node, key)
.
它的作用是幫我們找到新節點應該插入到哪個最合適的層級位置, 因此它是一個 遞迴函式
.
**當插入的 key 小於 node.key 時: **
- 如果 node.left 是葉子節點時, 則直接插入
- 否則就遞迴往左下層子樹查詢
當插入的 key 大於 node 時:
- 如果 node.right 是葉子節點時, 則直接插入
- 否則就遞迴往右下層子樹查詢
// 從某節點開始, 插入新的 key
insertNode(node, key) {
if (key < node.key) {
// 左子樹處理
if (node.left == null) {
node.left = new Node(key)
} else {
this.insertNOde(node.left, key)
}
} else {
// 右子樹處理
if (node.right == null) {
node.right = new Node(key)
} else {
this.insertNode(node.right, key)
}
}
}
這樣插入的邏輯就是變成從根節點開始進行判斷:
- 根節點無值, 則新增的 key 節點就是根節點
- 根節點有值, 則將根節點開始進行 insertNode(root, key) 找到合適位置插入
// 插入一個 key
insert(key) {
if this.root == null {
// 空樹的話那就是根節點
this.root = new Node(key)
} else {
// 有值則找到合適位置插入
this.insertNode(this.root, key)
}
}
二叉樹遍歷
二叉樹的遍歷方式基本就3種, 先序遍歷, 後序遍歷, 中序遍歷.
先序步驟: 根 => 左 => 右
應用: 複製樹, 序列化樹, 字首表示式解析.
中序步驟:左 => 根 => 右
應用: 排序, 中綴表示式解析 (a + b * c)
後序步驟:左 => 右 => 根
應用: 釋放記憶體, 字尾表示式解析 (棧), 修改樹.
1
/ \
2 3
/ / \
4 5 6
先序, 遍歷的結果是: 1, 2, 4, 3, 5, 6
後序, 遍歷的結果是: 4, 2, 5, 6, 3, 1
中序, 遍歷的結果是: 4, 2, 1, 5, 3, 6
先序遍歷
即按照 根 -> 左 -> 右
的順序, 先會訪問節點本身(父節點), 然後再訪問左側節點, 最後右側節點.
這裡對於節點訪問處理可以傳遞一個通用的 回撥函式 callback
, 同時用一個輔助方法 preOrderTraverseNode(node, callback)
來接受一個節點和回撥函式.
// 輔助方法: 節點遍歷
preOrderTraverseNode(node, callback) {
// 基本情況
if (node == null) return
// 遞迴情況: 先訪問自身
callback(node.key)
// 再訪問左樹, 再是右樹
this.preOrderTraverseNode(node.left, callback)
this.preOrderTraverseNode(node.right, callback)
}
// 先序遍歷: 從根節點開始輸入
preOrderTraverse(callback) {
this.preOrderTraver(this.root, callback)
}
中序遍歷
即按照 左 -> 根 -> 右
的順序, 先訪問子節點(葉子), 然後再訪問根節點, 最後右側節點.
同樣節點訪問處理可以傳遞一個通用的 回撥函式 callback
, 同時用一個輔助方法 inOrderTraverseNode(node, callback)
來接受一個節點和回撥函式.
// 輔助方法: 節點遍歷
inOrderTraverseNode(node, callback) {
// 基本情況
if (node == null) return
// 遞迴情況: 先左側到葉子節點
this.inOrderTraverse(node.left, callback)
// 訪問節點
callback(node.key)
// 再是右側節點
this.inOrderTraverse(node.right, callback)
}
// 中序遍歷
inOrderTraverse(callback) {
this.inOrderTraverse(this.root, callback)
}
後序遍歷
即按照 左 -> 右 -> 根
的順序, 先訪問子節點(葉子), 然後再訪問根節點, 最後右側節點.
同樣節點訪問處理可以傳遞一個通用的 回撥函式 callback
, 同時用一個輔助方法 postOrderTraverseNode(node, callback)
來接受一個節點和回撥函式.
// 輔助方法: 節點遍歷
postOrderTraverseNode(node, callback) {
// 基本情況
if (node == null) return
// 遞迴情況:
this.postOrderTraverse(node.left, callback)
this.postOrderTraverse(node.right, callback)
// 訪問節點
callback(node.key)
}
// 後序遍歷
postOrderTraverse(callback) {
this.postOrderTraverse(this.root, callback)
}
最大最小值
// BST 樹的最小值
min() {
return this.minNode(this.root)
}
minNode(node) {
let current = node
// 因為是 BST 樹, 小的值一定在左側, 從樹一層層往下到底就行
while (current != null && current.left != null) {
current = current.left
}
return current
}
// BST 樹的最大值
max() {
return this.maxNode(this.root)
}
maxNode(node) {
let current = node
while (current != null && current.right != null) {
current = current.right
}
return current.key
}
樹中查詢值
因為是 BST 樹, 左側深度遞迴查詢就是越來越小, 右側深度遞迴查詢的值就越來越大, 否則就基本條件, 沒有找到嘍.
// 查詢值
searchNode(node, key) {
// 基本情況, 沒有找到就返回 false
if (node == null) return false
// 若 key 小於當前節點, 則繼續左側深度遞迴搜尋
if (key < node.key) return this.searchNode(node.left, key)
// 若 key 大於當前節點, 則繼續右側深度遞迴搜尋
else if (key > node.key) return this.searchNode(node.right, key)
// 找到就返回
else return false
}
樹中移除值
這個算是非常複雜的邏輯了, 既要分析不同場景, 不同場景下又要進行遞迴, 就很難搞.
首先要做的就是要透過對子樹分析, 來確定將被刪節點的位置,
然後根據將要被刪的節點位置, 分情況進行討論處理.
* 如果不為 null,我們需要在樹中找到要移除的鍵
* 如果要找的鍵比當前節點的值小, 就沿著樹的左邊找到下一個節點
* 如果要找的鍵比當前節點的值大, 就那麼就沿著樹的右邊找到下一個節點
也就是說我們要分析它的子樹。如果我們找到了要找的鍵(鍵和 node.key 相等),就需要處理三種不同的情況:
- 移除一個葉節點 (無腿)
- 移除一個有左側 或者 右側子節點的節點 (一條腿)
- 移除有2個子節點的節點 (2條腿)
// 移除BST樹中的節點
removeNode(node, key) {
if (node == null) return null
if (key < node.key) {
// 從左子樹深度遞迴查詢
node.left = this.removeNode(node.left, key)
return node
} else if (key > node.key) {
// 從右子樹深度遞迴查詢
node.right = this.removeNode(node.right, key)
return node
} else {
// 找到了 key 的位置, 即 key == node.key 時候, 分3中情況討論
// 情況-01: 移除一個葉節點
if (node.left == null && node.right == null) {
node = null
// 透過返回 null 將父節點的指標指向 null
return node
}
// 情況-02: 移除一個有左或右節點的節點(1條腿)
// 跳過子節點, 直接將父節點指向其子節點
if (node.left == null) {
node = node.right
return node
} else if (node.right == null) {
node = node.left
return node
}
// 情況03 移除有兩個子節點的節點 (2條腿)
// a. 找到它右子樹中最小節點, 下下層元素 grandson
// b. 用 grandson 去替換掉被刪節點的值 (改了鍵, 則移除了)
// c. 繼續將右側子樹的最小節點移除 (刪掉重複鍵節點)
// d. 向父節點返回更新的引用
const grandson = this.minNode(node.right)
node.key = grandson.key
node.right = this.removeNode(node.right, grandson.key)
return node
}
}
測試
// 測試
const tree = new BSTtree()
tree.insert(11)
tree.insert(7);
tree.insert(15);
tree.insert(5);
tree.insert(3);
tree.insert(9);
tree.insert(8);
tree.insert(10);
tree.insert(13);
tree.insert(12);
tree.insert(14);
tree.insert(20);
tree.insert(18);
tree.insert(25);
tree.insert(6);
// 中序遍歷
function callback(value) {
console.log('callback-log: ', value);
}
// 中序遍歷
console.log('中序遍歷:');
tree.inOrderTraverse(callback)
console.log();
// 先序遍歷
console.log('先序遍歷:');
tree.preOrderTraverse(callback)
console.log();
// 後序遍歷
console.log('後序遍歷:');
tree.postOrderTraverse(callback)
// 樹的最小值
console.log();
console.log('樹的最小值是: ', tree.min());
// 樹的最大值
console.log();
console.log('樹的最大值是: ', tree.max());
// 查詢值
console.log();
console.log(tree.search(1));
console.log(tree.search(8));
// 刪除值
console.log('刪除值 25', tree.remove(25));
tree.inOrderTraverse(callback)
console.log('刪除值 15', tree.remove(15));
tree.inOrderTraverse(callback)
輸出:
PS F:\algorithms> node .\bst_tree.js
中序遍歷:
callback-log: 3
callback-log: 5
callback-log: 6
callback-log: 7
callback-log: 8
callback-log: 9
callback-log: 10
callback-log: 11
callback-log: 12
callback-log: 13
callback-log: 14
callback-log: 15
callback-log: 18
callback-log: 20
callback-log: 25
先序遍歷:
callback-log: 11
callback-log: 7
callback-log: 5
callback-log: 3
callback-log: 6
callback-log: 9
callback-log: 8
callback-log: 10
callback-log: 15
callback-log: 13
callback-log: 12
callback-log: 14
callback-log: 20
callback-log: 18
callback-log: 25
後序遍歷:
callback-log: 3
callback-log: 6
callback-log: 5
callback-log: 8
callback-log: 10
callback-log: 9
callback-log: 7
callback-log: 12
callback-log: 14
callback-log: 13
callback-log: 18
callback-log: 25
callback-log: 20
callback-log: 15
callback-log: 11
樹的最小值是: Node { key: 3, left: null, right: null }
樹的最大值是: 25
false
true
刪除值 25 undefined
callback-log: 3
callback-log: 5
callback-log: 6
callback-log: 7
callback-log: 8
callback-log: 9
callback-log: 10
callback-log: 11
callback-log: 12
callback-log: 13
callback-log: 14
callback-log: 15
callback-log: 18
callback-log: 20
刪除值 15
callback-log: 3
callback-log: 5
callback-log: 6
callback-log: 7
callback-log: 8
callback-log: 9
callback-log: 10
callback-log: 11
callback-log: 12
callback-log: 13
callback-log: 14
callback-log: 18
callback-log: 20
PS F:\algorithms>
至此, 關於樹, 二叉搜尋樹的基本實現就到這裡啦.