這週是六角鼠年鐵人賽第二十二週。
之前我們已經簡單介紹 樹 & 二元樹,但我們沒有實作它,接下來我們將說明一種最常使用的二元樹資料結構:「二元搜尋樹(Binary Search Tree)」,還有實作二元樹的走訪。
  
  
二元搜尋樹
二元搜尋樹(Binary Search Tree, BST)、二元搜索樹,也稱為 有序二元樹(Ordered binary tree) 或 排序二元樹(Sorted binary tree),是一種具有特殊性值的二元樹。
1. 定義
可以是一棵空樹或者具有下列性質的二元樹:
- 若任意節點的左子樹不空,則左子樹上所有節點的值均小於它的根節點的值;
- 若任意節點的右子樹不空,則右子樹上所有節點的值均大於它的根節點的值;
- 任意節點的左、右子樹也分別為二元搜尋樹。
這個定義可能會出現一些變化:
- 上面的定義,不能允許出現重複的資料。
- 若允許重複的資料,定義會更改成:
- 左子樹,小於等於;或是:
- 右子樹,大於等於。
 
這是一顆普通的二元搜尋樹的結構:
graph TB;
  10((10)) --- 5((5));
  10((10)) --- 15((15));
  5((5)) --- 4((4));
  15((15)) --- 12((12));
  5((5)) --- 8((8));
  8((8)) --- 6((6));
  8((8)) --- 9((9));
  15((15)) --- 16((16));
JavaScript 實作二元搜尋樹
1. 二元樹的基本結構
實作二元樹通常會用鏈結串列表示法。
二元樹的節點:
| 1
2
3
4
5
6
7
 | class BTNode {
  constructor(data) {
    this.data = data;
    this.left = null;
    this.right = null;
  }
}
 | 
 
- data:用來存放的資料值;
- left:指向左子樹的指標;
- right:指向右子樹的指標。
二元搜尋樹本體:
| 1
2
3
4
5
6
 | class BinarySearchTree {
  constructor() {
    this.root = null;
  }
  // methods
}
 | 
 
操作方法:
- 二元搜尋樹基本操作:
- 搜尋
- 新增
- 刪除
 
- 二元樹的走訪操作:
2. 搜尋操作
根據 BST 的性質,對於每個節點:
- 若目標值等於節點的值,則回傳節點;
- 若目標值小於節點的值,則繼續在左子樹中搜尋;
- 若目標值大於節點的值,則繼續在右子樹中搜尋;
- 若節點不存在,回傳 null。
使用迭代的方式實作:
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
 | search(data, node = this.root) {
  let curNode = node;
  while (curNode) {
    if (data === curNode.data) {
      return curNode;
    }
    if (data < curNode.data) {
      curNode = curNode.left;
    } else {
      curNode = curNode.right;
    }
  }
  return null;
}
 | 
 
使用遞迴實作:
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
 | search(data, node = this.root) {
  if (!node) {
    return null;
  }
  if (data === node.data) {
    return node;
  }
  if (data < node.data) {
    return this.search(data, node.left);
  }
  return this.search(data, node.right);
}
 | 
 
簡化:
| 1
2
3
4
 | search(data, node = this.root) {
  if (!node || node.data === data) return node;
  return node.data < data ? this.search(data, node.right) : this.search(data, node.left);
}
 | 
 
3. 新增操作
新增操作是建立 BST 的基礎操作,有許多不同的做法,但這裡只討論最經典的方式。
與搜尋操作類似,對於每個節點:
- 若不允重複值,目標值等於節點的值時,結束操作。
- 若目標值小於節點的值,則前往左子樹;
- 若目標值大於節點的值,則前往右子樹;
- 若節點為空,設置新節點。
使用迭代實作:
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
 | insert(data) {
  if (!this.root) {
    this.root = new BTNode(data);
    return;
  }
  let curNode = this.root;
  while (curNode) {
    if (data < curNode.data) {
      if (curNode.left) {
        curNode = curNode.left;
      } else {
        curNode.left = new BTNode(data);
        break;
      }
    } else if (data > curNode.data) {
      if (curNode.right) {
        curNode = curNode.right;
      } else {
        curNode.right = new BTNode(data);
        break;
      }
    } else {
      break;
    }
  }
}
 | 
 
- 先判斷樹是否為空樹,若是,則將新節點設為根節點,新增結束;否則:
- 判斷當前節點,預設為根節點:
- 若小於當前節點資料,判斷左子樹是否存在:
- 存在,將當前節點設為左子樹,重新判斷。
- 不存在,將右子樹設為新節點,新增結束。
 
- 若大於當前節點資料,判斷右子樹是否存在:
- 存在,將當前節點設為右子樹,重新判斷。
- 不存在,將右子樹設為新節點,新增結束。。
 
- 若等於當前節點資料,新增失敗。
 
使用遞迴實作:
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
 | insert(data) {
  const insertHelper = (node) => {
    const curNode = node;
    if (data < curNode.data) {
      if (curNode.left) {
        insertHelper(curNode.left);
      } else {
        curNode.left = new BTNode(data);
      }
    } else if (data > curNode.data) {
      if (curNode.right) {
        insertHelper(curNode.right);
      } else {
        curNode.right = new BTNode(data);
      }
    }
  };
  
  if (!this.root) {
    this.root = new BTNode(data);
  } else {
    insertHelper(this.root);
  }
}
 | 
 
4. 最小值/最大值
尋找最小值節點,就是一直往左子樹移動,直到空子樹,回傳最後一個左子樹:
| 1
2
3
4
5
6
7
 | findMin(node = this.root) {
  let currentNode = node;
  while (currentNode && currentNode.left ) {
    currentNode = currentNode.left;
  }
  return currentNode;
}
 | 
 
尋找最大值節點,反過來就是一直往右子樹移動,直到空子樹,回傳最後一個右子樹:
| 1
2
3
4
5
6
7
 | findMax(node = this.root) {
  let currentNode = node;
  while (currentNode && currentNode.right) {
    currentNode = currentNode.right;
  }
  return currentNode;
}
 | 
 
5. 刪除操作
在二元搜尋樹刪除一個節點,需要考慮節點的三種情況:
- 葉子節點(無子樹),直接刪除。
- 節點有單邊子樹,用子樹代替該節點。
- 節點有左右兩邊子樹,處理方式為:
- 尋找被刪除節點鄰近的節點值來代替;
- 取得鄰近的節點方式:
- 前驅節點:左子樹取最大值
- 後繼節點:右子樹取最小值(範例使用它)
 
- 接著刪除用來代替的節點。
 
比較麻煩的是,二元搜尋樹節點是單向的,沒有父節點指標,所以我們需要透過遞迴的方式來更新節點。
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
 | remove(data) {
  const removeNode = (data, node) => {
    const curNode = node;
    // 1
    if (!curNode) {
      return false;
    }
    // 2
    if (data < curNode.data) {
      curNode.left = removeNode(data, curNode.left);
    // 3
    } else if (data > curNode.data) {
      curNode.right = removeNode(data, curNode.right);
    // 4
    } else {
      // 4.1
      if (!curNode.left && !curNode.right) {
        return null;
      }
      // 4.2
      if (!curNode.left) {
        return curNode.right;
      }
      if (!curNode.right) {
        return curNode.left;
      }
      // 4.3
      const aux = this.findMin(curNode.right);
      curNode.data = aux.data;
      curNode.right = removeNode(aux.data, curNode.right);
    }
    return curNode;
  };
  this.root = removeNode(data, this.root);
}
 | 
 
要刪除節點,就比須先找到它:
- 節點不存在。
- 小於當前節點資料,前往左子樹;
- 大於當前節點資料,前往右子樹;
- 等於當前節點,刪除:
- 葉子節點,直接刪除。
- 單邊子樹,用子樹代替。
- 左右兩邊子樹:
- 取得右子樹最小值;
- 替換值;
- 刪除右子樹最小值節點。
 
 
6. DFS
DFS 共有三種走訪順序:
關於走訪,之前在 樹 & 二元樹 有說明。
6.1 遞迴
首先是前序走訪,執行順序為:
- (N)訪問當前節點
- (L)走訪左子樹
- (R)走訪右子樹
遞迴:
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
 | preOrderTraversal() {
  const temp = [];
  preHelper(this.root);
  return temp;
  function preHelper(node) {
    if (node) {
      temp.push(node.data);
      preHelper(node.left);
      preHelper(node.right);
    }
  }
}
 | 
 
中序、後序走訪差異不大:
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
 | inOrderTraversal() {
  const temp = [];
  inHelper(this.root);
  return temp;
  
  function inHelper(node) {
    if (node) {
      inHelper(node.left);
      temp.push(node.data);
      inHelper(node.right);
    }
  };
}
postOrderTraversal() {
  const temp = [];
  postHelper(this.root);
  return temp;
  
  function postHelper(node) {
    if (node) {
      postHelper(node.left);
      postHelper(node.right);
      temp.push(node.data);
    }
  };
}
 | 
 
6.2 迭代
我們可以使用堆疊(stack)來模擬遞迴結構。
前序走訪:
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
 | preOrderTraversal() {
  const temp = [];
  const stack = [];
  if (this.root) {
    stack.push(this.root);
  }
  while (stack.length) {
    const node = stack.pop();
    temp.push(node.data);
    
    if (node.right) {
      stack.push(node.right);
    }
    
    if (node.left) {
      stack.push(node.left);
    }
  }
  return temp;
}
 | 
 
因為堆疊是後進先出,因此要先將右子樹推入堆疊再推入左子樹。
中序走訪:
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
 | inOrderTraversal() {
  const temp = [];
  const stack = [];
  let node = this.root;
  
  while (node || stack.length) {
    while (node) {
      stack.push(node);
      node = node.left;
    }
    
    node = stack.pop();
    temp.push(node.data);
    
    node = node.right;
  }
  return temp;
}
 | 
 
