圖解集合7:紅/黑樹狀結構概念、紅/黑樹狀結構的插入及旋轉操作詳細解讀,紅黑解讀
原文地址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,我現在要統計:
就類似這些操作,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的原始碼。
二叉尋找樹是在產生的時候是非常容易失衡的,造成的最壞情況就是一邊倒(即只有左子樹/右子樹),這樣會導致樹檢索的效率大大降低。(關於樹和二叉尋找樹可以看我之前寫的一篇文章樹型結構)
紅/黑樹狀結構是為了維護二叉尋找樹的平衡而產生的一種樹,根據維基百科的定義,紅/黑樹狀結構有五個特性,但我覺得講得不太易懂,我自己總結一下,紅/黑樹狀結構的特性大致有三個(換句話說,插入、刪除節點後整個紅/黑樹狀結構也必須滿足下面的三個性質,如果不滿足則必須進行旋轉):
上述的性質約束了紅/黑樹狀結構的關鍵:從根到葉子的最長可能路徑不多於最短可能路徑的兩倍長。得到這個結論的理由是:
此時(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),但是為了維護其平衡特性,可能要在插入、刪除操作時進行多次的旋轉,產生比較大的消耗。
四個關注點在LinkedHashMap上的答案
| 關 注 點 |
結 論 |
| TreeMap是否允許索引值對為空白 |
Key不允許為空白,Value允許為空白 |
| TreeMap是否允許重複資料 |
Key重複會覆蓋,Value允許重複 |
| TreeMap是否有序 |
按照Key的自然順序排序或者Comparator介面指定的排序演算法進行排序 |
| TreeMap是否安全執行緒 |
非安全執行緒 |
TreeMap基本資料結構
TreeMap基於紅/黑樹狀結構實現,既然是紅/黑樹狀結構,那麼每個節點中除了Key-->Value映射之外,必然儲存了紅/黑樹狀結構節點特有的一些內容,它們是:
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 paths14 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 else24 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 else39 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 else46 parent.right = e;47 fixAfterInsertion(e);48 size++;49 modCount++;50 return null;51 }
從這段代碼,先總結一下TreeMap添加資料的幾個步驟:
第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的流程,上面示範的是左子樹插入流程,右子樹一樣,可以看到這是右子樹內側插入,需要進行兩次旋轉操作:
旋轉是紅/黑樹狀結構中最難理解也是最核心的操作,右旋和左旋是對稱的操作,我個人的理解,以右旋為例,對某個節點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")的操作類似,同樣是右子樹內側插入的情境,那麼需要進行兩次旋轉:
右旋+著色+左旋之後,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進行一次左旋,左旋之後的結構為:
重新恢複紅/黑樹狀結構的性質:
後記
本文通過不斷向紅/黑樹狀結構的右子樹插入資料,示範了紅/黑樹狀結構右側插入時可能出現的各種情況且應當如何處理這些情況,左側插入同理。
紅/黑樹狀結構還是有點難,因此我個人建議在學習紅/黑樹狀結構的時候一定要多畫(像我個人就畫了3張A4紙)+多想,這樣才能更好地理解紅/黑樹狀結構的原理,尤其是旋轉的原理。
TreeMap的插入操作和旋轉操作已經講完,後文會著眼於TreeMap的刪除操作以及一些統計操作(比如找到節點比50大的所有節點)是如何?的。