【轉】理解紅黑樹

Andy Niu發表於2013-11-04

樹型結構一直是一種很重要的資料結構, 我們知道二叉查詢樹BST提供了一種快速查詢, 插入的資料結構. 相比雜湊表來說BST佔用空間更小,對於資料量較大和空間要求較高的場合, BST就顯得大有用處了.BST的大部分操作平均執行時間為O(logN), 但是如果樹是含N個結點的線性鏈,則最壞情況執行時間會變為O(N). 為了避免出現最壞情況我們給它增加一些平衡條件, 使它的高度最多為2log(N+1), 最壞情況下執行是間花費也接近O(logN), 這就是我下面要討論的紅黑樹.由於紅黑樹的插入和刪除是相對複雜的操作,所以這裡我將重點討論這兩種操作.

AVL樹

在理解紅黑樹之前最好先來看看AVL樹, 相比紅黑樹AVL樹在插入操作時需要更多的調整次數, 但更容易理解. 所謂AVL樹就是每個節點的左子樹和右子樹的高度最多差1的二叉查詢樹.為什麼是差1而不是具有相同高度?因為如果要求左右子樹高度相同, 那麼就只有具有2k-1個節點的樹才滿足條件, 這樣的條件我們沒法做到. 於是需要放寬條件, 允許左右子樹高度差1.  下圖顯示一棵AVL樹:

 1-1
              AVL樹, 結點兩邊為子樹的高度

下面以AVL樹的插入操作為例說明AVL樹是如何自動平衡的, 這將有助於我們後面理解紅黑樹的插入. 考慮當我們向AVL樹中插入一新節點後可能破壞AVL樹的特性, 即某結點的左右子樹高差大於1. 如果發生這種情況,就要對樹進行旋轉來修正, 什麼是旋轉? 在下面我將會解釋這種步驟. 如果插入沒有破壞AVL樹的特性, 則可以按正常插入. 讓我們假設現在有一個必須重新平衡的節點a,  由於任意節點最多有兩個兒子,因此高度不平衡時,a點的兩棵子樹的高度差為2. 容易想到, 這種不平衡可能出現下面四種情況:
1) 對a的左兒子的左子樹進行一次插入 
2) 對a的左兒子的右子樹進行一次插入 
3) 對a的右兒子的左子樹進行一次插入 
4) 對a的右兒子的右子樹進行一次插入

情況1和4是插入發生在"外邊"的情況(即左-左的情況和右-右的情況),該情況通過對樹的一次單旋轉而完成調整.第2和3種情況是插入發生在"內部"的情況(即左-右的情況或右-左的情況), 該情況要通過稍複雜的雙旋轉來處理.下面先讓我們來看單旋轉時的情況.

假設AVL樹開始有兩個結點3和2, 如下圖. 當我們插入新結點1時, AVL特性被破壞, 在結點3上左子樹的高度2, 而右子樹高度為0. 很明顯結點1位於結點3的左兒子的左子樹中, 於是我們需要在根與左兒子之間施行單旋轉來修正. 如下圖所示:
1-2

圖中間為插入後的狀態, 右邊為旋轉後的狀態. 這裡我們設k2為指向結點3的指標, k1為指向結點2的指標,不存在的結點用NULL表示,  則完成上面的旋轉實際的程式碼為:
k2->left = k1->right; 
k1->right = k2;

我們看到旋轉完成後樹的高度降低了,而樹的性質沒有被破壞, 由此可以看出旋轉實際是對樹的變型. 由於旋轉後, 靠左邊的結點2被提升到上一層,而靠右邊的結點3被降到下一層,因此這種的單旋操作被稱為右旋. 下面我們繼續插入關鍵字4和5. 在插入5時又破壞了節點3處的AVL特性, 於是我們通過再一次單旋轉將其修正, 如下圖:

1-3

這裡我們設K1指向結點3, k2指向結點4, 則完成上面旋轉的程式碼如下: 
k1->right = k2->left; 
k2->left = K1;

