本文同步更新於個人網站中,有更好的排版和程式碼區塊 highlighting 支援。
Tree 是電腦程式設計中最重要、最核心的一種資料結構。樹狀結構是日常生活中廣泛存在的一種結構,例如族譜、公司組織架構等等。在程式設計中,Tree 更是無處不在,例如瀏覽器的 DOM、文件系統、編譯器中的語法樹也是一種樹。
而 Tree 這種資料結構,最經典、最基礎的例子就是二元樹(Binary Tree)。掌握好二元樹是學習其他樹狀結構的基石。
鏈結串列的每個節點都是物件,它由一個 next 屬性指向下一個節點,而樹是由多個屬性指向多個節點,然後每個節點都是同一種結構。整體結構可以參考附圖。
以前端人最熟悉的 HTML DOM Tree 為例:html 就是樹,然後它有兩個孩子 head 和 body,我們可以透過 html.firstChild、html.lastChild 存取它們。然後他們也可以透過 parentNode 存取到 html。head 裡也有許多元素節點。為了方便管理元素,瀏覽器提供了一個 children 屬性,裝載了某個元素的所有子元素節點。

接著來學習一下樹的基本術語:
樹的分類有很多種,但基本上都是二元樹的衍生。二元樹顧名思義,就是每個節點最多只能有兩個子節點的樹,這兩個子樹被稱為左子樹和右子樹。而左右子樹也必須是二元樹,並且次序不能任意顛倒。
二元樹是遞迴定義的,所以一般二元樹的相關題目也都可以使用遞迴的思想來解決。當然也有一些可以使用非遞迴的方法解決。
二元樹有 3 種型態:歪斜樹、完滿二元樹、完整二元樹。
n 個節點的二元樹按層序編號,如果編號為 i 的節點與同樣深度的完滿二元樹中編號為 i 的節點在二元樹中的位置完全相同,那麼它就是完整二元樹。完滿二元樹一定是完整二元樹,但完整二元樹不一定是完滿二元樹。兩者結構示意圖如下:
二元樹的主要方法如下:
二元樹的許多方法是遞迴實現的,實作程式碼如下:
class TreeNode {
  constructor(data) {
    this.parent = null;
    this.data = data;
    this.left = null;
    this.right = null;
  }
}
class Tree {
  constructor() {
    this.root = null;
    this._size = 0;
  }
  insert(data) {}
  remove(data) {}
  size() {}
  minNode() {}
  maxNode() {}
  min() {}
  max() {}
  getNodeSize() {}
  height() {}
  getNodeHeight() {}
}
我們新增一個私有屬性 _insertLeft,用來決定資料插入的位置。首先,我們需要判斷它有沒有左子樹節點:沒有則插入左邊,如果有則插入右邊。如果這個節點滿了,那就要選擇是從左子樹還是右子樹繼續往下找。我們通過 _insertLeft 來決定是往左邊還是右邊,可以這次是左邊,下次是右邊,每次執行前修改 _insertLeft 的值。
constructor() {
  this.root = null;
  this._size = 0;
  this._insertLeft = false;
}
insert(data) {
  const dir = (this._insertLeft = !this._insertLeft); // 插入方向
  function insertIt(node, data) {
    if (node.data === data) {
      return false;
    } else if (!node.left) {
      node.left = new TreeNode(data);
      node.left.parent = node;
      return true;
    } else if (!node.right) {
      node.right = new TreeNode(data);
      node.right.parent = node;
      return true;
    } else {
      if (dir === true) {
        return insertIt(node.left, data); // 遞迴
      }
      return insertIt(node.right, data); // 遞迴
    }
  }
  let ret = false;
  if (!this.root) {
    this.root = new TreeNode(data);
    ret = true;
  } else {
    ret = insertIt(this.root, data);
  }
  if (ret) {
    this._size++;
  }
  return ret;
}
const tree = new Tree();
tree.insert(1);
tree.insert(2);
tree.insert(3);
tree.insert(4);
tree.insert(5);
tree.insert(6);
tree.insert(7);
tree.insert(8);
console.log(tree);
執行一下上面的程式碼,在控制台確認一下結果:

在開始刪除操作之前,我們需要先實作一個搜尋方法,傳入 data,有與之對應的資料就回傳該節點,沒有就回傳 null。 由於我們的樹的資料是沒有放置規則的,所以只能全部遞迴搜尋一遍。
find(data) {
  let ret = null;
  function findIt(node, data) {
    if (node) {
      if (node.data === data) {
        ret = node;
      } else {
        findIt(node.left, data);
        findIt(node.right, data);
      }
    }
  }
  findIt(this.root, data);
  return ret;
}
刪除可以算是二元樹最為複雜的操作,因為刪除節點後,樹的結構會發生變化,所以刪除操作需要考慮很多情況。
在刪除時上面四種情況都必須考慮進去,並且這四種情況還有更細的劃分。實作程式碼如下:
remove(data) {
  const node = this.find(data);
  if (node) {
    this._size--;
    if (node === this.root) {
      this.root = null;
      return true;
    }
    let left = node.left;
    let right = node.right;
    let parent = node.parent;
    let isLeft = parent && parent.left === node;
    if (!left && !right) { // 沒有子節點
      // todo 1
    } else if (left && !right) { // 只有左子節點
      // todo 2
    } else if (!left && right) { // 只有右子節點
      // todo 3
    } else if (left && right) { // 有左右子節點
      // todo 4
    }
  }
}
(1) 如果被刪除的節點是葉節點,直接刪掉就好了,具體操作是判斷它是父節點哪一邊的子節點,然後將父節點的對應屬性設為 null。
if (isLeft) {
  parent.left = null;
} else {
  parent.right = null;
}
(2) 被刪除的節點只有左子節點。
if (isLeft) {
  parent.left = left;
} else {
  parent.right = left;
}
left.parent = parent;
當被刪除的節點只有一個子節點時,我們只需要將子節點提升到被刪除的節點的位置即可。只是在最後一步,我們需要將子節點的 parent 屬性指向被刪除節點的 parent 屬性。
(3) 被刪除的節點只有右子節點,這種情況跟第二種情況類似:
if (isLeft) {
  parent.left = right;
} else {
  parent.right = right;
}
right.parent = parent;
(4) 被刪除的節點有兩個子節點,這種情況最為麻煩,如果我們隨便選擇一個子節點來替代被刪除的節點,那麼這個分支上就只有一個子節點了。一個完整的二元樹不能在中間出現只有一個子節點的情況。因此,我們可以在被刪除節點的“孩子”裡找到一個葉節點,先讓它替代被刪除的節點,然後再刪除這個葉節點。
let child = right;
while (child.left) {
  child = child.left;
}
this.remove(child.data);
node.data = child.data;
這樣一來 remove 方法就成功了,但有許多可以改進的地方,我們可以將一些重複的程式碼獨立出來,例如連接被刪節點的“子節點”和“父節點”這一步,可以寫成一個 transplant 方法:
transplant(node, child) {
  if (node.parent == null) {
    this.root = child;
  } else if (node === node.parent.left) {
    node.parent.left = child;
  } else {
    node.parent.right = child;
  }
  if (child) {
    child.parent = node.parent;
  }
}
移除的方法可以弄成幾個分支:有兩個子節點,只有一個子節點,沒有子節點。程式碼如下:
remove(data) {
  const node = this.find(data);
  if (node) {
    this.removeNode(node);
    this._size--;
  }
}
removeNode(node) {
  // 如果有兩個子節點
  if (node.left !== null && node.right !== null) {
    let succ = null;
    for (succ = node.right; succ.left !== null; succ = succ.left); // 找到後繼
    node.data = succ.data; // 用後繼的值替換當前節點的值
    this.removeNode(succ); // 遞迴刪除,只可能遞迴一次
  } else {
    // 葉節點或只有一個子節點
    let child = node.left || node.right || null;
    this.transplant(node, child);
  }
}
如果這個二元樹是一個二元搜尋樹(後面會提到),那麼最大值和最小值就是最右邊的葉節點和最左邊的葉節點:
minNode(node) {
  let current = node || this.root;
  while (current.left) {
    current = current.left;
  }
  return current;
}
maxNode(node) {
  let current = node || this.root;
  while (current.right) {
    current = current.right;
  }
  return current;
}
min() {
  const node = this.minNode();
  return node ? node.data : null;
}
max() {
  const node = this.maxNode();
  return node ? node.data : null;
}
如果不是二元搜尋樹,那麼就需要遍歷整棵樹,找到最大值和最小值。
要取得二元樹的節點數,需要遍歷所有子樹,然後相加求出總和。
size() {
  return this._size; // this.getNodeSize(this.root);
}
getNodeSize(node) {
  if (node === null) {
    return 0;
  }
  const leftChildSize = this.getNodeSize(node.left);
  const rightChildSize = this.getNodeSize(node.right);
  return leftChildSize + rightChildSize + 1;
}
要取得樹的高度,需要遍歷所有子樹的高度,然後取最大值。
height() {
  return this.getNodeHeight(this.root);
}
getNodeHeight(node) {
  if (node === null) {
    return 0;
  }
  const leftChildHeight = this.getNodeHeight(node.left);
  const rightChildHeight = this.getNodeHeight(node.right);
  const max = Math.max(leftChildHeight, rightChildHeight);
  return max + 1; // 加上自己本身
}
我們今天已經對 Tree 有了一個初步的認識,並且實作了二元樹的建立、新增、刪除、搜尋、取得樹高、取得樹的節點數等方法,明天我們還要來看如何去走訪一棵樹,這也是一個非常基礎且重要的操作。