先將左子樹全部加入堆疊中,然後逐個取出。
後序走訪可以將前序作法的右子樹與左子樹的堆入順序交換,即 NLR 變成 NRL,最後輸出時反轉陣列,變成 LRN。
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
 | postOrderTraversal() {
  const temp = [];
  const stack = [];
  if (this.root) {
    stack.push(this.root);
  }
  while (stack.length) {
    const node = stack.pop();
    temp.push(node.data);
    if (node.left) {
      stack.push(node.left);
    }
    if (node.right) {
      stack.push(node.right);
    }
  }
  return temp.reverse();
}
 | 
 
6.3 輸出
這是二元搜尋樹結構:
graph TB;
  10((10)) --- 5((5));
  10((10)) --- 15((15));
  5((5)) --- 4((4));
  15((15)) --- 12((12));
  5((5)) --- 8((8));
  8((8)) --- 6((6));
  8((8)) --- 9((9));
  15((15)) --- 16((16));
輸出:
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
 | const nums = [10, 5, 4, 8, 6, 9, 15, 12, 16];
const BST = new BinarySearchTree();
for (const data of nums) {
  BST.insert(data);
}
console.log( BST.preOrderTraversal() );
// [ 10, 5, 4, 8, 6, 9, 15, 12, 16 ] 
console.log( BST.inOrderTraversal() );
// [ 4, 5, 6, 8, 9, 10, 12, 15, 16 ]  
console.log( BST.postOrderTraversal() );
// [ 4, 6, 9, 8, 5, 12, 16, 15, 10 ] 
 | 
 
二元搜尋樹使用不同順序的走訪,有不同的功能:
- 使用先序走訪,可以結構化輸出。
- 使用中序走訪,可以從小到大輸出,具有排序的功能。
- 使用後序走訪,可以用於計算有層級關係的所有元素的大小。
7. BFS
層序走訪會先訪問離根節點最近的節點,也就是它會由上而下,並在同一個階層,由左至右依序訪問節點。
通常會使用佇列(queue)來實現:
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
 | levelorderTraversal() {
  const temp = [];
  const queue = [];
  
  if (this.root) {
    queue.push(this.root);
  }
  
  while(queue.length) {
    const node = queue.shift();
    temp.push(node.data);
    if(node.left) {
      queue.push(node.left);
    }
    if(node.right) {
      queue.push(node.right);
    }
  }
  return temp;
}
 | 
 
層序走訪會依照階層,由左至右依序訪問節點:
| 1
2
 | console.log( BST.levelorderTraversal() );
// [ 10, 5, 15, 4, 8, 12, 16, 6, 9 ] 
 | 
 
我們可以稍作修改,用陣列存儲每一層的節點:
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
 | levelorderTraversal() {
  const temp = [];
  const queue = [];
  if (this.root) {
    queue.push(this.root);
  }
  while (queue.length) {
    const subTemp = [];
    const len = queue.length;
    for (let i = 0; i < len; i += 1) {
      const node = queue.shift();
      subTemp.push(node.data);
      if (node.left) {
        queue.push(node.left);
      }
      if (node.right) {
        queue.push(node.right);
      }
    }
    temp.push(subTemp);
  }
  return temp;
}
 | 
 
| 1
2
 | console.log( BST.levelorderTraversal() );
// [ [ 10 ], [ 5, 15 ], [ 4, 8, 12, 16 ], [ 6, 9 ] ] 
 | 
 
總結
1. 分析
二元搜尋樹的新增、搜尋、刪除操作時間複雜度會根據樹的高來決定,最佳、平均的時間複雜度為 $O(\log n)$。
但二元搜尋樹最大的問題就是,它會出現極端情況,傾斜某一邊。舉例來說,當我們順序新增元素,二元搜尋樹會退化成鏈結串列,元素數量多少,樹高就是多少,造成新增、搜尋、刪除操作最差時間複雜度為 $O(n)$。
舉例,依序輸入1 2 3 4:
graph TB;
  11((1)) --- 2((null)) & 22((2));
  22((2)) --- 3((null)) & 33((3));
  33((3)) --- 4((null)) & 44((4));
2. 將二元搜尋樹變平衡
如果要將一棵傾斜的二元搜尋樹變得平衡,可以這樣處理:
- 對二元搜尋樹執行的中序走訪取得有序的陣列;
- 利用有序的陣列,重建一棵平衡的二元搜尋樹:
- 從陣列的中間位置取一個元素,得到樹的根節點。
- 對陣列的左邊和右邊遞迴執行相同操作,得到根節點的左、右子樹。
 
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
 | balanceBST() {
  const nodeList = this.inOrderTraversal();
  const { length } = nodeList;
  if (length < 3) {
    return this.root;
  }
  this.root = rebuild(0, length - 1);
  function rebuild(start, end) {
    if (start > end) {
      return null;
    }
    const mid = Math.floor((start + end) / 2);
    const node = new BTNode(nodeList[mid]);
    node.left = rebuild(start, mid - 1);
    node.right = rebuild(mid + 1, end);
    return node;
  }
}
 | 
 
3. 平衡樹
為了避免二元搜尋樹出現極端情況,有人發明了「平衡樹(Balanced Tree)」,它能在新增節點時,自動平衡,下週詳細說明。
4. 視覺化
這是我用 Vue.js 製作的,可以很方便的觀察二元搜尋樹結構變化: