二分搜尋樹(Binary Search Tree)
為什麼要使用樹?
比如說我們電腦有磁碟, 磁碟下面有很多資料夾, 每個資料夾都分門別類的存放自己要查詢的東西。 假設有文學類資料夾、程式設計開發資料夾、畫畫資料夾等等等。
文學資料夾下又有散文、詩歌、小說、童話等等
程式設計資料夾下又有C++、JAVA、Python等等、
畫畫資料夾下又有油畫、插畫等等
每個大類下又分各種小類, 直到不能再細分到一個領域了。如果沒有樹結構的話, 我們如何能在大量檔案中 查詢到我們想要的書呢?即使能查到, 效率也是非常低的。
二叉樹
在瞭解二分搜尋樹之前, 我們先來看看二叉樹長什麼樣子。
通過下圖, 我們來大概瞭解一下什麼是二叉樹。
二叉樹和連結串列一樣屬於動態資料結構。我們不需要在建立資料結構的時候, 就去決定這個資料結構能夠儲存多少元素的問題。 如果要新增元素, 就new一個新空間新增到資料結構中, 刪除也是一樣的。
更具上圖, 我們如何構建一個二叉樹?
class Node {
E e ;
Node left ; // 左孩子
Node right ; // 右孩子
}
複製程式碼
二叉樹居右唯一一個根節點就是28這個元素。
在建立節點的同時, 我們還可以指定我們左邊和右邊的孩子是誰, 比如上圖中
元素28的左孩子是16右孩子是30
元素16的左孩子是13右孩子是22
元素30的左孩子是29右孩子是42
複製程式碼
每個節點都有一個父親節點, 除了根節點沒有父節點外。
16的父親節點是28
30的父親節點是28
複製程式碼
二叉樹顧名思義就是, 每個節點最多隻能分2個節點, 如果有多個節點我們可以更具它分為幾個叉就稱為幾叉樹(多叉樹)。
如果一個孩子都沒有的我們稱為葉子節點(左右孩子都為空就是葉子節點)。
二叉樹的遞迴
二叉樹具有天然的遞迴性, 每個節點又可以看做是一個二叉樹。
二叉樹一些形態
上面的話, 我們都是滿二叉樹, 但是很多時候都不是。如下圖
只有一個節點或者為空的二叉樹
只有左子樹的二叉樹
二分搜尋樹
定義:
- 若任意節點的左子樹不為空, 則左子樹上所有及誒按的值均小於它的根節點值
- 若任意節點的右子樹不為空, 則右子樹上所有節點的值均大於它的更及誒按的值
- 任意節點的左、右子樹分別為二分搜尋樹
二叉樹中每個元素都需要進行比較, 而且並不是都是一個滿二叉樹
實戰部分
經過前面的學習, 我們已經大概清楚什麼是二分搜尋樹了。下面我們通過程式碼來實現把。
/****
*
* 儲存的元素需要有可比較性, 所以我們需要繼承Comparable
* @param <E>
*/
public class BST<E extends Comparable<E>> {
private class Node {
E e ;
Node left ;
Node right ;
public Node(E e) {
this.e = e;
left = null ;
right = null ;
}
}
private Node root ;
private int size ;
public BST() {
this.root = null;
}
public int size() {
return size ;
}
public boolean isEmpty() {
return size == 0 ;
}
}
複製程式碼
新增元素
如何向二分搜尋樹新增一個元素?
假設當前二分搜尋樹為NULL, 我們新增元素5就作為root
即: 5
/ \
如果現在在來一個元素3呢? 更具上面瞭解的特性, 應該放在樹的左側
即: 5
/ \
3 NULL
如果再來一個元素9那麼則放在該樹的右側
即: 5
/ \
3 9
如果我們在加入一個元素9, 這裡我們不進行插入, 我們的二分搜尋樹不會插入重複資料
現在, 我們有了一個簡單的二分搜尋樹, 如果在插入新增的元素, 也是依次查詢判斷, 直到沒有可以比較的節點, 就進行插入。
複製程式碼
上面的圖片也更加直觀的展示如何插入一個元素了, 下面我們通過編寫程式碼來實現如何插入元素。
public void add(E e) {
// 如果當前樹節點為null, 直接讓當前元素為root
if (root == null) {
this.root = new Node(e) ;
size ++ ;
} else {
// 如果樹不為空, 則通過遞迴進行插入
add(root, e) ;
}
}
private void add(Node node, E e) {
if (e.compareTo(node.e) == 0) { // 如果是相同的值, 則直接終止
return ;
} else if (e.compareTo(node.e) < 0 && node.left == null) { // 如果小於並且該節點下面也沒有元素了, 則插入該元素
node.left = new Node(e) ;
size ++ ;
return ;
} else if (e.compareTo(node.e) > 0 && node.right == null) { // 如果大於並且該節點下面也沒有元素了, 則插入該元素
node.right = new Node(e) ;
size ++ ;
return ;
}
if (e.compareTo(node.e) < 0) {
add(node.left, e);
} else {
add(node.right, e);
}
}
複製程式碼
上面的方法確實已經實現了我們插入元素的要求, 但是判斷遞迴跳出條件卻很繁瑣。
假設遍歷到node就是為空了, 表示已經沒有節點可以判斷, 我們只需要建立一個node並返回。
遞迴的特性返回到上一層並獲取到返回值後, 我們只需要判斷是>0還是<0來設定為掛接到左子樹還是掛接到右子樹中。
改進後的程式
public void add(E e) {
root = add(root, e);
}
private Node add(Node node, E e) {
// 當我們遞迴遍歷到為空就表示當前節點已經到底了, 直接返回
if (node == null) {
size ++ ;
return new Node(e) ;
}
// 這裡能將上一次返回的值獲取到, 然後指定放在左子樹還是右子樹中
if (e.compareTo(node.e) < 0) {
node.left = add(node.left, e);
} else if (e.compareTo(node.e) > 0) {
node.right = add(node.right, e);
}
return node ;
}
複製程式碼
圖: 2
/ \
1 5
現在有這樣的一棵二分搜尋樹, 然後我要新增元素9會是如何呢?
1. 判斷當前節點是否為空
2. 判斷當前元素插入左子樹還是右子樹中, 指向下一個節點
3. 直到節點為空, 否則一直執行步驟1和2
4. 當節點為空後, 建立新節點返回資料, 並設定該節點掛載到左子樹或是右子樹中。
即:
| | | | | nil |
| | | 5 | | 5 |
| 2 | | 2 | | 2 |
|-----| |-----| |-----|
當碰到null的時候返回新的值, 此時5我們已經判斷他是大於的, 也就是5.right = 9
即: 2
/ \
1 5
\
9
複製程式碼
以上就是如何向二分搜尋樹中插入一個元素全過程。
二分搜尋樹查詢
如果上面的插入操作你已經理解了, 那麼查詢操作幾乎是手到擒來。它比插入操作更簡單, 因為不需要維護樹的結構, 只需要判斷是否存在。
public boolean contains(E e) {
return contains(root, e) ;
}
// 判斷二分搜尋樹是否包含該元素
private boolean contains(Node node, E e) {
if (node == null) {
return false ; // 如果樹為null, 則肯定不包含
}
if (e.compareTo(node.e) == 0) {
return true ; // 包含元素
} else if (e.compareTo(node.e) < 0) {
return contains(node.left, e) ;
} else {
return contains(node.right, e) ;
}
}
複製程式碼
整體邏輯幾乎是一模一樣的, 所以這裡不做過多的說明。
遍歷
- 前序遍歷
- 定義: 先訪問根節點, 然後前序遍歷左子樹, 在前序遍歷右子樹(中, 左, 右)
前序遍歷是怎麼個遍歷方式呢? 如下:
圖: 5
/ \
3 6
/ \ \
2 4 8
更具上面的定義, 在設計一個遞迴函式的時候, 我們要先輸出根節點, 然後遞迴左子樹, 左子樹沒有節點後在遍歷右子樹。
輸出: 5->3->2->4->6->8
複製程式碼
public void preOrder() {
preOrder(root);
}
// 前序遍歷二分搜尋樹
private void preOrder(Node node) {
if (node == null)
return ;
System.out.println(node.e);
preOrder(node.left); // 遍歷左子樹
preOrder(node.right); // 遍歷右子樹
}
複製程式碼
現在我們重寫toString方法, 利用前序遍歷的方法來輸出看看。
@Override
public String toString() {
StringBuffer res = new StringBuffer();
generateBSTString(root, 0, res);
return res.toString();
}
private void generateBSTString(Node node, int depth, StringBuffer res) {
if (node == null) {
res.append(generateDepthString(depth) + "null\n");
return ;
}
res.append(generateDepthString(depth) + node.e + "\n");
generateBSTString(node.left, depth + 1, res);
generateBSTString(node.right, depth + 1, res);
}
// depth深度, 這樣加入--就能知道在不在一個層級
private String generateDepthString(int depth) {
StringBuffer res = new StringBuffer();
for (int i = 0; i < depth; i ++) {
res.append("--");
}
return res.toString();
}
複製程式碼
上面的toString最終會輸出如下結果集:
5
--3
----2
------null
------null
----4
------null
------null
--6
----null
----8
------null
------null
這樣就知道3和6是同一個級別, 3是2的父節點
複製程式碼
- 中序遍歷
- 定義: 遍歷根節點的左子樹, 然後訪問根節點, 最後遍歷右子樹(左中右)
圖: 5
/ \
3 6
/ \ \
2 4 8
還是和上面一樣的樹結構, 如果不通過執行程式碼, 大家知道會輸出什麼結果嗎?
輸出: 2->3->4->5->6->8
你會發現輸出來之後, 是一個有順序的, 其實很正常所有左邊的節點資料都比中間的值要小, 所有右邊的資料都比中間值要大。所以輸出來是一個有順序的排列。
複製程式碼
public void inOrder() {
inOrder(root);
}
// 中序遍歷節點資料
private void inOrder(Node node) {
if (node == null) return ;
inOrder(node.left);
System.out.println(node.e);
inOrder(node.right);
}
複製程式碼
- 後序遍歷
- 定義: 從左到右先葉子後節點的方式遍歷訪問左右子樹, 最後訪問根節點(左右中)
圖: 5
/ \
3 6
/ \ \
2 4 8
還是和上面一樣的樹結構, 如果不通過執行程式碼, 大家知道會輸出什麼結果嗎?
輸出: 2->4->3->8->6->5
複製程式碼
public void postOrder() {
postOrder(root);
}
// 後序遍歷
private void postOrder(Node node) {
if (node == null) return ;
postOrder(node.left);
postOrder(node.right);
System.out.println(node.e);
}
複製程式碼
- 層次遍歷
圖: 5
/ \
3 6
/ \ \
2 4 8
輸出: 5->3->6->2->4->8
層次遍歷我們無法通過遞迴來實現, 我們需要藉助佇列來實現層次遍歷。
第一次, 28進入佇列, 然後獲取28的左右子樹(3和6)
第二次獲取3的左右子樹(2和4)
第三次獲取6的左右子樹(8)
F | 5 | F | 5 | F | 5 | F | 5 |
| | | 3 | | 3 | | 3 |
| | | 6 | | 6 | | 6 |
| | | | | 2 | | 2 |
| | | | | 4 | | 4 |
| | | | | | | 8 |
T | | T | | T | | T | |
複製程式碼
通過上面直觀的圖例, 我們看看程式碼如何實現把.
// 層序遍歷
public void levelOrder() {
Queue<Node> q = new LinkedList<>();
q.add(root);
while (!q.isEmpty()) {
Node cur = q.remove();
System.out.println(cur.e);
if (cur.left != null)
q.add(cur.left);
if (cur.right != null)
q.add(cur.right);
}
}
複製程式碼
刪除
在進行任意元素刪除, 我們先來做比較簡單的操作, 刪除最小和最大值開始。 已知二分搜尋樹的特性, 我們知道最左邊的值就是最小值, 最右邊的值就是最大值。
刪除最小值流程:
第一次刪除最小值13, 變成圖2的值
圖2中15成為最小的值, 就形成了圖3的值, 如果我們在刪除圖3中最小值之後(由於節點22有右子樹的值), 我們需要進行特殊處理。
在刪除節點22最小值的時候, 我們只需要把22的右子樹放置到41的左子樹就可以了。最終結果就是圖4了。
圖1: 41 圖2: 41 圖3: 41 圖4: 41
/ \ / \ / \ / \
22 58 22 58 22 58 33 58
/ \ / => / \ / => \ / => \ /
15 33 50 15 33 50 33 50 37 50
/ \ / \ \ / \ \ / \ / \
13 37 42 53 37 42 53 37 42 53 42 53
複製程式碼
刪除最大值流程:
第一次刪除最大值63, 變成圖2的值
當最大值為58的時候, 他有左子樹的資料, 和刪除最小值一樣也需要特殊處理一下將58的左子樹的值放到41的右子樹上就可以了。
圖1: 41 圖2: 41 圖3: 41
/ \ / \ / \
22 58 22 58 22 50
/ \ / \ => / \ / => / \ / \
15 33 50 63 15 33 50 15 33 42 53
/ \ / \ / \ / \ / \
13 37 42 53 13 37 42 53 13 37
複製程式碼
我們通過程式碼先來查詢出最小和最大值。
public E minimum() {
if (size == 0)
throw new IllegalArgumentException("BST is Empty");
return minimum(root).e;
}
// 查詢二分搜尋樹最小值
// 其實這種完全就破壞樹結構了, 和連結串列沒區別了, 一直掃左邊資料
private Node minimum(Node node) {
// 當前節點的left如果為空就表示當前節點為葉子節點退出遞迴條件
if (node.left == null)
return node ;
// 否則一直往左查詢
return minimum(node.left);
}
public E maximum() {
if (size == 0)
throw new IllegalArgumentException("BST is Empty");
return maximum(root).e;
}
// 查詢二分搜尋樹最大值
private Node maximum(Node node) {
// 當前節點的left如果為空就表示當前節點為葉子節點退出遞迴條件
if (node.right == null)
return node ;
// 否則一直往右查詢
return maximum(node.right);
}
複製程式碼
經過上面查詢最小和最大值的基礎, 其實我們在刪除節點只不過是說要稍微處理一下左右子樹的問題, 但其實和add()方法有點類似, 最後返回節點進行掛載.
public E removeMin() {
E e = minimum();
root = removeMin(root);
return e ;
}
// 刪除掉以node為根的二分搜尋樹中最小節點
// 返回刪除節點後新的二分搜尋樹的根
private Node removeMin(Node node) {
if (node.left == null) {
Node rightNode = node.right;
node.right = null;
size --;
return rightNode;
}
node.left = removeMin(node.left);
return node;
}
public E removeMax() {
E e = maximum();
root = removeMax(root);
return e ;
}
private Node removeMax(Node node) {
if (node.right == null) {
Node leftNode = node.left;
node.left = null;
size --;
return leftNode;
}
node.right = removeMax(node.right);
return node;
}
複製程式碼
接下來就是最主要的操作了, 如何刪除任意一個節點呢?
先來介紹刪除任意節點的幾種情況:
第一種情況: 刪除只有左孩子的節點(簡單)
如下圖1, 如果我要刪除的節點是58, 就和刪除最大節點在邏輯是一樣的, 把50節點掛載到41的右子樹上。
需要注意點是: 只有左孩子的節點, 不一定是最大值所在的節點, 比如節點15這個節點他也是有左孩子的
圖1: 41 圖2: 41
/ \ / \
22 58 22 50
/ \ / => / \ / \
15 33 50 15 33 42 53
/ / \ /
13 42 53 13
複製程式碼
第二種情況: 刪除只有右孩子的節點(簡單)
比如: 我們要刪除節點為58的值, 它只有右孩子, 和刪除最小值的邏輯基本一樣。
所以在刪除58的時候, 把58的右字數掛載到41的右子樹上即可。
圖1: 41 圖2: 41
/ \ / \
22 58 22 60
/ \ \ => / \ / \
15 33 60 15 33 59 63
/ \ / \ / \
13 37 59 63 13 37
複製程式碼
第三種情況: 刪除左右都有孩子的節點(重點難點)
比如現在我們還是刪除節點58的值, 但是左右孩子都不為空, 那如何處理呢?
1. 刪除左右都有孩子的節點起個別名為d
2. 如果刪除了58, 它既有左子樹又有右子樹, 我們應該找一個節點替代這個58的位置。我們這裡找的是58的"後繼節點"(就是離58元素最近的那個,
並且比58要大的節點)其實也就是59這個節點。如何找到59這個節點呢?(其實就是58的右子樹中對應最小值的節點)
58右子樹都比58大, 其中最小的那個元素就是比58大並且離58最近的元素。
3. 找到後繼節點s, 即: s = min(d->right), s是d的後繼
4. s->right = removeMin(d->right), 刪除並返回了一個的樹, 就會變為圖2。
這裡只掛載了右子樹。
5. s->left = d->left把別名d的左子樹掛載到後繼節點s上,就形成圖3了。
通過上面的步驟, d節點左右子樹已經被後繼節點s取代了, 這樣就可以放心的刪除節點d了。
圖1: 41 圖2: 41 圖3: 41
\ \ \
58 59 59
/ \ => \ => / \
50 60 60 50 60
/ \ / \ \ / \ \
42 53 59 63 63 42 53 63
複製程式碼
好了, 通過上面的學習, 瞭解了幾種場景解決方式, 現在通過程式碼來看看如何解決。
public void remove(E e) {
root = remove(root, e);
}
private Node remove(Node node, E e) {
if (e.compareTo(node.e) == 0) {
// 第一種情況: 刪除只有左孩子的節點(簡單)
if(node.left == null) {
Node rightNode = node.right;
node.right = null;
size --;
return rightNode;
}
// 第二種情況: 刪除只有右孩子的節點(簡單)
if (node.right == null) {
Node leftNode = node.left;
node.left = null;
size --;
return leftNode;
}
/***
* 待刪除節點左右子樹都不為空的情況
*/
// 1. 找到比待刪除節點大的最小節點, 即待刪除節點右子樹中最小的節點(找到後繼節點)
// 2. 用後繼節點頂替待刪除節點的位置
Node succeed = minimum(node.right);
// 返回刪除最小值後的一個新樹, 最小值已經被我們記錄住了, 然後設定左右子樹
succeed.right = removeMin(node.right);
succeed.left = node.left;
// 這裡之所以沒有size--, 是因為removeMin方法已經做了
node.left = node.right = null;
return succeed;
} else if (e.compareTo(node.e) > 0) {
node.right = remove(node.right, e);
return node;
} else {
node.left = remove(node.left, e);
return node;
}
}
複製程式碼