萌新從TreeMap學習紅黑樹
引
萌新學習資料結構挺久的了,常用資料結構都可以手撕,而平衡樹只是瞭解原理,撕不出來,看各種部落格文章也看得暈頭轉向的。
之前看《演演算法》紅皮書學習了左偏紅黑樹,這次從JDK的TreeMap來分析下常規紅黑樹。
閱讀需要有二叉查詢樹的知識背景
1.紅黑樹的基本性質
出自《演演算法導論》
- 每個節點是紅色或者黑色的
- 根節點黑
- 葉節點Nil黑
- 父子不能都是紅色的
- 從一個節點到其子孫節點的所有路徑上,黑色節點的數目是相同的
2.從TreeMap中提取紅黑樹相關的程式碼
因為TreeMap中有很多集合相關的操作,原始碼長度上千行,看得眼花了。所以這裡把其中和紅黑樹相關的部分提取出來分析。
基本類及其屬性
節點類
這裡為了方便,把其中的泛型部分簡化成int
private static class Node implements Comparable<Node> {
int key;
int val;
boolean color = BLACK;
Node left,right,parent;
Node(int key,int val,Node parent) {
this.key = key;
this.val = val;
this .parent = parent;
}
@Override
public int compareTo(Node o) {
return this.key - o.key;
}
}
複製程式碼
紅黑樹類
//紅黑樹
public class RedBlackTree{
//常量定義
private static final boolean RED = false;
private static final boolean BLACK = true;
private Node root;
private int size;
// 之前的節點類
private static class Node implements Comparable<Node> {...}
}
複製程式碼
工具方法
顏色相關操作
private static boolean colorOf(Node p) {
return (p == null ? BLACK : p.color);
}
private static void setColor(Node p,boolean c) {
if (p != null)
p.color = c;
}
複製程式碼
節點關係相關操作
private static Node parentOf(Node p) {
return (p == null ? null : p.parent);
}
private static Node leftOf(Node p) {
return (p == null) ? null : p.left;
}
private static Node rightOf(Node p) {
return (p == null) ? null : p.right;
}
private Node successor(Node tmp) {
//後繼節點的查詢
if (tmp == null) {
return null;
}
if (tmp.right != null) {
Node p = tmp.right;
while (p.left != null) {
p = p.left;
}
return p;
} else {
Node p = tmp.parent;
Node ch = tmp;
while (p != null && ch == p.right) {
ch = p;
p = p.parent;
}
return p;
}
}
複製程式碼
旋轉相關操作
所謂左旋,就是對原節點N,讓N的右子節點R代替N的位置,
N成為R的左子節點,至於他們的其他子節點,看著辦就好。
從觀感上有N下位成左子節點,N的右子節點上位之感,故謂之左旋。
右旋反之。
非常簡單的操作,無須多言。
private void rotateLeft(Node tmp) {
if (tmp == null) {
return;
}
Node r = tmp.right;
if (r == null) {
return;
}
tmp.right = r.left;
if (r.left != null) {
r.left.parent = tmp;
}
r.parent = tmp.parent;
if (tmp.parent == null) {
root = r;
} else if (tmp.parent.left == tmp) {
tmp.parent.left = r;
} else {
tmp.parent.right = r;
}
r.left = tmp;
tmp.parent = r;
}
private void rotateRight(Node tmp) {
if (tmp == null) {
return;
}
Node l = tmp.left;
if (l == null) {
return;
}
tmp.left = l.right;
if (l.right != null) {
l.parent = tmp;
}
l.parent = tmp.parent;
if (tmp.parent == null) {
root = l;
} else if (tmp == tmp.parent.left) {
tmp.parent.left = l;
} else {
tmp.parent.right = l;
}
l.right = tmp;
tmp.parent = l;
}
複製程式碼
插入
整體方法
- 空樹直接插入到root
- 非空樹插入後,看是否建立了新的節點,有新的節點就需要修復RBT的性質
- 這裡有兩個方法需要進一步分析,insert 和 fixAfterInsertion
public void put(int key,int val) {
if (this.root == null) {
//對於空樹,我們直接插入就好了,滿足RBT的各種性質
this.root = new Node(key,val,null);
size = 1;
} else {
Node newNode = insert(root,key,val);
//newNode為空,就是key存在,這時候沒有插入新節點
//非空的話,插入新節點,需要考慮修復被破壞的RBT性質
if (newNode != null) {
fixAfterInsertion(newNode);
}
}
}
複製程式碼
insert 方法分析
其實與常規二叉樹的操作一樣的
private Node insert(Node root,int key,int val) {
Node tmp = root,parent = tmp;
while (tmp != null) {
parent = tmp;
int cmp = tmp.key - key;
if (cmp < 0) {
tmp = tmp.left;
} else if (cmp > 0) {
tmp = tmp.right;
} else {
// key已經存在,直接修改val後返回就好了
tmp.val = val;
return null;
}
}
//tmp為null, parent是上一輪的tmp
//只需要把節點插入到這個parent下面
int cmp = parent.key - key;
Node newNode = new Node(key,parent);
if (cmp > 0) {
parent.right = newNode;
} else {
parent.left = newNode;
}
size++;
return newNode;
}
複製程式碼
fixAfterInsertion分析,重頭戲
方法名分析:插入之後的修復。修復必然是因為某些性質被破壞,這裡需要通過一些操作來還RBT的性質。
幾個關鍵點
- 首先新插入的節點設為紅色,這樣可以保證性質5,我們只需要維護性質4(父子節點不能都是紅節點)就好了
- 迴圈條件為當前節點的父節點為紅色,因為只有這種情況下我們才需要維護被破壞的性質4,否則的話符合RBT的性質,可以返回了
- 迴圈中的處理分成兩種,按當前節點Tmp的父節點Parent是Tmp的祖父節點的左子節點還是右子節點來分,其內部的處理方式是映象的,我們只需要分析其中一種就好(第一個if)
- 先獲取當前節點的叔節點,其父親的兄弟。
兄弟節點可能的幾種狀態:
- 紅色
- 黑色
- 不存在
- 當叔節點為RED時,即程式碼中第二個if處,父節點也為RED,祖節點必為BLACK。
我們把叔節點和父節點都變為BLACK,祖節點變為RED。
這樣操作後:
一方面,對於父節點和叔節點及他們的子節點來說,從根節點到當前節點路徑上的黑色節點數目沒有變化。不會破壞RBT的性質。
另一方面,父節點變成黑色,當前節點和父節點不再都是紅色了,性質四被修復。
這個修復只是針對當前節點來說的,因為祖節點變成了紅色,祖節點的性質4可能被破壞,所以需要把tmp指向祖節點,繼續下一輪迴圈。
這個操作,可以參考演演算法紅皮書中的flip方法
- 當叔節點為黑色或不存在時,第二個else那裡
6.1 判斷當前節點是左子節點,還是右子節點,如果是右子節點,那麼使用旋轉操作把他變成左子節點,方便後面的統一處理
注意在旋轉前,祖節點到tmp的路徑上只有一個黑色節點,祖節點到右邊的孫子的路徑上有兩個黑色節點,而旋轉後黑色節點的數目保持不變,從而維護了RBT的性質
private void fixAfterInsertion(Node insertNode) {
setColor(insertNode,RED);
Node tmp = insertNode;
while (tmp != null && tmp != root && tmp.parent.color == RED) {
//第一個if
if (parentOf(tmp) == leftOf(parentOf(parentOf(tmp)))) {
Node uncle = rightOf(parentOf(parentOf(tmp)));//tmp的叔節點
if (colorOf(uncle) == RED) {
//第二個if
//可參考紅皮書的翻轉操作
setColor(parentOf(tmp),BLACK);
setColor(uncle,BLACK);
setColor(parentOf(uncle),RED);
tmp = parentOf(uncle);
} else {//第二個else,叔節點為黑色或不存在
if (tmp == rightOf(parentOf(tmp))) {//第三個if
//若tmp為右節點,那麼通過旋轉操作,使tmp指向左子節點,方便下面的統一操作
tmp = parentOf(tmp);
rotateLeft(tmp);
}
setColor(parentOf(tmp),BLACK);
setColor(parentOf(parentOf(tmp)),RED);
rotateRight(parentOf(parentOf(tmp)));
}
}
//else中的內容為第一個if的映象
else {
Node uncle = leftOf(parentOf(parentOf(tmp)));//tmp的左叔節點
if (colorOf(uncle) == RED) {
setColor(parentOf(tmp),RED);
tmp = parentOf(uncle);
} else {
if (tmp == leftOf(parentOf(tmp))) {
tmp = parentOf(tmp);
rotateRight(tmp);
}
setColor(parentOf(tmp),RED);
rotateLeft(parentOf(parentOf(tmp)));
}
}
}
setColor(root,BLACK);
}
複製程式碼
從程式設計的角度理解,這裡迴圈的作用,是把“新加了一個紅色節點”這一事件逐層向上傳遞。
在傳遞過程中,可能某一層可以處理這一事件,那麼他就處理,然後終止這一事件的傳遞。如果處理不了這一事件,他就通過一系列轉換,把這個事件轉換成上層要處理的問題。
這樣遞迴到root就好了。
查詢
其實與常規二叉樹查詢一樣
public Node get(int key) {
Node tmp = root;
while (tmp != null) {
int cmp = tmp.key - key;
if (cmp > 0) {
tmp = tmp.right;
} else if (cmp < 0) {
tmp = tmp.left;
} else {
return tmp;
}
}
return null;
}
複製程式碼
刪除
基本方法
public void delete(int key) {
Node node = get(key);
if (node != null) {
size--;
deleteNode(node);
}
}
複製程式碼
deleteNode
關鍵點分析:
-
刪除紅色節點不會破壞RBT的性質,但是刪除黑色節點會破壞性質5,所以刪除黑色節點後需要呼叫fixAfterDeletion方法來修復性質5,具體後面分析
-
先是各種非空判斷。
-
語句1:對於要刪除有兩個子節點的節點Tmp,我們先找的其後繼節點s,然後把s提到Tmp的位置,轉為刪除s就好了。
因為Tmp有兩個子節點,可知s必定存在於Tmp的右子樹中,並且s沒有子節點或者只有一個子節點。
這樣我們就把所有的刪除操作都歸納到刪除葉子節點或者只有一個子節點兩種情況下了。 -
對於只有一個子節點的情況,我們在語句2中處理。 主要步驟就是找到這個子節點,然後子節點登基大寶,原節點被忘卻。
但是原節點若為黑色,那麼這條路徑下所有節點的路徑都少了一個黑色節點,不符合性質5了,所以要進行修復。 -
對於刪除葉子節點的情況,我們在語句3中處理。 該節點為紅色,我們就直接斷開各種連線就好了; 若該節點為黑色,我們還需要先進行fixAfterDeletion。
private void deleteNode(Node node) {
if (node == null) {
return;
}
if (node.parent == null) {
root = null;
return;
}
//語句1
if (node.left != null && node.right != null) {
Node s = successor(node);
node.key = s.key;
node.val = s.val;
node = s;
}
//語句2
if (node.left != null || node.right != null) {
//找到這個唯一的子節點
Node replacement = node.left == null ? node.right : node.left;
//把這個子節點頂上去,原節點的各種連線都被這個子節點所佔據
replacement.parent = node.parent;
if (node.parent == null) {
root = replacement;
} else if (node == node.parent.left) {
node.parent.left = replacement;
} else {
node.parent.right = replacement;
}
node.left = null;
node.right = null;
node.parent = null;
//老節點若為黑色,需要修復
if (node.color == BLACK) {
fixAfterDeletion(replacement);
}
} else {//語句3
if (node.parent == null) {
root = null;
} else {
if (node.color == BLACK) {
fixAfterDeletion(node);
}
//斷開連線
if (node.parent != null) {
if (node == node.parent.left) {
node.parent.left = null;
} else if (node == node.parent.right) {
node.parent.right = null;
}
node.parent = null;
}
}
}
}
複製程式碼
fixAfterDeletion
如果刪除體現了“新王上位老王敗潰”的無情,那麼修復則體現了“兄弟就是拿來坑的”的暖暖親情。
刪除之後,若不滿足RBT的性質,只會是不滿足性質5。即和其兄弟比起來,在路徑上少了一個黑色節點。
所以這個修復的關鍵是想辦法從兄弟那裡找一個紅色節點變成黑色,通過parent傳遞過來,從而達到平衡。實在搞不到了,那麼就把兄弟也減少一個黑色節點(黑變紅),然後把問題交給parent去處理,充分體現了高超的甩鍋水平。
關鍵點分析:
- 迴圈的條件就是當前節點是黑色,因為只有當前節點是黑色的時候,才會對路徑上缺少黑色節點這一問題束手無策;若當前節點是紅色,直接把他變成黑色,問題就解決了。這也是迴圈後要進行的一個處理。
- 和插入後的修復類似,這裡也是映象條件判斷,分成兩種情況來分析,即當前節點是父節點的左子節點還是右子節點,我們只需要分析當前節點是左子節點這一種情況就好了。
- 我們首先獲取兄弟節點bro,必定存在,不然不滿足性質5。對於bro,我們分成bro顏色為紅色和黑色來處理。
- 首先如果bro的顏色是紅色,我們通過一系列操作把他變成黑色的,見程式碼1。
即在不改變現有的路徑黑色節點數的前提下,通過旋轉和換色操作,把兄弟節點變成黑的,從而方便之後的統一處理。 - 無法從兄弟處找到紅色節點:對於程式碼2,此處bro的顏色必定為黑色,若bro的兩個子節點顏色也都是黑色,我們就沒辦法騙過來一個紅色節點,所以這時候只能把問題交給上一級去解決。
6. 從bro處借來一個節點:從時bro必定為黑色,且其子節點中至少存在一個紅色,我們把這個紅色變黑,bro就多了一個黑色,再把多的這個通過parent傳遞過來,來修復tmp路徑下的缺憾。 程式碼3,4
第一步: 為了統一處理,我們只處理bro的右子節點是紅色的情況,但如果他的右子節點是黑色怎麼辦?轉化!程式碼3 注意這裡的parent顏色不一定為黑,黑紅都可能
第二步:借節點,程式碼4
把兄弟提上去,兄弟的右子節點變黑(原先為紅,第一步中保證為紅) 把原先的parent變黑拽下來,給tmp的黑節點充數
private void fixAfterDeletion(Node tmp) {
while (tmp != root && colorOf(tmp) == BLACK) {
if (tmp == leftOf(parentOf(tmp))) {
//獲取兄弟節點
Node bro = rightOf(parentOf(tmp));
//程式碼1,把兄弟變成黑色
if (colorOf(bro) == RED) {
//parent一定為黑
setColor(bro,BLACK);
setColor(parentOf(tmp),RED);
rotateLeft(parentOf(tmp));
bro = rightOf(parentOf(tmp));
}
//程式碼2,bro的顏色必定為黑色
if (colorOf(leftOf(bro)) == BLACK && colorOf(rightOf(bro)) == BLACK) {
setColor(bro,RED);
tmp = parentOf(tmp);//向上一層傳遞事件
}
else {
//程式碼3,bro為黑色,並且bro至少有一個紅子節點
if (colorOf(rightOf(bro)) == BLACK) {
setColor(leftOf(bro),BLACK);//這種情況想bro的左子節點必定為RED
setColor(bro,RED);
rotateRight(bro);
bro = rightOf(parentOf(tmp));
//成功把bro的right轉成了紅色
}
//程式碼4這一步的作用是從bro那裡借來一個黑色節點
setColor(bro,colorOf(parentOf(tmp)));
setColor(parentOf(tmp),BLACK);
setColor(rightOf(bro),BLACK);
rotateLeft(parentOf(tmp));
tmp = root;//退出
}
} else {//映象操作,沒啥好說的
Node bro = leftOf(parentOf(tmp));
if (colorOf(bro) == RED) {
setColor(bro,RED);
rotateRight(parentOf(tmp));
bro = leftOf(parentOf(tmp));
}
if (colorOf(rightOf(bro)) == BLACK && colorOf(leftOf(bro)) == BLACK) {
setColor(bro,RED);
tmp = parentOf(tmp);
} else {
if (colorOf(leftOf(bro)) == BLACK) {
setColor(rightOf(bro),BLACK);
setColor(bro,RED);
rotateLeft(bro);
bro = leftOf(parentOf(tmp));
}
setColor(bro,BLACK);
setColor(leftOf(bro),BLACK);
rotateRight(parentOf(tmp));
tmp = root;
}
}
}
setColor(tmp,BLACK);
}
複製程式碼
理解這一步的關鍵就是:通過迴圈,把少了一個黑色節點這一事件逐層向上傳遞,直到被某一層處理。具體處理方法呢?就是在bro的子節點裡找到一個RED。
疑問為什麼不在bro為RED的時候直接把bro變成BLACK,通過parent傳遞過來呢?因為bro的子節點的黑色路徑數目會變,如下圖
3.總結
問題的等效轉換
初看可能會覺得,各種情況極其複雜,然而他們通過等效變換(換色+旋轉,保持各個節點路徑的黑色節點數目不變),將各種複雜情況歸納為兩三種簡單情況,對這兩三種簡單情況,又分為在本層解決,或者在本層區域性解決把問題推到上一層這兩種處理方式,從而保持或者修復RBT的各種性質。
插入總結:
- 父紅,叔節點為紅色。執行flip操作,把問題推到上一層
- 父紅,叔節點黑色,通過換色和旋轉,把一個紅色節點轉移到叔節點那棵樹上
刪除總結
- 先把兄弟節點變成黑色的
- 兄弟節點的左右子節點都是黑色:處理不了,把兄弟也變紅,保持tmp和兄弟的平衡,至於少了一個黑色的事情,交給parent去處理
- 兄弟節點的左右子節點存在紅色,把紅色統一轉移到右邊,然後parent下降到tmp這裡並變黑,補足黑節點,兄弟上升為新的parent,兄弟的右子節點變黑