結合 TreeMap 原始碼分析紅黑樹在 java 中的實現

揪克發表於2017-11-11

csdn 連結:blog.csdn.net/ziwang_/art…

注:本文的原始碼摘自 jdk1.8 中 TreeMap

紅黑樹的意義


紅黑樹本質上是一種特殊的二叉查詢樹,紅黑樹保證了一種平衡,插入、刪除、查詢的最壞時間複雜度都為 O(lgN)。那麼紅黑樹是如何實現這個特性的呢?紅黑樹區別於其他二叉查詢樹的規則在於它的每個結點擁有紅色或黑色中的一種顏色,然後按照一定的規則組成紅黑樹,而這個規則就是我們這篇文章所想要闡述的了。

紅黑樹的性質


紅黑樹遵循以下五點性質:

  • 性質1 結點是紅色或黑色。
  • 性質2 根結點是黑色。
  • 性質3 每個葉子結點(NIL結點,空結點)是黑色的。
  • 性質4 每個紅色結點的兩個子結點都是黑色。(從每個葉子到根的所有路徑上不能有兩個連續的紅色結點)
  • 性質5 從任一結點到其每個葉子結點的所有路徑都包含相同數目的黑色結點。

以下有幾個違反上述規則的結點示例:

違反性質1
違反性質1

結點必須是紅色或黑色

違反性質2
違反性質2

根結點必須是黑色的

違反性質3
違反性質3

葉子結點必須是黑色的

違反性質4
違反性質4

違反性質4
違反性質4

違反性質4
違反性質4

以上三個都是錯誤的紅黑樹示例,每個紅色結點的兩個子結點都是黑色,而如下是合格的

遵循性質4
遵循性質4

當然,細心的讀者應該發現了我只是展示了前四條性質而沒有展示第五條性質,沒有什麼理由,筆者就是懶,第五條挺好理解的。

左旋、右旋


在學習紅黑樹之前想要介紹一個概念——左旋、右旋。這是一種結點操作,是紅黑樹裡面時常出現的一個操作,請看下圖 ——

左旋右旋概念圖
左旋右旋概念圖

這裡的左旋右旋都是針對根節點而言的,所以左圖到右圖是 y 結點右旋,右圖到左圖是 x 結點左旋。

  • 左旋:根結點退居右位,左子結點上位,同時左子結點的右子結點變成根節點左結點。
  • 右旋:根節點退居左位,右子節點上位,同時右子結點的左子結點變成根節點右結點。

現在不理解這倆概念有什麼用不重要,但是希望讀者能理解它的變幻過程,到後面會涉及到。

說起來枯燥無意,我們可以結合 TreeMap 來看看左旋右旋的原始碼 ——

方法圖
方法圖

在這裡我們就針對左旋原始碼看看 ——

左旋原始碼
左旋原始碼

筆者就直接一行一行解釋吧:

private void rotateLeft(Entry<K,V> p) {
    if (p != null) {
        Entry<K,V> r = p.right;         // r 是根結點右子結點
        p.right = r.left;               // 為根結點的左結點指向右子結點(也就是 r)的左結點
        if (r.left != null)
            r.left.parent = p;          // 意義同第二步,這步是右子結點(也就是 r)的左結點將父結點引用指向 p
        r.parent = p.parent;            // 將 r 結點的父引用指向 p 結點的父引用
        if (p.parent == null)
            root = r;                   // 將根結點替換為 r
        else if (p.parent.left == p)
            p.parent.left = r;          // 意義同上
        else
            p.parent.right = r;         // 意義同上
        r.left = p;                     // r 左結點引用指向 p 結點
        p.parent = r;                   // p 結點父引用指向 r 結點
    }
}複製程式碼


假設現在我們找到了相應的結點插入位置,那麼我們接下來就可以插入相應的結點了,這個時候迎來一個頭疼的問題,我們知道紅黑樹結點是有顏色的,那麼我們應該給它設定成黑色的還是紅色的呢?

