紅黑樹(Red-Black Tree)解析
這一篇我們來聊聊紅黑樹,寫這篇文章的起因是在閱讀HashMap原始碼時,發現JDK1.8對於HashMap的實現引入了紅黑樹來處理雜湊衝突以提高效能(戳這裡,有詳述),而紅黑樹的資料結構和操作都是較為複雜的,自己看得過程中有些地方也反覆了多次。。。俗話說得好,好記性不如爛筆頭,因此決定寫下這篇筆記供自己和需要的人日後參考。在開始之前,首先要感謝張拭心同學的這兩篇關於紅黑樹和二叉查詢樹的文章:
這兩篇文章講得十分詳細,使我受益匪淺,在這裡也強烈推薦大家閱讀一下。由於拭心同學的文章在分析二叉查詢樹的查詢,插入和刪除時引用的是遞迴的實現,為了不重複,本文分析時將採用迴圈的實現,為大家提供另一種思路。
OK,正式開始,何為紅黑樹?紅黑樹(Red-Black Tree) 是一種自平衡二叉查詢樹,其每個節點都帶有黑或紅的顏色屬性。由於它的本質也是一種二叉查詢樹,因此它的查詢,插入和刪除操作均以二叉查詢樹的對應操作作為基礎;但由於紅黑樹自身要保證平衡(也即要始終滿足其五條特性,這個下文會有詳述),每次插入和刪除之後它都要進行額外的調整,以恢復自身的平衡,這是它與普通二叉查詢樹不同的地方,也正因為如此,紅黑樹的查詢,插入和刪除操作在最壞情況下的時間複雜度也能保證為O(logN),其中N為樹中元素個數。
既然紅黑樹本質是二叉查詢樹,那麼就有必要先來看一下二叉查詢樹的相關知識。
二叉查詢樹
二叉查詢樹(Binary Search Tree)
a. 若左子樹不為空,則根節點的值大於其所有左子樹中節點的值;
b. 若右子樹不為空,則根節點的值小於或等於其所有右子樹中節點的值;
c. 左右子樹也分別為二叉查詢樹;
d. 沒有鍵值相等的節點。由於以上性質,中序遍歷二叉查詢樹可得到一個關鍵字的有序序列,一個無序序列可以通過構造一棵二叉查詢樹變成一個有序序列,構造樹的過程即為對無序序列進行查詢的過程。每次插入的新的結點都是二叉查詢樹上新的葉子結點,在進行插入操作時,不必移動其它結點,只需改動某個結點的指標,由空變為非空即可。搜尋、插入、刪除的複雜度等於樹高,期望 O(logN),最壞 O(N)(數列有序,樹退化成線性表)。
這裡先給出一個二叉查詢樹節點的結構,下文程式碼中就用它作為樹節點的類:
class BSTNode{ int value; //節點的值 BSTNode left; //節點的左子樹 BSTNode right; //節點的右子樹 BSTNode parent; //節點的父節點 BSTNode(int value, BSTNode parent) { this.value = value; this.parent = parent; } @Override public boolean equals(Object obj) { //兩個節點的value相等,則認為兩個節點相等 return (obj instanceof BSTNode) && (((BSTNode) obj).value == this.value); } }
下面就分別看一下二叉查詢樹的查詢,插入和刪除操作的實現,此處採用迴圈來實現。
查詢
在二叉搜尋樹T中查詢key的過程為:
a. 若T是空樹,則搜尋失敗,否則:
b. 若key等於T的根節點的資料域之值,則查詢成功;否則:
c. 若key小於T的根節點的資料域之值,則搜尋左子樹;否則:
d. 查詢右子樹。下面是這個過程的Java程式碼實現:
/** * @param key 目標節點的鍵值 * @return 與key匹配的節點,若未能成功匹配則返回null */ BSTNode searchBST(int key) { //若根節點為空,或根節點與key匹配成功,則直接返回 if (mRoot == null || mRoot.value == key) { return mRoot; } BSTNode t = mRoot; //從根節點開始迴圈查詢 do { if (key < t.value) { t = t.left; //若key比節點小,則在左子樹中繼續查詢 } else if (key > t.value) { t = t.right; //若key比節點大,則在右子樹中繼續查詢 } else { return t; //匹配成功,返回匹配節點 } } while (t != null); return null; //匹配失敗,返回null }
插入
插入可以理解為先查詢,找到了就說明已經存在該節點不用再進行插入了(也有可能找到後做覆蓋操作,比如HashMap的put方法),找不到就將指標最後停留的葉子節點當做待插入節點的父節點,根據兩個節點值的大小關係確定該作為左子樹還是右子樹插入。下面是相關程式碼:
/** * @param key 待插入節點的鍵值 */ void insertBST(int key) { if (mRoot == null) { //若根節點為空,則使用key建立根節點,插入完成 mRoot = new BSTNode(key, null); return; } BSTNode t = mRoot; BSTNode parent; //指向當前遍歷到的節點的指標 //從根節點開始迴圈查詢 do { parent = t; if (key < t.value) { t = t.left; //若key比節點小,則在左子樹中繼續查詢 } else if (key > t.value) { t = t.right; //若key比節點大,則在右子樹中繼續查詢 } else { return; //若key與節點的值相等,則說明節點已存在,不需要插入,直接返回(若需要覆蓋節點,在這裡完成) } } while (t != null); //執行到這一步說明值為key的節點不存在,新建立一個節點,將parent指標指向的節點作為父節點 BSTNode nodeToInsert = new BSTNode(key, parent); if (key < parent.value) { parent.left = nodeToInsert; //若key比parent的值小,則作為parent的左子樹插入 } else { parent.right = nodeToInsert; //若key比parent的值大,則作為parent的右子樹插入 } }
刪除
刪除操作第一步也是查詢,找到待刪除節點後分下列幾種情況:
a. 若節點為子節點,直接刪除即可;
b. 若節點只有左子樹或右子樹,則刪除該節點後,將其唯一的子樹與父節點相連;
c. 若節點有兩個子樹,則需要選擇一個子樹,並從中選出合適的節點K與待刪除節點的父節點相連。這時樹的結構會發生變化,節點K將接替待刪除節點作為這一棵子樹的根,那麼顯然,K需要大於其左子樹的所有節點且小於右子樹的所有節點。這裡對於K有兩種選擇,要麼選擇待刪除節點的左子樹中最大的節點,要麼選擇其右子樹中最小的節點,二者皆可,我們選擇前者來實現。下面是刪除操作相關程式碼:
/** * @param key 待刪除節點的鍵值 */ void deleteBST(int key) { if (mRoot == null) { return; //若樹為空,則無法刪除,返回 } BSTNode t = mRoot; BSTNode nodeToDelete = null; //需要刪除的節點 //迴圈查詢待刪除節點 do { if (key < t.value) { t = t.left; //在左子樹中繼續 } else if (key > t.value) { t = t.right; //在右子樹中繼續 } else { nodeToDelete = t; //匹配成功,找到待刪除節點,退出迴圈 break; } } while (t != null); if (nodeToDelete == null) { return; //未找到待刪除節點,結束返回 } //若待刪除節點為葉子節點 if (nodeToDelete.left == null && nodeToDelete.right == null) { if (nodeToDelete.parent == null) { mRoot = null; //待刪除節點為根節點,置空全域性變數mRoot } else if (nodeToDelete.value < nodeToDelete.parent.value) { nodeToDelete.parent.left = null; //待刪除節點為其父節點的左子樹,則將其父節點的左子樹置空 } else { nodeToDelete.parent.right = null; //待刪除節點為其父節點的右子樹,則將其父節點的右子樹置空 } } //若待刪除節點有且僅有左子樹,則其左子樹應該直接接替其位置 else if (nodeToDelete.left != null && nodeToDelete.right == null) { if (nodeToDelete.parent == null) { mRoot = nodeToDelete.left; //待刪除節點為根節點,則將mRoot賦值為待刪除節點的左子樹 } else if (nodeToDelete.value < nodeToDelete.parent.value) { nodeToDelete.parent.left = nodeToDelete.left; //待刪除節點為其父節點的左子樹,則將其父節點的左子樹賦值為待刪除節點的左子樹 } else { nodeToDelete.parent.right = nodeToDelete.left; //待刪除節點為其父節點的右子樹,則將其父節點的右子樹賦值為待刪除節點的左子樹 } } //若待刪除節點有且僅有右子樹,則其右子樹應該直接接替其位置 else if (nodeToDelete.left == null) { if (nodeToDelete.parent == null) { mRoot = nodeToDelete.right; //待刪除節點為根節點,則將mRoot賦值為待刪除節點的右子樹 } else if (nodeToDelete.value < nodeToDelete.parent.value) { nodeToDelete.parent.left = nodeToDelete.right; //待刪除節點為其父節點的左子樹,則將其父節點的左子樹賦值為待刪除節點的右子樹 } else { nodeToDelete.parent.right = nodeToDelete.right; //待刪除節點為其父節點的右子樹,則將其父節點的右子樹賦值為待刪除節點的右子樹 } } //若待刪除節點的左右子樹都存在,則按照上文所說選擇一種方案,這裡我們選擇用其左子樹中最大的節點來接替其位置 else { /****這裡分兩步做: *第一步先組裝繼承節點; *第二步將調整好的繼承節點與待刪除節點的父節點相連。 ****/ /****---第一步---****/ BSTNode inheritNode = nodeToDelete.left; //繼承節點inheritNode,初始值為待刪除節點的左孩子 if (inheritNode.right == null) { //若inheritNode沒有右子樹,則直接上位,將待刪除節點(即當前inheritNode的父節點)的右子樹據為己有 inheritNode.right = nodeToDelete.right; } else { //否則的話找到其左子樹中最右邊的節點,即左子樹中的最大節點 while (inheritNode.right != null) { inheritNode = inheritNode.right; } //到這一步,inheritNode已經是待刪除節點左子樹中的最大節點 inheritNode.parent.right = inheritNode.left; //inheritNode的左子樹(可能為空)交給它的父節點 inheritNode.left = nodeToDelete.left; //inheritNode上位繼承,將其左子樹置為待刪除節點的左子樹 inheritNode.right = nodeToDelete.right; //同樣,其右子樹置為待刪除節點的右子樹 } /****---第二步---****/ //到這裡繼承節點已經調整好了,開始認爸爸 if (nodeToDelete.parent == null) { mRoot = inheritNode; //若待刪除節點是根節點,則將mRoot置為繼承節點inheritNode } else if (nodeToDelete.value < nodeToDelete.parent.value) { nodeToDelete.parent.left = inheritNode; //待刪除節點為其父節點的左子樹,則將其父節點的左子樹賦值為繼承節點inheritNode } else { nodeToDelete.parent.right = inheritNode; //待刪除節點為其父節點的右子樹,則將其父節點的右子樹賦值為繼承節點inheritNode } } nodeToDelete = null; //將臨時變數置空,結束 }
至此,二叉查詢樹的查詢,插入和刪除的實現就都分析完畢了。可以看到其效能跟樹的結構息息相關,最差情況如果元素有序插入,則會形成一條連結串列而非樹,這時操作的複雜度就變成了O(N)。這個問題在紅黑樹中得到了很好的解決,下面我們就在二叉查詢樹的基礎上繼續分析紅黑樹。
紅黑樹
上文提到過,紅黑樹是每個節點都帶有顏色屬性(紅或黑)的二叉查詢樹。在二叉查詢樹性質的基礎上,紅黑樹額外規定了以下五條性質。這些約束確保了紅黑樹的關鍵特性:從根到葉子的最長的可能路徑不多於最短的可能路徑的兩倍長。結果是這個樹大致上是平衡的。因為操作比如插入、刪除和查詢某個值的最壞情況時間都要求與樹的高度成比例,這個在高度上的理論上限允許紅黑樹在最壞情況下都是高效的,而不同於普通的二叉查詢樹。
要知道為什麼這些性質確保了這個結果,注意到性質4導致了路徑不能有兩個毗連的紅色節點就足夠了。最短的可能路徑都是黑色節點,最長的可能路徑有交替的紅色和黑色節點。因為根據性質5所有最長的路徑都有相同數目的黑色節點,這就表明了沒有路徑能多於任何其他路徑的兩倍長。
紅黑樹的查詢操作與普通二叉查詢樹的完全相同,而在進行插入和刪除時則有可能導致其不再滿足紅黑樹的性質,因此在這種情況下需要通過節點顏色變更和不超過三次的節點旋轉(包括左旋和右旋,對於插入操作最多旋轉兩次)來使其恢復平衡,操作複雜度為O(logN)。下面一一來分析。
五條性質
a. 每個結點非黑即紅;
b. 根結點是黑的;
c. 每個葉結點(葉結點即指樹尾端NIL指標或NULL結點)都是黑的;
d. 如果一個結點是紅的,那麼它的兩個兒子都是黑的;
e. 對於任一結點而言,其到葉結點樹尾端NIL指標的每一條路徑都包含相同數目的黑結點。只有滿足這五條性質的二叉查詢樹才是一棵紅黑樹,那麼當有插入刪除操作導致樹的平衡被破壞時,就需要通過下面的節點左右旋轉操作來重新保證這五條性質,從而恢復紅黑樹的平衡。
左旋
首先說明的是,左旋或右旋都是針對一個節點的操作,而非以整棵樹為物件。
先來看對節點X的左旋,X是紅黑樹中的一個節點,假設其左右孩子都存在,並且左孩子為T,右孩子為Z,即X.left=T, X.right=Z。那麼對X左旋的過程為:
a. 將X變為其右孩子Z的左孩子,即Z替代X成為這一部分樹的根節點;
b. 將Z原本的左孩子(可能為空)變為X的新右孩子;
c. 讓Z與X原先的父節點相認(若X原先沒有父節點,則Z成為整顆紅黑樹的新的根節點)。下面是程式碼實現:
void rotateLeftBST(BSTNode x) { //判斷x合法性,若為空或沒有右孩子,直接返回 if (x == null || x.right == null) return; //暫存x的父節點parent,右孩子z,以及右孩子z的左孩子zLeft BSTNode parent = x.parent; BSTNode z = x.right; BSTNode zLeft = z.left; //上面的步驟a,x變為右孩子z的左孩子 z.left = x; x.parent = z; //步驟b,z原本的左孩子zLeft變為x的新右孩子 x.right = zLeft; if (zLeft != null) { //若zLeft存在,則將x認作新的父親 zLeft.parent = x; } //步驟c,z的父節點變為原先x的父節點 z.parent = parent; if (parent == null) { //若之前x為整棵樹的根節點,則z接替它成為新的根節點mRoot mRoot = z; } //z接替x成為x原先父節點的左孩子或右孩子 else if (parent.left == x) { parent.left = z; } else if (parent.right == x) { parent.right = z; } }
右旋
下面來看右旋的過程:
a. 將X變為其左孩子T的右孩子,即T替代X成為這一部分樹的根節點;
b. 將T原本的右孩子(可能為空)變為X的新左孩子;
c. 讓T與X原先的父節點相認(若X原先沒有父節點,則T成為整顆紅黑樹的新的根節點)。下面是程式碼實現:
void rotateRightBST(BSTNode x) { //判斷x合法性,若為空或沒有左孩子,直接返回 if (x == null || x.left == null) return; //暫存x的父節點parent,左孩子t,以及左孩子t的右孩子tRight BSTNode parent = x.parent; BSTNode t = x.left; BSTNode tRight = t.right; //上面的步驟a,x變為左孩子t的右孩子 t.right = x; x.parent = t; //步驟b,t原本的右孩子tRight變為x的新左孩子 x.left = tRight; if (tRight != null) { tRight.parent = x; } //步驟c,t的父節點變為原先x的父節點 t.parent = parent; if (parent == null) { //若之前x為整棵樹的根節點,則t接替它成為新的根節點mRoot mRoot = t; } //t接替x成為x原先父節點的左孩子或右孩子 else if (parent.left == x) { parent.left = t; } else if (parent.right == x) { parent.right = t; } }
到這裡,紅黑樹節點的左右旋轉就分析完畢了,這兩個操作在恢復紅黑樹平衡的過程中扮演了重要的作用,下面就來看看紅黑樹經過插入或刪除操作後的調整過程。
插入後調整
前面說過,紅黑樹的插入操作是在普通二叉查詢樹插入的基礎上進行調整,所以將元素按鍵值大小插入到合適位置這一步跟前面介紹的二叉查詢樹的實現一致,在此基礎上我們需要進行調整使紅黑樹繼續滿足其五條特性。
首先,前三條特性不會因為一個節點的插入而被破壞,所以只需要關心後面兩條即可。這裡引用張拭心同學的文章中的一段話:
“插入一個節點後要擔心違反特徵 4 和 5,數學裡最常用的一個解題技巧就是把多個未知數化解成一個未知數。我們這裡採用同樣的技巧,把插入的節點直接染成紅色,這樣就不會影響特徵 5,只要專心調整滿足特徵 4 就好了。這樣比同時滿足 4、5 要簡單一些。染成紅色後,我們只要關心父節點是否為紅,如果是紅的,就要把父節點進行變化,讓父節點變成黑色,或者換一個黑色節點當父親,這些操作的同時不能影響 不同路徑上的黑色節點數一致的規則。”
所以我們插入的節點均為紅色,若插入後父節點為黑色則不用調整;若父節點為紅色,則根據其叔叔節點的顏色有兩種情況,下面我們分別來分析。(注:下圖中,X是待新增節點,插入後X是作為L或是R的子樹對於後續的調整操作是有影響的,但這兩種情況下的操作是對稱的,張拭心同學的文章介紹的是插入到L下的情況,為避免重複,本文選擇另一種情況進行分析)。
第一種情況:父節點R為紅色,叔叔節點L也為紅色(顯然爺爺節點P肯定是黑色)。如下圖左邊所示,X和R形成了兩個連續的紅色節點,破壞了性質4,這時由於叔叔節點L也是紅色,所以只需要把叔叔L和父親R都染成黑色,同時將爺爺P染成紅色就能使這一部分的紅黑樹重新恢復平衡(沒有兩個連續紅色節點,同時每條路上黑色節點的數量也沒有改變),即下圖右邊的情況。但此時P的染紅會導致更上層的樹結構被破壞,這時我們只需將P當做新插入的節點,再次向上進行相同的調整,以此迴圈直到父節點為黑色即可。
第二種情況:父節點R為紅色,但叔叔節點和爺爺節點都為黑色。如下圖左邊所示,這種情況下光靠染色已經不足以完成調整,因為若把R染成黑色或把L染成紅色都會改變該條路徑上黑色節點的個數,即會破壞性質5。此時該節點旋轉登場了。
考慮下圖左邊的情況,P-L路徑兩個黑色節點,P-R-X路徑一個黑色節點,現在要求黑色節點個數不變,將紅色的X接到一個黑色的父節點上,似乎那個紅色的R是最大的阻礙,如果能把這個紅色節點(注意,是紅色節點,不是R節點)挪到左邊那條路徑去就能解決問題了。這時我們可以考慮對爺爺節點P進行左旋,這樣一來爺爺節點就變成了紅色的R,而黑色的P成了R的左孩子。然後我們再講P和R的顏色交換,即R染成黑色,P染成紅色,這樣就得到了下圖中右邊的結構,可以看到左邊R-P-L路徑仍舊是兩個黑色節點,右邊R-X路徑也依舊是一個黑色節點,同時也沒有兩個相連的紅色節點,而且由於新的根節點R也為黑色,不需要再像情況1中那樣迴圈向上進行調整,整棵紅黑樹已恢復平衡。
另外,第二種情況下的調整策略也分為兩種,上面是新插入節點X是其父節點R的右孩子時的情況,下面這種是X為R的左孩子的情況,如下圖最左邊所示,此時如果直接對P進行左旋,則X會成為P的右孩子,這樣無法使樹恢復平衡。所以需要多做一次旋轉,先對節點R做一次右旋,得到下圖中間所示結構,之後再對P進行左旋,並交換X和P的顏色即可。
下面我們對照JDK1.8中HashMap新增的balanceInsertion方法原始碼過一下上文所述的過程:
//root是整棵樹的根節點,x是新插入的節點,方法的返回值為插入操作後的根節點 static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root, TreeNode<K,V> x) { //新插入的節點先染紅 x.red = true; //開始迴圈,xp為上圖中的R(父親),xpp為P(爺爺), //xppl為L(R為P的右孩子時,P的左孩子,即叔叔),xppr是R為P的左孩子時P的右孩子,也是叔叔 for (TreeNode<K,V> xp, xpp, xppl, xppr;;) { if ((xp = x.parent) == null) { x.red = false; //xp為空,即x為根節點,染成黑色,返回 return x; } else if (!xp.red || (xpp = xp.parent) == null) { return root; //若xp是黑色,或者xp是根節點(也就等於xp是黑色),則無需調整,直接返回 } //父節點xp為紅色 //如果x的父節點xp是爺爺節點xpp的左孩子 if (xp == (xppl = xpp.left)) { //如果叔叔節點也是紅色 if ((xppr = xpp.right) != null && xppr.red) { xppr.red = false; //叔叔染黑色 xp.red = false; //父親染黑色 xpp.red = true; //爺爺染紅色 x = xpp; //將x賦值為其爺爺節點xpp,繼續向上層迴圈調整 } //如果叔叔節點是黑色 else { if (x == xp.right) { root = rotateLeft(root, x = xp); //若x是其父節點的右孩子,則需要多做一次左旋 xpp = (xp = x.parent) == null ? null : xp.parent; //這時x和xp的層數會互換,x變為xp的父節點 } if (xp != null) { //父節點xp和爺爺節點xpp互換顏色,並且對於爺爺節點xpp做右旋 xp.red = false; if (xpp != null) { xpp.red = true; root = rotateRight(root, xpp); //root有可能會改變,具體請參照rotateRightBST方法 } } } } //如果x的父節點xp是爺爺節點xpp的右孩子,這就是上文分析的那種情況,與上面if中的操作對稱 else { if (xppl != null && xppl.red) { xppl.red = false; xp.red = false; xpp.red = true; x = xpp; } else { if (x == xp.left) { root = rotateRight(root, x = xp); //若x是其父節點的左孩子,則需要多做一次右旋 xpp = (xp = x.parent) == null ? null : xp.parent; } if (xp != null) { //父節點xp和爺爺節點xpp互換顏色,並且對於爺爺節點xpp做左旋 xp.red = false; if (xpp != null) { xpp.red = true; root = rotateLeft(root, xpp); } } } } } }
至此,紅黑樹插入節點後的調整操作就分析完畢了,整個過程通過對節點染色和對節點旋轉這兩個操作來恢復平衡,由於節點染色是一個非常快的操作,而旋轉雖然較為複雜,但是對於插入調整來說至多隻需要做兩次旋轉便可使整棵樹恢復平衡,因此也不會對效能造成大的影響。
刪除後調整
和插入操作一樣,紅黑樹的刪除操作也是在二叉查詢樹刪除操作的基礎上進行必要的調整以恢復紅黑樹的平衡。同樣的,紅黑樹的前三條性質不會被破壞,所以僅考慮第4,5條性質。
顯然,如果刪除的是一個紅色節點,則不會對樹的平衡產生任何影響,即不需要調整。如果是一個黑色節點,則會導致其所在的子樹路徑上黑色節點的個數比另一棵子樹路徑的少,也即破壞了紅黑樹的第5條性質。所以我們要做的,要麼直接將另一條路徑中的某一個黑色節點染紅(不一定能這麼做),要麼通過節點旋轉加染色來調整兩邊的節點。說到底還是旋轉和染色這兩個手段。
考慮上圖中的情況,最左邊是刪除前的部分紅黑樹,可以看到左右子樹的每條路徑上都是兩個黑色節點,現在要刪除節點X,刪除後變為中間的不平衡結構,因為P的左子樹少了一個黑色節點。現做如下調整:
a. 首先將X的兄弟節點Y染成黑色,同時將X的父節點P染成紅色;
b. 接著對節點P做左旋轉,然後由於L已經是葉子節點,所以染紅並向上以P為物件繼續迴圈調整,下一輪中由於P是紅色,直接染成黑色並返回(這一步是根據原始碼得出,雖然在我們當前的這個例子中稍顯累贅,但是畢竟我們舉的是一個較簡單的例子,實際的情況會更復雜),最終得到了上圖最右邊的結構,可以看到這時左右子樹的每條路徑上又變成了各自兩個黑色節點,紅黑樹再次恢復了平衡。
這便是刪除後調整的一種情況,下面我們通過HashMap中的balanceDeletion方法分析刪除調整可能遇到的所有情況。
在開始看balanceDeletion方法的原始碼前,先看一下它被呼叫的地方,這裡主要關注它的兩個輸入引數的含義:
final void removeTreeNode(HashMap<K,V> map, Node<K,V>[] tab, boolean movable) { ...... //p是待刪除節點,這個replacement變數是為了之後紅黑樹的刪除調整準備的,它記錄的是樹經過二叉查詢樹刪除調整後最終變化的地方,當p為葉子節點時,p==replacement,否則按照HashMap原始碼,replacement應為p的右子樹中最左節點的右孩子 if (replacement != p) { //若p不為葉子節點,則讓繼承者跟父節點相認 TreeNode<K,V> pp = replacement.parent = p.parent; if (pp == null) root = replacement; else if (p == pp.left) pp.left = replacement; else pp.right = replacement; p.left = p.right = p.parent = null; } //若待刪除的節點p時紅色的,則樹平衡未被破壞,無需進行調整。否則進行刪除後調整,balanceDeletion方法就是在這裡被呼叫的 TreeNode<K,V> r = p.red ? root : balanceDeletion(root, replacement); //p為葉子節點,沒有繼承者,則將p從樹中清除 if (replacement == p) { // detach TreeNode<K,V> pp = p.parent; p.parent = null; if (pp != null) { if (p == pp.left) pp.left = null; else if (p == pp.right) pp.right = null; } } }
可以看出,第一個輸入引數是整棵紅黑樹的根節點,第二個輸入引數是待刪除節點或是其繼承者,可以當做是上圖中的X節點。搞清楚了輸入引數,下面我們就開始分析喪心病狂的balanceDeletion方法。
//x就是上文中說到的replacement static <K,V> TreeNode<K,V> balanceDeletion(TreeNode<K,V> root, TreeNode<K,V> x) { for (TreeNode<K,V> xp, xpl, xpr;;) { if (x == null || x == root) return root; //x為空或x為根節點,返回 else if ((xp = x.parent) == null) { x.red = false; //x為根節點,染成黑色,返回 return x; } else if (x.red) { x.red = false; return root; //x為紅色,則無需調整,返回 } //x為其父節點的左孩子 else if ((xpl = xp.left) == x) { if ((xpr = xp.right) != null && xpr.red) { //有紅色的兄弟節點xpr,則父親節點xp必為黑色 xpr.red = false; //兄弟染成黑色 xp.red = true; //父親染成紅色 root = rotateLeft(root, xp); //對父節點xp做左旋轉 xpr = (xp = x.parent) == null ? null : xp.right; //重新將xp指向原先x的父節點,xpr則指向xp新的右孩子 } if (xpr == null) x = xp; //若新的xpr為空,即上圖中L節點為空,則向上繼續調整,將x的父節點xp作為新的x繼續迴圈 else { //此時的xpr就是上圖中的節點L,sl和sr就是其左右孩子 TreeNode<K,V> sl = xpr.left, sr = xpr.right; if ((sr == null || !sr.red) && (sl == null || !sl.red)) { xpr.red = true; //若sl和sr都為黑色或者不存在,即xpr沒有紅色孩子,則將xpr染紅 x = xp; //本輪結束,繼續向上迴圈,這就是上圖中的情況,新的x即為紅色的P節點,下一次迴圈時P會被染黑,然後迴圈結束 } else { //否則的話,就需要進一步調整 //現在的情況是被刪除的X的路徑需要一個新的黑色節點,即上圖中P的左子樹中,只能考慮從P的右子樹搬運 //所以最終要做的是對P做左旋,但L的左子樹sl會在左旋後變為P的右子樹,因此在左旋之前需要對sl和sr做處理 if (sr == null || !sr.red) { if (sl != null) //若左孩子為紅,右孩子不存在或為黑 sl.red = false; //左孩子染黑 xpr.red = true; //將xpr染紅 root = rotateRight(root, xpr); //此時考慮上圖,P和L均為紅,於是需要右旋L將黑色的sl換過來 xpr = (xp = x.parent) == null ? null : xp.right; //右旋後,xpr指向xp(上圖P)的新右孩子,即上一步中的sl } if (xpr != null) { xpr.red = (xp == null) ? false : xp.red; //xpr染成跟父節點一致的顏色,為後面父節點xp的左旋做準備 if ((sr = xpr.right) != null) sr.red = false; //xpr新的右孩子染黑,防止出現兩個紅色相連 } if (xp != null) { xp.red = false; //將xp染黑,並對其左旋,這樣就能保證被刪除的X所在的路徑又多了一個黑色節點,從而達到恢復平衡的目的 root = rotateLeft(root, xp); } //到此調整已經完畢,故將x置為root,進入下一次迴圈後將直接退出 x = root; } } } //x為其父節點的右孩子,與上述過程對稱 else { // symmetric if (xpl != null && xpl.red) { xpl.red = false; xp.red = true; root = rotateRight(root, xp); xpl = (xp = x.parent) == null ? null : xp.left; } if (xpl == null) x = xp; else { TreeNode<K,V> sl = xpl.left, sr = xpl.right; if ((sl == null || !sl.red) && (sr == null || !sr.red)) { xpl.red = true; x = xp; } else { if (sl == null || !sl.red) { if (sr != null) sr.red = false; xpl.red = true; root = rotateLeft(root, xpl); xpl = (xp = x.parent) == null ? null : xp.left; } if (xpl != null) { xpl.red = (xp == null) ? false : xp.red; if ((sl = xpl.left) != null) sl.red = false; } if (xp != null) { xp.red = false; root = rotateRight(root, xp); } x = root; } } } } }
以上就是HashMap中對於紅黑樹刪除後調整的實現,邏輯很繞,但最終的目的就是讓整棵樹再次滿足紅黑樹的5條特性。
最後
紅黑樹是一種特殊的二叉查詢樹,它的五條特性保證了其查詢,插入和刪除操作的複雜度均為O(logN)。
紅黑樹的查詢演算法和二叉查詢樹無異,插入和刪除也是基於二叉查詢樹的做法,只是在其基礎上需要進行調整以重新恢復樹的平衡(主要是重新滿足第4,5條特性)。
由於其有序,快速的特點,紅黑樹在很多場景下都有被應用,比如Java中的TreeMap,以及Java 8中的HashMap。對於HashMap中紅黑樹的應用,這篇文章中進行了詳細的分析和說明,敬請狠戳。
感謝閱讀!