1. 程式人生 > >玩轉資料結構——第五章:二分搜尋樹

玩轉資料結構——第五章:二分搜尋樹

內容概要:

  1. 為什麼要研究樹結構
  2. 二分搜尋樹基礎
  3. 向二分搜尋樹中新增元素
  4. 改進新增操作:深入理解遞迴終止條件
  5. 二分搜尋樹的查詢操作
  6. 二手搜尋樹的前序遍歷
  7. 二分搜尋樹的中序遍歷和後序遍歷
  8. 深入理解二分搜尋樹的前中後遍歷(深度遍歷)
  9. 二分搜尋樹是的前序遍歷的非遞迴實現
  10. 二分搜尋樹的層序遍歷(廣度遍歷)
  11. 刪除二分搜尋樹的最大元素和最小元素
  12. 刪除二分搜尋數的任意元素

1-為什麼要研究樹結構

  • 樹結構本身是一種天然的組織結構
  • 高效
  • 將資料使用樹結構儲存後,出奇的高效

2- 二分搜尋樹基礎

什麼是二叉樹?

  • 跟連結串列一樣,二叉樹也是一種動態資料結構,即,不需要在建立時指定大小。

  • 跟連結串列不同的是,二叉樹中的每個節點,除了要存放元素e,它還有兩個指向其它節點的引用,分別用Node left和Node right來表示。

  • 類似的,如果每個節點中有3個指向其它節點的引用,就稱其為"三叉樹"...

  • 二叉樹具有唯一的根節點。

  • 二叉樹中每個節點最多指向其它的兩個節點,我們稱這兩個節點為"左孩子"和"右孩子",即每個節點最多有兩個孩子。

  • 一個孩子都沒有的節點,稱之為"葉子節點"。

  • 二叉樹的每個節點,最多隻能有一個父親節點,沒有父親節點的節點就是"根節點"。

  • 二叉樹的形象化描述如下圖:

Class Node{
E e;
Node left;//左孩子
Node right;//右孩子
}
  • 二叉樹具有天然的遞迴結構
  1. 每個節點的左子樹也是二叉樹
  2. 每個節點的右子數也是二叉樹
  • 二叉樹不一定是"滿"的
  1. 二叉樹不一定是"滿的",即,某些節點可能只有一個子節點;
  2. 更極端一點,整棵二叉樹可以僅有一個節點;在極端一點,整棵二叉樹可以一個節點都沒有;

什麼是二分搜尋樹(Binary Search Tree)?

  • 二分搜尋樹是二叉樹
  • 二分搜尋樹的每一個節點的值:
  1. 大於其左子樹的所有節點的值
  2. 小於其右子樹的所有節點的值
  • 每一顆子樹也是二分搜尋樹
  • 不一定每個二分搜尋樹都是"滿"的
  • 儲存的元素必須有可比較性

二分搜尋樹的底層程式碼


/**
 * 二分搜尋樹 Binray searc Tree
 */
public class BST<E extends Comparable<E>> {//E的型別必須滿足可比較性

    private class Node {
        public E e;//存放元素
        public Node left, right;

        //帶參建構函式
        public Node(E e) {
            this.e = e;
            left = null;
            right = null;
        }
    }

    //成員變數
    private Node root;//根節點
    private int size;//儲存多少個元素

    //二分搜尋樹的建構函式
    public BST() {
        root = null;
        size = 0;
    }

    //成員函式,當前儲存多少元素
    public int size() {
        return size;
    }

    public boolean isEmpty() {
        return size == 0;
    }

}

3-向二分搜尋樹中新增元素

compareTo() 方法

compareTo() 方法用於將 Number 物件與方法的引數進行比較。可用於比較 Byte, Long, Integer等。

該方法用於兩個相同資料型別的比較,兩個不同型別的資料不能用此方法來比較。

  • A.compareTo(B) :A是指定的數、B是引數

  • 如果指定的數與引數相等返回0。(A=B)

  • 如果指定的數小於引數返回 -1。(A<B)

  • 如果指定的數大於引數返回 1。(A>B)

