資料結構-二叉搜尋樹的實現

IAM四十二發表於2019-02-24

定義

二叉搜尋樹(Binary Search Tree,BST),也稱為二叉排序樹或二叉查詢樹。

相較於普通的二叉樹,非空的二叉搜尋樹有如下性質:

  1. 非空左子樹的所有鍵值小於其根結點的鍵值;
  2. 非空右子樹的所有鍵值大於其根結點的鍵值;
  3. 左右子樹均為二叉搜尋樹
  4. 樹中沒有鍵值相等的結點

可以看到,二叉搜尋樹的性質很鮮明,這也使得二叉樹也有了實際意義。

二叉搜尋樹的常用操作

對於二叉搜尋樹,除了常規的4種遍歷之外,還有如下一些關鍵的操作值得我們去關注。

二叉搜尋樹ADT
二叉搜尋樹ADT

二叉樹的儲存結構實現

對於二叉樹,我們還是習慣的選擇採用鏈式儲存結構實現。

二叉樹結點定義

二叉搜尋樹最大的特點,就是他的元素是可以比較大小的。這一點是需要注意的地方。

/**
 * Created by engineer on 2017/10/26.
 * <p>
 * 二叉搜尋樹樹結點定義
 */

public class TreeNode<T extends Comparable<T>> {

    // 資料域
    private T data;
    // 左子樹
    public TreeNode<T> leftChild;
    // 右子樹
    public TreeNode<T> rightChild;


    public TreeNode(T data) {
        this(null, data, null);
    }

    public TreeNode(TreeNode leftChild, T data, TreeNode rightChild) {
        this.leftChild = leftChild;
        this.data = data;
        this.rightChild = rightChild;
    }

    public T getData() {
        return data;
    }

    public TreeNode<T> getLeftChild() {
        return leftChild;
    }

    public TreeNode<T> getRightChild() {
        return rightChild;
    }

    public void setData(T data) {
        this.data = data;
    }

}複製程式碼

二叉搜尋樹插入

有了根節點,我們就可以根據二叉樹的性質,從根節點出發,構建出一顆二叉樹。

/**
     * 樹中插入元素
     *
     * @param value
     */
    void insert(T value) {
        if (value == null) {
            return;
        }
        root = insert(root, value);
    }

    private TreeNode<T> insert(TreeNode<T> node, T value) {
        if (node == null) {
            // 樹為空,則建立根節點
            return new TreeNode<>(value);
        } else {
            if (compare(node, value) < 0) { // 插入值比根節點小,在左子樹繼續建立二叉搜尋樹
                node.leftChild = insert(node.getLeftChild(), value);
            } else if (compare(node, value) > 0) { // 插入值比根節點大,在右子樹繼續建立二叉搜尋樹
                node.rightChild = insert(node.getRightChild(), value);
            }
        }

        return node;
    }
    private int compare(TreeNode<T> node, T value) {
        return value.compareTo(node.getData());
    }複製程式碼

根據二叉搜尋樹的特性,我們很容易使用遞迴實現二叉樹的插入操作;總的來說,就是每次插入一個結點,從根節點出發作比較,小的就往左子樹插,大的就往右子樹插。這和二叉搜尋樹的定義時完全一致的。

我們可以簡單測試一下,這個insert方法的正確性。

測試二叉搜尋樹插入操作

public class BinarySearchTreeTest {

    private static Integer[] arrays = new Integer[]{10, 8, 3, 12, 9, 4, 5, 7, 1,11, 17};

    public static void main(String[] args) {
        BinarySearchTree<Integer> mSearchTree = new BinarySearchTree<>();

        for (Integer data : arrays) {
            mSearchTree.insert(data);
        }
        // 列印二叉樹的三種遍歷順序
        mSearchTree.printTree();

    }
}複製程式碼

關於樹的遍歷已在上文中詳細分析,此處不再做深入探討

這裡定義了一個隨機陣列,這個將這個陣列的按序插入到樹中,並按照樹的三種遍歷結構列印樹。按照這個陣列我們將構建出如下所示的一顆二叉搜尋樹:

手繪二叉樹
手繪二叉樹

看一下程式輸出的遍歷結果。

前序遍歷:10 8 3 1 4 5 7 9 12 11 17 
中序遍歷:1 3 4 5 7 8 9 10 11 12 17 
後序遍歷:1 7 5 4 3 9 8 11 17 12 10複製程式碼

可以看到,遍歷結果和我們畫出來二叉樹是一致的,因此可以驗證插入方法是正確的。

查詢

