圖解集合7:紅黑樹概念、紅黑樹的插入及旋轉操作詳細解讀

五月的倉頡發表於2017-05-20

原文地址http://www.cnblogs.com/xrq730/p/6867924.html,轉載請註明出處,謝謝!

 

初識TreeMap

之前的文章講解了兩種Map,分別是HashMap與LinkedHashMap,它們保證了以O(1)的時間複雜度進行增、刪、改、查,從儲存角度考慮,這兩種資料結構是非常優秀的。另外,LinkedHashMap還額外地保證了Map的遍歷順序可以與put順序一致,解決了HashMap本身無序的問題。

儘管如此,HashMap與LinkedHashMap還是有自己的侷限性----它們不具備統計效能,或者說它們的統計效能時間複雜度並不是很好才更準確,所有的統計必須遍歷所有Entry,因此時間複雜度為O(N)。比如Map的Key有1、2、3、4、5、6、7,我現在要統計:

  1. 所有Key比3大的鍵值對有哪些
  2. Key最小的和Key最大的是哪兩個

就類似這些操作,HashMap和LinkedHashMap做得比較差,此時我們可以使用TreeMap。TreeMap的Key按照自然順序進行排序或者根據建立對映時提供的Comparator介面進行排序。TreeMap為增、刪、改、查這些操作提供了log(N)的時間開銷,從儲存角度而言,這比HashMap與LinkedHashMap的O(1)時間複雜度要差些;但是在統計效能上,TreeMap同樣可以保證log(N)的時間開銷,這又比HashMap與LinkedHashMap的O(N)時間複雜度好不少。

因此總結而言:如果只需要儲存功能,使用HashMap與LinkedHashMap是一種更好的選擇;如果還需要保證統計效能或者需要對Key按照一定規則進行排序,那麼使用TreeMap是一種更好的選擇。

 

紅黑樹的一些基本概念

在講TreeMap前還是先說一下紅黑樹的一些基本概念,這樣可以更好地理解之後TreeMap的原始碼。

二叉查詢樹是在生成的時候是非常容易失衡的,造成的最壞情況就是一邊倒(即只有左子樹/右子樹),這樣會導致樹檢索的效率大大降低。(關於樹和二叉查詢樹可以看我之前寫的一篇文章樹型結構

紅黑樹是為了維護二叉查詢樹的平衡而產生的一種樹,根據維基百科的定義,紅黑樹有五個特性,但我覺得講得不太易懂,我自己總結一下,紅黑樹的特性大致有三個(換句話說,插入、刪除節點後整個紅黑樹也必須滿足下面的三個性質,如果不滿足則必須進行旋轉):

  1. 根節點與葉節點都是黑色節點,其中葉節點為Null節點
  2. 每個紅色節點的兩個子節點都是黑色節點,換句話說就是不能有連續兩個紅色節點
  3. 從根節點到所有葉子節點上的黑色節點數量是相同的

上述的性質約束了紅黑樹的關鍵:從根到葉子的最長可能路徑不多於最短可能路徑的兩倍長。得到這個結論的理由是:

  1. 紅黑樹中最短的可能路徑是全部為黑色節點的路徑
  2. 紅黑樹中最長的可能路徑是紅黑相間的路徑

此時(2)正好是(1)的兩倍長。結果就是這個樹大致上是平衡的,因為比如插入、刪除和查詢某個值這樣的操作最壞情況都要求與樹的高度成比例,這個高度的理論上限允許紅黑樹在最壞情況下都是高效的,而不同於普通的二叉查詢樹,最終保證了紅黑樹能夠以O(log2 n) 的時間複雜度進行搜尋、插入、刪除

下面展示一張紅黑樹的例項圖:

可以看到根節點到所有NULL LEAF節點(即葉子節點)所經過的黑色節點都是2個。

另外從這張圖上我們還能得到一個結論:紅黑樹並不是高度的平衡樹。所謂平衡樹指的是一棵空樹或它的左右兩個子樹的高度差的絕對值不超過1,但是我們看:

  • 最左邊的路徑0026-->0017-->0012-->0010-->0003-->NULL LEAF,它的高度為5
  • 最後邊的路徑0026-->0041-->0047-->NULL LEAF,它的高度為3

左右子樹的高度差值為2,因此紅黑樹並不是高度平衡的,它放棄了高度平衡的特性而只追求部分平衡,這種特性降低了插入、刪除時對樹旋轉的要求,從而提升了樹的整體效能。而其他平衡樹比如AVL樹雖然查詢效能為效能是O(logn),但是為了維護其平衡特性,可能要在插入、刪除操作時進行多次的旋轉,產生比較大的消耗。

 

四個關注點在TreeMap上的答案

關 注 點 結  論
TreeMap是否允許鍵值對為空 Key不允許為空,Value允許為空 
TreeMap是否允許重複資料 Key重複會覆蓋,Value允許重複 
TreeMap是否有序 按照Key的自然順序排序或者Comparator介面指定的排序演算法進行排序 
TreeMap是否執行緒安全  非執行緒安全

 

TreeMap基本資料結構

TreeMap基於紅黑樹實現,既然是紅黑樹,那麼每個節點中除了Key-->Value對映之外,必然儲存了紅黑樹節點特有的一些內容,它們是:

  1. 父節點引用
  2. 左子節點引用
  3. 右子節點引用
  4. 節點顏色

TreeMap的節點Java程式碼定義為:

1 static final class Entry<K,V> implements Map.Entry<K,V> {
2         K key;
3         V value;
4         Entry<K,V> left = null;
5         Entry<K,V> right = null;
6         Entry<K,V> parent;
7         boolean color = BLACK;
8         ...
9 }

由於顏色只有紅色和黑色兩種,因此顏色可以使用布林型別(boolean)來表示,黑色表示為true,紅色為false。

 

TreeMap新增資料流程總結

首先看一下TreeMap如何新增資料,測試程式碼為:

 1 public class MapTest {
 2 
 3     @Test
 4     public void testTreeMap() {
 5         TreeMap<Integer, String> treeMap = new TreeMap<Integer, String>();
 6         treeMap.put(10, "10");
 7         treeMap.put(85, "85");
 8         treeMap.put(15, "15");
 9         treeMap.put(70, "70");
10         treeMap.put(20, "20");
11         treeMap.put(60, "60");
12         treeMap.put(30, "30");
13         treeMap.put(50, "50");
14 
15         for (Map.Entry<Integer, String> entry : treeMap.entrySet()) {
16             System.out.println(entry.getKey() + ":" + entry.getValue());
17         }
18     }
19     
20 }

本文接下來的內容會給出插入每條資料之後紅黑樹的資料結構是什麼樣子的。首先看一下treeMap的put方法的程式碼實現:

 1 public V put(K key, V value) {
 2     Entry<K,V> t = root;
 3     if (t == null) {
 4         compare(key, key); // type (and possibly null) check
 5 
 6         root = new Entry<>(key, value, null);
 7         size = 1;
 8         modCount++;
 9         return null;
10     }
11     int cmp;
12     Entry<K,V> parent;
13     // split comparator and comparable paths
14     Comparator<? super K> cpr = comparator;
15     if (cpr != null) {
16         do {
17             parent = t;
18             cmp = cpr.compare(key, t.key);
19             if (cmp < 0)
20                 t = t.left;
21             else if (cmp > 0)
22                 t = t.right;
23             else
24                 return t.setValue(value);
25         } while (t != null);
26     }
27     else {
28         if (key == null)
29             throw new NullPointerException();
30         Comparable<? super K> k = (Comparable<? super K>) key;
31         do {
32             parent = t;
33             cmp = k.compareTo(t.key);
34             if (cmp < 0)
35                 t = t.left;
36             else if (cmp > 0)
37                 t = t.right;
38             else
39                 return t.setValue(value);
40         } while (t != null);
41     }
42     Entry<K,V> e = new Entry<>(key, value, parent);
43     if (cmp < 0)
44         parent.left = e;
45     else
46         parent.right = e;
47     fixAfterInsertion(e);
48     size++;
49     modCount++;
50     return null;
51 }

從這段程式碼,先總結一下TreeMap新增資料的幾個步驟:

  1. 獲取根節點,根節點為空,產生一個根節點,將其著色為黑色,退出餘下流程
  2. 獲取比較器,如果傳入的Comparator介面不為空,使用傳入的Comparator介面實現類進行比較;如果傳入的Comparator介面為空,將Key強轉為Comparable介面進行比較
  3. 從根節點開始逐一依照規定的排序演算法進行比較,取比較值cmp,如果cmp=0,表示插入的Key已存在;如果cmp>0,取當前節點的右子節點;如果cmp<0,取當前節點的左子節點
  4. 排除插入的Key已存在的情況,第(3)步的比較一直比較到當前節點t的左子節點或右子節點為null,此時t就是我們尋找到的節點,cmp>0則準備往t的右子節點插入新節點,cmp<0則準備往t的左子節點插入新節點
  5. new出一個新節點,預設為黑色,根據cmp的值向t的左邊或者右邊進行插入
  6. 插入之後進行修復,包括左旋、右旋、重新著色這些操作,讓樹保持平衡性

第1~第5步都沒有什麼問題,紅黑樹最核心的應當是第6步插入資料之後進行的修復工作,對應的Java程式碼是TreeMap中的fixAfterInsertion方法,下面看一下put每個資料之後TreeMap都做了什麼操作,藉此來理清TreeMap的實現原理。

 

put(10, "10")

首先是put(10, "10"),由於此時TreeMap中沒有任何節點,因此10為根且根節點為黑色節點,put(10, "10")之後的資料結構為:

 

put(85, "85")

接著是put(85, "85"),這一步也不難,85比10大,因此在10的右節點上,但是由於85不是根節點,因此會執行fixAfterInsertion方法進行資料修正,看一下fixAfterInsertion方法程式碼實現:

 1 private void fixAfterInsertion(Entry<K,V> x) {
 2     x.color = RED;
 3 
 4     while (x != null && x != root && x.parent.color == RED) {
 5         if (parentOf(x) == leftOf(parentOf(parentOf(x)))) {
 6             Entry<K,V> y = rightOf(parentOf(parentOf(x)));
 7             if (colorOf(y) == RED) {
 8                 setColor(parentOf(x), BLACK);
 9                 setColor(y, BLACK);
10                 setColor(parentOf(parentOf(x)), RED);
11                 x = parentOf(parentOf(x));
12             } else {
13                 if (x == rightOf(parentOf(x))) {
14                     x = parentOf(x);
15                     rotateLeft(x);
16                 }
17                 setColor(parentOf(x), BLACK);
18                 setColor(parentOf(parentOf(x)), RED);
19                 rotateRight(parentOf(parentOf(x)));
20             }
21         } else {
22             Entry<K,V> y = leftOf(parentOf(parentOf(x)));
23             if (colorOf(y) == RED) {
24                 setColor(parentOf(x), BLACK);
25                 setColor(y, BLACK);
26                 setColor(parentOf(parentOf(x)), RED);
27                 x = parentOf(parentOf(x));
28             } else {
29                 if (x == leftOf(parentOf(x))) {
30                     x = parentOf(x);
31                     rotateRight(x);
32                 }
33                 setColor(parentOf(x), BLACK);
34                 setColor(parentOf(parentOf(x)), RED);
35                 rotateLeft(parentOf(parentOf(x)));
36             }
37         }
38     }
39     root.color = BLACK;
40 }

我們看第2行的程式碼,它將預設的插入的那個節點著色成為紅色,這很好理解:

根據紅黑樹的性質(3),紅黑樹要求從根節點到葉子所有葉子節點上經過的黑色節點個數是相同的,因此如果插入的節點著色為黑色,那必然有可能導致某條路徑上的黑色節點數量大於其他路徑上的黑色節點數量,因此預設插入的節點必須是紅色的,以此來維持紅黑樹的性質(3

當然插入節點著色為紅色節點後,有可能導致的問題是違反性質(2),即出現連續兩個紅色節點,這就需要通過旋轉操作去改變樹的結構,解決這個問題。

接著看第4行的判斷,前兩個條件都滿足,但是因為85這個節點的父節點是根節點的,根節點是黑色節點,因此這個條件不滿足,while迴圈不進去,直接執行一次30行的程式碼給根節點著色為黑色(因為在旋轉過程中有可能導致根節點為紅色,而紅黑樹的根節點必須是黑色,因此最後不管根節點是不是黑色,都要重新著色確保根節點是黑色的)。

那麼put(85, "85")之後,整個樹的結構變為:

 

fixAfterInsertion方法流程

在看put(15, "15")之前,必須要先過一下fixAfterInsertion方法。第5行~第21行的程式碼和第21行~第38行的程式碼是一樣的,無非一個是操作左子樹另一個是操作右子樹而已,因此就看前一半:

 1 while (x != null && x != root && x.parent.color == RED) {
 2     if (parentOf(x) == leftOf(parentOf(parentOf(x)))) {
 3         Entry<K,V> y = rightOf(parentOf(parentOf(x)));
 4         if (colorOf(y) == RED) {
 5             setColor(parentOf(x), BLACK);
 6             setColor(y, BLACK);
 7             setColor(parentOf(parentOf(x)), RED);
 8             x = parentOf(parentOf(x));
 9         } else {
10             if (x == rightOf(parentOf(x))) {
11                 x = parentOf(x);
12                 rotateLeft(x);
13             }
14             setColor(parentOf(x), BLACK);
15             setColor(parentOf(parentOf(x)), RED);
16             rotateRight(parentOf(parentOf(x)));
17         }
18     }
19     ....
20 }

第2行的判斷注意一下,用語言描述出來就是:判斷當前節點的父節點與當前節點的父節點的父節點的左子節點是否同一個節點。翻譯一下就是:當前節點是否左子節點插入,關於這個不明白的我就不解釋了,可以自己多思考一下。對這整段程式碼我用流程圖描述一下:

這裡有一個左子樹內側插入與左子樹點外側插入的概念,我用圖表示一下:

其中左邊的是左子樹外側插入,右邊的是左子樹內側插入,可以從上面的流程圖上看到,對於這兩種插入方式的處理是不同的,區別是後者也就是左子樹內側插入多一步左旋操作

能看出,紅黑樹的插入最多隻需要進行兩次旋轉,至於紅黑樹的旋轉,後面結合程式碼進行講解。

 

put(15, "15")

看完fixAfterInsertion方法流程之後,繼續新增資料,這次新增的是put(15, "15"),15比10大且比85小,因此15最終應當是85的左子節點,預設插入的是紅色節點,因此首先將15作為紅色節點插入85的左子節點後的結構應當是:

但是顯然這裡違反了紅黑樹的性質(2),即連續出現了兩個紅色節點,因此此時必須進行旋轉。回看前面fixAfterInsertion的流程,上面演示的是左子樹插入流程,右子樹一樣,可以看到這是右子樹內側插入,需要進行兩次旋轉操作:

  1. 對新插入節點的父節點進行一次右旋操作
  2. 新插入節點的父節點著色為黑色,新插入節點的祖父節點著色為紅色
  3. 對新插入節點的祖父節點進行一次左旋操作

旋轉是紅黑樹中最難理解也是最核心的操作,右旋和左旋是對稱的操作,我個人的理解,以右旋為例,對某個節點x進行右旋,其實質是:

  • 降低左子樹的高度,增加右子樹的高度
  • 將x變為當前位置的右子節點

左旋是同樣的道理,在旋轉的時候一定要記住這兩句話,這將會幫助我們清楚地知道在不同的場景下旋轉如何進行。

先看一下(1)也就是"對新插入節點的父節點進行一次右旋操作",原始碼為rotateRight方法:

 1 private void rotateRight(Entry<K,V> p) {
 2     if (p != null) {
 3         Entry<K,V> l = p.left;
 4         p.left = l.right;
 5         if (l.right != null) l.right.parent = p;
 6         l.parent = p.parent;
 7         if (p.parent == null)
 8            root = l;
 9         else if (p.parent.right == p)
10             p.parent.right = l;
11         else p.parent.left = l;
12         l.right = p;
13         p.parent = l;
14     }
15 }

右旋流程用流程圖畫一下其流程:

再用一張示例圖表示一下右旋各節點的變化,旋轉不會改變節點顏色,這裡就不區分紅色節點和黑色節點了,a是需要進行右旋的節點:

左旋與右旋是一個對稱的操作,大家可以試試看把右圖的b節點進行左旋,就變成了左圖了。這裡多說一句,旋轉一定要說明是對哪個節點進行旋轉,網上看很多文章講左旋、右旋都是直接說旋轉之後怎麼樣怎麼樣,我認為脫離具體的節點講旋轉是沒有任何意義的。

這裡可能會有的一個問題是:b有左右兩個子節點分別為d和e,為什麼右旋的時候要將右子節點e拿到a的左子節點而不是b的左子節點d?

一個很簡單的解釋是:如果將b的左子節點d拿到a的左子節點,那麼b右旋後右子節點指向a,b原來的右子節點e就成為了一個遊離的節點,遊離於整個資料結構之外

回到實際的例子,對85這個節點進行右旋之後還有一次著色操作(2),分別是將x的父節點著色為黑色,將x的祖父節點著色為紅色,那麼此時的樹形結構應當為:

然後對節點10進行一次左旋操作(3),左旋之後的結構為:

最後不管根節點是不是黑色,都將根節點著色為黑色,那麼插入15之後的資料結構就變為了上圖,滿足紅黑樹的三條特性。

 

put(70, "70")

put(70, "70")就很簡單了,70是85的左子節點,由於70的父節點以及叔父節點都是紅色節點,因此直接將70的父節點85、將70的叔父節點10著色為黑色即可,70這個節點著色為紅色,即滿足紅黑樹的特性,插入70之後的結構圖為:

 

put(20, "20")

put(20, "20"),插入的位置應當是70的左子節點,預設插入紅色,插入之後的結構圖為:

問題很明顯,出現了連續兩個紅色節點,20的插入位置是一種左子樹外側插入的場景,因此只需要進行著色+對節點85進行一次右旋即可,著色+右旋之後資料結構變為:

 

put(60, "60")

下面進行put(60, "60")操作,節點60插入的位置是節點20的右子節點,由於節點60的父節點與叔父節點都是紅色節點,因此只需要將節點60的父節點與叔父節點著色為黑色,將節點60的組父節點著色為紅色即可。

那麼put(60, "60")之後的結構為:

 

put(30, "30")

put(30, "30"),節點30應當為節點60的左子節點,因此插入節點30之後應該是這樣的:

顯然這裡違反了紅黑樹性質(2)即連續出現了兩個紅色節點,因此這裡要進行旋轉。

put(30, "30")的操作和put(15, "15")的操作類似,同樣是右子樹內側插入的場景,那麼需要進行兩次旋轉:

  1. 對節點30的父節點節點60進行一次右旋
  2. 右旋之後對節點60的祖父節點20進行一次左旋

右旋+著色+左旋之後,put(30, "30")的結果應當為:

 

put(50, "50")

下一個操作是put(50, "50"),節點50是節點60的左子節點,由於節點50的父親節點與叔父節點都是紅色節點,因此只需要將節點50的父親節點與叔父節點著色為黑色,將節點50的祖父節點著色為紅色即可:

節點50的父節點與叔父節點都是紅色節點(注意不要被上圖迷糊了!上圖是重新著色之後的結構而不是重新著色之前的結構,重新著色之前的結構為上上圖),因此插入節點50只需要進行著色,本身這樣的操作是沒有任何問題的,但問題的關鍵在於,著色之後出現了連續的紅色節點,即節點30與節點70。這就是為什麼fixAfterInsertion方法的方法體是while迴圈的原因:

1 private void fixAfterInsertion(Entry<K,V> x) {
2     x.color = RED;
3 
4     while (x != null && x != root && x.parent.color == RED) {
5     ...
6     }
7 }

因為這種著色方式是將插入節點的祖父節點著色為紅色,因此著色之後必須將當前節點指向插入節點的祖父節點,判斷祖父節點與父節點是否連續紅色的節點,是就進行旋轉,重新讓紅黑樹平衡。

接下來的問題就是怎麼旋轉了。我們可以把節點15-->節點70-->節點30連起來看,是不是很熟悉?這就是上面重複了兩次的右子樹內側插入的場景,那麼首先對節點70進行右旋,右旋後的結果為:

下一步,節點70的父節點著色為黑色,節點70的祖父節點著色為紅色(這一步不理解或者忘了為什麼的,可以去看一下之前對於fixAfterInsertion方法的解讀),重新著色後的結構為:

最後一步,對節點70的父節點節點15進行一次左旋,左旋之後的結構為:

重新恢復紅黑樹的性質:

  1. 根節點為黑色節點
  2. 沒有連續紅色節點
  3. 根節點到所有葉子節點經過的黑色節點都是2個

 

後記

本文通過不斷向紅黑樹的右子樹插入資料,演示了紅黑樹右側插入時可能出現的各種情況且應當如何處理這些情況,左側插入同理。

紅黑樹還是有點難,因此我個人建議在學習紅黑樹的時候一定要多畫(像我個人就畫了3張A4紙)+多想,這樣才能更好地理解紅黑樹的原理,尤其是旋轉的原理。

TreeMap的插入操作和旋轉操作已經講完,後文會著眼於TreeMap的刪除操作以及一些統計操作(比如找到節點比50大的所有節點)是如何實現的。

相關文章