使用遞迴的方式解決二分搜尋樹的新增操作 

  1.  首先最基本的問題,執行到函式最底層時要進行終止(返回),否則遞迴就會一直下去
  2.  最基本的問題是:當某個不為空的節點插入新的節點,假設插入右節點,那麼插入的數肯定比當前節點大,
  3.  然後繼續往下,沒有節點了
  4.   node此時為空,則當node為空時(返回要插入的這個元素的節點)<——這是最基本問題(最底層)
  5.   當前的節點的右節點就可以順利插入元素e;

    /**二分搜尋樹:當前節點
     * 大於其左子樹的所有節點的值
     * 小於其右子樹的所有節點的值
     * @param node
     * @param e
     * @return
     */
    //向以node為根節點的二分搜尋樹插入元素e,遞迴演算法
    //返回插入新節點後二分搜尋樹的根
    private Node add(Node node,E e){
        //1找出最基本的問題,執行到函式最底層時要進行終止(返回),否則遞迴就會一直下去
        //最基本的問題是:當某個不為空的節點插入新的節點,假設插入右節點,那麼插入的數肯定比當前節點大,
        // 然後繼續往下,沒有節點了
        // node此時為空,則當node為空時(返回要插入的這個元素的節點)<——這是最基本問題(最底層)
        // 當前的節點的右節點就可以順利插入元素e;
        //1最基本問題的解決
        if(node==null) {//還有一種是從空節點的二叉樹中插入元素
            size++;//記錄元素個數
            return new Node(e);
        }

        if(e.compareTo(node.e)<0){//當前指定的元素e小於node的值,則在左邊插入
            node.left=add(node.left,e);//呼叫遞迴
        }
        else if(e.compareTo(node.e)>0) {//當前指定的元素e大於node的值,則在右邊插入
            node.right=add(node.right,e);
        }

        return node;//返回插入新節點後二分搜尋樹的根
    }
    //在二分搜尋樹中新增元素e
    public  void add(E e){
       root= add(root,e);
    }

5-二分搜尋樹的查詢操作(遞迴演算法)

這是最基本的問題:(類似終止條件)

  • 1:空二分搜尋樹
  • 2:沒有找到 
  • 3:找到了

    //使用者使用的新增方法
    public void add(E e) {
        root = add(root, e);
    }

    //看以node為根的二分搜尋樹是否包含元素e,遞迴演算法
    private boolean contains(Node node, E e) {
        if (node == null)//(這是最基本的問題1:空二分搜尋樹 2:找到最底部都沒有 3:找到了)
            return false;

        //找到這個元素(這是也最基本的問題)
        if (e.compareTo(node.e) == 0)
            return true;

            //如果這個元素在node的左邊,它將遞迴查詢左邊的數
        else if (e.compareTo(node.e) < 0)
            return contains(node.left, e);
            //如果這個元素在node的右邊
        else //e.compareTo(node.e)>0
            return contains(node.right, e);
    }

    //看二分搜尋樹中是否包含元素e
    public boolean contain(E e) {
        return contains(root, e);
    }

6-二分搜尋樹的遍歷(深度優先遍歷)

所謂的遍歷:

對於每個節點而言,可能會有左、右兩個孩子,所以分成下圖中3個點,每次遞迴過程中會經過這3個點

  • 前序遍歷:先訪問當前節點,再依次遞迴訪問左右子樹
  • 中序遍歷:先遞迴訪問左子樹,再訪問自身,再遞迴訪問右子樹
  • 後續遍歷:先遞迴訪問左右子樹,再訪問自身節點

1. 前序遍歷

(1)演算法思想

前序遍歷:先訪問當前節點,再依次遞迴訪問左右子樹。檢視以下動畫即可

圖片來源:https://blog.csdn.net/ITermeng/article/details/77737480

其實在遍歷過程中每個節點都訪問了3次,對應著這3個小點,順序為前-> 中 -> 後,只有在“”點時才會列印該節點元素值。

最終列印結果:

程式碼實現:

 //二分搜尋樹的前序遍歷
    public void preOrder() {
        preOrder(root);
    }

    //前序遍歷以node為根節點的二分搜尋樹,遞迴演算法
    private void preOrder(Node node) {
        if (node == null)//遞迴的終止條件
            return;
        System.out.println(node.e);//列印它走過的節點
        preOrder(node.left);
        preOrder(node.right);
    }
  

測試準備:

 @Override
    public String toString() {
        StringBuilder res = new StringBuilder();
        generateBSTString(root, 0, res);
        return res.toString();
    }

    // 生成以node為根節點,深度為depth的描述二叉樹的字串
    private void generateBSTString(Node node, int depth, StringBuilder 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);
    }

    private String generateDepthString(int depth) {
        StringBuilder res = new StringBuilder();
        for (int i = 0; i < depth; i++)
            res.append("--");
        return res.toString();
    }

在Main中測試

public class Main {

    public static void main(String[] args) {

        BST<Integer> bst = new BST<>();
        int[] nums = {5, 3, 6, 8, 4, 2};
        for(int num: nums)
            bst.add(num);

        /////////////////
        //      5      //
        //    /   \    //
        //   3    6    //
        //  / \    \   //
        // 2  4     8  //
        /////////////////
        bst.preOrder();
        System.out.println();

        System.out.println(bst);
    }
}

結果:

5
3
2
4
6
8

