換個角度徹底理解紅黑樹

0 、前言

紅黑樹是軟件工程中非常重要的數據結構,在很多的工程領域都有它的身影,比如java的treemap、linkedhashmap,linux內核、linux的高并發多路復用利器epoll的核心數據結構就是紅黑樹;然而這個數據結構卻不是那么容易理解,特別是網絡上缺少對紅黑樹本質的分析,一般都是自底向上的來講述,非常tricky,往往看了一段就不知所云,最后放棄了。但是,紅黑樹真的沒這么復雜,本文從自頂向下的方式來解構紅黑樹,爭取寫一篇能持續看下去,最后達到能夠手寫(手畫)一棵紅黑樹出來的目的。

本文沒有代碼,全是圖解,請放心閱讀

1、什么是紅黑樹

紅黑樹是查找表(符號表)數據結構群中的一員,這群數據結構主要的功能是維護一組key-value鍵值對,并通過增刪查改等操作對其進行操作。比如:hash表、二分查找表、BST、2-3樹,跳表、紅黑樹都是這些數據結構的一員。

2、紅黑樹有啥用?

在眾多查找表中,可以說紅黑樹是最穩定(增刪查改都是O(logn)的復雜度),動態增刪性能最好(Ologn),編碼最容易的一種實現(奇怪吧,hashtable不是都是O(1)么?)。

hash表不是查找,插入都是O(1)么,怎么紅黑樹會是綜合性能最好的?這就要說到工程特性了。一個技術從科學研究到工程實踐可行是有一個鴻溝的,而hash表就是這個悖論的典型例子。雖然容易理解,但是hashtable并不容易實現,而且它的效率并不穩定,原因有以下兩個:

1、惱人的最差性能,其實hashtable最差的效率是O(n),就是在沖突不斷增加的時候,性能會急劇下降;這時需要做非常tricky的補救措施;

2、這個補救措施就是rehashing,簡單來講就是將這個數組重新搬到大一倍的空間中,然后重新算hash映射,除此之外沒別的辦法。這個過程耗時耗力,耗盡了hashtable的好印象。在這個過程中還會出線程同步問題,而且編碼難度極大?比如,如果在多線程環境下,對線程不安全版本的hashtable做頻繁的新增操作會死鎖(親測這個死鎖幾乎7 80%會出現),具體的原因,主要是rehashing的時候開鏈發對鏈表新增節點時的同步問題;

3、而且在java中,hashmap其實在鏈表的數量大于8時,會將鏈表轉成紅黑樹來加快查詢。

紅黑樹的穩定性與動態維護超大數據量kv表的能力使它成為互聯網應用的重要數據結構,對于一個10億規模的數據量,最多也只要查30多次就能找到你想要的key-value pair。而且對于很多linux內核的代碼中也是大量使用紅黑樹來保持插入與查詢的穩定性。

3、解構紅黑樹

既然,紅黑樹這么重要,對于有追求的碼農應該有必要了解它的原理。

但是遺憾的是,網絡上對紅黑樹的解讀大都比較難懂,大部分的解釋都是來自一本神書《算法導論》,這也是萬惡之源:

1、這本書并不是面向程序員的,別被導論這兩個字迷惑,這個導論可能是對于計算機科學家來說的吧~

2、這個書中所描述的紅黑樹定義是結論型的,也就是假設你對BST樹、2-3樹都很熟悉的情況下得出的結論,比如《導論》中對紅黑樹的定義是:

? ? 1.節點是紅色或黑色。

? ? 2.根節點是黑色。

? ? 3.每個葉子節點都是黑色的空節點(NIL節點)。

? ? 4 每個紅色節點的兩個子節點都是黑色。(從每個葉子到根的所有路徑上不能有兩個連續的紅色節點)

? ? 5.從任一節點到其每個葉子的所有路徑都包含相同數目的黑色節點。

很多人,包括我也疑惑很多年,這是啥?好好的數據結構,咋還要染色?為啥是紅黑,黑白可以嗎?為啥會制定這么復雜的規則?這么復雜的規則是怎么得出來的?

后來看了《算法》這本面向程序員的算法書后才霍然開朗,這里我試著將這本書對紅黑樹的闡述總結一下。

3.1 還是得先講講BST

BST(Binary Search Tree)是二叉查找樹的簡稱,其實很簡單。大概的意思如下圖:


BST樹

1、樹左邊的都比根??;

2、右邊都比根大;

3、將規則遞歸到所有節點,就得到一顆BST。

細心的你會發現BST有個”bug“,在插入的時候,如果是按照升序插入,那么就是一個鏈表了那么這時的查詢也從O(logn)退化到了O(n).


退化后的BST

所以可以推斷,一顆好的查找樹一定是平衡的,而且最好是完美平衡的(任何節點的左右子樹深度都相等),因為這樣它的查詢操作就會穩定到O(logn)——也就是樹的高度了。畢竟,在工程領域最重要的準則就是——穩定壓倒一切!

3.2、2-3 tree

那么主角2-3 tree就登場了,為啥2-3樹是主角?劇透一下,因為紅黑樹本質就是2-3tree,而且”紅“就是2-3樹中的3節點,”黑“就是2節點。下面詳細講解。

2-3 tree的定義其實很簡單:

1、2-3tree是BST的一種,繼承所有BST的特點;

2、整棵樹由2-node與3-node組成;

3、2-node就是包含一個key節點,兩個左右子樹鏈接(對應BST中的普通節點),3-node就是包含2個key,同時包含3個子樹鏈接的節點,就是3叉樹節點;

4、2-3樹是完美平衡的。

有圖更好理解:


2-3 tree

可以看出來,2-node就是二叉樹普通節點,3-node就是三叉樹的普通節點。那么2-3 tree是由2-node與3-node組成的完美平衡的搜索樹,通俗的講就是,這棵樹中根節點的左右子樹中2-3 node高度始終都一樣高。

怎么保持樹的完美平衡呢?

我們通過例子看看插入的規則,我們以順序插入1-10來作為例子,先不講規則,我們看看例子,然后總結出規則,我們以依次向2-3 tree中插入1-10個數字的例子來演示。

1、插入1,只有一個節點,簡單:


插入1

2、插入2


插入2,由于樹不平衡,所以進行融合

3、插入3


插入3

這里說明下,2-3樹在插入過程中會臨時形成4-node(也就是有3個key,4個子樹的node),而這時,4-node要馬上分解成3個2-node。

4、插入4

插入4

5、插入5

插入5-1


插入5-2


插入5-3

插入5的時候比較復雜,臨時的4-node會分解成3個2-nodes,這時會導致樹不平衡,右邊子樹高度+1,那么需要將3個2-node的中間節點向上融合(調平的重要步驟)。

6、插入6

插入6

7、插入7


插入7-1


插入7-2


插入7-3


插入7-4

可以看出插入7的結果會不斷將臨時4-node分裂成3個2-node,并向上傳遞,最后會使樹的高度增加1,但是整個樹是完美平衡的。

8、插入8


插入8


9、插入9


插入9-1


插入9-2


插入9-3

10、插入10


插入10


好了,畫完了,樹中2-3節點完美平衡。找到規律了么?沒有的話,我總結下:

1、在2-node中插入新節點,形成3-node,不破壞樹的平衡,done;

2、在3-node中插入新節點分為3步:

? ? 2.1、形成新的臨時4-node;

? ? 2.2、將4-node分解成3個2-node;

? ? 2.3、如果樹不平衡,就將中間節點向上融合,重新遞歸1、2、兩個步驟,直到樹平衡。

看懂了么?是不是很簡單,如果沒有,就再看看上面的圖解吧~

3.3 重新發明紅黑樹

您可能會問,2-3 tree已經很完美了,不管怎么插入數據,只要根據"兩個"簡單的規則就能維持一個樹的完美平衡,為啥還要發明紅黑樹呢?

原因很簡單,工程實現難度!又是工程的實現難度!說白了就是容易出bug……

跟hashmap一樣,實現一個工程可用的2-3tree是比較困難的(觀點來自于《算法》沒有親測):

1、樹中間包含3種節點,相比BST來說意味著更多的判斷,比如,從4-node,分裂成3個2-node,從2-node合并成3-node等等;情況多,編碼復雜;

2、各種node的節點在互相轉變的過程中需要拷貝的信息也比較多,比如3-node可以有3個子樹,2-node有2個,那么融合的時候情況較多,不能直接向BST一樣統一處理,算法實現要處理的情況太多,容易出bug;

3、代碼不能重用(BST的代碼不能重用)。

但是我覺得最重要的是2-3 tree原理完美,但是結構不完美。一個完美的數據結構一定是原理與結構都是統一的、樸素簡單的。2-3 tree更像一個通向最終答案的中間解,只要把中間的3-node與4-node這些tricky的node想辦法變換成2-node就結構也統一完美了;那么BST的很多代碼就可以稍微修改一下就可以重用了(特別是刪除節點的代碼~)。

怎么統一呢?

方法其實也很簡單、直接,我們只要將2-3 tree在結構上對齊BST,在保持平衡的原理上維持現狀不就行了?也就是西瓜芝麻都一起抓——去掉2-3 tree中的3-node。

3.3.1 去掉3-node

怎么去掉3-node?很簡單,真的很簡單,還是看圖說話:

3-node到2個2-node

就是把3-node拉直就行了!

更一般化的形式為

《算法》節選

但是,左子樹的高度加了1,那么結構的算法特性有沒有發生改變呢?

其實沒有,因為你依然可以把”4-7“這個紅色標記的鏈接看做是一個3-node,那么它的搜索特性就沒有變化。本質來講,當我們在2-3 tree里面做搜索的時候,當我們碰到3-node的時候,其實就相當于在節點內部要比2-node多做一次比較,拉直了相當于把這個多出來的比較形式化成了一條鏈接,計算量是沒有變化的。

所以紅黑樹跟2-3 tree是完全等價的!或者說通過把2-3 tree中的3-node拉直而形成的BST樹就是——紅黑樹!

書中的例子,更加清楚:


截圖自《算法》

3.3.2 定義的解析

3.3.2.1 一些前提與說明

之前我們只是把邊涂成紅色,但是BST是沒有邊的定義的,所以這個顏色只能記錄在節點的數據結構中,所以我們把從父節點到子節點的紅邊會存儲在子節點中——將子節點涂成紅色,構成紅色節點,其他的自然都是黑色節點。

對于網上比較常見的一個紅黑樹例子:

網上常用的例子

細心的讀者會發現,這個根本不是一個2-3 tree,但是確實是紅黑樹,這是怎么回事?

因為紅黑樹定義有不同,相同節點的紅黑樹會根據定義不同產生不同的紅黑節點數量,如果直接從2-3tree轉的紅黑樹可能跟以上5條定義獲得的紅黑樹稍有不同,但是他們都是紅黑樹。

再來看看經典紅黑樹的定義《導論》中的定義(針對的是節點顏色):

?1.節點是紅色或黑色。(貌似是廢話,難道對于紅黑樹還有第三種顏色?)

? ? 2.根節點是黑色。(沒啥用)

? ? 3.每個葉子節點都是黑色的空節點(NIL節點)。

? ? 4 每個紅色節點的兩個子節點都是黑色。(從每個葉子到根的所有路徑上不能有兩個連續的紅色節點)

? ? 5.從任一節點到其每個葉子的所有路徑都包含相同數目的黑色節點。

再看看《算法》中的定義(針對的是線的顏色):

?1、紅色的線永遠是左側鏈接;(強行的規定)

? 2、沒有一個節點同時鏈了兩條紅色的線;(也就是沒有3-node)

? 3、任何從根到葉子節點的路徑上有相同顏色的黑線。

可以看出定義是完全不同的(一個針對節點顏色,一個針對線的顏色——而線的顏色存在node中,所以還是紅黑樹),但是都能構成一棵紅黑樹。

而我們的解析是采用《算法》中的,因為它跟2-3 tree一一對應,很好理解。而《導論》中的定義是經過2-3 tree,2-3-4 tree的抽象總結出來的非常通用抽象的解析,其經過很多思維迭代后的結果,所以很抽象,但是本質來說他們是一回事,是可以通過有限次推導(reduction)互相轉換的。

那么下面我們對網上的例子做些轉換,將它化簡成一個2-3 tree的原型:


有兩個紅色子節點的都可以做傳遞轉換

轉換得到的依然是紅黑樹。

然后可以將紅色節點跟父節點融合成一個3-node

3.3.3 一步步構建紅黑樹

還是通過依次插入1-10,用《算法》中講述的紅黑線試圖來解析。

1、插入1

很簡單……

2、插入2

rule1:插入的新節點跟父節點用red線連接


插入2

3、插入3

rule2:一個節點不能有兩個紅線相連(不是父子),否則就要通過旋轉將中間節點移動成父節點。

rule3:如果一個節點父子都是紅線相連,則需要翻轉顏色,并把父節點與祖父節點的鏈接變成紅色(也就是2-3 tree中分解4-node的時候,3個2-node中間的那個向上傳遞的過程)。

插入3-1


插入3-2

所以,可見紅黑樹中的翻轉其本質就是將4-node分解成3個2-node,并將中間節點與父節點融合的過程。

4、插入4


插入4

插入4,很簡單,紅線相連即可,因為每條路徑黑線數目一樣。

5、插入5


插入5-1


插入5-2

6、插入6


插入6

此時可以跟2-3 tree插入到6的圖比較下,其實可以找到規律:->


2-3 tree插入6

是不是紅色線跟3-node一一對應?

7、插入7

插入7-1


插入7-2


插入7-3


插入7-4

可以發現2-3 tree插入7也是4步,跟紅黑樹的旋轉變色的過程一一對應。

8、插入8


插入8

9、插入9


插入9-1


插入9-2

10、插入10


插入10

完畢,這就是一個以邊為視角的插入過程,只需要將邊的顏色信息存儲到節點中就可以得到一棵紅黑樹:


插入10后的紅黑樹

總結下2-3tree與紅黑樹的操作對應:

1、2-3 tree中的4-node分裂成3個2-node,并讓中間節點上移;等價于紅黑樹中的左右旋轉;

2、2-3tree中間節點上移與父節點融合過程;等價于紅黑樹中的反色操作。

是不是很好理解了?


3.4 后續的操作

紅黑樹的插入操作我們解構完了,我們還剩下一個比較復雜的刪除操作。這個操作其實是使用BST的標準刪除操作,一個比較標準的算法是,將要刪除節點的右子樹中的最小值(或者左子樹中最大值)替換掉當前刪除的節點,當然針對不同的情況需要做些判斷。而且在工程領域,也會引入隨機的刪除左右子樹中的最大最小交替的方式,來完成刪除,以至于不會使得樹的稀疏度差異太大,從而降低搜索效率那是另一個范疇的事情了。

4、總結

紅黑樹是一個非常重要的,而且在工程中常用的數據結構。比如jvm的hashmap,linux中還為紅黑樹單獨建立了數據結構對象(linux內核源碼的lib/rbtree.c文件)因為在linux內核中的進程調度,虛擬內存管理等操作頻繁而且性能critical的地方都會使用紅黑樹;另外在linux的epoll多路復用的模型中,核心數據結構就是紅黑樹。那么為什么紅黑樹這么好,一句話:

1、動態插入、刪除、查找都是O(logn),而且只要內存足夠,性能穩定在這個數量級上;

2、工程上好實現,編碼簡單。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。