死磕 java集合之TreeMap原始碼分析(四)——紅黑樹全解析

彤哥讀原始碼發表於2019-04-13

歡迎關注我的公眾號“彤哥讀原始碼”,檢視更多原始碼系列文章, 與彤哥一起暢遊原始碼的海洋。


二叉樹的遍歷

我們知道二叉查詢樹的遍歷有前序遍歷、中序遍歷、後序遍歷。

(1)前序遍歷,先遍歷我,再遍歷我的左子節點,最後遍歷我的右子節點;

(2)中序遍歷,先遍歷我的左子節點,再遍歷我,最後遍歷我的右子節點;

(3)後序遍歷,先遍歷我的左子節點,再遍歷我的右子節點,最後遍歷我;

這裡的前中後都是以“我”的順序為準的,我在前就是前序遍歷,我在中就是中序遍歷,我在後就是後序遍歷。

下面讓我們看看經典的中序遍歷是怎麼實現的:

public class TreeMapTest {

    public static void main(String[] args) {
        // 構建一顆10個元素的樹
        TreeNode<Integer> node = new TreeNode<>(1, null).insert(2)
                .insert(6).insert(3).insert(5).insert(9)
                .insert(7).insert(8).insert(4).insert(10);

        // 中序遍歷,列印結果為1到10的順序
        node.root().inOrderTraverse();
    }
}

/**
 * 樹節點,假設不存在重複元素
 * @param <T>
 */
class TreeNode<T extends Comparable<T>> {
    T value;
    TreeNode<T> parent;
    TreeNode<T> left, right;

    public TreeNode(T value, TreeNode<T> parent) {
        this.value = value;
        this.parent = parent;
    }

    /**
     * 獲取根節點
     */
    TreeNode<T> root() {
        TreeNode<T> cur = this;
        while (cur.parent != null) {
            cur = cur.parent;
        }
        return cur;
    }

    /**
     * 中序遍歷
     */
    void inOrderTraverse() {
        if(this.left != null) this.left.inOrderTraverse();
        System.out.println(this.value);
        if(this.right != null) this.right.inOrderTraverse();
    }

    /**
     * 經典的二叉樹插入元素的方法
     */
    TreeNode<T> insert(T value) {
        // 先找根元素
        TreeNode<T> cur = root();

        TreeNode<T> p;
        int dir;

        // 尋找元素應該插入的位置
        do {
            p = cur;
            if ((dir=value.compareTo(p.value)) < 0) {
                cur = cur.left;
            } else {
                cur = cur.right;
            }
        } while (cur != null);

        // 把元素放到找到的位置
        if (dir < 0) {
            p.left = new TreeNode<>(value, p);
            return p.left;
        } else {
            p.right = new TreeNode<>(value, p);
            return p.right;
        }
    }
}
複製程式碼

TreeMap的遍歷

從上面二叉樹的遍歷我們很明顯地看到,它是通過遞迴的方式實現的,但是遞迴會佔用額外的空間,直接到執行緒棧整個釋放掉才會把方法中申請的變數銷燬掉,所以當元素特別多的時候是一件很危險的事。

(上面的例子中,沒有申請額外的空間,如果有宣告變數,則可以理解為直到方法完成才會銷燬變數)

那麼,有沒有什麼方法不用遞迴呢?

讓我們來看看java中的實現:

@Override
public void forEach(BiConsumer<? super K, ? super V> action) {
    Objects.requireNonNull(action);
    // 遍歷前的修改次數
    int expectedModCount = modCount;
    // 執行遍歷,先獲取第一個元素的位置,再迴圈遍歷後繼節點
    for (Entry<K, V> e = getFirstEntry(); e != null; e = successor(e)) {
        // 執行動作
        action.accept(e.key, e.value);

        // 如果發現修改次數變了,則丟擲異常
        if (expectedModCount != modCount) {
            throw new ConcurrentModificationException();
        }
    }
}
複製程式碼

是不是很簡單?!

(1)尋找第一個節點;

從根節點開始找最左邊的節點,即最小的元素。

    final Entry<K,V> getFirstEntry() {
        Entry<K,V> p = root;
        // 從根節點開始找最左邊的節點,即最小的元素
        if (p != null)
            while (p.left != null)
                p = p.left;
        return p;
    }
複製程式碼

(2)迴圈遍歷後繼節點;