5
--3
----2
------null
------null
----4
------null
------null
--6
----null
----8
------null
------null

2. 中序遍歷

(1)演算法思想

中序遍歷:先遞迴訪問左子樹,再訪問自身,再遞迴訪問右子樹。

 

在遍歷過程中每個節點都訪問了3次,對應著這3個小點,順序為前-> 中 -> 後,只有在“”點時才會列印該節點元素值。

最終列印結果:

檢視其列印結果,是按照從小到大的順序進行列印的,所以在進行實際應用時,可使用二分搜尋輸的中序遍歷將元素按照從小到大順序輸出。其原因與二分搜尋樹定義相關的!

程式碼實現:

    //二分搜尋樹的中序遍歷
    public void inOrder() {
        inOrder(root);
    }

    //中序遍歷以node為根的二分搜尋樹,遞迴演算法
    private void inOrder(Node node) {
        if (node == null)
            return;
        inOrder(node.left);
        System.out.println(node.e);//列印當前根節點
        inOrder(node.right);
    }

 Main函式測試:

public class Main {

    public static void main(String[] args) {

        BST<Integer> bst = new BST<>();
        int[] nums = {5, 3, 6, 8, 4, 2};
        for(int num: nums)
            bst.add(num);
        /////////////////
        //      5      //
        //    /   \    //
        //   3    6    //
        //  / \    \   //
        // 2  4     8  //
        /////////////////
        bst.inOrder();
        System.out.println();
    }
}

結果:

2
3
4
5
6
8

3. 後序遍歷

(1)演算法思想

後續遍歷:先遞迴訪問左右子樹,再訪問自身節點。

 

在遍歷過程中每個節點都訪問了3次,對應著這3個小點,順序為前-> 中 -> 後,只有在“”點時才會列印該節點元素值。

最終列印結果:

程式碼實現:


    //二分搜尋樹的後序遍歷
    public void postOrder() {
        postOrder(root);
    }

    //後序遍歷以node為根的二分搜尋樹,遞迴演算法
    public 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  //
        /////////////////

2
4
3
8
6
5

以上所有深度優先遍歷程式碼實現可分為3個步驟:

  • 遞迴左孩子
  • 遞迴右孩子
  • 列印自身

以上遍歷只是交換了這3個步驟的執行順序。

9-二分搜尋樹是的前序遍歷的非遞迴實現

28進隊後出隊,其右左節點進隊(後進先出),然後左節點16出隊,對16節點進行訪問,16節點有左右節點,將16的左右節點先右節點、後左節點的方式存入..


    /*用非遞迴的方式(藉助棧)實現二分搜尋樹的前序遍歷
     */
    public void preNROrder() {
        Stack<Node> stack = new Stack<>();
        stack.push(root);//把根節點放到棧裡面
        while (!stack.isEmpty()) {//當棧裡面不為空
            Node cur = stack.pop();//拿到棧頂元素,放到cur裡面
            System.out.println(cur.e);
            //棧是後入先出的(要實現先訪問左子樹再訪問右子樹,就先把右子樹放進棧裡
            if (cur.right != null)
                stack.push(cur.right);
            if (cur.left != null)
                stack.push(cur.left);
        }
    }

10-二分搜尋樹的層序遍歷(廣度遍歷)

前序、中序、後序遍歷本質都是深度優先遍歷

  • 層序遍歷:根節點設定為第0層;先遍歷第0層28、再遍歷第1層16、30;
  • 再遍歷第2層13、22、29、42;逐層向下遍歷的節點在廣度上進行拓展;
  • 這種遍歷方式也稱為廣度優先遍歷;通常使用非遞迴的方式實現(藉助佇列Queue)

圖解:用佇列的方式實現層序遍歷

1.每一次一個元素入隊,從隊尾的位置進入佇列,初始化時將根節點入隊

2.以後每一次要做的事就是先看隊首(看該到誰開始遍歷了),出隊的就是根節點28,訪問28對其進行相應的操作,這樣28就遍歷完了;

3.將根節點 28 的左右孩子(16和30)分別入隊【對於佇列來說,是先進先出,所以我們按照從左到右的順序進行入隊,所以先入隊16後入隊30】

4.現在的隊首是16,將16拿出來,對其進行訪問

5.將 16 的左右孩子 13 和 22 入隊,

6.對隊首元素 30 出隊,對其進行操作,並將 30 的左右孩子(29和42)入隊

7.對隊首元素 13 出隊,對其進行操作,但13沒有左右節點,他是個葉子節點;

8.然後與上述相同,依次對隊首元素進行操作,佇列全部出隊;【到這一步,佇列的排序就與層序遍歷相同了】


    //二分搜尋樹的層序遍歷
    public void levelOrder() {
        Queue<Node> q = new LinkedList<>();
        q.add(root);
        while (!q.isEmpty()) {//如果佇列不為空
            Node cur = q.remove();//出隊元素放到cur
            System.out.println(cur.e);
            if (cur.left != null)//有左孩子
                q.add(cur.left);
            if (cur.right != null)//有右孩子
                q.add(cur.right);
        }

    }

測試結果

        /////////////////
        //      5      //
        //    /   \    //
        //   3    6    //
        //  / \    \   //
        // 2  4     8  //
        /////////////////
5
3
6
2
4
8

11-刪除二分搜尋樹的最大元素和最小元素

 random.nextInt(int n)

該方法的作用是生成一個隨機的int值,該值介於[0,n)的區間,也就是0到n之間的隨機int值,包含0而不包含n。

尋找二分搜尋樹的最小元素:

最小元素都在node的左邊

 // 尋找二分搜尋樹的最小元素
    public E minimum() {
        if (size == 0)
            throw new IllegalArgumentException("BST is empty");

        Node minNode = minimum(root);
        return minNode.e;
    }

    // 返回以node為根的二分搜尋樹的最小值所在的節點
    private Node minimum(Node node) {
        if (node.left == null)
            return node;

        return minimum(node.left);
    }

尋找二分搜尋樹的最大元素:

最小元素都在node的右邊

 // 尋找二分搜尋樹的最大元素
    public E maximum() {
        if (size == 0)
            throw new IllegalArgumentException("BST is empty");

        return maximum(root).e;
    }

    // 返回以node為根的二分搜尋樹的最大值所在的節點
    private Node maximum(Node node) {
        if (node.right == null)
            return node;

        return maximum(node.right);
    }

 刪除二分搜尋樹最小節點

 // 從二分搜尋樹中刪除最小值所在節點, 返回最小值
    public E removeMin() {
        E ret = minimum();
        root = removeMin(root);
        return ret;
    }

    // 刪除掉以node為根的二分搜尋樹中的最小節點
    // 返回刪除節點後新的二分搜尋樹的根
    private Node removeMin(Node node) {
        //遞迴的終止條件(最基本問題)
        if (node.left == null) {//當此時node沒有左子樹
            Node rightNode = node.right;//儲存node右子樹
            node.right = null;//另當前節點node.left=node.right=null 脫離二分搜尋樹
            size--;//控制二分搜尋樹的大小
            return rightNode;//返回它的右子樹
        }

        //遞迴操作
        node.left = removeMin(node.left);
        return node;
    }

刪除二分搜尋樹的最大節點


    // 從二分搜尋樹中刪除最大值所在節點
    public E removeMax() {
        E ret = maximum();
        root = removeMax(root);
        return ret;
    }

    // 刪除掉以node為根的二分搜尋樹中的最大節點
    // 返回刪除節點後新的二分搜尋樹的根
    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.要刪除的節點沒有左子樹

2.要刪除的節點沒有右子樹

3.要刪除的節點左右子樹都有

當要刪除的節點左右子樹都有:

可以採用2種方式:

1.將該節點的左子樹的最大值(前驅),頂替該節點的位置,然後讓該節點脫離二分搜尋樹

2.將該節點的右子樹的最小值(後繼),頂替該節點的位置,然後讓該節點脫離二分搜尋樹

以下程式碼是第二種後繼的方式:

    //從二分搜尋樹中刪除元素為e的節點
    public void remove(E e) {
        root = remove(root, e);

    }

    //刪除以node為根的二分搜尋樹中值為e的節點,遞迴演算法
    //返回刪除節點後新的二分搜尋樹的根
    private Node remove(Node node, E e) {
        if (node == null)
            return null;
        if (e.compareTo(node.e) < 0) {//要刪除的元素e比當前元素小
            node.left = remove(node.left, e);
            return node;
        } else if (e.compareTo(node.e) > 0) {
            node.right = remove(node.right, e);
            return node;
        } else {//e.equals(node.e)
            //待刪除節點的左子樹為空的情況
            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;//讓當前的node與二分搜尋樹脫離 滿足node.left=node.right=null
                size--;
                return leftNode;
            }
            //第三種情況
            //待刪除的節點左右子樹均不為空
            //找到比待刪除節點到達的最小節點,即待刪除節點的右節點的最小節點
            //用這個節點頂替待刪除節點的位置
            Node successor = minimum(node.right);//找到當前節點右子樹最小的值
            //successor為頂替待刪除的節點(後繼)
            successor.right = removeMin(node.right);//將node.right的最小值給刪除
            successor.left = node.left;

            node.left = node.right = null;//讓當前node與二分搜尋樹脫離關係
            return successor;


        }
    }

(轉自發條魚)