設定成黑色的吧,就違反了性質5,設定成了紅色的吧,就容易違反了性質4。那怎麼辦?總要給一個顏色,那我們就給紅色的吧。為什麼?因為如果設定成黑色的話,該分支的黑色結點數量肯定比其他分支多一個,而這樣的話相當地不好做調整。如果將插入結點顏色置為紅色的話,運氣比較好的情況下該父結點就是黑色的,那這樣就不需要做任何調整。另一種情況是插入結點的父結點顏色是紅色的,這種情況我們就需要詳細討論了,具體分為以下兩種(此處我們以插入結點的父結點是爺爺結點的左子結點為例(有點拗口),映象操作道理相同):

  • 1.父結點與叔叔結點都為紅

父結點與叔叔結點都為紅
父結點與叔叔結點都為紅

父結點與叔叔結點都為紅的話那麼必定爺爺結點為黑,實際上此時我們最簡單的操作就是將父結點和叔叔結點染黑,將爺爺結點染紅(將爺爺結點染紅的目的是為了保證爺爺結點路徑的黑色結點數量不改變),如下 ——

染黑
染黑

現在目標結點、父結點、叔叔結點都符合要求了,但是爺爺結點的父結點是紅色的,那麼就衝突了,聰明的讀者可能已經發現了,此時的爺爺結點就相當於目標結點,我們不妨將爺爺結點置換為目標結點,再進行遞迴操作就可以達到解決衝突的目的了。

  • 2.父結點為紅,叔叔結點為黑

父結點為紅,叔叔結點為黑
父結點為紅,叔叔結點為黑

但凡有一個結點是紅色,那麼它的父結點必定是黑色(性質4),所以爺爺結點一定是黑色的。

有細心的小夥伴可能覺察到,上圖違反了性質五。實際上上圖是一張簡化後的圖,為了我們後面的內容更加便於理解,上圖的原圖應該是以下模樣 ——

上圖原圖
上圖原圖

ps:上圖中叔叔結點和兄弟結點可以理解成 java 中的 null 結點,筆者特地將它們的個頭縮小了,以便區分。

那麼此時該怎麼操作呢?爺爺結點右旋,爺爺結點置紅,父結點置黑。這條操作過後,性質4、5都沒有違反。

爺爺結點右旋,爺爺結點置紅,父結點置黑
爺爺結點右旋,爺爺結點置紅,父結點置黑

當然,上圖也只是一張簡化圖,實際上原圖如下:

上圖原圖
上圖原圖

那麼結合 TreeMap 原始碼我們來看看:

插入調整原始碼
插入調整原始碼

翻譯如下:

private void fixAfterInsertion(Entry<K,V> x) {
    x.color = RED;      // 目標結點顏色賦紅

    // 目標結點非空,非根,同時父結點為紅,此時才需要調整
    while (x != null && x != root && x.parent.color == RED) {
        // 父結點是爺爺的左子結點
        if (parentOf(x) == leftOf(parentOf(parentOf(x)))) {
            Entry<K,V> y = rightOf(parentOf(parentOf(x)));  // y 是叔叔結點
            // 情況1 叔叔結點也為紅
            if (colorOf(y) == RED) {
                setColor(parentOf(x), BLACK);               // 父結點賦黑
                setColor(y, BLACK);                         // 叔叔結點賦黑
                setColor(parentOf(parentOf(x)), RED);       // 爺爺結點賦紅
                x = parentOf(parentOf(x));                  // 爺爺結點置為目標結點,遞迴
            } else {
                // 情況2 叔叔結點為黑
                // 小插曲,如果目標結點是父結點的右子結點,左旋父結點
                // 當然,此時目標結點應改為父結點
                if (x == rightOf(parentOf(x))) {
                    x = parentOf(x);
                    rotateLeft(x);
                }
                setColor(parentOf(x), BLACK);               // 父結點賦黑
                setColor(parentOf(parentOf(x)), RED);       // 爺爺結點賦紅
                rotateRight(parentOf(parentOf(x)));         // 爺爺結點右旋
            }
        } else {
            // 映象操作,道理同上
            Entry<K,V> y = leftOf(parentOf(parentOf(x)));
            if (colorOf(y) == RED) {
                setColor(parentOf(x), BLACK);
                setColor(y, BLACK);
                setColor(parentOf(parentOf(x)), RED);
                x = parentOf(parentOf(x));
            } else {
                if (x == leftOf(parentOf(x))) {
                    x = parentOf(x);
                    rotateRight(x);
                }
                setColor(parentOf(x), BLACK);
                setColor(parentOf(parentOf(x)), RED);
                rotateLeft(parentOf(parentOf(x)));
            }
        }
    }
    root.color = BLACK;     // 根結點必須賦黑
}複製程式碼

看完程式碼我們發現我們好像漏了一個小插曲(當然,這是筆者故意的),那麼小插曲是一個什麼情況呢?言語來說,在叔叔結點為黑的前提下,當目標結點是父結點的右子結點的時候,需要對父結點進行左旋然後才能接續下一步操作,為什麼會這樣,我們一圖勝千言 ——

小插曲
小插曲

如果忽略上述情況,那麼最終會得到以下情況:

小插曲忽略情況下實現
小插曲忽略情況下實現

由於目標結點是父結點的右子節點,在爺爺結點右旋過程中,它會轉為原爺爺結點的左子結點,這樣的話就違反了特性4和特性5。解決方法就是上面所提到的將父結點先進行左旋然後再進行前面所提到的操作,如下圖 ——

小插曲修正
小插曲修正

當然,不要忘了,現在需要調整的結點是原父結點,也就是要將上圖左下角那個結點作為目標結點進行調整。

所以紅黑樹的添操作分為以下三步:

  • 找到相應的插入位置
  • 將目標結點設定為紅色並插入
  • 通過著色和旋轉等操作使之重新成為一棵二叉樹


這一小節我想先 show 出原始碼再來解釋 ——

刪除結點原始碼
刪除結點原始碼

翻譯如下:

private void deleteEntry(Entry<K,V> p) {
    // 優先選擇左子結點作為被刪結點的替代結點
    Entry<K,V> replacement = (p.left != null ? p.left : p.right);

    // 如果替代結點不為空
    if (replacement != null) {
        replacement.parent = p.parent;
        // 如果刪除結點為根節點,那麼根節點重定向引用指向替代結點
        if (p.parent == null)
            root = replacement;
        else if (p == p.parent.left)
            // 如果刪除結點是其父結點的左子結點,更改父結點左子結點引用指向替代結點
            p.parent.left  = replacement;
        else
            // 如果刪除結點是其父結點的右子結點,更改父結點右子結點引用指向替代結點
            p.parent.right = replacement;

        // 將刪除結點的各個引用置 null
        p.left = p.right = p.parent = null;

        // 如果刪除結點顏色為黑色,那麼需要進行刪後調整
        if (p.color == BLACK)
            fixAfterDeletion(replacement);
    } else if (p.parent == null) {
        // 如果替代結點為空且刪除結點為 root 結點
        root = null;
    } else {
        // 如果刪除結點為空且不是 root 結點
        // 如果刪除結點顏色為黑色,那麼需要進行刪後調整
        if (p.color == BLACK)
            fixAfterDeletion(p);

        // 將刪除結點的各個引用置 null
        if (p.parent != null) {
            if (p == p.parent.left)
                p.parent.left = null;
            else if (p == p.parent.right)
                p.parent.right = null;
            p.parent = null;
        }
    }
}複製程式碼

刪除時可能分為三種情況,具體的做法也在上述程式碼中做了清晰的解釋,筆者在此就不擴充套件了,細心的讀者可能發現了,上述刪除操作凡是涉及到了刪除結點是黑色的情況下,都需要呼叫 fixAfterDeletion() 方法對紅黑樹進行調整。這是因為如果刪除結點是黑色的,當它被刪除後就會違反性質5,所以我們需要對紅黑樹進行結構調整。

為了便於理解紅色結點為什麼不會影響紅黑樹整體結構,筆者還是舉了一個例子給各位讀者理解一下,下圖是刪除前:

刪除前
刪除前

下圖是刪除後:

刪除後
刪除後

實際上紅黑樹是使用以下2點思想來進行調整的(筆者認為,在分析 fixAfterDeletion() 程式碼實現之前,作為開發者應該去自行思考一下如果我們作為原始碼設計者,我們會如何來解決這個問題。) ——

1.給刪除結點的路徑增加一個黑色結點(將兄弟路徑的一個黑色結點移過來)
2.給刪除結點的兄弟路徑減少一個黑色結點(將兄弟路徑的一個紅色結點染黑)

ps:後面我們會針對第一條稱為思想1,第二條稱為思想2

說完思想,我們討論一下具體刪除操作是如何進行的。紅黑樹在保障刪除結點的兄弟結點為黑色的情況下(沒有什麼特殊緣由,僅僅是為了後期好操作),分以下兩點來進行分析:

1.兄弟結點的兩個子結點都是黑色的
2.另一種情況(兄弟結點的兩個子結點至多一個黑色的)

ps:後面我們會針對第一條稱為情況1,第二條稱為情況2

對於情況1來說,紅黑樹採用思想2,將兄弟結點置為紅色,但是這樣帶來了兩個問題——對於父路徑來說,它與兄弟路徑黑色結點數量不同,違反性質5;且如果父結點也是紅色,那麼它勢必與孩子結點衝突,還會違反性質4,如下圖——

下圖示例違反性質5:

原圖
原圖

違反性質5
違反性質5

下圖示例違反性質5且違反性質4:

原圖
原圖

違反性質4、5
違反性質4、5

對於前一個問題用遞迴的思想來解決,將父親結點置為目標結點,讓父親結點的兄弟結點也要減少一個黑色結點就可以了(借鑑思想2);而對於後一個問題,只需要將父結點置黑即可(借鑑思想2)。jdk 中相關實現原始碼如下:

while (x != root && colorOf(x) == BLACK) {
    Entry<K,V> sib = rightOf(parentOf(x));
    if (colorOf(leftOf(sib))  == BLACK &&
        colorOf(rightOf(sib)) == BLACK) {
        setColor(sib, RED);
        x = parentOf(x);
    }
}

setColor(x, BLACK);複製程式碼

前面闡述的是針對情況1而言,針對於情況2而言,紅黑樹採用的是思想1,具體做法分為又得分為以下兩種小情況:

  • 兄弟結點的右子結點不為黑
  • 兄弟結點的右子結點為黑

對於第一種小情況,紅黑樹採用以下操作:

1.兄弟結點置父結點顏色(準備謀權篡位)
2.父結點置黑、兄弟結點右結點置黑
3.父結點左旋

該思想不僅保證了更新結點後不會衝突(父結點與兄弟結點不衝突,兄弟結點與右子結點不衝突,兄弟結點左子結點與父結點不衝突),並且保證了黑色結點數量不會改變,一圖勝千言——

第一種小情況原圖
第一種小情況原圖

第一種小情況刪除後修正
第一種小情況刪除後修正

jdk 中相關原始碼如下:

while (x != root && colorOf(x) == BLACK) {
    setColor(sib, colorOf(parentOf(x)));
    setColor(parentOf(x), BLACK);
    setColor(rightOf(sib), BLACK);
    rotateLeft(parentOf(x));
    x = root;
}

setColor(x, BLACK);複製程式碼

而對於第二種小情況,紅黑樹採用以下操作:

1.將兄弟結點的左子結點染黑
2.兄弟結點染紅
3.兄弟結點右旋

第二種小情況原圖
第二種小情況原圖

第二種小情況刪除後修正
第二種小情況刪除後修正

實際上細心的讀者發現了,轉換後的結構是等同於第一種小情況的初始結構,所以接下來就按照第一種小情況的步驟去變換結構,相關原始碼如下:

while (x != root && colorOf(x) == BLACK) {
    if (colorOf(rightOf(sib)) == BLACK) {   // 情況2
        setColor(leftOf(sib), BLACK);
        setColor(sib, RED);
        rotateRight(sib);
        sib = rightOf(parentOf(x));
    }

    // 情況1
    setColor(sib, colorOf(parentOf(x)));
    setColor(parentOf(x), BLACK);
    setColor(rightOf(sib), BLACK);
    rotateLeft(parentOf(x));
    x = root;
}

setColor(x, BLACK);複製程式碼

