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

樹的整體結構
接著來學習一下樹的基本術語:
- 節點(Node):指樹中的一個元素。
- 分支度(Degree):指節點擁有的子樹的個數,二元樹的分支度不大於 2。
- 階層(Level):從根節點開始,根節點的階層為 1,根節點的子節點的階層為 2,以此類推。
- 兄弟節點(Siblings):具有相同父節點的節點互相稱為兄弟節點。如上圖:B、C、D 互為兄弟節點。
- 父節點(Parent):若一個節點含有子節點,則此節點稱為其子節點的父節點。如上圖:B 為 E 的父節點。
- 子節點(Child):如果此節點有上層節點,則此節點稱為其上層節點的子節點。如上圖:G 為 C 的子節點。
- 根節點(Root):沒有父節點的節點稱為根節點。如上圖:A。
- 葉節點(Leaf):沒有子節點的節點。如上圖:K、L、F、G、M、I、J。
- 非終端節點(Non-terminal Nodes):除了樹葉節點之外,其他都是非終端節點。
- 樹高(Height)或樹深(Depth):樹的最大階層數。
- 祖先節點(Ancestors):指某節點到根節點之間所經過的所有節點。
- 子孫節點(Descendants):以某節點為根的子樹中的所有節點。
二元樹 Binary Tree 
樹的分類有很多種,但基本上都是二元樹的衍生。二元樹顧名思義,就是每個節點最多只能有兩個子節點的樹,這兩個子樹被稱為左子樹和右子樹。而左右子樹也必須是二元樹,並且次序不能任意顛倒。
二元樹是遞迴定義的,所以一般二元樹的相關題目也都可以使用遞迴的思想來解決。當然也有一些可以使用非遞迴的方法解決。
二元樹有 3 種型態:歪斜樹、完滿二元樹、完整二元樹。
- 歪斜樹(Skewed Tree):所有節點都只有左子樹(左歪斜樹)或都只有右子樹(右歪斜樹),應用不多。其實也可以看成是鏈結串列。
- 完滿二元樹(Full Binary Tree):所有的分支節點都存在左子樹和右子樹,且所有葉節點都在同一層上。關鍵在於樹的平衡,根據完滿二元樹的定義,得到的特點如下: - 葉節點只能出現在最下層,出現在其他層就不可能達成平衡。
- 非葉節點的分支度一定是 2。
- 在同樣深度的二元樹中,完滿二元樹的節點個數最多,葉子樹最多。
- 節點個數的計算公式:,其中 h 為樹高。 
 
- 完整二元樹(Complete Binary Tree):對一棵具有 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() {}
}Tree 的插入操作 
我們新增一個私有屬性 _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);執行一下上面的程式碼,在控制台確認一下結果:

二元樹插入操作
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;
}Tree 的刪除操作 
刪除可以算是二元樹最為複雜的操作,因為刪除節點後,樹的結構會發生變化,所以刪除操作需要考慮很多情況。
- 被刪除的節點是葉節點。
- 被刪除的節點只有左子節點。
- 被刪除的節點只有右子節點。
- 被刪除的節點有左右子節點。
在刪除時上面四種情況都必須考慮進去,並且這四種情況還有更細的劃分。實作程式碼如下:
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 有了一個初步的認識,並且實作了二元樹的建立、新增、刪除、搜尋、取得樹高、取得樹的節點數等方法,明天我們還要來看如何去走訪一棵樹,這也是一個非常基礎且重要的操作。