[譯文] 初學者應該瞭解的資料結構: Tree

sea_ljf發表於2018-08-11

原文連結:Tree Data Structures for Beginners

眾成翻譯地址:初學者應該瞭解的資料結構: Tree

系列文章,建議不瞭解樹的同學慢慢閱讀一下這篇文章,希望對你有所幫助~至於系列的最後一篇自平衡二叉搜尋樹就不再翻譯了(主要是原文坑太多,很難填),以下是譯文正文:

Tree 是很多(上層的)資料結構(如 Map、Set 等)的基礎。同時,在資料庫中快速搜尋(元素)也用到了樹。HTML 的 DOM 節點也通過樹來表示對應的層次結構。以上僅僅是樹在實際應用中的一小部分例子。在這篇文章中,我們將探討不同型別的樹,如二叉樹、二叉搜尋樹以及如何實現它們。

Tree Data Structures for Beginners

上一篇文章譯文)中,我們探討了資料結構:圖,它是樹一般化的情況。讓我們開始學習樹吧!


本篇是以下教程的一部分(譯者注:如果大家覺得還不錯,我會翻譯整個系列的文章):

初學者應該瞭解的資料結構與演算法(DSA)

  1. 演算法的時間複雜性與大 O 符號
  2. 每個程式設計師應該知道的八種時間複雜度
  3. 初學者應該瞭解的資料結構:Array、HashMap 與 List (譯文)
  4. 初學者應該瞭解的資料結構: Graph (譯文)
  5. 初學者應該瞭解的資料結構:Tree ? 即本文
  6. 自平衡二叉搜尋樹
  7. 附錄 I:遞迴演算法分析

樹的基本概念

在樹中,每個節點可有零個或多個子節點,每個節點都包含一個。和圖一樣,節點之間的連線被稱為。樹是圖的一種,但並不是所有圖都是樹(只有無環無向圖才是樹)。

這種資料型別之所以被稱為樹,是因為它長得很像一棵(倒置的)樹 ?。它從節點出發,它的子節點是它的分支,沒有任何子節點的節點就是樹的葉子(即葉節點)。

[譯文] 初學者應該瞭解的資料結構: Tree

以下是樹的一些屬性:

  • 最頂層的節點被稱為(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);
複製程式碼

這樣就完成啦,我們有了一棵樹!

[譯文] 初學者應該瞭解的資料結構: Tree
Simpson tree data structure

節點 abe節點,而節點 bartlisamaggie 則是這棵樹的 節點。注意,樹的節點的子節點可以是任意數量的,無論是 0 個、1 個、3 個或是多個均可。

二叉樹

樹的節點可以有 0 個或多個子節點。然而,當一棵樹(的所有節點)最多隻能有兩個子節點時,這樣的樹被稱為二叉樹

二叉樹是樹中最常見的形式之一,它應用廣泛,如:

  • Maps
  • Sets
  • 資料庫
  • 優先佇列
  • 在 LDAP(Lightweight Directory Access Protocol)中查詢相應資訊。
  • 在 XML/HTML 檔案中,使用文件物件模型(DOM)介面進行搜尋。

完滿二叉樹、完全二叉樹、完美二叉樹

取決於二叉樹節點的組織方式,一棵二叉樹可以是完滿二叉樹完全二叉樹完美二叉樹

  • 完滿二叉樹(Full binary tree):除去葉節點,每個節點都有兩個子節點。
  • 完全二叉樹(Complete binary tree):除了最深一層之外,其餘所有層的節點都必須有兩個子節點(譯者注:其實還需要最深一層的節點均集中在左邊,即左對齊)。
  • 完美二叉樹(Perfect binary tree):滿足完全二叉樹性質,樹的葉子節點均在最後一層(也就是形成了一個完美的三角形)。

(譯者注:國內外的定義是不同的,此處根據原文與查詢的資料,作了一定的修改,用的是國外的標準)

下圖是上述概念的例子:

[譯文] 初學者應該瞭解的資料結構: Tree
Full vs. Complete vs. Perfect Binary Tree

完滿二叉樹、完全二叉樹與完美二叉樹並不總是互斥的:

  • 完美二叉樹必然是完滿二叉樹和完全二叉樹。
    • 完美的二叉樹正好有 2 的 k 次方 減 1 個節點,其中 k 是樹的最深一層(從1開始)。.
  • 完全二叉樹並不總是完滿二叉樹。
    • 正如上面的完全二叉樹例子,最右側的灰色節點是它父子點僅有的一個子節點。如果移除掉它,這棵樹就既是完全二叉樹,也是完滿二叉樹。(譯者注:其實有了那個灰色節點的話,這顆樹不能算是完全二叉樹的,因為完滿二叉樹需要左對齊)
  • 完滿二叉樹並不一定是完全二叉樹與完美二叉樹。

二叉搜尋樹 (BST)

二叉搜尋樹(Binary Search Tree,簡寫為:BST)是二叉樹的特定應用。BST 的每個節點如二叉樹一樣,最多隻能有兩個子節點。然而,BST 左子節點的值必須小於父節點的值,而右子節點的值則必須大於父節點的值。

強調一下:一些 BST 並不允許重複值的節點被新增到樹中,如若允許,那麼重複值的節點將作為右子節點。有些二叉搜尋樹的實現,會記錄起重複的情況(這也是接下來我們需要實現的)。

一起來實現二叉搜尋樹吧!

BST 的實現

BST 的實現與上文樹的實現相像,然而有兩點不同:

  • 節點最多隻能擁有兩個子節點。
  • 節點的值滿足以下關係:左子節點 < 父節點 < 右子節點

以下是樹節點的實現,與之前樹的實現類似,但會為左右子節點新增 gettersetter。請注意,例項中會儲存父節點的引用,當新增新的子節點時,將更新(子節點中)父節點的引用。

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 節點的插入

要將一個新的節點插入到二叉搜尋樹中,我們需要以下三步:

  1. 如果樹中沒有任何節點,第一個節點當成為根節點
  2. (將新插入節點的值)與樹中的根節點或樹節點進行對比,如果值 更大,則放至右子樹(進行下一次對比),反之放到左子樹(進行對比) 。如果值一樣,則說明被重複新增,可增加重複節點的計數。
  3. 重複第二點操作,直至找到空位插入新節點。

讓我們通過以下例子來說明,樹中將依次插入30、40、10、15、12、50:

[譯文] 初學者應該瞭解的資料結構: Tree
Inserting nodes on a Binary Search Tree (BST)

程式碼實現如下:

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
複製程式碼

刪除根節點與此前討論的機制情況差不多。唯一的區別是需要更新二叉搜尋樹例項中根節點的引用。

以下的動畫是上述操作的具體展示:

[譯文] 初學者應該瞭解的資料結構: Tree
Removing a node with 0, 1, 2 children from a binary search tree

在動畫中,被移動的節點是左子節點或者左子樹,右子節點或右子樹位置保持不變。

關於刪除節點,已經有了思路,讓我們來實現它吧:

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. 非平衡樹

目前,我們已經討論瞭如何新增、刪除與查詢元素。然而,我們並未談到(相關操作的)時間複雜度,先思考一下最壞的情況。

假設按升序新增數字:

[譯文] 初學者應該瞭解的資料結構: Tree
Inserting values in ascending order in a Binary Search Tree

樹的左側沒有任何節點!在這顆非平衡樹( Non-balanced Tree)中進行查詢元素並不比使用連結串列所花的時間短,都是 O(n)。 ?

在非平衡樹中查詢元素,如同以逐頁翻看的方式在字典中尋找一個單詞。但如果樹是平衡的,將類似於對半翻開字典,視乎該頁的字母,選擇左半部分或右半部分繼續查詢(對應的詞)。

需要找到一種方式使樹變得平衡!

如果樹是平衡的,查詢元素不在需要遍歷全部元素,時間複雜度降為 O(log n)。讓我們探討一下平衡樹的意義。

[譯文] 初學者應該瞭解的資料結構: Tree
Balanced vs unbalanced Tree

如果在非平衡樹中尋找值為 7 的節點,就必須從節點 #1 往下直到節點 #7。然而在平衡樹中,我們依次訪問 #4、#6 後,下一個節點將到達 #7。隨著樹規模的增大,(非平衡樹的)表現會越來越糟糕。如果樹中有上百萬個節點,查詢一個不存在的元素需要上百萬次訪問,而平衡樹中只要20次!這是天壤之別!

我們將在下一篇文章中通過自平衡樹來解決這個問題。

總結

我們討論了不少樹的基礎,以下是相關的總結:

  • 樹是一種資料結構,它的節點有 0 個或多個子節點。
  • 樹並不存在環,圖才存在。
  • 在二叉樹中,每個節點最多隻有兩個子節點。
  • 當一顆二叉樹中,左子節點的值小於節點的值,而右子節點的值大於節點的值時,這顆樹被稱為二叉搜尋樹
  • 可以通過先序、後續和中序的方式訪問一棵樹。
  • 在非平衡樹中查詢的時間複雜度是 O(n)。 ??
  • 在平衡樹中查詢的時間複雜度是 O(log n)。 ?

相關文章