注意上面程式碼中還沒有將結點2的右兒子設定為指向結點4, 在後面的完整的插入程式碼中我將會補上. 可以看到這次的旋轉與上一次旋轉的方向相反,所以稱為左旋. 另外要注意的是我們在修正時採用的是至下而上的檢測, 即總是從新插入結點開始, 沿插入路徑向上走到根來判斷是否出現高度不平衡的情況, 當發現第一個不平衡的結點時就進行修正.下面我們來看插入結點6情況.

1-4

這次我們從插入路徑向上直到根結點才發現不平衡, 因此旋轉發生在結點2和結點4之間. 旋轉與上一次相同也是一個左旋, 因為對應前面的是第4種情況, 即結點6的插入是對k1的右兒子的右子樹的插入, 程式碼與上面相同. 下面我們繼續依次插入結點7, 16和15.  首先插入的7和16沒有破壞AVL樹的性質, 在插入15時卻會引起節點7處的高度不平衡, 如下圖. 這屬於前面講到的第3種情況, 需要一次右-左雙旋來修正.  

1-5

這次我們讓k1指向結點7, k3指向結點16, k2指向結點15. 先在k2和k3之間作一次右旋,然後在k1和k2之間作一次左旋來完成修正, 相應程式碼如下:
k3->left = k2->right; 
k2->right = k3; 
k1->right = k2->left; 
k2->left = k1;

你會發現這段程式碼完全是上面右旋程式碼與左旋程式碼的合併, 所以所謂右-左雙旋就是對樹先右旋一次再左旋一次. 下面我們插入14, 它也需要一個雙旋, 如下圖所示. 當我們沿插入路徑向上時在結點6碰到高度不平衡, 此時我們觀察到結點14是位於結點6的右兒子的左子樹中,因此與上一次相同屬於第3種情況.修正辦法還是右-左雙旋轉.

1-6

跟據上面例子分析相信你已經明白AVL樹是如何利用旋轉來達到平衡的了, 而旋轉的主要思想就是在不破壞樹的性質下對樹進行變型, 使樹的高度降低. 下面是AVL樹插入操作的遞迴程式碼實現:

typedef struct Node *Position; 
typedef struct Node *Tree; 

struct Node 
{ 
    ElementType  element; 
    Tree  left; 
    Tree  right; 
    int     height; 
}; 

int Height(Position p) 
{ 
    if (p == NULL) return –1; 
    else return p->height; 
} 

Position SingleRightRotate(Position k2) 
{ 
    Position k1; 
    k1 = k2->left; 
    k2->left = k1->right; 
    k1->right = k2; 
    k2->height = Max(Height(k2->left), Height(k2->right)) + 1; 
    k1->height = Max(Height(k1->left), k2->height) + 1; 
    return k1; 
} 

Position DoubleRightRotate(Position k3) 
{ 
    k3->left = SingleLeftRotate(k3->left); 
    return SingleRightRotate(k3); 
} 

Tree Insert(ElementType x, Tree t) 
{ 
    if (t == NULL) { 
        t = malloc(sizeof(struct Node)); 
        t->element = x; t->height = 0; 
        t->left = t->right = NULL; 
    } else if (x < t->element) { 
        t->left = Insert(x, t->left); 
        if (Height(t->left) – Height(t->right) == 2) 
            if (x < t->left->element) 
                t = SingleRightRotate( t ); 
            else 
                t = DoubleRightRotate( t ); 
    } else if (x < t->element) { 
        t->right = Insert(x, t->right); 
        if (Height(t->right) – Height(t->left) == 2) 
            if (x > t->right->element) 
                t = SingleLeftRotate( t ); 
            else 
                t = DoubleLeftRotatel( t ); 
    } 
    t->height = Max(Height(t->left), Height(t->right)) + 1; 
    return t; 
}

在上面的程式碼中, SingleRightRotate函式實現右旋, 左旋SingleLeftRotate函式與右旋程式碼對稱; Insert函式首先遞迴找到插入的位置併產生新結點, 然後判斷結點高度是否破壞平衡條件, 如果平衡條件被破壞則通過旋轉修正, 最後更新根結點.

紅黑樹

紅黑樹是AVL樹的變種, 紅黑樹通過一些著色法則確保沒有一條路徑會比其它路徑長出兩倍,因而達到接近平衡目的.本文下面將重點討論紅黑樹的插入和刪除操作. 紅黑樹的性質如下:
1) 每一個節點或者著紅色,或者著成黑色. 
2) 根是黑色的 
3) 如果一個節點是紅色的,那麼它的子節點必須是黑色的. 
4) 從一個節點到一個NULL指標的每一條路徑必須包含相同數目的黑色節點.  
1-7

還是以插入操作開始,紅黑樹的性質1和性質2對插入操作沒有影響,當一個新結點被插入時, 如果該結點是黑色則肯定違反條件4, 因此新結點必須是紅色. 如果新結點的父結點是黑的,則我們插入完成, 沒有性質被違反. 如果父節點已經是紅的,那麼我們得到連續紅色節點,這就違反了條件3.   在這種情況下, 我們必須調整該樹以確保條件3滿足且不會破壞條件4.注意在這裡我們依然使用從下到上的調整過程, 即我們從新結點沿插入路徑向上對結點顏色調整和旋傳來保待樹的性質.另一種辦法是使用從上到下的調整過程. 

如上所述在從下到時上的調整過程中, 當我們遇到連續紅色節點時需要對樹進行調整, 這裡我們設新結點為z, z的叔叔結點為y, 一共有三種情況需要處理, 實際上有一共有六種情況,但其中三種與另外三種相互對稱, 這可以通過z的父結點是在其祖父結點的左子樹中還是右子樹中來判斷. 下面我們只討論z的父結點是在左子樹的情況:

1) z的叔叔y是紅色的, 如下圖, 這種情況下調整辦法非常簡單, 不論z位於紅結點的左子樹還是右子樹, 只需將z的父結點和叔叔結點都翻轉為黑色, z的祖父結點翻轉成紅色即可. 但到這裡並沒有結束整個調整過程, 因為z的祖父結點仍可能遇到連續紅節點的情況, 所以最後我們將祖父結點當作新增結點z來繼續向上調整.
1-8

相應程式碼如下: 
z->parent->color = BLACK; 
y->color = BLACK; 
z->parent->parent = RED; 
z = z->parent->parent;

2) z的叔叔y是黑色的,而且z是右孩子 
3) z的叔叔y是黑色的,而且z是左孩子 
如下圖, 在情況2和情況3中, z的叔叔是黑色的.這兩種情況是通過判斷z是其父結點的左孩子還是右孩子來區別的. 如果是情況2,則我們使用一個左旋來將狀況轉變為情況3. 此時結點z可以看作是左孩子. 在情況3中我們先把z的祖父結點顏色翻轉為紅色, 把z的父結點翻轉為黑色, 並作一次右旋傳. 此時兩個連續的紅結點已經消除, 紅黑樹性質沒有改變, 整個調整過程處理完畢.  
1-9
下面是相應程式碼: 
if (z == z->parent->right) { 
    z = z->parent; 
    LeftRotate( z ); 
} 
z->parent->color = BLACK; 
z->parent->parent->color = RED; 
RightRotate( z->parent->parent );

從上面的分析可以看出整個調整過程由性質3觸發, 同時在調整過程中確保了性質4不被破壞. 紅黑樹在插入過程中碰到旋轉的情況比AVL樹要少得多, 其原理來源於2-3-4樹, 當兩個連續的紅結點出現時,相當於發現一個2-3-4樹中的4結點,因此需要向上分裂.有興趣的同學可以參考2-3-4樹的原理. 接下來就讓我們實現整個插入操作, 我們將使用非遞迴的方法來實現. 儘管有些書上是用遞迴來實現, 但紅黑樹的魅力卻在於非遞迴的實現上.首先我們要對上面AVL的樹結構定義作一些修改, 使之成為紅黑樹結構. 程式碼如下:

typedef enum Color { RED, BLACK } Color; 

