原文連接:Tree Data Structures for Beginnersnode
衆成翻譯地址:初學者應該瞭解的數據結構: Tree程序員
系列文章,建議不瞭解樹的同窗慢慢閱讀一下這篇文章,但願對你有所幫助~至於系列的最後一篇自平衡二叉搜索樹就再也不翻譯了(主要是原文坑太多,很難填),如下是譯文正文:算法
Tree 是不少(上層的)數據結構(如 Map、Set 等)的基礎。同時,在數據庫中快速搜索(元素)也用到了樹。HTML 的 DOM 節點也經過樹來表示對應的層次結構。以上僅僅是樹在實際應用中的一小部分例子。在這篇文章中,咱們將探討不一樣類型的樹,如二叉樹、二叉搜索樹以及如何實現它們。數據庫
在上一篇文章(譯文)中,咱們探討了數據結構:圖,它是樹通常化的狀況。讓咱們開始學習樹吧!數據結構
本篇是如下教程的一部分(譯者注:若是你們以爲還不錯,我會翻譯整個系列的文章):ide
初學者應該瞭解的數據結構與算法(DSA)函數
在樹中,每一個節點可有零個或多個子節點,每一個節點都包含一個值。和圖同樣,節點之間的鏈接被稱爲邊。樹是圖的一種,但並非全部圖都是樹(只有無環無向圖纔是樹)。post
這種數據類型之因此被稱爲樹,是由於它長得很像一棵(倒置的)樹 🌳。它從根節點出發,它的子節點是它的分支,沒有任何子節點的節點就是樹的葉子(即葉節點)。學習
如下是樹的一些屬性:動畫
A
的度是 3。I
的度是 0(譯者注:子樹也是樹,I 的度是指 I 爲根節點的子樹的度)。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 個或多個子節點。然而,當一棵樹(的全部節點)最多隻能有兩個子節點時,這樣的樹被稱爲二叉樹。
二叉樹是樹中最多見的形式之一,它應用普遍,如:
取決於二叉樹節點的組織方式,一棵二叉樹能夠是完滿二叉樹、徹底二叉樹或完美二叉樹。
(譯者注:國內外的定義是不一樣的,此處根據原文與查找的資料,做了必定的修改,用的是國外的標準)
下圖是上述概念的例子:
完滿二叉樹、徹底二叉樹與完美二叉樹並不老是互斥的:
二叉搜索樹(Binary Search Tree,簡寫爲: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() { /* ... */ }
}
複製代碼
下面先編寫插入新節點相關的的代碼。
要將一個新的節點插入到二叉搜索樹中,咱們須要如下三步:
讓咱們經過如下例子來講明,樹中將依次插入30、40、十、1五、十二、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
沿着樹的結構搜索值。它從根節點出發,往左仍是往右搜索取決於節點的值。若是已存在相同值的節點,函數返回找到的節點(即相同值的節點)與它的父節點。若是沒有相同值的節點,則返回最後找到的節點(即將變爲新插入節點父節點的節點)。
咱們已經知道如何(在二叉搜索樹中)插入與查找值,如今將實現刪除操做。這比插入而言稍微麻煩一點,讓咱們用下面的例子進行說明:
刪除葉節點(即沒有任何子節點的節點)
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
複製代碼
中序遍歷將按照如下順序輸出對應的值:三、四、五、十、1五、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;
}
複製代碼
後序遍歷將按照如下順序輸出對應的值:三、四、五、1五、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); }
}
複製代碼
先序遍歷將按照如下順序輸出對應的值:十、五、四、三、30、1五、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 將按照如下順序輸出對應的值:十、五、30、四、1五、40、3。
目前,咱們已經討論瞭如何新增、刪除與查找元素。然而,咱們並未談到(相關操做的)時間複雜度,先思考一下最壞的狀況。
假設按升序添加數字:
樹的左側沒有任何節點!在這顆非平衡樹( Non-balanced Tree)中進行查找元素並不比使用鏈表所花的時間短,都是 O(n)。 😱
在非平衡樹中查找元素,如同以逐頁翻看的方式在字典中尋找一個單詞。但若是樹是平衡的,將相似於對半翻開字典,視乎該頁的字母,選擇左半部分或右半部分繼續查找(對應的詞)。
須要找到一種方式使樹變得平衡!
若是樹是平衡的,查找元素不在須要遍歷所有元素,時間複雜度降爲 O(log n)。讓咱們探討一下平衡樹的意義。
若是在非平衡樹中尋找值爲 7 的節點,就必須從節點 #1 往下直到節點 #7。然而在平衡樹中,咱們依次訪問 #四、#6 後,下一個節點將到達 #7。隨着樹規模的增大,(非平衡樹的)表現會愈來愈糟糕。若是樹中有上百萬個節點,查找一個不存在的元素須要上百萬次訪問,而平衡樹中只要20次!這是天壤之別!
咱們將在下一篇文章中經過自平衡樹來解決這個問題。
咱們討論了很多樹的基礎,如下是相關的總結: