轉載請注明出處!http://www.lxweimin.com/p/d9d9f223f0ad
此篇需要二叉樹基本知識,若對二叉樹不了解,請移步一篇文章搞懂二叉樹!
Welcome back!
在了解了二叉樹的基本知識后,我們知道,二叉樹雖然可以在一般情況下控制比較次數(shù)在logN內(nèi)結束,但是一旦遇到較差的情況,導致樹的高度很高的時候,性能就不是很令人滿意了。另外,對于插入過程中保持二叉樹的完美動態(tài)平衡這一操作,代價又顯得太高了。所以,我們稍微放松點完美平衡的要求,將操作的性能保持在logN內(nèi)完成即可。
下面,在正式學習紅黑二叉樹之前,我們先學習另一種樹形結構“2-3查找樹(B-樹)”。這種樹形結構也是紅黑二叉樹形成的歷史由來,掌握它非常簡單,并且能更好的幫助我們理解紅黑二叉樹。
2-3查找樹
2-3查找樹只是高級數(shù)據(jù)結構B-樹中非常簡單的一種情況。之前二叉樹中了解到,操作的復雜度與樹的高度成正相關。那么在如何維持樹的高度和平衡性,我們這里需要一些靈活性。
- 我們允許二叉樹中的一個結點保存多個鍵值
- 我們將標準二叉樹中的結點(1個鍵值)稱之為2-結點。
- 我們將含有2個鍵值和3個連接線(連接子結點)的稱之為3-結點。
一個2-3查找樹或是一個空樹,或是由下列結點組成:
- 2-結點:含有1個鍵與2條連接線的結點,其與標準二叉樹的結點性質(zhì)一樣,x.left < x < x. right。
- 3-結點:含有2個鍵與3條連接線的結點,其中左連接線都小于2個鍵,中連接線在2個鍵之間,右連接線都大于2個鍵。
- 一個完美的2-3平衡樹,它的所有空鏈接到根節(jié)點的距離應該是相同的。
2-3樹 查找 get:
設查找的鍵為key,查找的節(jié)點為x。與二叉樹相似:
- 若未命中,則返回null。
- 若 key < x.key , 在x的左子樹中尋找。
- 若 key > x.key , 在x的右子樹中尋找。
- 若結點x是2-結點,則與二叉樹一樣。
-
若結點是3-結點,則比較key與2個鍵的大小,看是直接命中還是在哪個連接線繼續(xù)尋找。
2-3樹查找
2-3樹 插入 put:
- 先進行一次未命中查找。如果命中,則替換value。如果沒有命中,則創(chuàng)建新結點掛在樹的底部。
向2-結點 h 中插入
直接掛在樹的底部會有些問題,破壞了樹的平衡性,我們想要保持插入也是平衡的,所以選擇結點上變化。那么分兩種情況:
-
如果h無子結點或h的子結點是2-結點,則與二叉樹一樣插入,2-結點變3-結點。
結點為2結點插入 - 如果h的子結點是3-結點,則3-結點變成4-結點了。(見下)
向3-結點 h 中插入
- 如果向只含有一個3-結點的樹插入,則3-結點變4-結點。這個情況則需要稍微變形處理下,可以將4-結點轉化為2個2-結點。
規(guī)定:4-結點中有3個鍵,4個連接線。中間的鍵可以提到上面,成為左右兩個鍵的父結點,此時樹的高度+1。由于變換的是根節(jié)點,依然維持了完美平衡性。
理解樹的變化是非常重要的,這關乎2-3樹如何成長。
-
如果向父結點為2-結點的3-結點 h插入(接上),則3-結點變4-結點。變換后,提上來的中間鍵與父結點合并,注意,此時仍要保持父結點內(nèi)鍵的大小順序。
父結點為3結點插入 -
如果向父結點為3-結點的3-結點 h插入,則子結點變?yōu)?-結點,變換后父結點也變?yōu)?-結點,則再次向上變換,直至沒有4-結點。
父結點為3-結點 -
如果插入的路徑向上全是3-結點,則我們的根節(jié)點變?yōu)榕R時的4-結點,此時我們可以向情況1.中所描述的處理,分解4-結點,樹的高度+1。
根節(jié)點為4-結點
請注意,2-3樹中任何4-節(jié)點的分解與轉化,均是局部操作,不會影響到樹的平衡性!
只要符合相應的形態(tài),變換即可進行。
2-3樹 性能:
2-3樹的高度必然在 (logN)/(log3) ~ logN 之間,故增刪查改的性能也在 O[ (logN)/(log3) ]~ O( logN ) 之間。
OK,2-3樹的介紹就到這里,代碼沒寫,因為我們的重點不是2-3樹,而是紅黑樹。現(xiàn)在我們離紅黑樹只差一句話的距離了。當然,在告別2-3樹之前,我們再看一眼2-3樹的完美身影 :) 。
(喝口水,休息一下)
紅黑二叉查找樹
首先,讓我們來去掉3-結點,就從2-3樹變成了紅黑樹。
本質(zhì)上,紅黑樹就是利用標準二叉樹中結點的額外信息表示2-3樹。所以,紅黑樹是一個二叉樹,或者說,紅黑樹是二叉樹的子類(不太準確的說法)。
去掉3-結點
上面說的結點的額外信息,其實就是指的(結點)鏈接的顏色。在標準二叉樹中,所有鏈接的顏色都是黑色,而在紅黑樹中,我們規(guī)定有紅色和黑色兩種鏈接。
注:不少教科書上都是用結點為紅/黑來定義紅黑樹,這里采用的是另一種定義規(guī)則,并不沖突。
- 黑鏈接是2-3樹中的普通鏈接,也是標準二叉樹中的鏈接。
- 紅鏈接是將兩個2-結點,變?yōu)橐粋€3-結點。
所以,我們只需要將3-結點還原成由紅鏈接連接的2個2-結點,即可去掉3-結點!而對于3-結點的子結點,則分配給兩個2-結點來完成。
等價定義
我們這里為了方便討論情況,不影響性質(zhì)的前提下,定義以下規(guī)則:
一個靜態(tài)的紅黑樹:(不需要再變換的、操作完成后的)
- 所有紅鏈接均為左鏈接
- 沒有任何一個結點與兩個紅鏈接相連
- 該樹是完美黑色平衡的,即任意空結點到根結點的路徑上黑鏈接數(shù)量相同!(黑鏈數(shù)目既是樹的高度)
肯定有小朋友對上面三條規(guī)則不滿的,這邊我來解釋一下:
- 前提是變換好的紅黑樹。
- 紅鏈接為右鏈接可不可以,我說可以,但是為了統(tǒng)一和美觀,以及減少討論的情況和復雜度,我們統(tǒng)一規(guī)定左邊。
- 為啥不能兩個紅鏈接相連?兩個紅鏈接相連其實就產(chǎn)生了4-結點,這在2-3樹中是會被轉化的,而我們紅黑樹是由2-3樹演變而來,所以應該通過相應的轉化變換解決這個問題,后面會有介紹方法,且聽我細細說來。
- 因為完美平衡二叉樹是黑色平衡的,所以我們紅黑也是完美黑色平衡的。或者等會你就能明白,為什么我們不計入紅色為樹的高度了,看下面。
- 注意這里紅鏈接橫置后的連接順序,沒有改變
我們將紅黑樹中,所有的紅鏈接都橫過來畫(或者說,將2-3樹中所有3-結點內(nèi)部都加上紅鏈接),即顯示了為何紅黑樹也是2-3樹,并且紅黑樹的高度就是2-3樹的高度,也就是空結點到根節(jié)點路徑上普通黑鏈接的數(shù)量。
- 紅黑樹既是二叉樹,也是2-3樹。
所以我們在紅黑樹中可以使用2-3樹高效的插入算法,以及二叉樹中高效的查找算法。
結點構成及顏色表示
boolean RED = true
boolean BLACK = false
我們在二叉樹的基礎上,加入了結點的顏色來表示指向當前結點的鏈接顏色。
private class Node {
private Node left;
private Node right;
private boolean color = BLACK;
private Key key;
private Value value;
private int size;
public Node(Key key, Value value, int size, boolean color) {
this.key = key;
this.value = value;
this.size = size;
this.color = color;
}
}
private boolean isRed(Node h) {
if (h == null) return false;
return h.color;
}
旋轉 rotate:
為了將我們所有的紅鏈接都能自由的左右旋轉,于是有以下兩個方法:假設結點為 h
向左旋轉 rotateLeft:
左旋可以將紅鏈從右邊移至左邊。
這個方法多數(shù)用于刪除操作和調(diào)整樹的平衡,以滿足我們的等價定義。
當然從視覺上來看就是h 的右子結點不動,h 自己旋轉至左下方,同時,交換子結點和紅鏈。
- 旋轉后需要重置父結點的鏈接。
- 旋轉后需要調(diào)整結點的大小,因為結點的高度變化了。
private Node rotateLeft(Node h) {
if (h == null) throw new IllegalArgumentException();
Node x = h.right;
h.right = x.left;
x.left = h;
x.color = x.left.color;//x.color = h.color
x.left.color = RED;
x.size = h.size;
h.size = 1 + size(h.left) + size(h.right);
return x;
}
向右旋轉 rotateRight:
右旋可以將紅鏈從左邊移至右邊。
這個方法多數(shù)臨時用于刪除操作中,后面我們會介紹刪除操作。
操作與左旋完全相反,不再贅述。
private Node rotateRight(Node h) {
if (h == null) throw new IllegalArgumentException();
Node x = h.left;
h.left = x.right;
x.right = h;
x.color = x.right.color;//x.color = h.color
x.right.color = RED;
x.size = h.size;
h.size = 1 + size(h.left) + size(h.right);
return x;
}
插入 put:
紅黑樹的插入是為數(shù)不多的比較復雜的實現(xiàn)之一(還有就是刪除),聯(lián)系2-3樹,這里我們先分情況討論:
- 規(guī)定:插入的新結點的color都是RED
這個只要想想2-3樹中插入就明白了。
向單個2-結點中插入新鍵:
如果一個紅黑樹只有一個2-結點,即根節(jié)點root,那么插入的時候會有三種情況:
- key == root,替換root 的值。
- key < root,則插入左子結點,此時不需要調(diào)整。
- key > root,則插入右子結點,此時為了滿足等價定義,rotateLeft一下就好了。
向樹底部的2-結點插入新鍵:
和上面三個情況差不多,只要保證我們的等價定義,以及二叉樹的基本定義(x.left < x < x.right),調(diào)整并更新父鏈接就好啦。
向一個雙鍵樹(3-結點)插入新鍵:
分以下三種情況:
1. 新插入的鍵最大:
插入右子結點x.right。則形成4-結點,此時需要進行變換。這里介紹一個flipColors
方法,用來分解4-結點:
- 一個鍵h 的左右子結點都是紅色,h 為黑色。
- 將h 的左右子結點都變?yōu)楹谏琱 變?yōu)榧t色。
- 此時樹的高度+1。
-
調(diào)整后的h 可以根據(jù)其他情況進行繼續(xù)變換。
變換前
private Node flipColors(Node h) {
if (h == null || h.left == null || h.right == null) throw new IllegalArgumentException();
h.color = !h.color;
h.left.color = !h.left.color;
h.right.color = !h.right.color;
return h;
}
2. 新插入的鍵最小:
插入左子結點的子結點 x.left.left,則此時形成連續(xù)的左鏈接都是紅色(A 插入 B-C ,形成 A-B-C)。
這時候需要我們先對C進行右旋 rotateRight(C),然后就形成了上面1.的情況。
3. 新插入的鍵大小在兩個鍵之間:
插入左子結點的右結點 x.left.right,這種情況最為復雜。例如B插入A-C
此時需要先對A進行左旋 rotateLeft(A),然后就形成了上面2.的情況。
- 由此我們可以看出,插入總是在“ 情況3 -> 情況2 -> 情況1 ”之間轉化。
- 請確保完全理解了上述中樹的變化,再繼續(xù)看下去。
根節(jié)點總是黑色
當我們進行插入后,根節(jié)點有時候會變?yōu)榧t色,此時當根節(jié)點由紅色轉為黑色時,樹的高度+1。
代碼實現(xiàn):
插入的實現(xiàn),可以參考2-3樹,這里只是在插入完成后,重新調(diào)整樹的結構以達到完美平衡,滿足等價定義。
public void put(Key key, Value value) {
root = put(root, key, value);
root.color = BLACK;//根節(jié)點總是黑色
}
private Node put(Node h, Key key, Value value) {
if (key == null) throw new IllegalArgumentException();
//創(chuàng)建新子結點,顏色為紅色
if (h == null) return new Node(key, value, 1, RED);
int comp = key.compareTo(h.key);
if (comp > 0) h.right = put(h.right, key, value);
else if (comp < 0) h.left = put(h.left, key, value);
else h.value = value;
//插入完成后重新調(diào)整樹以滿足等價定義
if (isRed(h.right) && !isRed(h.left)) h = rotateLeft(h);//情況3 -> 情況2
if (isRed(h.left) && isRed(h.left.left)) h = rotateRight(h);//情況2
if (isRed(h.left) && isRed(h.right)) h = flipColors(h);//情況1
//調(diào)整完成后需要重新計算樹的高度
h.size = size(h.left) + size(h.right) + 1;
return h;
}
刪除 delete:
刪除任意一個鍵,是紅黑樹中最為復雜的算法。而在刪除任意結點的時候,我們先想想二叉樹中是如何進行刪除操作的。
當x不是樹的末結點,若直接刪除會造成空缺,樹不連續(xù),我們需要變換一下。
則當x不是樹的末結點且x有兩個子結點時:
刪除x后,需要尋找x的右子結點中最小的(或者x的左子結點中最大的)來代替x的位置,保證二叉樹的性質(zhì)“每個結點的鍵都大于任意左子節(jié)點而小于任意右子節(jié)點”。
故我們可以這樣理解,最復雜的情況下,假設x不是樹的末結點且x有兩個子結點:
如果我們將x先和右結點最小的(或者左結點最大的)進行交換,然后就將x變?yōu)闃涞哪┙Y點,此時即可直接刪除。
所以,刪除的操作可以簡化為 刪除最小值 或 刪除最大值 的操作。
刪除最小值 deleteMin:
由二叉樹性質(zhì)可知,最小鍵一定是在樹的最左邊。并且由等價定義可知,最小值一定是在樹的末端最左邊。
當我們進行刪除末結點的時候,讓我們先回歸2-3樹,并且稍微允許臨時4-結點的存在。
- 有時候為了讓父結點變?yōu)榧t色,需要臨時合并為4-結點。
- 如果刪除的末結點是3-結點,則直接刪除即可。
- 如果刪除的末結點是2-結點,直接刪除會破壞樹的完美平衡。所以此時我們需要進行變換。
分以下幾種情況:
- 如果此時要刪除的結點,它的父結點、兄弟結點(父結點的右結點)都是2-結點,則可以將這三個結點
flipColors
,還原為一個臨時的4-結點。這樣在我們刪除后,依然是3-結點,不會破壞完美平衡,高度-1。
情況1.父結點、兄弟結點都是2-結點
如果它的父結點不是紅色,則想辦法變?yōu)榧t色。
-
如果此時要刪除的結點,它的父結點是2-結點,而它的兄弟結點不是2-結點,則此時需要向兄弟結點借一個結點過來,形成3-結點。
從兄弟結點中借一個結點,e可有可無 -
如果此時要刪除的結點,它的父結點不是2-結點,那么從父結點借一個結點過來,形成3-結點。
兩種情況,分別是兄弟結點是否為2-結點
若兄弟結點不是2-結點,則從父結點借一個結點后,兄弟結點可以補給父結點一個最小的結點,保持樹的完美平衡性。
若兄弟結點是2-結點,則父結點中最小的結點向下合并,形成臨時4-結點。
待刪除完成后,需要自下而上重新整理樹的結構,將所有的臨時4-結點分解,從而滿足我們的等價定義。
代碼實現(xiàn):
沿著樹的最左路徑,一路向下的過程中,當遇到2-結點,實現(xiàn)一些變換
moveRedLeft()
,從而保證當前結點不是2-結點。
這里也就是想辦法變紅色,從而能夠刪除鍵而不破壞樹的完美平衡。
private Node moveRedLeft(Node h) {
//假設h為紅色,h.left 和h.left.left都是黑色(h.left和h.left.left都是2-結點)
//將h.left 或h.left 的子結點之一變紅(想辦法變紅,變?yōu)?-結點)
flipColors(h);//這個方法可以在拆分4-結點和組合4-結點之間變換。
if (isRed(h.right.left)) {
//兄弟結點為非2-結點,此時經(jīng)旋轉,將紅鍵從右往左傳遞。見下圖。
h.right = rotateRight(h.right);
h = rotateLeft(h);
}
return h;
}
public void deleteMin() {
if (isEmpty()) throw new NoSuchElementException();
if (!isRed(root.left) && !isRed(root.right))
root.color = RED;
root = deleteMin(root);
if (!isEmpty()) root.color = BLACK;
}
private Node deleteMin(Node h) {
if (h.left == null) return null;
if (!isRed(h.left) && !isRed(h.left.left))
h = moveRedLeft(h);
h.left = deleteMin(h.left);
return balance(h);//自下而上重新整理樹的結構。
}
一開始,如果根節(jié)點的兩個子鍵都沒有紅鍵,則需要我們臨時將根節(jié)點變紅,從而可以拆分出紅鍵。
而在刪除完成最后,如果樹還有結點,則要將根節(jié)點還原為黑色,以滿足根節(jié)點總是黑色。
其中balance()
方法與之前我們進行put
后的操作類似,不再贅述。
private Node balance(Node h) {
if (!isRed(h.left) && isRed(h.right)) h = rotateLeft(h);
if (isRed(h.left) && isRed(h.left.left)) h = rotateRight(h);
if (isRed(h.left) && isRed(h.right)) flipColors(h);
h.size = size(h.left) + size(h.right) + 1;
return h;
}
刪除最大值 deleteMax:
由于我們的紅鏈都是左鏈,所以這里與deleteMin
稍有不同。
沿著樹的最右路徑,一路向下的過程中,當遇到2-結點,實現(xiàn)變換
moveRedRight()
,從而保證當前結點不是2-結點。
private Node moveRedRight(Node h) {
//假設h為紅色,h.right 和h.right.left都是黑色(h.right和h.right.left都是2-結點)
//將h.right 或h.right 的子結點之一變紅(想辦法變紅,變?yōu)?-結點)
flipColors(h);
if (!isRed(h.left.left))//兩個子結點均為2-結點
h = rotateRight(h);
return h;
}
如圖示不難理解,由于我們刪除的是最大值,所以鍵一定在右子結點中,故要將紅鍵從左往右傳遞。其與操作與deleteMin差不多,就不贅述了。但是要記住,我們這些局部的旋轉和移動都不會改變樹的組成(見2-3樹性質(zhì))。
public void deleteMax() {
if (isEmpty()) throw new NoSuchElementException();
if (!isRed(root.left) && !isRed(root.right))
root.color = RED;
root = deleteMax(root);
if (!isEmpty()) root.color = BLACK;
}
private Node deleteMax(Node h) {
if (isRed(h.left))//由于我們刪除的鍵總在右子結點中
h = rotateRight(h);
if (h.right == null) return null;
if (!isRed(h.right) && !isRed(h.right.left))//h.right是個2-結點
h = moveRedRight(h);//此時將紅鍵從左向右傳遞
h.right = deleteMax(h.right);
return balance(h);
}
刪除任意結點的實現(xiàn):
刪除任意結點就是轉為為刪除最小值和刪除最大值的操作。
public void delete(Key key) {
if (key == null) throw new IllegalArgumentException();
if (isEmpty()) throw new NoSuchElementException();
if (!isRed(root.left) && !isRed(root.right))
root.color = RED;
root = delete(root, key);
if (!isEmpty()) root.color = BLACK;
}
上面這段代碼就不解釋了,和之前deleteMin
的原因一樣。我們主要看下面的具體實現(xiàn)。
private Node delete(Node h, Key key) {
if (key.compareTo(h.key) < 0) {
if (!isRed(h.left) && !isRed(h.left.left))
h = moveRedLeft(h);
h.left = delete(h.left, key);
} else {
if (isRed(h.left))
h = rotateRight(h);
if (key.compareTo(h.key) == 0 && h.right == null)
return null;
if (!isRed(h.right) && !isRed(h.right.left))
h = moveRedRight(h);
if (key.compareTo(h.key) == 0) {
h.value = get(h.right, min(h.right).key);
h.key = min(h.right).key;
h.right = deleteMin(h.right);
} else h.right = delete(h.right, key);
}
return balance(h);
}
- 如果尋找的鍵在左邊,則消除左邊路徑上的2-結點,參考
deleteMin
。 - 如果尋找的鍵在右邊,則消除右邊路徑上的2-結點,參考
deleteMax
。 - 我們在這里主要采用的是尋找右子鍵中最小值來交換自己的位置,此時待刪除結點就從樹的中間部分被交換到了樹的末端,從而刪除右子鍵中的最小值,簡化為刪除最小值的問題。
-
最后也要記得自下而上整理整個樹的結構,滿足我們的等價定義。
刪除B的簡易示意圖
性能:
- 無論我們?nèi)绾尾迦腈I值,紅黑樹都是幾乎完美平衡的。
這個可以通過我們2-3樹的性質(zhì)得到。
- 大小為N的紅黑樹的高度不會超過2logN。
所以其操作復雜度始終維持在 ~O(logN)。一般來說,想構建2logN高度的紅黑樹比較困難,需要最左邊一條路徑均是3-結點,而其他的子結點都是2-結點才可以。我們在一般數(shù)據(jù)里很少能遇到。
- 大小為N的紅黑樹,根節(jié)點到任意子結點的平均路徑長度為 ~1.00logN
這個性質(zhì)很容易得到。而紅黑樹相對于二叉樹而言,我們提高了40%以上的性能,能夠使任何操作都在 ~O(logN) 內(nèi)完成。
結束語:
復雜的紅黑樹終于了解完了,可以看出,理解2-3樹對于掌握紅黑樹的原理至關重要。在樹的生長變換中,局部變換并不會改變整體的結構這一點非常重要,也是紅黑樹的靈魂。
在下一篇文章,將會帶來Java中另一個最常用的數(shù)據(jù)結構——散列表。
謝謝觀看。
參考文獻:《算法導論》 《Algorithms, 4th Edition》