尋找後繼節點這個方法我們在刪除元素的時候也用到過,當時的場景是有右子樹,則從其右子樹中尋找最小的節點。

static <K,V> TreeMap.Entry<K,V> successor(Entry<K,V> t) {
    if (t == null)
        // 如果當前節點為空,返回空
        return null;
    else if (t.right != null) {
        // 如果當前節點有右子樹,取右子樹中最小的節點
        Entry<K,V> p = t.right;
        while (p.left != null)
            p = p.left;
        return p;
    } else {
        // 如果當前節點沒有右子樹
        // 如果當前節點是父節點的左子節點,直接返回父節點
        // 如果當前節點是父節點的右子節點,一直往上找,直到找到一個祖先節點是其父節點的左子節點為止,返回這個祖先節點的父節點
        Entry<K,V> p = t.parent;
        Entry<K,V> ch = t;
        while (p != null && ch == p.right) {
            ch = p;
            p = p.parent;
        }
        return p;
    }
}
複製程式碼

讓我們一起來分析下這種方式的時間複雜度吧。

首先,尋找第一個元素,因為紅黑樹是接近平衡的二叉樹,所以找最小的節點,相當於是從頂到底了,時間複雜度為O(log n);

其次,尋找後繼節點,因為紅黑樹插入元素的時候會自動平衡,最壞的情況就是尋找右子樹中最小的節點,時間複雜度為O(log k),k為右子樹元素個數;

最後,需要遍歷所有元素,時間複雜度為O(n);

所以,總的時間複雜度為 O(log n) + O(n * log k) ≈ O(n)。

雖然遍歷紅黑樹的時間複雜度是O(n),但是它實際是要比跳錶要慢一點的,啥?跳錶是啥?安心,後面會講到跳錶的。

總結

到這裡紅黑樹就整個講完了,讓我們再回顧下紅黑樹的特性:

(1)每個節點或者是黑色,或者是紅色。

(2)根節點是黑色。

(3)每個葉子節點(NIL)是黑色。(注意:這裡葉子節點,是指為空(NIL或NULL)的葉子節點!)

(4)如果一個節點是紅色的,則它的子節點必須是黑色的。

(5)從一個節點到該節點的子孫節點的所有路徑上包含相同數目的黑節點。

除了上述這些標準的紅黑樹的特性,你還能講出來哪些TreeMap的特性呢?

(1)TreeMap的儲存結構只有一顆紅黑樹;

(2)TreeMap中的元素是有序的,按key的順序排列;

(3)TreeMap比HashMap要慢一些,因為HashMap前面還做了一層桶,尋找元素要快很多;

(4)TreeMap沒有擴容的概念;

(5)TreeMap的遍歷不是採用傳統的遞迴式遍歷;

(6)TreeMap可以按範圍查詢元素,查詢最近的元素;

(7)歡迎補充...

帶詳細註釋的原始碼地址

微信使用者請“閱讀原文”,進入倉庫檢視,其它渠道直接點選此連結即可跳轉。

彩蛋

上面我們說到的刪除元素的時候,如果當前節點有右子樹,則從右子樹中尋找最小元素所在的位置,把這個位置的元素放到當前位置,再把刪除的位置移到那個位置,再看有沒有替代元素,balabala。

那麼,除了這種方式,還有沒有其它方式呢?

答案當然是肯定的。

上面我們說的紅黑樹的插入元素、刪除元素的過程都是標準的紅黑樹是那麼幹的,其實也不一定要完全那麼做。

比如說,刪除元素,如果當前節點有左子樹,那麼,我們可以找左子樹中最大元素的位置,然後把這個位置的元素放到當前節點,再把刪除的位置移到那個位置,再看有沒有替代元素,balabala。

舉例說明,比如下面這顆紅黑樹:

treemap-other1

我們刪除10這個元素,從左子樹中找最大的,找到了9這個元素,那麼把9放到10的位置,然後把刪除的位置移到原來9的位置,發現不需要作平衡(紅+黑節點),直接把這個位置刪除就可以了。

treemap-other2

同樣是滿足紅黑樹的特性的。

所以,死讀書不如無書,學習的過程也是一個不斷重塑知識的過程。


現在公眾號文章沒辦法留言了,如果有什麼疑問或者建議請直接在公眾號給我留言。


歡迎關注我的公眾號“彤哥讀原始碼”,檢視更多原始碼系列文章, 與彤哥一起暢遊原始碼的海洋。

qrcode

相關文章