三十張圖助你看清紅黑樹的前世今生


微信公眾號:小超說

這是查找算法系列的第三篇 : 三十張圖助你看清紅黑樹的前世今生

在《算法》(第4版)中,紅黑樹的實現直接采用了左傾紅黑樹 (LLRB) 的方法,左傾紅黑樹可以用更少的代碼量實現紅黑樹,在本文中我也使用他的方法理解。相比於經典紅黑樹,增加了一個限制紅節點一定是父節點的左子節點,但是實現卻容易不少

一、紅黑樹的定義

1.每個節點要么是黑色要么是紅色;

2.根節點是黑色;

3.每個葉子節點都是黑色的空節點(NIL),也就是說,葉子節點不存儲數據;

4.任何相鄰的節點都不能同時為紅色,也就是說,紅色節點是被黑色節點隔開的;

5.每個節點,從該節點到達其可達葉子節點的所有路徑,都包含相同數目的黑色節點;

這就是紅黑樹的定義,但你看完肯定會一臉懵,我也是一樣。我會想:紅黑樹的來源是什么?為什么要區分紅色和黑色節點呢?這些性質是怎么來的,或者有什么作用?不着急,你聽我慢慢道來,我希望,你能夠通過這篇文章對紅黑樹有一個清晰的認識,包括它的來歷,意義以及各種操作。

二、平衡二叉查找樹

首先,還記得咱們上次文章中介紹的二叉查找樹嗎?盡管它具有不錯的性能,但是仍然無法避免極端的情況。一般的二叉查找樹的生成和數據插入的順序密切相關,我們來看一組情況:


我們依次插入[B,A,C,G,R,Z],最后得到的樹無疑已經退化到接近鏈表,這時,性能會受到很大地影響。
我們的任務就是試圖構造一種新的數據結構來解決這種問題,即構建平衡二叉查找樹

平衡二叉查找樹的嚴格定義是:每個節點的左子樹和右子樹的高度差至多等於1。但我們不必嚴格死扣定義,我們只需要知道,平衡二叉查找樹中“平衡”的意思,其實就是讓整棵樹左右看起來比較“對稱”、比較“平衡”,不要出現左子樹很高、右子樹很矮的情況。這樣就能讓整棵樹的高度相對來說低一些,相應的插入、刪除、查找等操作的效率高一些。

三、2-3樹

我們不着急直接介紹紅黑樹,請跟隨我的思路先學習一下2-3樹,循序漸進地走向紅黑樹。

2-3樹的定義

一棵2-3樹由以下節點組成:

  • 2-節點,含有一個鍵(及其對應的值)和兩條鏈接,左連接指向的2-3樹中的鍵都小於該節點,右鏈接指向的2-3樹中的鍵都大於該節點。
  • 3-節點,含有兩個鍵(及其對應的值)和三條鏈接,左鏈接指向的2-3樹中的鍵都小於該節點,中鏈接指向的2-3樹中的鍵都位於該節點的兩個鍵之間,右鏈接指向的2-3樹中的鍵都大於該節點。

如果將一棵2-3樹進行中序遍歷,得到的是一個有序的序列,比如我們對下圖進行中序遍歷,會得到[A,C,F,J,K,M,O,Q,R,S,T]

2-3樹的創建

我們首先給出兩條原則【融合】與【拆分】:

  • 原則1. 加入新節點時,不會往空的位置添加節點,而是添加到最后一個葉子節點上(使2-節點變為3-節點,3-節點變為4-節點)

  • 原則2. 如果出現4-節點,要將它分解成三個2-節點組成的樹,並且分解后新樹的根節點需要向上和父節點融合(父節點變成3-節點或者4-節點)

接下來我們一起看一下2-3樹的生長過程:

可以發現,雖然我們的插入順序是[A->C->F->J->K->M->O->],是升序的,但是我們構造的2-3樹卻始終保持平衡。

那么,如何實現這種高效的數據結構呢?上面的一些操作我們描述清楚是一回事,但是實現又是另外一回事。我們選擇紅黑二叉查找樹這種數據結構來實現它,簡稱紅黑樹

紅黑樹與2-3樹的等價

紅黑樹背后的思想是用標准的二叉查找樹(完全由2-節點構成)和一些額外的信息(替換3-節點)來表示2-3樹。我們將樹中的鏈接分為兩種類型:一種是紅鏈接(左傾),一種是黑鏈接。其中紅鏈接將兩個2-節點連接起來構成一個3-節點,黑節點則是2-3樹中的普通節點。為了方便,我們把紅鏈接指向的節點標記為紅色,其他節點標記為黑色,這就是紅黑樹的由來,具體見下圖所示:

我們再重新審視一下紅黑樹定義要求的各種性質:

(1)每個節點要么是黑色要么是紅色;(這個就不多說了,很自然)

(2)根節點是黑色;

根節點只有兩種情況,第一種可能是2-節點,此時對應着黑色;第二種可能是3-節點,此時根節點也是黑色的,你可以結合上圖理解。

(3)每個葉子節點都是黑色的空節點(NIL),也就是說,葉子節點不存儲數據;

這一條主要是為了簡化紅黑樹的實現而設置的,嚴格來說,我們之前畫的還不完整。

(4)任何相鄰的節點都不能同時為紅色,也就是說,紅色節點是被黑色節點隔開的;

我們假設存在同時為紅色的兩個相鄰節點,那么會怎么樣呢?見下圖:

(5)每個節點,從該節點到達其可達葉子節點的所有路徑,都包含相同數目的黑色節點;

這條要求保證了紅黑樹的性能。我們隨意選一條從根節點到葉子節點的路徑,由於性質4,任何相鄰的節點不能同時為紅色,所以每存在一個紅節點,至少對應了一個黑節點,即紅色節點個數<=黑色節點個數,我們再結合2-3樹,每個黑色節點對應着一個2-節點或一個3-節點;根據2-3樹的性質,其節點<=log(N),可以推出黑色節點<=log(N),那么加上紅色節點不會大於2log(N)

總結如下就是:

紅色節點個數<=黑色節點個數=>每一個黑色節點對應2-3樹的每一個節點>2-3樹節點<=log(N)>黑色節點<=log(N)>紅黑樹節點<=2log(N)

我們將2-3樹染色后稱為紅黑樹,然后給出了一些顏色的規則,這些規則能夠幫助我們快速的構建使用紅黑樹。這使得我們以后只需要將注意力集中在顏色上面,只需要去維護上述規定的性質即可。我想,這大概就是紅黑樹存在的意義!

紅黑樹的節點表示代碼如下:

private static final boolean RED = true;
private static final boolean BLACK = false;

private class Node{
    int key;//鍵
    String value;//值
    Node left,right;//左右子樹
    boolean color;
}

//構造函數
Node(int key,String value,boolean color){
    this.key=key;
    this.value=value;
    this.color=color;
}

//判斷節點x的顏色
private boolean isRed(Node x){
    if(x==null) return false;
    return x.color==RED;
}

四、紅黑樹的創建

首先,我們回顧一下2-3樹的創建過程,

  • 原則1. 加入新節點時,不會往空的位置添加節點,而是添加到最后一個葉子節點上(使2-節點變為3-節點,3-節點變為4-節點)

  • 原則2. 如果出現4-節點,要將它分解成三個2-節點組成的樹,並且分解后新樹的根節點需要向上和父節點融合(父節點變成3-節點或者4-節點)

原則1我們可以對應為:每次插入新節點的時候都插入紅色節點+根節點永遠是黑色。這是因為紅色節點被紅色鏈接所指向,而紅色鏈接是2-3節點的內部鏈接,或者也可以理解為始終插入的位置都是同級節點。另一方面,初始化樹的時候,第一個節點應該是黑色的,所以增加這一條規則。

原則2我們可以對應為三種簡單的操作:左旋轉,右旋轉和改變顏色

所以,綜合以上對應關系,我們可以直接創建紅黑樹而不必考慮2-3樹的種種性質

  • 1、保持根節點是黑色
  • 2、左旋轉
  • 3、右旋轉
  • 4、改變顏色

1.左旋轉[圍繞某個節點左旋轉]

在我們定義的紅黑樹中,紅色鏈接永遠是左鏈接,換句話說,紅色節點永遠是父節點的左子節點,所以,在在操作的過程中,為了維持這種性質,我們實現了左旋轉這個操作。

功能:右鏈接變左鏈接

具體過程見圖片:

我們只是將用兩個鍵中較小的那個作為根節點改變為將較大者作為根節點,代碼的實現還是比較容易的,結合上圖理解更輕松。

Node rotateLeft(Node h){
    Node x=h.right;
    h.right=x.left;
    x.left=h;
    h.color=RED;
    return x;
}

2.右旋轉[圍繞某個節點右旋轉]

右旋轉其實和左旋轉完全類似,它的功能是:左鏈接變右鏈接,代碼只需要將leftright互換即可。


代碼如下:

Node rotateRight(Node h){
    Node x=h.left;
    h.left=x.right;
    x.right=h;
    x.color=RED;
    return x;
}

3.改變顏色

void flipColors(Node h){
    h.color=RED;
    h.left.color=BLACK;
    h.right.color=BLACK;
}

如果你已經看到這里了,我先給你樹個大拇指👍,接下來更要打起精神來!

五、紅黑樹的插入

我們先給出所有可能的情況,心中有一個總體的大綱,注意:我們總是插入紅色節點

1、插入的節點父親為黑色(2-節點)

2、插入的節點父親為紅色(3-節點)

  • 插入的節點的鍵大於3-節點的兩個鍵
  • 插入的節點的鍵小於3-節點的兩個鍵
  • 插入的節點的鍵介於3-節點的兩個鍵之間

針對以上所有情況,我們的做法就是通過左旋轉、右旋轉、改變顏色來使得被破壞的紅黑樹的性質得以恢復

第一種情況:

第二種情況:

(1)新鍵最大

(2)新鍵最小

(3)新鍵介於兩者之間

總結一下,我們只要謹慎地使用左旋轉、右旋轉和改變顏色這三個簡單的操作。我們就能保證插入操作后紅黑樹和2-3樹的一一對應關系。在沿着插入點到根節點的路徑向上移動時所經過的每個節點中順序的完成以下操作,我們就能完成插入操作。

  • 如果右子節點是紅色而左子節點是黑色,進行左旋轉
  • 如果左子節點是紅色且它的左子節點也是紅色,進行右旋轉
  • 如果左右子節點均為紅色,改變顏色

以下,我們看一下具體的代碼實現:

public class LLRBTree{
    private Node root;
    private class Node;//見前文代碼部分
    
    //見前文
    private boolean isRed(Node h);
    private Node rotateLeft(Node h);
    private Node rotateRight(Node h);
    private void flipColors(Node h);
    
    public void put(int key,String value){
        //查找key,找到則更新它的值,找不到就創建一個新的節點
        root=put(root,key,value);
        root.color=BLACK;//根節點永遠是黑色的
    }
    
    private Node put(Node h,int key,String value){
        if(h==null) //標准的插入操作,和父節點用紅鏈接相連
            return new Node(key,value,RED);
        if(key<h.key) h.left=put(h.left,key,value);
        else if(key>h.key) h.right=put(h.right,key,value);
        else h.value=value;
        
        if(isRed(h.right)&&!isRed(h.left)) h=rotateLeft(h);
        if(isRed(h.left)&&isRed(h.left.left) h=rotateRight(h);
        if(isRed(h.left)&&isRed(h.right)) flipColors(h);
        
        return h;
    }
}

到現在為止,紅黑樹的插入操作已經完成了,如果哪里不明白,仔細想想,或者你可以翻閱以下《算法》(第四版)。加油!

六、紅黑樹的刪除

對於經典的紅黑樹來說,刪除操作涉及的情況分類比較多和復雜,但是,左傾紅黑樹(LLRB)簡化了這一操作。我們按照以下三步去講解:注意,我們會使用4-節點(三個鍵,4個鏈接)去理解

  • 刪除最小鍵
  • 刪除最大鍵
  • 刪除任意鍵

1.刪除最小鍵

  • 1.遞歸地搜索左子樹,直到找到做左邊的元素(即最小鍵)
  • 2.如果搜索結束於一個3-節點或一個4-節點,直接把最小鍵刪除即可
  • 3.如果結束於2-節點,刪除2-節點(黑節點)會破壞紅黑樹的性質
    • 沿着搜索路徑改變樹的節點
    • 如果當前的節點不是2-節點就不改變

對於第2條,如果結束於3-節點或者4-節點,說明最小鍵是紅節點,直接刪除紅節點不會改變紅黑樹的性質。第三條說的就是解決方案,我們通過一些調整使得最小鍵處於一個3-節點或4-節點上

左邊是原始的結束於2-節點的情況,右邊是我們希望的狀態。

我們的操作是“將一個紅鏈接一直搬運到最左端”

h從根節點沿着路徑下移的過程中,如果h.left或者h.left.left中有一個是紅節點,就不做調整;當且僅當h.lefth.left.left都是黑色的時候,我們做調整(制造紅色節點)。

這又分為兩種情況:h.right.left.color=BLACKh.right.left.color=RED

具體的實現代碼:

樣例圖示:

2.刪除最大鍵

和刪除最小鍵同樣的道理,具體的代碼如下:

樣例圖示:

小超在這里除了感覺比較巧妙與神奇外沒有任何想法:(233 但是,毫無疑問,代碼的實現是簡潔與優美的。

3.刪除任意鍵

有了前面兩個函數做支撐,我們可以很輕松的實現刪除任意鍵。

具體做法:用待刪除點的后繼節點代替該節點,然后刪除后繼節點(deleteMin(h.right)。這里為什么用到后繼節點呢?前驅和后繼節點是與待刪除節點最近的節點,改變它們的位置只會影響整個樹的一小部分(一個局部),從而我們可以較為輕松地恢復紅黑樹的性質。請看下面這張圖:

我們在遞歸向下的過程中始終保證一點:節點h或者節點h的孩子節點其中至少有一個是紅節點。

  • 沿着路經向左搜索:使用函數 moveRedLeft()
  • 沿着路徑向右搜索:使用函數moveRedRight()
  • 找到后在底部刪除節點
  • 沿着原路徑返回時修復之前產生的右鏈接

具體代碼:

總結一下,我們的刪除最大鍵和刪除最小鍵的算法,其實是結合2-3-4樹的一些性質,在刪除之前通過一系列操作為刪除創造條件,而這個條件一旦滿足后,我們就可以在不產生任何后果的情況下進行刪除操作。刪除前的一系列鋪墊小超這里覺得實在是巧妙😲,我覺得,你根據后面的這些配圖和代碼應該可以理解他的思路,畢竟,情況不多!左傾紅黑樹(LLRBTree)大大減少了需要分類討論的情況。這是一項偉大的發明!

推薦大家去看 《算法4》(后台回復【算法4】即可),另外,英語不錯的話推薦看參考中的兩篇文章(一篇論文,一篇PPT)

參考:
[1]《算法》(第四版)

[2]Left-leaning Red-Black Trees Robert Sedgewick

[3]Left-Leaning Red-Black Trees

七、總結與后話

我覺得,對於紅黑樹,我們知道它的由來,知道它相比於其他平衡樹的特性及優點,知道它的應用場景,知道它能夠解決的問題就可以了。這些內部的實現、各種調整策略真的不需要去記憶!

好了,關於紅黑樹的內容就到這里了,我希望通過這篇文章你對於紅黑樹的認識又上了一個台階!下一次,我們會介紹散列表(Hash Table),小超與你不見不散!

碼字繪圖不易,如果覺得本文對你有幫助,關注作者就是最大的支持!順手點個在看更感激不盡!

歡迎大家關注我的公眾號:小超說,之后我會繼續創作算法與數據結構以及計算機基礎知識的文章。也可以加我微信chao_hey,我們一起交流,一起進步!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM