資料結構-二分搜尋樹

小墨魚3發表於2020-01-31

二分搜尋樹(Binary Search Tree)

為什麼要使用樹?

比如說我們電腦有磁碟, 磁碟下面有很多資料夾, 每個資料夾都分門別類的存放自己要查詢的東西。 假設有文學類資料夾、程式設計開發資料夾、畫畫資料夾等等等。

文學資料夾下又有散文、詩歌、小說、童話等等
程式設計資料夾下又有C++、JAVA、Python等等、
畫畫資料夾下又有油畫、插畫等等

每個大類下又分各種小類, 直到不能再細分到一個領域了。如果沒有樹結構的話, 我們如何能在大量檔案中 查詢到我們想要的書呢?即使能查到, 效率也是非常低的。

二叉樹

在瞭解二分搜尋樹之前, 我們先來看看二叉樹長什麼樣子。

通過下圖, 我們來大概瞭解一下什麼是二叉樹。

avatar

二叉樹和連結串列一樣屬於動態資料結構。我們不需要在建立資料結構的時候, 就去決定這個資料結構能夠儲存多少元素的問題。 如果要新增元素, 就new一個新空間新增到資料結構中, 刪除也是一樣的。

更具上圖, 我們如何構建一個二叉樹?

class Node {
  E e ;
  Node left ; // 左孩子
  Node right ; // 右孩子
}
複製程式碼

二叉樹居右唯一一個根節點就是28這個元素。

在建立節點的同時, 我們還可以指定我們左邊和右邊的孩子是誰, 比如上圖中

元素28的左孩子是16右孩子是30
元素16的左孩子是13右孩子是22
元素30的左孩子是29右孩子是42
複製程式碼

每個節點都有一個父親節點, 除了根節點沒有父節點外。

16的父親節點是28
30的父親節點是28
複製程式碼

二叉樹顧名思義就是, 每個節點最多隻能分2個節點, 如果有多個節點我們可以更具它分為幾個叉就稱為幾叉樹(多叉樹)。

如果一個孩子都沒有的我們稱為葉子節點(左右孩子都為空就是葉子節點)。

二叉樹的遞迴

二叉樹具有天然的遞迴性, 每個節點又可以看做是一個二叉樹。

avatar

二叉樹一些形態

上面的話, 我們都是滿二叉樹, 但是很多時候都不是。如下圖

只有一個節點或者為空的二叉樹

avatar

只有左子樹的二叉樹

avatar

avatar

avatar

avatar

二分搜尋樹

定義:

  • 若任意節點的左子樹不為空, 則左子樹上所有及誒按的值均小於它的根節點值
  • 若任意節點的右子樹不為空, 則右子樹上所有節點的值均大於它的更及誒按的值
  • 任意節點的左、右子樹分別為二分搜尋樹

avatar

二叉樹中每個元素都需要進行比較, 而且並不是都是一個滿二叉樹

avatar

實戰部分

經過前面的學習, 我們已經大概清楚什麼是二分搜尋樹了。下面我們通過程式碼來實現把。


/****
 *
 * 儲存的元素需要有可比較性, 所以我們需要繼承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, 這裡我們不進行插入, 我們的二分搜尋樹不會插入重複資料

現在, 我們有了一個簡單的二分搜尋樹, 如果在插入新增的元素, 也是依次查詢判斷, 直到沒有可以比較的節點, 就進行插入。
複製程式碼

avatar

上面的圖片也更加直觀的展示如何插入一個元素了, 下面我們通過編寫程式碼來實現如何插入元素。


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

整體邏輯幾乎是一模一樣的, 所以這裡不做過多的說明。

遍歷
  1. 前序遍歷
  • 定義: 先訪問根節點, 然後前序遍歷左子樹, 在前序遍歷右子樹(中, 左, 右)

前序遍歷是怎麼個遍歷方式呢? 如下:

圖:                 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的父節點
複製程式碼
  1. 中序遍歷
  • 定義: 遍歷根節點的左子樹, 然後訪問根節點, 最後遍歷右子樹(左中右)
圖:                 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);
}
複製程式碼
  1. 後序遍歷
  • 定義: 從左到右先葉子後節點的方式遍歷訪問左右子樹, 最後訪問根節點(左右中)
圖:                 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);
}
複製程式碼
  1. 層次遍歷
圖:                 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;
   }
}
複製程式碼

avatar

相關文章