原文連結:Tree Data Structures for Beginners
眾成翻譯地址:初學者應該瞭解的資料結構: Tree
系列文章,建議不瞭解樹的同學慢慢閱讀一下這篇文章,希望對你有所幫助~至於系列的最後一篇自平衡二叉搜尋樹就不再翻譯了(主要是原文坑太多,很難填),以下是譯文正文:
Tree 是很多(上層的)資料結構(如 Map、Set 等)的基礎。同時,在資料庫中快速搜尋(元素)也用到了樹。HTML 的 DOM 節點也通過樹來表示對應的層次結構。以上僅僅是樹在實際應用中的一小部分例子。在這篇文章中,我們將探討不同型別的樹,如二叉樹、二叉搜尋樹以及如何實現它們。
在上一篇文章(譯文)中,我們探討了資料結構:圖,它是樹一般化的情況。讓我們開始學習樹吧!
本篇是以下教程的一部分(譯者注:如果大家覺得還不錯,我會翻譯整個系列的文章):
初學者應該瞭解的資料結構與演算法(DSA)
- 演算法的時間複雜性與大 O 符號
- 每個程式設計師應該知道的八種時間複雜度
- 初學者應該瞭解的資料結構:Array、HashMap 與 List (譯文)
- 初學者應該瞭解的資料結構: Graph (譯文)
- 初學者應該瞭解的資料結構:Tree ? 即本文
- 自平衡二叉搜尋樹
- 附錄 I:遞迴演算法分析
樹的基本概念
在樹中,每個節點可有零個或多個子節點,每個節點都包含一個值。和圖一樣,節點之間的連線被稱為邊。樹是圖的一種,但並不是所有圖都是樹(只有無環無向圖才是樹)。
這種資料型別之所以被稱為樹,是因為它長得很像一棵(倒置的)樹 ?。它從根節點出發,它的子節點是它的分支,沒有任何子節點的節點就是樹的葉子(即葉節點)。
以下是樹的一些屬性:
- 最頂層的節點被稱為根(root)節點(譯者注:即沒有任何父節點的節點)。
- 沒有任何子節點的節點被稱為葉節點(leaf node)或者終端節點(terminal node)。
- 樹的度(Height)是最深的葉節點與根節點之間的距離(即邊的數量)。
A
的度是 3。I
的度是 0(譯者注:子樹也是樹,I 的度是指 I 為根節點的子樹的度)。
- 深度(Depth)或者層次(level)是節點與根節點的距離。
H
的層次是 2。B
的層次是 1。
樹的簡單實現
正如此前所見,樹的節點有一個值,且存有它每一個子節點的引用。
以下是節點的例子:
class TreeNode {
constructor(value) {
this.value = value;
this.descendents = [];
}
}
複製程式碼
我們可以建立一棵樹,它有三個葉節點:
// create nodes with values
const abe = new TreeNode('Abe');
const homer = new TreeNode('Homer');
const bart = new TreeNode('Bart');
const lisa = new TreeNode('Lisa');
const maggie = new TreeNode('Maggie');
// associate root with is descendents
abe.descendents.push(homer);
homer.descendents.push(bart, lisa, maggie);
複製程式碼
這樣就完成啦,我們有了一棵樹!
節點 abe
是根節點,而節點 bart
、lisa
和 maggie
則是這棵樹的 葉節點。注意,樹的節點的子節點可以是任意數量的,無論是 0 個、1 個、3 個或是多個均可。
二叉樹
樹的節點可以有 0 個或多個子節點。然而,當一棵樹(的所有節點)最多隻能有兩個子節點時,這樣的樹被稱為二叉樹。
二叉樹是樹中最常見的形式之一,它應用廣泛,如:
- Maps
- Sets
- 資料庫
- 優先佇列
- 在 LDAP(Lightweight Directory Access Protocol)中查詢相應資訊。
- 在 XML/HTML 檔案中,使用文件物件模型(DOM)介面進行搜尋。
完滿二叉樹、完全二叉樹、完美二叉樹
取決於二叉樹節點的組織方式,一棵二叉樹可以是完滿二叉樹、完全二叉樹或完美二叉樹。
- 完滿二叉樹(Full binary tree):除去葉節點,每個節點都有兩個子節點。
- 完全二叉樹(Complete binary tree):除了最深一層之外,其餘所有層的節點都必須有兩個子節點(譯者注:其實還需要最深一層的節點均集中在左邊,即左對齊)。
- 完美二叉樹(Perfect binary tree):滿足完全二叉樹性質,樹的葉子節點均在最後一層(也就是形成了一個完美的三角形)。
(譯者注:國內外的定義是不同的,此處根據原文與查詢的資料,作了一定的修改,用的是國外的標準)
下圖是上述概念的例子:
完滿二叉樹、完全二叉樹與完美二叉樹並不總是互斥的:
- 完美二叉樹必然是完滿二叉樹和完全二叉樹。
- 完美的二叉樹正好有 2 的 k 次方 減 1 個節點,其中 k 是樹的最深一層(從1開始)。.
- 完全二叉樹並不總是完滿二叉樹。
- 正如上面的完全二叉樹例子,最右側的灰色節點是它父子點僅有的一個子節點。如果移除掉它,這棵樹就既是完全二叉樹,也是完滿二叉樹。(譯者注:其實有了那個灰色節點的話,這顆樹不能算是完全二叉樹的,因為完滿二叉樹需要左對齊)
- 完滿二叉樹並不一定是完全二叉樹與完美二叉樹。
二叉搜尋樹 (BST)
二叉搜尋樹(Binary Search Tree,簡寫為:BST)是二叉樹的特定應用。BST 的每個節點如二叉樹一樣,最多隻能有兩個子節點。然而,BST 左子節點的值必須小於父節點的值,而右子節點的值則必須大於父節點的值。
強調一下:一些 BST 並不允許重複值的節點被新增到樹中,如若允許,那麼重複值的節點將作為右子節點。有些二叉搜尋樹的實現,會記錄起重複的情況(這也是接下來我們需要實現的)。
一起來實現二叉搜尋樹吧!
BST 的實現
BST 的實現與上文樹的實現相像,然而有兩點不同:
- 節點最多隻能擁有兩個子節點。
- 節點的值滿足以下關係:
左子節點 < 父節點 < 右子節點
。
以下是樹節點的實現,與之前樹的實現類似,但會為左右子節點新增 getter
與 setter
。請注意,例項中會儲存父節點的引用,當新增新的子節點時,將更新(子節點中)父節點的引用。
const LEFT = 0;
const RIGHT = 1;
class TreeNode {
constructor(value) {
this.value = value;
this.descendents = [];
this.parent = null;
//譯者注:原文並沒有以下兩個屬性,但不加上去話下文的實現會報錯
this.newNode.isParentLeftChild = false;
this.meta = {};
}
get left() {
return this.descendents[LEFT];
}
set left(node) {
this.descendents[LEFT] = node;
if (node) {
node.parent = this;
}
}
get right() {
return this.descendents[RIGHT];
}
set right(node) {
this.descendents[RIGHT] = node;
if (node) {
node.parent = this;
}
}
}
複製程式碼
OK,現在已經可以新增左右子節點。接下來將編寫 BST 類,使其滿足 左子節點 < 父節點 < 右子節點
。
class BinarySearchTree {
constructor() {
this.root = null;
this.size = 0;
}
add(value) { /* ... */ }
find(value) { /* ... */ }
remove(value) { /* ... */ }
getMax() { /* ... */ }
getMin() { /* ... */ }
}
複製程式碼
下面先編寫插入新節點相關的的程式碼。
BST 節點的插入
要將一個新的節點插入到二叉搜尋樹中,我們需要以下三步:
- 如果樹中沒有任何節點,第一個節點當成為根節點。
- (將新插入節點的值)與樹中的根節點或樹節點進行對比,如果值 更大,則放至右子樹(進行下一次對比),反之放到左子樹(進行對比) 。如果值一樣,則說明被重複新增,可增加重複節點的計數。
- 重複第二點操作,直至找到空位插入新節點。
讓我們通過以下例子來說明,樹中將依次插入30、40、10、15、12、50:
程式碼實現如下:
add(value) {
const newNode = new TreeNode(value);
if (this.root) {
const { found, parent } = this.findNodeAndParent(value);
if (found) { // duplicated: value already exist on the tree
found.meta.multiplicity = (found.meta.multiplicity || 1) + 1;
} else if (value < parent.value) {
parent.left = newNode;
//譯者注:原文並沒有這行程式碼,但不加上去的話下文實現會報錯
newNode.isParentLeftChild = true;
} else {
parent.right = newNode;
}
} else {
this.root = newNode;
}
this.size += 1;
return newNode;
}
複製程式碼
我們使用了名為 findNodeAndParent
的輔助函式。如果(與新插入節點值相同的)節點已存在於樹中,則將節點統計重複的計數器加一。看看這個輔助函式該如何實現:
findNodeAndParent(value) {
let node = this.root;
let parent;
while (node) {
if (node.value === value) {
break;
}
parent = node;
node = ( value >= node.value) ? node.right : node.left;
}
return { found: node, parent };
}
複製程式碼
findNodeAndParent
沿著樹的結構搜尋值。它從根節點出發,往左還是往右搜尋取決於節點的值。如果已存在相同值的節點,函式返回找到的節點(即相同值的節點)與它的父節點。如果沒有相同值的節點,則返回最後找到的節點(即將變為新插入節點父節點的節點)。
BST 節點的刪除
我們已經知道如何(在二叉搜尋樹中)插入與查詢值,現在將實現刪除操作。這比插入而言稍微麻煩一點,讓我們用下面的例子進行說明:
刪除葉節點(即沒有任何子節點的節點)
30 30
/ \ remove(12) / \
10 40 ---------> 10 40
\ / \ \ / \
15 35 50 15 35 50
/
12*
複製程式碼
只需要刪除父節點(即節點 #15)中儲存著的 節點 #12 的引用即可。
刪除有一個子節點的節點
30 30
/ \ remove(10) / \
10* 40 ---------> 15 40
\ / \ / \
15 35 50 35 50
複製程式碼
在這種情況中,我們將父節點 #30 中儲存著的子節點 #10 的引用,替換為子節點的子節點 #15 的引用。
刪除有兩個子節點的節點
30 30
/ \ remove(40) / \
15 40* ---------> 15 50
/ \ /
35 50 35
複製程式碼
待刪除的節點 #40 有兩個子節點(#35 與 #50)。我們將待刪除節點替換為節點 #50。待刪除的左子節點 #35 將在原位不動,但它的父節點已被替換。
另一個刪除節點 #40 的方式是:將左子節點 #35 移到節點 #40 的位置,右子節點位置保持不變。
30
/ \
15 35
\
50
複製程式碼
兩種形式都可以,這是因為它們都遵循了二叉搜尋樹的原則:左子節點 < 父節點 < 右子節點
。
刪除根節點
30* 50
/ \ remove(30) / \
15 50 ---------> 15 35
/
35
複製程式碼
刪除根節點與此前討論的機制情況差不多。唯一的區別是需要更新二叉搜尋樹例項中根節點的引用。
以下的動畫是上述操作的具體展示:
在動畫中,被移動的節點是左子節點或者左子樹,右子節點或右子樹位置保持不變。
關於刪除節點,已經有了思路,讓我們來實現它吧:
remove(value) {
const nodeToRemove = this.find(value);
if (!nodeToRemove) return false;
// Combine left and right children into one subtree without nodeToRemove
const nodeToRemoveChildren = this.combineLeftIntoRightSubtree(nodeToRemove);
if (nodeToRemove.meta.multiplicity && nodeToRemove.meta.multiplicity > 1) {
nodeToRemove.meta.multiplicity -= 1; // handle duplicated
} else if (nodeToRemove === this.root) {
// Replace (root) node to delete with the combined subtree.
this.root = nodeToRemoveChildren;
this.root.parent = null; // clearing up old parent
} else {
const side = nodeToRemove.isParentLeftChild ? 'left' : 'right';
const { parent } = nodeToRemove; // get parent
// Replace node to delete with the combined subtree.
parent[side] = nodeToRemoveChildren;
}
this.size -= 1;
return true;
}
複製程式碼
以下是實現中一些要注意的地方:
- 首先,搜尋待刪除的節點是否存在。如果不存在,返回
false
。 - 如果待刪除的節點存在,則將它的左子樹合併到右子樹中,組合為一顆新子樹。
- 替換待刪除的節點為組合好的子樹。
將左子樹組合到右子樹的函式如下:
combineLeftIntoRightSubtree(node) {
if (node.right) {
//譯者注:原文是 getLeftmost,尋找左子樹最大的節點,這肯定有問題,應該是找最小的節點才對
const leftLeast = this.getLefLeast(node.right);
leftLeast.left = node.left;
return node.right;
}
return node.left;
}
複製程式碼
正如下面例子所示,我們想把節點 #30 刪除,將待刪除節點的左子樹整合到右子樹中,結果如下:
30* 40
/ \ / \
10 40 combine(30) 35 50
\ / \ -----------> /
15 35 50 10
\
15
複製程式碼
現在把新的子樹的根節點作為整個二叉樹的根節點,節點 #30 將不復存在!
二叉樹的遍歷
根據遍歷的順序,二叉樹的遍歷有若干種形式:中序遍歷、先序遍歷與後序遍歷。同時,我們也可以使用在《初學者應該瞭解的資料結構: Graph》 (譯文一文中學到的 DFS 或 BFS 來遍歷整棵樹。以下是具體的實現:
中序遍歷(In-Order Traversal)
中序遍歷訪問節點的順序是:左子節點、節點本身、右子節點。
* inOrderTraversal(node = this.root) {
if (node.left) { yield* this.inOrderTraversal(node.left); }
yield node;
if (node.right) { yield* this.inOrderTraversal(node.right); }
}
複製程式碼
用以下這棵樹作為例子:
10
/ \
5 30
/ / \
4 15 40
/
3
複製程式碼
中序遍歷將按照以下順序輸出對應的值:3、4、5、10、15、30、40。也就是說,如果待遍歷的樹是一顆二叉搜尋樹,那輸出值的順序將是升序的。
後序遍歷(Post-Order Traversal)
後序遍歷訪問節點的順序是:左子節點、右子節點、節點本身。
* postOrderTraversal(node = this.root) {
if (node.left) { yield* this.postOrderTraversal(node.left); }
if (node.right) { yield* this.postOrderTraversal(node.right); }
yield node;
}
複製程式碼
後序遍歷將按照以下順序輸出對應的值:3、4、5、15、40、30、10。
先序遍歷與 DFS(Pre-Order Traversal)
先序遍歷訪問節點的順序是:節點本身、左子節點、右子節點。
* preOrderTraversal(node = this.root) {
yield node;
if (node.left) { yield* this.preOrderTraversal(node.left); }
if (node.right) { yield* this.preOrderTraversal(node.right); }
}
複製程式碼
先序遍歷將按照以下順序輸出對應的值:10、5、4、3、30、15、40。與深度優先搜尋(DPS)的順序是一致的。
廣度優先搜尋 (BFS)
樹的 BFS 可以通過佇列來實現:
* bfs() {
const queue = new Queue();
queue.add(this.root);
while (!queue.isEmpty()) {
const node = queue.remove();
yield node;
node.descendents.forEach(child => queue.add(child));
}
}
複製程式碼
BFS 將按照以下順序輸出對應的值:10、5、30、4、15、40、3。
平衡樹 vs. 非平衡樹
目前,我們已經討論瞭如何新增、刪除與查詢元素。然而,我們並未談到(相關操作的)時間複雜度,先思考一下最壞的情況。
假設按升序新增數字:
樹的左側沒有任何節點!在這顆非平衡樹( Non-balanced Tree)中進行查詢元素並不比使用連結串列所花的時間短,都是 O(n)。 ?
在非平衡樹中查詢元素,如同以逐頁翻看的方式在字典中尋找一個單詞。但如果樹是平衡的,將類似於對半翻開字典,視乎該頁的字母,選擇左半部分或右半部分繼續查詢(對應的詞)。
需要找到一種方式使樹變得平衡!
如果樹是平衡的,查詢元素不在需要遍歷全部元素,時間複雜度降為 O(log n)。讓我們探討一下平衡樹的意義。
如果在非平衡樹中尋找值為 7 的節點,就必須從節點 #1 往下直到節點 #7。然而在平衡樹中,我們依次訪問 #4、#6 後,下一個節點將到達 #7。隨著樹規模的增大,(非平衡樹的)表現會越來越糟糕。如果樹中有上百萬個節點,查詢一個不存在的元素需要上百萬次訪問,而平衡樹中只要20次!這是天壤之別!
我們將在下一篇文章中通過自平衡樹來解決這個問題。
總結
我們討論了不少樹的基礎,以下是相關的總結:
- 樹是一種資料結構,它的節點有 0 個或多個子節點。
- 樹並不存在環,圖才存在。
- 在二叉樹中,每個節點最多隻有兩個子節點。
- 當一顆二叉樹中,左子節點的值小於節點的值,而右子節點的值大於節點的值時,這顆樹被稱為二叉搜尋樹。
- 可以通過先序、後續和中序的方式訪問一棵樹。
- 在非平衡樹中查詢的時間複雜度是 O(n)。 ??
- 在平衡樹中查詢的時間複雜度是 O(log n)。 ?