手擼二叉樹——二叉查詢樹

牛初九發表於2024-10-13

二叉樹是資料結構中非常重要的一種資料結構,它是的一種,但是每個節點的子節點不能多餘兩個,可以是0,1,2個子節點,0個子節點代表沒有子節點。常見的二叉樹結構如下圖所示:

每個節點的子節點不多於2個,其中3,4,5沒有子節點,2有一個子節點,0,1都有兩個子節點。

基礎概念

根節點:樹的其實節點,沒有父節點。

葉子節點:沒有子節點的節點叫做葉子節點。

節點深度:從根節點到該節點的距離叫做深度,如上圖:節點3的深度是2,節點1的深度是1。

節點高度:該節點到距離最長的葉子節點的距離。

二叉查詢樹

二叉樹最重要的一個應用是在查詢方面的應用,很多的索引結構都是二叉查詢樹,還有向HashMap裡也使用到了紅黑樹,紅黑樹也是二叉查詢樹的一種。二叉查詢樹的一個重要性質,就是任何一個節點,它的左子樹中的節點都小於該節點,它的右子樹中的節點都大於該節點。最開始我們的例圖它不是一棵二叉查詢樹,它不符合我們剛才說的性質。我們再看看下面的例圖:

這是一棵二叉查詢樹,它的任何一個節點的子節點都小於該節點,右子樹的節點都大於該節點。這樣我們在查詢資料的時候,就可以從根節點開始查詢,如果查詢的值小於該節點,就去左子樹中查詢,如果大於該節點,就去右子樹中查詢,如果等於,那就不用說了,直接返回就可以了。這種可以大大提升我們的查詢效率,它的時間複雜度是O(logN)。

手擼二叉查詢樹

首先我們要抽象出節點類,每個節點可以有左子節點,和右子節點,當然節點要儲存一個值,這個值的型別我們不做限制,可以是數字型,也可以是字串,還可以是自己定義的類,但是這裡要加一個前提條件,就是這個值是可比較的,因為兩個節點比較後才能確定位置,所以節點值的型別要實現Comparable介面。好了,滿足上面的條件,我們就可以抽象出二叉樹節點的類了,如下:

public class BinaryNode<T extends Comparable<T>> {
    //節點資料
    @Setter@Getter
    private T element;
    //左子節點
    @Setter@Getter
    private BinaryNode<T> left;
    //右子節點
    @Setter@Getter
    private BinaryNode<T> right;

    //建構函式
    public BinaryNode(T element) {
        this(element,null,null);
    }
    //建構函式
    public BinaryNode(T element, BinaryNode<T> left, BinaryNode<T> right) {
        if (element == null) {
            throw new RuntimeException("二叉樹節點元素不能為空");
        }
        this.element = element;
        this.left = left;
        this.right = right;
    }
}

我們定義二叉樹節點的類為BinaryNode,我們注意一下後面的泛型,它要實現Comparable介面。然後我們定義節點資料element,左子節點left,和右子節點right,並且使用@Setter@Getter註解實現其set和get方法。接下來就是定義兩個構造方法,一個是隻傳入節點元素的,另一個是傳入節點元素和左右子樹的。節點的元素是不能為空的,如果是空則丟擲異常。

然後,我們再定義二叉查詢樹類,類中包括一些二叉查詢樹的基本操作方法,這些基本的操作方法我們後面講,先看定義的基本元素,如下:

public class BinarySearchTree<T extends Comparable<T>> {
    //根節點
    private BinaryNode<T> root;

    public BinarySearchTree() {
        this.root = null;
    }
    
    //將樹變為空樹
    public void makeEmpty() {
        this.root = null;
    }

    //判斷樹是否為空
    public boolean isEmpty() {
        return this.root == null;
    }
}

類的名字定義為:BinarySearchTree,同樣我們注意一下這裡的泛型,它和BinaryNode的泛型是一樣的,因為這個型別我們傳遞給BinaryNode。類中定義了樹的根節點root,以及構造方法,構造方法只是定義了一棵空樹,根節點為空。然後是兩個比較基礎的樹的操作方法makeEmptyisEmpty,將樹變為空樹和判斷樹是否為空。

  1. 現在我們要編寫一些樹的操作方法了,首先我們要編寫的就是contains方法,它會判斷樹中是否包含某個元素,比如上面例圖中,我們判斷樹中是否包含3這個元素。具體實現如下:
/**
 * 二叉樹是否包含某個元素
 *
 * @param element 檢查的元素
 * @return true or false
 */
public boolean contains(T element) {
    return contains(root, element);
}

/**
 * 二叉樹是否包含某個元素
 *
 * @param tree    整棵樹或左右子樹
 * @param element 檢查的元素
 * @return true or false
 */
private boolean contains(BinaryNode<T> tree, T element) {
    if (tree == null) {
        return false;
    }

    int compareResult = element.compareTo(tree.getElement());

    if (compareResult > 0) {
        return contains(tree.getRight(), element);
    }
    if (compareResult < 0) {
        return contains(tree.getLeft(), element);
    }
    return true;
}

這裡我們定義了兩個contains方法,第一個contains方法呼叫第二個contains方法,第二個contains方法是私有的,外部不能訪問。在呼叫第二個contains方法時,我們將root傳進去,也就是整棵樹傳入去查詢。在第二個contains方法中,我們先判斷樹是否為空,如果為空,肯定不會包含我們要查詢的元素,則直接返回false。然後我們用查詢的元素和當前節點的元素作比較,這裡我們使用compareTo方法,它是Comparable介面中定義好的方法,這也是我們定義泛型時要實現Comparable介面的原因了。比較結果大於0,說明查詢的值大於當前節點值,我們遞迴呼叫contains方法,將右子樹和查詢的值傳入;比較結果小於0,說明查詢的值小於當前節點值,我們同樣遞迴呼叫contains方法,將左子樹和查詢的值傳入進行查詢。最後如果比較結果等於0,說明查詢的值和當前節點值是一樣的,我們返回true就可以了。

contains方法算是一個開胃小菜,其中用到了遞迴,這也讓我們對二叉樹的編寫方法有了一個初步的瞭解。

  1. 接下來我們要編寫的是findMinfindMax方法,分別是找出樹中最小值和最大值的方法。由於我們的樹是一棵二叉查詢樹,左子樹的值要小於當前節點,右子樹的值大於當前節點,所以,最左側節點的值就是最小值,最右側的值則是最大值。我們用程式碼實現一下,
/**
 * 找出二叉樹的最小元素
 *
 * @return
 */
public T findMin() {
    if (isEmpty()) throw new RuntimeException("二叉樹為空");
    return findMin(root);
}

private T findMin(BinaryNode<T> tree) {
    if (tree.getLeft() != null) {
        return findMin(tree.getLeft());
    }
    return tree.getElement();
}

/**
 * 找出二叉樹的最大元素
 *
 * @return
 */
public T findMax() {
    if (isEmpty()) throw new RuntimeException("二叉樹為空");
    return findMax(root);
}

private T findMax(BinaryNode<T> tree) {
    while (tree.getRight() != null) {
        tree = tree.getRight();
    }
    return tree.getElement();
}

我們先來看findMin方法,先判斷樹是否為空,空樹沒有最小值,也沒有最大值,所以我們這裡丟擲異常。然後我們將整棵樹傳入第二個findMin方法,在第二個findMin方法中,我們一直去尋找左子節點,如果左子節點不為空,我們就遞迴的再去尋找,直到節點的左子節點為空,那麼當前節點就是整棵樹的最左節點,那麼它的值就是最小的,我們返回就可以了。

我們再來看findMax方法,和findMin方法一樣,先判斷樹是否為空,為空則丟擲異常。我們要重點看的是第二個findMax方法,這個方法中,我們沒有使用遞迴去尋找最右側的節點,而是使用了一個while迴圈,去找到最右側的節點。這裡我們使用了兩種不同的方法實現了findMinfindMax,一個使用了遞迴,另一個使用了while迴圈,其實這兩種方式也是互通的,能用遞迴的方法也可以用while迴圈去實現,反之亦然。

  1. 接下來我們再來看一下二叉查詢樹的一個非常重要的方法,那就是insert插入方法了。當我們向二叉查詢樹中新增一個節點時,要和當前節點做比較,如果小於當前節點值,則在左側插入,如果大於則在右側插入,這裡我們不討論等於的情況。具體程式碼如下:
/**
 * 插入元素
 *
 * @param element
 */
public void insert(T element) {
    if (root == null) {
        root = new BinaryNode<>(element);
        return;
    }
    insert(root, element);
}

private void insert(BinaryNode<T> tree, T element) {
    int compareResult = element.compareTo(tree.getElement());
    if (compareResult > 0) {
        if (tree.getRight() == null) {
            tree.setRight(new BinaryNode<>(element));
        } else {
            insert(tree.getRight(), element);
        }
    }

    if (compareResult < 0) {
        if (tree.getLeft() == null) {
            tree.setLeft(new BinaryNode<>(element));
        } else {
            insert(tree.getLeft(), element);
        }
    }
}

在插入節點的過程中,我們先判斷根節點是否為空,如果為空,說明是一棵空樹,我們直接將插入元素給到根節點就可以了。如果根節點不為空,我們進入到第二個insert方法,在第二個insert方法中,我們先將插入的值和當前節點做比較,比較結果如果大於0,說明插入的值比當前節點大,所以我們要在右側插入,如果當前節點的右子節點為空,我們直接插入就可以了;如果右子節點不為空,還要和右子節點作比較,這裡我們用遞迴的方法實現,邏輯比較清晰。同理,如果比較結果小於0,我們對左側節點做操作就可以了,這裡不再贅述。

  1. 上面我們做了節點的插入,最後再來看看節點的刪除remove。要刪除一個節點,首先我們要找到這個節點,找到這個節點後,要分情況對這個節點進行處理,如下:
  • 刪除節點沒有子節點:我們直接將該節點刪除,也就是將節點置為null;
  • 刪除節點只有左子節點或右子節點:這種只有一個子節點的情況,我們直接將要刪除的節點改為它的唯一的子節點就可以了。這裡等於是用子節點覆蓋掉當前節點;
  • 刪除節點有兩個子節點:這種是最複雜的情況,要解決這個問題,我們還是要利用二叉查詢樹的特性,就是當前節點的左子樹的值都比當前節點小,右子樹的值都比當前節點大。那麼我們把當前節點刪除後,用哪個節點代替當前節點呢?這裡我們可以在左子樹中找到最大的值,或者從右子樹中找到最小的值,代替當前要刪除的節點。這樣替換後,還是可以保證左子樹的值比當前值小,右子樹的值比當前值大。然後我們再把替換的值,也就是左子樹中的最大值,或者右子樹中的最小值,在左或右子樹中刪掉就可以了。這一段邏輯比較繞,小夥伴們可以多讀幾遍,理解一下。具體實現如下:
/**
 * 刪除元素
 * @param element
 */
public void remove(T element) {
    remove(root, element);
}

private void remove(BinaryNode<T> tree, T element) {
    if (tree == null) {
        return;
    }
    int compareResult = element.compareTo(tree.getElement());
    if (compareResult > 0) {
        remove(tree.getRight(), element);
        return;
    }
    if (compareResult < 0) {
        remove(tree.getLeft(), element);
        return;
    }
    if (tree.getLeft() != null && tree.getRight() != null) {
        tree.setElement(findMin(tree.getRight()));
        remove(tree.getRight(), tree.getElement());
    } else {
        tree = tree.getLeft() != null ? tree.getLeft() : tree.getRight();
    }
}

第一個remove方法不說了,我們重點看第二個。方法進來後,節點是否為空,為空則說明是空樹,或者要刪除的節點沒有找到,那麼直接返回就可以了。然後再用刪除的元素和當前節點作比較,如果大於0,我們用遞迴方法在右子樹中繼續執行刪除方法。同理如果小於0,用左子樹遞迴。再下面就是等於0的情況,也就是找到了要刪除的節點。我們先處理最複雜的情況,就是刪除節點左右子節點都存在的情況,我們使用上面的邏輯,使用右子樹中最小的節點覆蓋當前節點,然後再在右子樹中,將這個值刪掉,我們也是遞迴的呼叫了remove方法。當然這裡也可以使用左子樹中的最大值,小夥伴們自己實現吧。最後就是處理沒有子節點和只有一個子節點的情況,這兩種情況在程式碼中可以合併,如果左子節點不為空,就用左子節點覆蓋掉當前節點,否則使用右子節點覆蓋。如果右子節點也為空,也就是沒有子節點,那麼當前節點也就變為空了。

問題

到這裡,二叉查詢樹的基本的操作方法就編寫完了。這裡引申一個問題,如果我們順序的向一棵樹中插入1,2,3,4,5,這個樹會是什麼形狀?這個也不難想象,如下:

這和連結串列沒有什麼區別了呀,查詢的效能和連結串列一樣了,並沒有提升。這就引出了下一篇的內容:平衡二叉樹,小夥伴們,敬請期待~~

相關文章