1. 程式人生 > >面試舊敵之紅黑樹(直白介紹深入理解)

面試舊敵之紅黑樹(直白介紹深入理解)

開發十年,就只剩下這套架構體系了! >>>   

讀完本文你將瞭解到:

 

 

上篇文章 《重溫資料結構:二叉排序樹的查詢、插入、刪除》 我們提到,二叉排序樹的效能取決於二叉樹的層數:

  • 最好的情況是 O(logn),存在於完全二叉排序樹情況下,其訪問效能近似於折半查詢;
  • 最差時候會是 O(n),比如插入的元素是有序的,生成的二叉排序樹就是一個連結串列,這種情況下,需要遍歷全部元素才行(見下圖 b)。

shixinzhang

為了改變排序二叉樹存在的不足,Rudolf Bayer 在 1972 年發明了另一種改進後的排序二叉樹:紅黑樹,他將這種排序二叉樹稱為“對稱二叉 B 樹”,而紅黑樹這個名字則由 Leo J. Guibas 和 Robert Sedgewick 於 1978 年首次提出。

本文介紹了紅黑樹的基本性質和基本操作。

什麼是紅黑樹

紅黑樹本質上是一種二叉查詢樹,但它在二叉查詢樹的基礎上額外添加了一個標記(顏色),同時具有一定的規則。這些規則使紅黑樹保證了一種平衡,插入、刪除、查詢的最壞時間複雜度都為 O(logn)。

它的統計效能要好於平衡二叉樹(AVL樹),因此,紅黑樹在很多地方都有應用。比如在 Java 集合框架中,很多部分(HashMap, TreeMap, TreeSet 等)都有紅黑樹的應用,這些集合均提供了很好的效能。

由於 TreeMap 就是由紅黑樹實現的,因此本文將使用 TreeMap 的相關操作的程式碼進行分析、論證。

黑色高度

從根節點到葉節點的路徑上黑色節點的個數,叫做樹的黑色高度。

紅黑樹的 5 個特性

shixinzhang

紅黑樹在原有的二叉查詢樹基礎上增加了如下幾個要求:

  1. Every node is either red or black.
  2. The root is black.
  3. Every leaf (NIL) is black.
  4. If a node is red, then both its children are black.
  5. For each node, all simple paths from the node to descendant leaves contain the same number of black nodes.

中文意思是:

  1. 每個節點要麼是紅色,要麼是黑色;
  2. 根節點永遠是黑色的;
  3. 所有的葉節點都是是黑色的(注意這裡說葉子節點其實是上圖中的 NIL 節點);
  4. 每個紅色節點的兩個子節點一定都是黑色;
  5. 從任一節點到其子樹中每個葉子節點的路徑都包含相同數量的黑色節點;

注意: 
性質 3 中指定紅黑樹的每個葉子節點都是空節點,而且並葉子節點都是黑色。但 Java 實現的紅黑樹將使用 null 來代表空節點,因此遍歷紅黑樹時將看不到黑色的葉子節點,反而看到每個葉子節點都是紅色的。

性質 4 的意思是:從每個根到節點的路徑上不會有兩個連續的紅色節點,但黑色節點是可以連續的。 
因此若給定黑色節點的個數 N,最短路徑的情況是連續的 N 個黑色,樹的高度為 N - 1;最長路徑的情況為節點紅黑相間,樹的高度為 2(N - 1) 。

性質 5 是成為紅黑樹最主要的條件,後序的插入、刪除操作都是為了遵守這個規定。

紅黑樹並不是標準平衡二叉樹,它以性質 5 作為一種平衡方法,使自己的效能得到了提升。

紅黑樹的左旋右旋

shixinzhang

紅黑樹的左右旋是比較重要的操作,左右旋的目的是調整紅黑節點結構,轉移黑色節點位置,使其在進行插入、刪除後仍能保持紅黑樹的 5 條性質。

比如 X 左旋(右圖轉成左圖)的結果,是讓在 Y 左子樹的黑色節點跑到 X 右子樹去。

我們以 Java 集合框架中的 TreeMap 中的程式碼來看下左右旋的具體操作方法:

指定節點 x 的左旋 (右圖轉成左圖):

//這裡 p 代表 x
private void rotateLeft(Entry p) {
    if (p != null) {
        Entry r = p.right; // p 是上圖中的 x,r 就是 y
        p.right = r.left;       // 左旋後,x 的右子樹變成了 y 的左子樹 β 
        if (r.left != null)         
            r.left.parent = p;  //β 確認父親為 x
        r.parent = p.parent;        //y 取代 x 的第一步:認 x 的父親為爹
        if (p.parent == null)       //要是 x 沒有父親,那 y 就是最老的根節點
            root = r;
        else if (p.parent.left == p) //如果 x 有父親並且是它父親的左孩子,x 的父親現在認 y 為左孩子,不要 x 了
            p.parent.left = r;
        else                            //如果 x 是父親的右孩子,父親就認 y 為右孩子,拋棄 x
            p.parent.right = r;
        r.left = p;     //y 逆襲成功,以前的爸爸 x 現在成了它的左孩子
        p.parent = r;
    }
}

可以看到,x 節點的左旋就是把 x 變成 右孩子 y 的左孩子,同時把 y 的左孩子送給 x 當右子樹。

簡單點記就是:左旋把右子樹裡的一個節點(上圖 β)移動到了左子樹。

指定節點 y 的右旋(左圖轉成右圖):

private void rotateRight(Entry p) {
    if (p != null) {
        Entry l = p.left;
        p.left = l.right;
        if (l.right != null) l.right.parent = p;
        l.parent = p.parent;
        if (p.parent == null)
            root = l;
        else if (p.parent.right == p)
            p.parent.right = l;
        else p.parent.left = l;
        l.right = p;
        p.parent = l;
    }
}

同理,y 節點的右旋就是把 y 變成 左孩子 x 的右孩子,同時把 x 的右孩子送給 x 當左子樹。

簡單點記就是:右旋把左子樹裡的一個節點(上圖 β)移動到了右子樹。

瞭解左旋、右旋的方法及意義後,就可以瞭解紅黑樹的主要操作:插入、刪除。

紅黑樹的平衡插入

紅黑樹的插入主要分兩步:

  • 首先和二叉查詢樹的插入一樣,查詢、插入
  • 然後調整結構,保證滿足紅黑樹狀態 
    • 對結點進行重新著色
    • 以及對樹進行相關的旋轉操作

紅黑樹的插入在二叉查詢樹插入的基礎上,為了重新恢復平衡,繼續做了插入修復操作。

二叉查詢樹的插入

上篇文章 介紹過,二叉查詢樹的就是一個二分查詢,找到合適的位置就放進去。

插入後調整紅黑樹結構

紅黑樹的第 5 條特徵規定,任一節點到它子樹的每個葉子節點的路徑中都包含同樣數量的黑節點。也就是說當我們往紅黑樹中插入一個黑色節點時,會違背這條特徵。

同時第 4 條特徵規定紅色節點的左右孩子一定都是黑色節點,當我們給一個紅色節點下插入一個紅色節點時,會違背這條特徵。

因此我們需要在插入黑色節點後進行結構調整,保證紅黑樹始終滿足這 5 條特徵。

調整思想

前面說了,插入一個節點後要擔心違反特徵 4 和 5,數學裡最常用的一個解題技巧就是把多個未知數化解成一個未知數。我們這裡採用同樣的技巧,把插入的節點直接染成紅色,這樣就不會影響特徵 5,只要專心調整滿足特徵 4 就好了。這樣比同時滿足 4、5 要簡單一些。

染成紅色後,我們只要關心父節點是否為紅,如果是紅的,就要把父節點進行變化,讓父節點變成黑色,或者換一個黑色節點當父親,這些操作的同時不能影響 不同路徑上的黑色節點數一致的規則。

注:插入後我們主要關注插入節點的父親節點的位置,而父親節點位於左子樹或者右子樹的操作是相對稱的,這裡我們只介紹一種,即插入位置的父親節點為左子樹。

【插入、染紅後的調整有 2 種情況:】

情況1.父親節點和叔叔節點都是紅色:

shixinzhang

假設插入的是節點 N,這時父親節點 P 和叔叔節點 U 都是紅色,爺爺節點 G 一定是黑色。

紅色節點的孩子不能是紅色,這時不管 N 是 P 的左孩子還是右孩子,只要同時把 P 和 U 染成黑色,G 染成紅色即可。這樣這個子樹左右兩邊黑色個數一致,也滿足特徵 4。

但是這樣改變後 G 染成紅色,G 的父親如果是紅色豈不是又違反特徵 4 了? 
這個問題和我們插入、染紅後一致,因此需要以 爺爺節點 G 為新的調整節點,再次進行調整操作,以此迴圈,直到父親節點不是紅的,就沒有問題了。

情況2.父親節點為紅色,叔叔節點為黑色:

shixinzhang

假設插入的是節點 N,這時父親節點 P 是紅色,叔叔節點 U 是黑色,爺爺節點 G 一定是黑色。

紅色節點的孩子不能是紅色,但是直接把父親節點 P 塗成黑色也不行,這條路徑多了個黑色節點。怎麼辦呢?

既然改變不了你,那我們就此別過吧,我換一個更適合我的!

我們怎麼把 P 弄走呢?看來看去,還是右旋最合適,通過把 爺爺節點 G 右旋,P 變成了這個子樹的根節點,G 變成了 P 的右子樹。

右旋後 G 跑到了右子樹上,這時把 P 變成黑的,多了一個黑節點,再把 G 變成紅的,就平衡了!

上面講的是插入節點 N 在父親節點 P 的左孩子位置,如果 N 是 P 的右孩子,就需要多進行一次左旋,把情況化解成上述情況。

shixinzhang

N 位於 P 的右孩子位置,將 P 左旋,就化解成上述情況了。

根據 TreeMap 的程式碼來驗證這個過程:

下面是 TreeMap 在插入後進行調整的程式碼,可以看出來跟我們分析的一致。

private void fixAfterInsertion(Entry x) {
    x.color = RED;  //直接染成紅色,少點麻煩
 
    //這裡分析的都是父親節點為紅色的情況,不是紅色就不用調整了
    while (x != null && x != root && x.parent.color == RED) {
        if (parentOf(x) == leftOf(parentOf(parentOf(x)))) { // 插入節點 x 的父親節點位於左孩子    
            Entry y = rightOf(parentOf(parentOf(x)));  // y 是 x 的叔叔節點
            if (colorOf(y) == RED) {    //如果 y 也是紅色,只要把父親節點和 y 都變成黑色,爺爺節點變成紅的,就 Ok 了
                setColor(parentOf(x), BLACK);
                setColor(y, BLACK);
                setColor(parentOf(parentOf(x)), RED);
                x = parentOf(parentOf(x));
            } else {    //如果叔叔節點 y 不是紅色,就需要右旋,讓父親節點變成根節點,爺爺節點去右子樹去,然後把父親節點變成黑色、爺爺節點變成紅色
                    //特殊情況:x 是父親節點的右孩子,需要對父親節點進行左旋,把 x 移動到左子樹
                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 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;
}

紅黑樹的平衡刪除

紅黑樹的插入平衡需要好好理解下,如果前面沒有理解,刪除後的調整平衡更加難懂,前方高能,請注意!

紅黑樹的刪除也是分兩步:

  1. 二叉查詢樹的刪除
  2. 結構調整

二叉查詢樹的刪除

上篇文章 介紹了,二叉查詢樹的刪除分三種情況:

  1. 要刪除的節點正好是葉子節點,直接刪除就 OK 了;
  2. 只有左孩子或者右孩子,直接把這個孩子上移放到要刪除的位置就好了;
  3. 有兩個孩子,就需要選一個合適的孩子節點作為新的根節點,該節點稱為 繼承節點。

三種情況的圖片示意 
(圖來自:shmilyaw-hotmail-com.iteye.com/blog/183643…):

1.要刪除的節點正好是葉子節點,直接刪除就 OK 了(右圖有錯誤,應該是 z 不是 r)

shixinzhang

2.有左孩子或者右孩子,直接把這個孩子上移放到要刪除的位置就好了

shixinzhang

3.有兩個孩子,就需要選一個合適的孩子節點作為新的根節點,該節點稱為 繼承節點

shixinzhang

刪除後的結構調整

根據紅黑樹的第 5 個特性:

如果當前待刪除節點是紅色的,它被刪除之後對當前樹的特性不會造成任何破壞影響。 
而如果被刪除的節點是黑色的,這就需要進行進一步的調整來保證後續的樹結構滿足要求。

這裡研究的是刪除黑色節點的情況。

調整思想

為了保證刪除節點父親節點左右兩邊黑色節點數一致,需要重點關注父親節點沒刪除的那一邊節點是不是黑色。如果刪除後父親節點另一邊比刪除的一邊黑色節點多,就要想辦法搞到平衡,具體的平衡方法有如下幾種方法:

  1. 把父親節點另一邊(即刪除節點的兄弟樹)其中一個節點弄成紅色,也少一個黑色
  2. 或者把另一邊多的黑色節點轉過來一個

刪除節點在父親節點的左子樹還是右子樹,調整方式都是對稱的,這裡以當前節點為父節點的左孩子為例進行分析。

【刪除後的調整主要分三步】:

第一步:

  • 兄弟如果是紅的,說明孩子都是黑的 【旋轉的情況 1 】 
    • 把兄弟搞成黑的
    • 父親搞成紅的
    • 左旋轉父親(嘿嘿,兄弟給我分一個黑孩子)
    • 接下來對比旋轉後的兄弟

第一步解釋:

這一步的目的是將兄弟節點變成黑的,轉變成第二步兩種情形中的某一種情況。

在做後續變化前,這棵樹還是保持著原來的平衡。

第二步,有兩種情況:

情況1 :兄弟節點的孩子都是黑色

  • 把兄弟搞成紅的
  • continue 下一波(這個子樹搞完了,研究父親節點,去搞上一級樹,進入第三步)

第二步情況 1 解釋:

這裡將兄弟節點變成紅色後,從它的父節點到下面的所有路徑就都統一少了 1 個,同時也不影響別的特徵,但是把兄弟節點變紅後,如果有父親節點也是紅的,就可能違反紅黑樹的特徵 4,因此需要到更高一級樹進行鑑別、調整。

shixinzhang

情況2 :兄弟節點的孩子至多有一個是黑的

  • 把不是黑的那個孩子搞黑 【旋轉的情況 2 】 
    • 兄弟搞紅
    • 兄弟右旋轉
    • 以後對比旋轉後的兄弟
  • 把兄弟塗成跟父親一樣的顏色 【旋轉的情況 3 】
  • 然後把父親搞黑
  • 把兄弟的右孩子搞黑
  • 父親節點左旋
  • 研究根節點,進入第三步

第二步情況 2 解釋:

旋轉的情況 2 是將兄弟節點的左右孩子都移動到右邊,方便後續操作,如下圖所示:

shixinzhang

旋轉的情況 3 將兄弟的孩子移到左邊來,同時黑色的父親變到了左邊(總之就是讓左邊多些黑色節點),如下圖所示:

shixinzhang

第三步:

  • 如果研究的不是根節點並且是黑的,重新進入第一步,研究上一級樹;
  • 如果研究的是根節點或者這個節點不是黑的,就退出 
    • 把研究的這個節點塗成黑的。

第三步解釋:

第三步中選擇根節點為結束標誌,是因為在第二步中,有可能出現我們正好給刪除黑色節點的子樹補上了一個黑色節點,同時不影響其他子樹,這時我們的調整已經完成,可以直接設定調整節點 x = root,等於宣告調整結束。

因為我們當前調整的可能只是一棵樹中間的子樹,這裡頭的節點可能還有父節點,這麼一直往上到根節點。當前子樹少了一個黑色節點,要保證整體合格還是不夠的。

這裡需要在程式碼裡有一個保證。假設這裡 B 已經是紅色的了。那麼調整結束,最後對 B 節點,也就是調整目標 x 所指向的這個節點塗成黑色。這樣保證前面虧的那一個黑色節點就補回來了。

前面討論的這4種情況是在當前節點是父節點的左子節點的條件下進行的。如果當前節點是父節點的右子節點,則可以對應的做對稱的操作處理,過程也是一樣的。

其中具體旋轉方向根據調整節點在父節點的左/右位置決定。

根據 TreeMap 的程式碼來驗證這個過程:

private void fixAfterDeletion(Entry x) {
    while (x != root && colorOf(x) == BLACK) {
        if (x == leftOf(parentOf(x))) {
            Entry sib = rightOf(parentOf(x));
 
            //左旋,把黑色節點移到左邊一個
            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 {
                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 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);
}

當調整的節點屬於父親節點的左子樹時,調整方法對應的流程圖如下:

shixinzhang

當調整的節點屬於父親節點的右子樹時,調整方法也類似,旋轉的方向相對稱。

這裡列出刪除後調整的全部邏輯流程圖(右鍵新視窗開啟圖片更清晰):

shixinzhang

總結

紅黑樹並不是真正的平衡二叉樹,但在實際應用中,紅黑樹的統計效能要高於平衡二叉樹,但極端效能略差。

紅黑樹的插入、刪除調整邏輯比較複雜,但最終目的是滿足紅黑樹的 5 個特性,尤其是 4 和 5。

在插入調整時為了簡化操作我們直接把插入的節點塗成紅色,這樣只要保證插入節點的父節點不是紅色就可以了。

而在刪除後的調整中,針對刪除黑色節點,所在子樹缺少一個節點,需要進行彌補或者對別人造成一個黑色節點的傷害。具體調整方法取決於兄弟節點所在子樹的情況。

紅黑樹的插入、刪除在樹形資料結構中算比較複雜的,理解起來比較難,但只要記住,紅黑樹有其特殊的平衡規則,而我們為了維持平衡,根據鄰樹的狀況進行旋轉或者塗色。

紅黑樹這麼難理解,必定有其過人之處。它的有序、快速特性在很多場景下都有用到,比如 Java 集合框架的 TreeMap, TreeSet 等。

Thanks

coolingxyz 前輩寫過資料結構相關的課件,flash 動態演示資料結構演算法,可以去看看: 
xu-laoshi.cn/shujujiegou…

一個不錯的線上演示新增、刪除紅黑樹: 
sandbox.runjs.cn/show/2nngvn…

《演算法導論》

en.wikipedia.org/wiki/Red–black_tree

www.cnblogs.com/skywang1234…

shmilyaw-hotmail-com.iteye.com/blog/183643…

blog.csdn.net/speedme/art…

blog.csdn.net/eson_15/art…

blog.csdn.net/v_july_v/ar…

dongxicheng.o