struct Node 
{ 
    ElementType element; 
    Tree left; 
    Tree right;  
    Tree parent; 
    Color color;  
}; 

Position NullNode = NULL; 
Position RootNode = NULL; 

Node結構的parent成員指向父結點, color指出該結點的顏色. 在上面我們還增加一個全域性的空結點NullNode, 空結點將在初始化時被建立, 該空結點的左右子樹指向自身,且顏色為黑. 當建立一個新結點時, 我們總是將新結點的左右子樹指向它. 另外我們維護一個指向根結點的指標RootNode, 並且我們還設定根結點的父結點也指向NullNode. 好了, 下面就來看看如何實現插入程式碼:

void RBTInsert(ElementType e) 
{ 
    Position z, y, x; 
    y = NullNode; 
    x = RootNode; 
    z = CreateNode(e); 
    while (x != NullNode) { 
        y = x; 
        if (z->element < x->element) 
            x = x->left; 
        else 
            x = x->right; 
    } 
    z->parent = y; 
    if (y == NullNode) { 
        RootNode = z; 
    } else { 
        if (z-> element < y->element)  
            y->left = z; 
        else 
            y->right = z; 
    } 
    z->left = z->right = NullNode; 
    z->color = RED; 
    RBTInsertFixup( z ); 
}

RBTInsert其實跟普通BST的插入差不多, 首先沿樹根向下比較元素值並將新結點z插入正確位置(注意我們將新結點的顏色設為紅的). 最後呼叫RBTInsertFixup來對結點進行修正. CreateNode用來建立一個新結點, 這裡沒有給出程式碼,相信你可以輕鬆實現它.

void RBTInsertFixup(Position z) 
{ 
    while (z->parent->color == RED) { 
        if (z->parent == z->parent->parent->left) { 
            y = z->parent->parent->right; 
            if (y->color == RED) { 
                z->parent->color = BLACK; 
                y->color = BLACK; 
                z->parent->parent->color = RED; 
                z = z->parent->parent; 
            } else { 
                if (z ==  z->parent->right) { 
                    z = z->parent; 
                    LeftRotate( z ); 
                } 
                z->parent->color = BLACK; 
                z->parent->parent->color = RED; 
                RightRotate(z->parent->parent); 
            } 
        } else { 
             /* 這裡的程式碼與上面程式碼相對稱, 只要將所有right和left交換就可以了 */ 
        } 
    } 
    RootNode->color = BLACK; 
} 

RBTInsertFixup實現了整個樹的修正過程, 首先while迴圈判斷是否出現連續的紅結點, 接著下面的if判斷新結點z的父結點, 是在其祖父結點的左子樹還是右子樹中. 由於右子樹的修正程式碼與左子樹的程式碼完全對稱, 所以就不再重複了. 這裡你可能會擔心z指向的結點如果是根結點時, 對其祖父結點的訪問會有問題. 其實不用擔心, 因為前面我們說過根結點的父結點指向的是空結點, 而空結點的左右孩子指向自已, 並且空結點的顏色為黑色.所以根結點永遠不會進入while迴圈, 在while迴圈結束後,我們又將根結點的顏色設回黑色, 保證了紅黑樹性質2不被破壞. 同時我們也不用擔心對樹邊界的訪問, 這也是為什麼要在實現中增加空結點的原因.接下來的程式碼判斷z的叔叔的結點y是否為紅色,對應前面講到的第1種情況. 如果y不是紅色則要進行旋轉,分別對應第2,3種情況.下面是左旋的程式碼, 同樣右旋的程式碼與它對稱, 我不再重複.   

void LeftRotate(Position x) 
{ 
    Position y = x->right; 
    x->right = y->left; 
    if (y->left != NullNode) 
        y->left->parent = x; 
    y->parent = x->parent; 
    if (x->parent == NullNode) { 
        RootNode = y; 
    } else { 
        if (x == x->parent->left)  
            x->parent->left = y;  
        else 
            x->parent->right = y; 
    } 
    y->left = x; 
    x->parent = y; 
} 

上面的左旋程式碼比前面AVL的左旋程式碼稍複雜一些, 主要是增加了父指標更新操作, 即在修改兩個相鄰結點路徑時,要同時修改子結點和指標和父結點指標. 另外由於我們使用非遞迴的實現, 所以要顯式的更新x的父結點指標.

刪除

紅黑樹的刪除比插入情況複雜, 儘管一般我們會使用"墮性刪除", 即在刪除操作中不對結點進行刪除,而是對結點進行標記, 當有新資料插入時再將它放到原來的結點上. 不管怎樣我們還是要討論真正刪除情況. 首先考慮我們要刪除結點的顏色, 如果是紅色, 則刪除一個紅色結點不會破壞紅黑樹的性質, 所以刪除方法與在BST的刪除沒有差別. 如果結點是黑色, 則會遇到下面的情況:
1) 如果要刪除的是根結點,  且根結點的一個紅色孩子結點將成為新的根, 這就違反了性質2; 
2) 如果刪除某個結點後, 在其原來父結點上出現連續的紅結點, 則違反了性質4; 
3) 刪除某個結點後, 導致先前包含該結點的路徑上黑結點數少1, 因此違反了性質5;

下面我們就來討論如何恢復性質2,4,5, 注意我們仍然採用從下到上的調整方法. 從上面的情況看, 恢復性質2和性質4是很簡單的, 只要翻轉被刪除結點的子結點顏色為黑色就可以了, 關鍵是要保證性質5不被破壞, 即從樹根到每個葉節點的黑結點個數相同, 為了做到這一點我們必須對一些結點進行旋轉和改變顏色. 讓我先設y指向要刪除的結點,  x指向y的子結點, w指向x的兄弟結點. 當y被刪除後, 如果y的顏色為紅色則不需要調整, 相反如果y為黑色, 則要檢查x此時是否是根, 如果是根那麼直接將其顏色設為黑色,結束調整.如果x不是根, 但顏色是紅, 情況相同, x直接翻轉為黑色完事. 因為被刪除的結點為黑, 相當於在刪除y後, 所有經過x的路徑上的黑結點數都少1, 所以需要補回來, 保證性質5沒有被破壞. 注意此時x可能是指向空結點, 這沒有什麼問題, 因為空結點是黑的, 所以會進入下面的調整過程.

接著往下分析, 如果x此時是黑的就複雜了, 會遇到4種情況, 實際是8種, 因為其中4種相互對稱, 這可以通過判斷x是其父結點右孩子還是左孩子區分. 下面我們以x是其父結點的左孩子的情況來分析這4種情況. 實際接下來的調整過程, 就是要想方設法將經過x的所有路徑上的黑結數增1.

1) x 的兄弟w是紅色 
1-10
這種情況下我們改變w和x的父親結點顏色,再對x的父親結點和w之間做一次左旋, 此時x的新兄弟指向旋轉之前w的某個孩子.但還不算完, 只是暫時將情況1轉變成了情況2, 3, 4.

2) x的兄弟w是黑色,且w的兩個孩子都是黑色  
1-11
在這種情況下我們翻傳w的顏色, 將x指標向上移一層. 此時如果以前x的父結點是紅的, 如上圖, 我們將x上移一層後, x變成指向紅結點, 還記得前面講過, 如果x為紅結點則將它直接翻轉為黑結點嗎?沒錯, 這裡有一個出口, 在遇到x指向紅結點時調整將結束, 我們在調整結束時又將x設為黑結點, 因此相當於在經過新x結點的所有路徑上黑結點數增加了1, 此時由於原w結點是黑的, w的路徑上就多了一個黑色結點, 所以我們將它翻轉為紅. 注意如果新x結點指向的是黑結點, 則還要以新x結點為參考進行下一次調整.

3) x的兄弟w是黑色, 且w的左孩子是紅色, 右孩子是黑色的 
1-13
情況3中的操作是交換w和其左孩子的顏色, 並對w和其左孩子進行右旋. 於是情況3馬上變成了情況4.

4) x的兄弟w是黑色, 且w的右孩子是紅色的 
1-14
在情況4中我們要設定反轉w的顏色為紅色, 並對w的父結點和x做一次左旋, 旋轉後w的兩個子結點也要跟著反轉成黑色. 此時我們看到原x的路徑上黑結點數增加了1, 而其它路徑黑結點數不變. 所以我們將x指向根結點, 並結束調整過程.

從上面來看情況1可以轉變成情況2,3, 4, 情況3可轉變為情況4, 調整過程在遇到情況4時結束. 當遇到情況2時,其指標x沿樹上升至多O(logN)次, 但不需要執行任何旋轉. 雖然調整過程效複雜,但最多也只花O(logN)次. 程式碼如下:

void RBTDeleteFixup(Position x) 
{ 
    Position w; 
    while (x != RootNode && x->color == BLACK) { 
        if (x == x->parent->left) { 
            w = x->parent->right; 
            /* Case1 */ 
            if (w->color == RED) { 
                w->color = BLACK; 
                x->parent->color = RED; 
                LeftRotate(x->parent); 
                w = x->parent->right; 
            } 
           /* Case2 */ 
            if (w->left->color == BLACK && w->right->color == BLACK) { 
                w->color = RED; 
                x = x->parent; 
            } else { 
                /* Case 3 */ 
                if (w->right->color == BLACK) { 
                    w->left->color = BLACK; 
                    w->color = RED; 
                    RightRotate( w ); 
                    w = x->parent->right; 
                } 
                /* Case 4 */ 
                w->color = x->parent->color; 
                x->parent->color = BLACK; 
                w->right->color = BLACK; 
                LeftRotate( x->parent ); 
                x = RootNode; 
            } 
        } else { 
            /* 處理x是右孩子的情況, 與上面的程式碼對稱 */ 
        } 
    } 
    x->color = BLACK; 
} 

Postion RBTDelete( z ) 
{ 
     Postion y, x; 
     if (z->left == NullNode || z->right == NullNode)  
          y = z; 
      else  
          y = TreeSuccessor( z ); 
     
    if (y->left != NullNode) 
         x = y->left; 
    else 
         x = y->right; 

    x->parent = y->parent; 
    if (y->parent == NullNode) 
        RootNode = x; 
    else if (y == y->parent->left) 
         y->parent->left = x; 
    else 
         y->parent->right = x; 

    if (y != z) 
        z->element = y->element; 

    if (y->color == BLACK) 
        RBTDeleteFixup( x ); 

    return y; 
} 

RBTDelete的程式碼與BST的刪除程式碼基本相同, 唯一不同是在y結點為黑時要觸發修正過程. 另外結點x的父指標總是指向被刪除結點的父結點, 既使x為空結點也沒有問題, 因為空結點的父指標在我們的程式碼中沒有定義. TreeSuccessor函式獲取某結點的後繼, 程式碼如下:

Position TreeMinimum(Position x) 
{ 
    while (x->left != NullNode) 
       x = x->left; 
    return x; 
} 

Position TreeSuccessor(Position x) 
{ 
    Position y; 
    if (x->right != NullNode) 
        return TreeMinimum(x->right); 

    y = x->parent; 
    while (y != NullNode && x == y->right) { 
        x = y; 
        y = y->parent; 
    } 
    return y; 
}

總結

本文中我僅僅介紹了紅黑樹的插入刪除操作, 由於紅黑樹的其它操作與普通BST的操作相同, 所以這裡沒有作討論. 在紅黑樹的插入和刪除中我採用非遞迴的方法, 使用自下往上的平衡過程. 你也可以使用自上往下的平衡的辦法來實現, 兩種方法都差不多複雜. 並且在本文中我把重點放在描述平衡過程, 對平衡步驟未加證明. 有興趣的同學可以參考<演算法導論>.  實際上除了AVL樹和紅黑樹以外還有一些其它一些平衡樹, 如AA樹, treap樹和kd樹等. 這裡只是希望通過本文對大家理解紅黑樹這種資料結構有所幫助. 水平有限, 歡迎大家指正.  

相關文章