通過插入操作,我們已經實現了一顆二叉搜尋樹,下面就來看看如何從樹中查詢元素。

  • 查詢最大值與最小值

根據二叉搜尋樹的特點,我們知道在一顆二叉搜尋樹上,最小的值一定在最最左邊的結點上,而最大值一定在最最右邊的結點上。因此,查詢二叉樹最值就變得非常容易了。


/**
     * 查詢最大值
     *
     * @return
     */
    public T findMax() {
        if (isEmpty()) return null;
        return findMax(root);
    }

    /**
     * 從特定結點開始尋找最大值
     *
     * @param node
     * @return
     */
    private T findMax(TreeNode<T> node) {
        TreeNode<T> temp = node;
        while (temp.getRightChild() != null) {
            temp = temp.getRightChild();
        }
        return temp.getData();
    }


    /**
     * 查詢最小值
     *
     * @return
     */
    public T findMin() {
        if (isEmpty()) return null;
        return findMin(root);
    }

    /**
     * 從特定結點開始尋找最小值
     *
     * @param node
     * @return
     */
    private T findMin(TreeNode<T> node) {
        TreeNode<T> temp = node;
        while (temp.getLeftChild() != null) {
            temp = temp.getLeftChild();
        }
        return temp.getData();
    }複製程式碼

可以看到,演算法實現非常簡單,就是不斷後移結點找到沒有子樹的結點,就是最邊界位置的結點了。

  • 查詢特定值

在二叉搜尋樹中,怎樣快速找到一個值為特定元素的結點呢?想想我們是怎樣實現結點插入的?這個問題就很簡單了。

遞迴實現,查詢特定結點

**
/**
     * find 特定值 遞迴實現
     *
     * @param value
     * @return
     */
    public TreeNode<T> find(T value) {
        if (isEmpty()) {
            return null;
        } else {
            return find(root, value);
        }
    }

    private TreeNode<T> find(TreeNode<T> node, T value) {
        if (node == null) {
            // 當查詢一個不在樹中元素時,丟擲異常
            throw  new RuntimeException("the value must not in the tree");
        }

        if (compare(node, value) < 0) {
            // 小於根節點時,從去左子樹找
            return find(node.getLeftChild(), value);
        } else if (compare(node, value) > 0) {
            // 大於根節點時,從右子樹找
            return find(node.getRightChild(), value);
        } else {
            // 剛好等於,找到了
            return node;

            // 剩下還有一種情況,就是不等於,也就是所查詢的元素不在樹中
        }
    }複製程式碼

查詢的實現思路,總體上和插入是一致的;無非就是做不同的操作;這裡需要注意的是,為了程式的健壯性,我們還得考慮如果查詢的元素不在樹中這種情況。

迭代實現,查詢特定值

有了前面查詢最大值、最小值的經驗,我們也可以考慮使用迭代演算法實現查詢指定元素的演算法。


/**
     * 查詢特定值-非遞迴實現
     *
     * @param value
     * @return 結點
     */
    public TreeNode<T> findIter(T value) {
        TreeNode<T> current = root;
        while (current != null) {
            if (compare(current, value) < 0) {
                current = current.getLeftChild();
            } else if (compare(current, value) > 0) {
                current = current.getRightChild();
            } else {
                return current;
            }
        }
        // current為null,說明所查詢的元素不在tree裡
        return null;
    }複製程式碼

這裡同樣測試一下,查詢方法的正確性:

        System.out.printf("\nfind value %d in mSearchTree \n", 12);
        TreeNode mTreeNode = mSearchTree.find(12);
        TreeNode mTreeNode_1 = mSearchTree.findIter(12);
        System.out.println("遞迴實現結點  = :" + mTreeNode + ", value=" + mTreeNode.getData());
        System.out.println("非遞迴實現結點= :" + mTreeNode_1 + ", value=" + mTreeNode_1.getData());

        System.out.println("\nfind the max value in mSearchTree = " + mSearchTree.findMax());
        System.out.println("find the min value in mSearchTree = " + mSearchTree.findMin());複製程式碼

輸出:

find value 12 in mSearchTree 
遞迴實現結點  = :com.avaj.datastruct.tree.bst.TreeNode@4b67cf4d, value=12
非遞迴實現結點= :com.avaj.datastruct.tree.bst.TreeNode@4b67cf4d, value=12

find the max value in mSearchTree = 17
find the min value in mSearchTree = 1複製程式碼

我們分別用遞迴和迭代兩種方式去查詢 12,可以看到兩次找到是同一個物件,這個物件的值為12;找到的最大值和最小值也是正確的;因此查詢功能的實現是正確的。

刪除結點

從二叉搜尋樹中,刪除一個結點可以算是最複雜的操作了,主要是因為所要刪除的結點,所處的位置被刪除後,依然需要保持整棵樹依然為二叉樹,因此需要就不同的情況就像分析。

手繪二叉樹
手繪二叉樹

就拿我們上面建立的這顆二叉樹來說,如果要刪除的結點是1,7,11,17 這樣的葉子結點,就很容易了;讓其父結點指向為null即可;而如果是4,5 這樣包含一顆子樹的結點,換個角度來說,這其實就是單向連結串列,從單向連結串列中間位置刪除一個結點也比較容易;最麻煩的就是如果要刪除的結點是10,8,3,12 這類結點包含左右子樹,我們就需要從左子樹中找一個最大值,或者是右子樹中的最小值來替代這個值。總結一下刪除結點的操作:

  • 葉子結點:直接刪除,其父結點指向null
  • 包含一個孩子的結點 :父結點指向要刪除結點的自結點(相當於連結串列中間刪除一個元素);
  • 包含左右子樹的結點:右子樹最小值或左子樹最大值替換此結點

結合以上分析,得出從二叉搜尋樹中刪除結點的實現。

/**
     * 從樹中刪除值為value 的特定結點
     *
     * @param value
     */
    public void delete(T value) {
        if (value == null || isEmpty()) {
            return;
        }

        root = delete(root, value);
    }


    private TreeNode<T> delete(TreeNode<T> node, T value) {

        // 結點為空,要出刪除的元素不在樹中
        if (node == null) {
            return node;
        }

        if (compare(node, value) < 0) { // 去左子樹刪除
            node.leftChild = delete(node.getLeftChild(), value);
        } else if (compare(node, value) > 0) { // 去右子樹刪除
            node.rightChild = delete(node.getRightChild(), value);
        } else { // 要刪除的就是當前結點
            if (node.getLeftChild() != null && node.getRightChild() != null) {// 被刪除的結點,包含左右子樹
                T temp = findMin(node.getRightChild()); // 得到右子樹的最小值
                node.setData(temp); //右子樹最小值替換當前結點
                node.rightChild = delete(node.getRightChild(), temp); // 從右子樹刪除這個最小值的結點
            } else {// 被刪除的結點,包含一個子樹或沒有子樹
                if (node.getLeftChild() != null) {
                    node = node.getLeftChild();
                } else {
                    node = node.getRightChild();
                }
            }
        }

        return node;
    }複製程式碼

這裡選擇使用右子樹的最小值替換,是因為刪除這個最小值的結點會比較容易,因為他一定是不會是一個包含左右子樹的結點。

同樣,這裡測試一下刪除結點的功能:

        // 刪除只帶一個子樹的結點
        mSearchTree.delete(4);
        mSearchTree.printTree();
        System.out.println();
        // 刪除帶左右子樹的根節點
        mSearchTree.delete(10);
        mSearchTree.printTree();複製程式碼

輸出:

前序遍歷:10 8 3 1 5 7 9 12 11 17 
中序遍歷:1 3 5 7 8 9 10 11 12 17 
後序遍歷:1 7 5 3 9 8 11 17 12 10 

前序遍歷:11 8 3 1 5 7 9 12 17 
中序遍歷:1 3 5 7 8 9 11 12 17 
後序遍歷:1 7 5 3 9 8 17 12 11複製程式碼

通過和我們一開始畫出來的樹相比較,發現是對應的。

二叉搜尋樹的高度

最後,再來看看如何計算一顆二叉搜素樹的度。

public int getTreeHeight() {
        if (isEmpty()) {
            return 0;
        }

        return getTreeHeight(root);

    }

    private int getTreeHeight(TreeNode<T> node) {
        if (node == null) {
            return 0;
        }
        int leftHeight = getTreeHeight(node.getLeftChild());
        int rightHeight = getTreeHeight(node.getRightChild());
        int max = leftHeight > rightHeight ? leftHeight : rightHeight;
        // 得到左右子樹中較大的返回.
        return max + 1;
    }複製程式碼

順便來算一算,到最後我們建立的樹,經過插入刪除操作高度變成了多少。

System.out.println("\n\nTree's height =" + mSearchTree.getTreeHeight());複製程式碼

輸出:

Tree's height =5複製程式碼

可以看到,由於結點4被刪除,樹由原來的6層變成了5層,結果是正確的!


好了,二叉搜尋樹的分析就是這些了!文中所有原始碼地址.

相關文章