紅黑樹數據結構剖析
紅黑樹是計算機科學內比較常用的一種數據結構,它使得對數據的搜索,插入和刪除操作都能保持在O(lgn)的時間復雜度。然而,相比於一般的數據結構,紅黑樹的實現的難度有所增加。網絡上關於紅黑樹的實現資料汗牛充棟,但是乏於系統介紹紅黑樹實現的資料。本文通過一個自己實現的紅黑樹數據結構以及必要的搜索,插入和刪除操作算法,為大家更系統地剖析紅黑樹數據結構的實現。
對於大部分數據結構,一般都會使用抽象數據類型的方式實現,C++提供的模板機制可以做到數據結構與具體數據類型無關,就像STL實現的那樣。不過本文並非去實現STL中的紅黑樹,更重要的是透過紅黑樹的實現學習相關的算法和思想。當然,我們還是會借鑒STL中關於紅黑樹實現部分有價值內容。
一、基本概念
在具體實現紅黑樹之前,必須弄清它的基本含義。紅黑樹本質上是一顆二叉搜索樹,它滿足二叉搜索樹的基本性質——即樹中的任何節點的值大於它的左子節點,且小於它的右子節點。
圖1 二叉搜索樹
按照二叉搜索樹組織數據,使得對元素的查找非常快捷。比如圖1中的二叉搜索樹,如果查詢值為48的節點,只需要遍歷4個節點即可完成。理論上,一顆平衡的二叉搜索樹的任意節點平均查找效率為樹的高度h,即O(lgn)。但是如果二叉搜索樹的失去平衡(元素全在一側),搜索效率就退化為O(n),因此二叉搜索樹的平衡是搜索效率的關鍵所在。為了維護樹的平衡性,數據結構內出現了各種各樣的樹,比如AVL樹通過維持任何節點的左右子樹的高度差不大於1保持樹的平衡,而紅黑樹使用顏色的概念維持樹的平衡,使二叉搜索樹的左右子樹的高度差保持在固定的范圍。相比於其他二叉搜索樹樹,紅黑樹對二叉搜索樹的平衡性維持有着自身的優勢。
顧名思義,紅黑樹的節點是有顏色概念的,即非紅即黑。通過顏色的約束,紅黑樹維持着二叉搜索樹的平衡性。一顆紅黑樹必須滿足以下幾點條件:
規則1、根節點必須是黑色。
規則2、任意從根到葉子的路徑不包含連續的紅色節點。
規則3、任意從根到葉子的路徑的黑色節點總數相同。
如圖2所示,為一顆合法的紅黑樹,可以發現紅黑樹在維持二叉搜索樹的基本性質的前提下,並滿足了紅黑樹的顏色條件,整體上保持了二叉搜索樹的平衡性。(構造如下紅黑樹的數據序列為:(50,35,78,27,56,90,45,40,48),讀者可以自行驗證。)
圖2 紅黑樹
二、數據結構設計
和一般的數據結構設計類似,我們用抽象數據類型表示紅黑樹的節點,使用指針保存節點之間的相互關系。
作為紅黑樹節點,其基本屬性有:節點的顏色、左子節點指針、右子節點指針、父節點指針、節點的值。
圖3 紅黑樹節點基本屬性
為了方便紅黑樹關鍵算法的實現,還定義了一些簡單的操作(都是內聯函數)。
template< class T>
class rb_tree_node
{
typedef rb_tree_node_color node_color;
typedef rb_tree_node<T> node_type;
public:
node_color color; // 顏色
node_type*parent; // 父節點
node_type*left; // 左子節點
node_type*right; // 右子節點
T value; // 值
rb_tree_node(T&v); // 構造函數
inline node_type*brother(); // 獲取兄弟節點
inline bool on_left(); // 自身是左子節點
inline bool on_right(); // 自身是右子節點
inline void set_left(node_type*node); // 設置左子節點
inline void set_right(node_type*node); // 設置左子節點
};
為了表示紅黑樹節點的顏色,我們定義一個簡單的枚舉類型。
enum rb_tree_node_color
{
red= false,
black= true
};
有了節點,剩下的就是實現紅黑樹的構造、插入、搜索、刪除等關鍵算法了。
template< class T>
class rb_tree
{
public:
typedef rb_tree_node<T> node_type;
rb_tree();
~rb_tree();
void clear();
void insert(T v); // 添加節點
bool insert_unique(T v); // 添加唯一節點
node_type* find(T v); // 查詢節點
bool remove(T v); // 刪除節點
inline node_type* maximum(); // 最大值
inline node_type* minimum(); // 最小值
inline node_type* next(node_type*node); // 下一個節點
inline node_type* prev(node_type*node); // 上一個節點
void print(); // 輸出
int height(); // 高度
unsigned count(); // 節點數
bool validate(); // 驗證
unsigned get_rotate_times(); // 獲取旋轉次數
private:
node_type*root; // 樹根
unsigned rotate_times; // 旋轉的次數
unsigned node_count; // 節點數
void __clear(node_type*sub_root); // 清除函數
void __insert(node_type*&sub_root,node_type*parent,node_type*node); // 內部節點插入函數
node_type* __find(node_type*sub_root,T v); // 查詢
inline node_type* __maximum(node_type*sub_root); // 最大值
inline node_type* __minimum(node_type*sub_root); // 最小值
void __rebalance(node_type*node); // 新插入節點調整平衡
void __fix(node_type*node,node_type*parent, bool direct); // 刪除節點調整平衡
void __rotate(node_type*node); // 自動判斷類型旋轉
void __rotate_left(node_type*node); // 左旋轉
void __rotate_right(node_type*node); // 右旋轉
void __print(node_type*sub_root); // 輸出
int __height(node_type*&sub_root); // 高度
bool __validate(node_type*&sub_root, int& count); // 驗證紅黑樹的合法性
};
在紅黑樹類中,定義了樹根(root)和節點數(count),其中還記錄紅黑樹在插入刪除操作時執行的旋轉次數rotate_times。其中核心操作有插入操作(insert),搜索操作(find),刪除操作(remove),遞減操作(prev)——尋找比當前節點較小的節點,遞增操作(next)——尋找比當前節點較大的節點,最大值(maximum)和最小值(minimum)操作等。其中驗證操作(__ validate)通過遞歸操作紅黑樹,驗證紅黑樹的三個基本顏色約束,用於操縱紅黑樹后驗證紅黑樹是否保持平衡。
由於插入和刪除操作是紅黑樹的關鍵所在,下邊重點介紹這兩個操作。其他的操作一般通過對樹進行遞歸操作都可以輕松的完成,這里不再贅述。
三、紅黑樹的插入操作
紅黑樹的插入操作和查詢操作有些類似,它按照二分搜索的方式遞歸尋找插入點。不過這里需要考慮邊界條件——當樹為空時需要特殊處理(這里未采用STL對樹根節點實現的特殊技巧)。如果插入第一個節點,我們直接用樹根記錄這個節點,並設置為黑色,否則作遞歸查找插入(__insert操作)。
默認插入的節點顏色都是紅色,因為插入黑色節點會破壞根路徑上的黑色節點總數,但即使如此,也會出現連續紅色節點的情況。因此在一般的插入操作之后,出現紅黑樹約束條件不滿足的情況(稱為失去平衡)時,就必須要根據當前的紅黑樹的情況做相應的調整(__rebalance操作)。和AVL樹的平衡調整通過旋轉操作的實現類似,紅黑樹的調整操作一般都是通過旋轉結合節點的變色操作來完成的。
紅黑樹插入節點操作產生的不平衡來源於當前插入點和父節點的顏色沖突導致的(都是紅色,違反規則2)。
圖4 插入沖突
如圖4所示,由於節點插入之前紅黑樹是平衡的,因此可以斷定祖父節點g必存在(規則1:根節點必須是黑色),且是黑色(規則2:不會有連續的紅色節點),而叔父節點u顏色不確定,因此可以把問題分為兩大類:
1、叔父節點是黑色(若是空節點則默認為黑色)
這種情況下通過旋轉和變色操作可以使紅黑樹恢復平衡。但是考慮當前節點n和父節點p的位置又分為四種情況:
A、n是p左子節點,p是g的左子節點。
B、n是p右子節點,p是g的右子節點。
C、n是p左子節點,p是g的右子節點。
D、n是p右子節點,p是g的左子節點。
情況A,B統一稱為外側插入,C,D統一稱為內側插入。之所以這樣分類是因為同類的插入方式的解決方式是對稱的,可以通過鏡像的方法相似完成。
首先考慮情況A:n是p左子節點,p是g的左子節點。針對該情況可以通過一次右旋轉操作,並將p設為黑色,g設為紅色完成重新平衡。
圖5 左外側插入調整
右旋操作的步驟是:將p掛接在g節點原來的位置(如果g原是根節點,需要考慮邊界條件),將p的右子樹x掛到g的左子節點,再把g掛在p的右子節點上,完成右旋操作。這里將最終旋轉結果的子樹的根節點作為旋轉軸(p節點),也就是說旋轉軸在旋轉結束后稱為新子樹的根節點!這里需要強調一下和STL的旋轉操作的區別,STL的右旋操作的旋轉軸視為旋轉之前的子樹根節點(g節點),不過這並不影響旋轉操作的效果。
類比之下,情況B則需要使用左單旋操作來解決平衡問題,方法和情況A類似。
圖6 右外側插入
接下來,考慮情況C:n是p左子節點,p是g的右子節點。針對該情況通過一次左旋,一次右旋操作(旋轉軸都是n,注意不是p),並將n設為黑色,g設為紅色完成重新平衡。
圖7 左內側插入
需要注意的是,由於此時新插入的節點是n,它的左右子樹x,y都是空節點,但即使如此,旋轉操作的結果需要將x,y新的位置設置正確(如果不把p和g的對應分支設置為空節點的話,就會破壞樹的結構)。在之后的其他操作中,待旋轉的節點n的左右子樹可能就不是空節點了。
類比之下,情況D則需要使用一次右單旋,一次左單旋操作來解決平衡問題,方法和情況C類似。
圖8 右內側插入
2、叔父節點是紅色
當叔父節點是紅色時,則不能直接通過上述方式處理了(把前邊的所有情況的u節點看作紅色,會發現節點u和g是紅色沖突的)。但是我們可以交換g與p,u節點的顏色完成當前沖突的解決。
圖9 叔父節點為紅的插入
但是僅僅這樣做顏色交換是不夠的,因為祖父節點g的父節點(記作gp)如果也是紅色的話仍然會有沖突(g和gp是連續的紅色,違反規則2)。為了解決這樣的沖突,我們需要從當前插入點n向根節點root回溯兩次。
第一次回溯時處理所有擁有兩個紅色節點的節點,並按照圖9中的方式交換父節點g與子節點p,u的顏色,並暫時忽略gp和p的顏色沖突。如果根節點的兩個子節點也是這種情況,則在顏色交換完畢后重新將根節點設置為黑色。
第二次回溯專門處理連續的紅色節點沖突。由於經過第一遍的處理,在新插入點n的路徑上一定不存在同為紅色的兄弟節點了。而仍出現gp和p的紅色沖突時,gp的兄弟節點(gu)可以斷定為黑色,這樣就回歸前邊討論的叔父節點為黑色時的情況處理。
圖10 消除連續紅色節點
由於發生沖突的兩個紅色節點位置可能是任意的,因此會出現上述的四種旋轉情況。不過我們把靠近葉子的紅色節點(g)看作新插入的節點,這樣面對A,B情況則把p的父節點gp作為旋轉軸,旋轉后gp會是新子樹的根,而面對C,D情況時把p作為旋轉軸即可,旋轉后p為新子樹的根(因此可以把四種旋轉方式封裝起來)。
在第二次回溯時,雖然每次遇到紅色沖突旋轉后都會提升g和gp節點的位置(與根節點的距離減少),但是無論g和gp誰是新子樹的根都不會影響新插入節點n到根節點root路徑的回溯,而且一旦新子樹的根到達根節點(parent指針為空)就可以停止回溯了。
通過以上的樹重新平衡策略可以完美地解決紅黑樹插入節點的平衡問題。
四、紅黑樹的刪除操作
相比於插入操作,紅黑樹的刪除操作顯得更加復雜。很多資料都沒有將紅黑樹的刪除解釋清楚,清華的數據結構教材對紅黑樹刪除的描述也十分混亂,《STL源碼剖析》中侯sir對紅黑樹的刪除更是閉口不談。這里參考了STL對紅黑樹刪除操作的實現方式,並做了適當的修改(紅黑樹使用哨兵節點表示空節點,而這里使用空指針的方式,因此要杜絕空指針的引用問題)。
由於紅黑樹就是二叉搜索樹,因此節點的刪除方式和二叉搜索樹相同。不過紅黑樹刪除操作的難點不在於節點的刪除,而在於刪除節點后的調整操作。因此紅黑樹的刪除操作分為兩步,首先確定被刪除節點的位置,然后調整紅黑樹的平衡性。
先考慮刪除節點的位置,如果待刪除節點擁有唯一子節點或沒有子節點,則將該節點刪除,並將其子節點(或空節點)代替自身的位置。如果待刪除節點有兩個子節點,則不能將該節點直接刪除。而是從其右子樹中選取最小值節點(或左子樹的最大值節點)作為刪除節點(該節點一定沒有兩個子節點了,否則還能取更小的值)。當然在刪除被選取的節點之前,需要將被選取的節點的數據拷貝到原本需要刪除的節點中。選定刪除節點位置的情況如圖11所示,這和二叉搜索樹的節點刪除完全相同。
圖11 刪除點的選定
圖11中用紅色標記的節點表示被選定的真正刪除的節點(節點y)。其中綠色節點(yold)表示原本需要刪除的節點,而由於它有兩個子節點,因此刪除y代替它,並且刪除y之前需要將y的值拷貝到yold,注意這里如果是紅黑樹也不會改變yold的顏色!通過上述的方式,將所有的節點刪除問題簡化為獨立后繼(或者無后繼)的節點刪除問題。然后再考慮刪除y后的紅黑樹平衡調整問題。由於刪除y節點后,y的后繼節點n會作為y的父節點p的孩子。因此在進行紅黑樹平衡調整時,n是p的子節點。
下邊考慮平衡性調整問題,首先考慮被刪除節點y的顏色。如果y為紅色,刪除y后不會影響紅黑樹的平衡性,因此不需要做任何調整。如果y為黑色,則y所在的路徑上的黑色節點總數減少1,紅黑樹失去平衡,需要調整。
y為黑色時,再考慮節點n的顏色。如果n為紅色,因為n是y的唯一后繼,如果把n的顏色設置為黑色,那么就能恢復y之前所在路徑的黑色節點的總數,調整完成。如果n也是黑色,則需要按照以下四個步驟來考慮。
設p是n的父節點,w為n節點的兄弟節點。假定n是p的左子節點,n是p的右子節點情況可以鏡像對稱考慮。
步驟1:若w為紅色,則斷定w的子節點(如果存在的話或者為空節點)和節點p必是黑色(規則2)。此時將w與p的顏色交換,並以w為旋轉軸進行左旋轉操作,最后將w設定為n的新兄弟節點(原來w的左子樹x)。
通過這樣的轉換,將原本紅色的w節點情況轉換為黑色w節點情況。若w原本就是黑色(或者空節點),則直接進入步驟2。
圖12 節點刪除情況1
步驟2:無論步驟1是否得到處理,步驟2處理的總是黑色的w節點,此時再考慮w的兩個子節點x,y的顏色情況。如果x,y都是黑色節點(或者是空節點,如果父節點w為空節點,認為x,y也都是空節點),此時將w的顏色設置為紅色,並將n設定為n的父節點p。此時,如果n為紅色,則直接設定n為黑色,調整結束。否則再次回到步驟1做相似的處理。注意節點n發生變化后需要重新設定節點w和p。
考慮由於之前黑色節點刪除導致n的路徑上黑色節點數減1,因此可以把節點n看作擁有雙重黑色的節點。通過此步驟將n節點上移,使得n與根節點距離減少,更極端的情況是當n成為根節點時,樹就能恢復平衡了(因為根節點不在乎多一重黑色)。另外,在n的上移過程中可能通過后續的轉換已經讓樹恢復平衡了。
圖13 節點刪除情況2
步驟3:如果步驟2中的w的子節點不是全黑色,而是左紅(x紅)右黑(y黑)的話,將x設置為黑色,w設置為紅色,並以節點x為旋轉軸右旋轉,最后將w設定為n的新兄弟(原來的x節點)。
通過這樣的轉換,讓原本w子節點左紅右黑的情況轉化為左黑右紅的情況。若w的右子節點原本就是紅色(左子節點顏色可黑可紅),則直接進入步驟4。
圖14 節點刪除情況3
步驟4:該步驟處理w右子節點y為紅色的情況,此時w的左子節點x可黑可紅。這時將w的右子節點y設置為黑色,並交換w與父節點p的顏色(w原為黑色,p顏色可黑可紅),再以w為旋轉軸左旋轉,紅黑樹調整算法結束。
通過該步驟的轉換,可以徹底解決紅黑樹的平衡問題!該步驟的實質是利用左旋恢復節點n上的黑色節點總數,雖然p和w雖然交換了顏色,但它們都是n的祖先,因此n路徑上的黑色節點數增加1。同時由於左旋,使得y路徑上的黑色節點數減1,恰巧的是y的顏色為紅,將y設置為黑便能恢復y節點路徑上黑色節點的總數。
圖15 節點刪除情況4
總結以上步驟,對紅黑樹節點刪除的平衡性調整歸納為如下流程。
圖16 節點刪除調整流程
通過上述的調整策略,可以完美解決紅黑樹節點刪除時平衡性問題。
五、隨機測試
對數據結構准確性的測試主要考察以下操作:插入,刪除,查詢,遍歷和驗證。插入和刪除操作前邊做了充分的介紹,由inset和remove實現,查詢操作在插入和刪除操作時會間接調用,由find實現,遍歷操作分為正序(由minimum和next實現)和逆序遍歷(由maximim和prev實現),驗證操作主要是驗證插入和刪除后紅黑樹的合法性(規則1、2、3),由validate實現。至於其他和紅黑樹統計特性相關的操作,比如獲取樹高、節點數和累計的旋轉次數等可以很容易實現。
我們使用隨機數產生器隨機產生一批數據插入到紅黑樹內,然后再隨機產生一批數據作為刪除操作的參數。其中每次插入和刪除時都會對樹的合法性進行驗證,並且在插入后刪除數據結束后以正序和逆序的方式輸出紅黑樹的節點以及其他統計信息。測試代碼如下:
#include <time.h>
#include <windows.h>
int main()
{
srand((unsigned)GetCurrentTime());
int times= 10,len= 30;
while(times--)
{
rb_tree< int> tree;
for( int i= 0;i<len;i++)
{
int num=rand()%len;
tree.insert_unique(num);
if(!tree.validate())cout<< " 插入時失去平衡 "<<endl;
}
cout<< " 正序: ";
for(rb_tree< int>::node_type*node=tree.minimum();node;node=tree.next(node))
{
cout<<node->value<< " ";
}
cout<< " \n旋轉次數-黑高-節點數: "<<tree.get_rotate_times()
<< " "<<tree.height()<< " "<<tree.count()<<endl;
cout<< " 刪除: ";
for( int i= 0;i<len;i++)
{
int num=rand()%len;
if(tree.remove(num))cout<<num<< " ";
if(!tree.validate())cout<< " 刪除時失去平衡 "<<endl;
}
cout<<endl;
cout<< " 逆序: ";
for(rb_tree< int>::node_type*node=tree.maximum();node;node=tree.prev(node))
{
cout<<node->value<< " ";
}
cout<< " \n旋轉次數-黑高-節點數: "<<tree.get_rotate_times()
<< " "<<tree.height()<< " "<<tree.count()<<endl;
cout<< " ________________________________________________________________________________ "<<endl;
}
return 0;
}
經過大量的循環隨機測試,可以驗證紅黑樹數據結構的穩定性以及平衡性調整算法的正確性,下邊是測試結果的部分截圖。
本文構造的紅黑樹數據結構源代碼下載地址為:https://github.com/fanzhidongyzby/RBTree。
讀者感興趣的話可以下載驗證。
圖17 測試結果
綜上所述,我們對紅黑樹數據結構有了更充分地了解,尤其是復雜的紅黑樹的插入刪除平衡性調整算法,最后進行的測試驗證了紅黑樹的核心算法的正確性。通過對紅黑樹數據結構的詳盡剖析,相信大家對數據結構在計算機學科的重要性有了更充分地認識,希望本文對你有所幫助。