這一塊可能有一些複雜,但記住以下三點核心思想問題就不是很大了:

  • 父結點替換刪除結點(保障了刪除結點路徑上的黑色結點數量不變)
  • 兄弟結點替換父結點(保障了父結點路徑上的黑色結點數量不變)
  • 右子結點(結構變化前一定是紅色的,變換後置黑)替換兄弟結點(保障了兄弟路徑上的黑色結點數量不變)

那麼接下來就是看看 fixAfterDeletion() 的程式碼實現了 ——

結點刪除調整原始碼
結點刪除調整原始碼

解釋如下:

private void fixAfterDeletion(Entry<K,V> x) {
    while (x != root && colorOf(x) == BLACK) {
        // 目標結點是左子結點
        if (x == leftOf(parentOf(x))) {
            // 目標結點的兄弟結點
            Entry<K,V> sib = rightOf(parentOf(x));

            // 小插曲1,如果兄弟結點為紅
            // 這步是保障兄弟結點一定為黑
            if (colorOf(sib) == RED) {
                setColor(sib, BLACK);           // 兄弟結點置黑
                setColor(parentOf(x), RED);     // 父結點置紅
                rotateLeft(parentOf(x));        // 父結點左旋
                sib = rightOf(parentOf(x));     // 重定向兄弟結點
            }

            // 兄弟結點的兩個子結點是黑色
            if (colorOf(leftOf(sib))  == BLACK &&
                colorOf(rightOf(sib)) == BLACK) {
                setColor(sib, RED);             // 兄弟結點置紅
                x = parentOf(x);                // 重定向目標結點為父結點
            } else {
                // 兄弟結點的子結點至多一個是黑色的

                // 小插曲2,兄弟結點左子結點為紅,右子結點為黑的情況
                // 這步的意義是讓兄弟結點的右子結點的數量多一個
                if (colorOf(rightOf(sib)) == BLACK) {
                    setColor(leftOf(sib), BLACK);
                    setColor(sib, RED);
                    rotateRight(sib);
                    sib = rightOf(parentOf(x));
                }
                // 將兄弟結點顏色置為父結點顏色(言外之意肯定是兄弟結點要替換父結點的位置)
                setColor(sib, colorOf(parentOf(x)));
                // 將父結點置黑
                setColor(parentOf(x), BLACK);
                // 將兄弟結點右子結點置黑
                setColor(rightOf(sib), BLACK);
                // 左旋父結點
                rotateLeft(parentOf(x));
                x = root;
            }
        } else { // 映象操作
            Entry<K,V> sib = leftOf(parentOf(x));

            if (colorOf(sib) == RED) {
                setColor(sib, BLACK);
                setColor(parentOf(x), RED);
                rotateRight(parentOf(x));
                sib = leftOf(parentOf(x));
            }

            if (colorOf(rightOf(sib)) == BLACK &&
                colorOf(leftOf(sib)) == BLACK) {
                setColor(sib, RED);
                x = parentOf(x);
            } else {
                if (colorOf(leftOf(sib)) == BLACK) {
                    setColor(rightOf(sib), BLACK);
                    setColor(sib, RED);
                    rotateLeft(sib);
                    sib = leftOf(parentOf(x));
                }
                setColor(sib, colorOf(parentOf(x)));
                setColor(parentOf(x), BLACK);
                setColor(leftOf(sib), BLACK);
                rotateRight(parentOf(x));
                x = root;
            }
        }
    }

    setColor(x, BLACK);
}複製程式碼

總結


紅黑樹的插入操作是基於插入結點顏色為紅色,原因是如果插入結點是黑色的話,會導致涉及到該結點的路徑上的黑色結點數量會比兄弟路徑的黑色結點數量多一個,那麼整體調節起來勢必很不方便。而刪除操作是基於刪除結點如果是黑色的情況下,才需要進行調整,因為黑色結點的刪除會導致涉及到該結點的路徑上的黑色結點數量會比兄弟路徑的黑色結點數量少一個,那麼就需要進行整體調節。

紅黑樹在 java 中的運用實際上還是挺多的,例如 TreeSet 的預設底層實現實際上也是 TreeMap;jdk 8中的 HashMap 實現也由原來的陣列+連結串列更改為了陣列+連結串列/紅黑樹。

相關文章