前言
本篇文章我將向大家介紹最負盛名的自平衡二叉查詢樹——紅黑樹。
紅黑樹由魯道夫.貝爾——一位慕尼黑科技大學資訊科技教授發明。1978 年,在羅伯特.塞奇威克發表的一篇論文中為這種資料結構正式命名為紅黑樹,並提供了完整的複雜度證明。
紅黑樹雖然繁瑣,但是它在實踐中有著非常高的效率,因為紅黑樹是自平衡的(紅黑樹實現的其實是一種偽平衡,在後文中我們會提到),所以它不會出現像二分搜尋樹在極端的情況下退化為連結串列的這種情況,可以在 $O(logN)$ 的時間做元素的查詢,插入以及刪除操作。因為其優秀的效能,在一些底層資料結構設計中被廣泛使用——譬如 Java 語言中的 TreeMap 與 TreeSet 就是紅黑樹。
接下來,我們就一起探索,紅黑樹究竟是怎樣的一種資料結構。
紅黑樹與 2-3 樹
在介紹大名鼎鼎的紅黑樹之前,我們先來看一下 2-3 樹這種資料結構。提前瞭解 2-3 樹對我們接下來學習紅黑樹有很大的幫助,因為紅黑樹與 2-3 樹的原理是相通的,並且 2-3 樹要比複雜的紅黑樹更容易理解。
什麼是 2-3 樹呢?首先,2-3 樹是一種絕對平衡的多叉樹。
我們都知道平衡二叉樹的概念——平衡二叉樹是一棵二分搜尋樹。它或者是一顆空樹,或它的左子樹與右子樹的高度之差(左子樹的高度 - 右子樹的高度,其別名叫平衡因子:balance factor)的絕對值不超過 1,且一棵平衡二叉樹的左右子樹均是一棵平衡二叉樹。
如下圖所示,該樹就是一棵平衡二叉樹:
而所謂的絕對平衡則比平衡二叉樹裡面的平衡性要更加嚴格,絕對平衡的定義是:對於樹中任意一個節點,左右子樹高度相同。
如上圖所示,該示意圖表示一棵 2-3 樹。這棵樹滿足了絕對平衡的定義。
除此之外, 2-3 樹並不是一棵標準的二叉樹,而是一棵多叉樹。在上圖的 2-3 樹中,我們可以看到,有的節點存放了一個元素,有的節點存放了兩個元素。存放了一個元素的節點我們稱之為 2-節點,而存放了兩個元素的節點我們則稱之為 3-節點,2-3 樹中只具有這兩種型別的節點,這也是 2-3 樹其名字的由來。
接下來,我將演示向 2-3 樹插入元素的操作,讓我們一起來看一下 2-3 樹究竟是通過什麼神奇的操作來維持整棵樹是絕對平衡的。
首先,我們向一棵空的 2-3 樹中插入一個節點 “42”,然後再插入節點 “37”:
向 2-3 樹插入節點的原則與二分搜尋樹是一樣的,不過它還會遵循另外一個原則,那就是:新的節點永遠不會插入到一個空的位置上。
如果這棵樹是二分搜尋樹,那麼節點 “37” 將會插入到節點 “42” 的左孩子的位置上,不過這樣一來,就破壞了 2-3 樹的絕對平衡性。所以,節點 “37” 並不會插入到一個空的位置,而是與節點 “42” 融合成為一個新的 3-節點:
接下來,我們向 2-3 樹中插入元素 “12”:
依據我們的原則“新的節點永遠不會插入到一個空的位置上”,節點 “12” 將會和當前的 3-節點融合成為一個 “4-節點”:
然後,這個 4-節點會分裂成 3 個 2-節點,來維持絕對平衡的狀態並保證遵循 2-3 樹的定義:
我們繼續向當前的 2-3 樹中插入元素 ”18“:
向當前的 2-3 樹中插入元素 ”6“:
向當前的 2-3 樹中插入元素 ”11“:
向當前的 2-3 樹中插入元素 ”5“:
通過上面幾張模擬圖的演示,想必大家已經能夠初步地掌握 2-3 樹的特點以及在向 2-3 樹新增元素時,2-3 樹如何維護絕對平衡。
我們之前說過,紅黑樹和 2-3 樹的本質是相同的,如果大家理解了 2-3 樹,那麼接下來我要介紹的紅黑樹也就不難理解了。2-3 樹中有兩種節點:2-節點與 3-節點,2-節點儲存一個元素,3-節點儲存兩個元素。而紅黑樹則是一種“二叉樹形式的 2-3 樹”,它使用單個黑色的節點來表示 2-節點,使用 “紅-黑” 這兩個節點表示一個三節點:
這樣一來,紅黑樹中所有的紅色節點必然為左孩子節點,所以我們也將這種紅黑樹稱為“左傾紅黑樹”(Left-Leaning Red-Black Tree)。
譬如這樣的一棵 2-3 樹:
轉換為紅黑樹之後就是這個樣子:
這也就是為什麼我們會說紅黑樹與 2-3 樹是等價的,對於任意的一棵 2-3 樹都可以通過這種方式轉換為一棵紅黑樹。
紅黑樹的實現
紅黑樹的基本性質
紅黑樹具有以下五條基本性質:
1. 每個節點或者為紅色,或者為黑色。
這一點沒什麼可說的,畢竟叫做紅黑樹嘛,節點非紅即黑。
2. 根節點的顏色一定是黑色。
我們可以類比考慮一下 2-3 樹,2-3 樹的節點不是 2-節點就是 3-節點,而無論是哪一種節點作為紅黑樹的根節點,都會使得根節點的顏色必然是黑色的。
3. 每一個葉子節點(最後的空節點)一定是黑色的。
如果紅黑樹為空,那麼根節點也為空,這一點和第二點可以相互對應起來,因為根節點的顏色必然是黑色的。
4. 如果一個節點是紅色的,那麼它的孩子節點都是黑色的。
這一點大家同樣可以類比下 2-3 樹,紅黑樹中紅色節點的左右節點不是 2-節點就是 3-節點,無論是哪一種節點,與紅色節點相連的必然都是黑色節點。
5. 從任意一個節點到葉子節點,經過的黑色節點的數量一樣多。
上面這棵 2-3 樹轉換為紅黑樹之後,是這樣的:
從這張圖裡,我們可以清晰地看出,從任意一個節點到葉子節點,經過黑色節點的數量是一樣多的。
這也就是我們所說的——紅黑樹是一棵保持“黑平衡”的二叉樹(黑色節點是絕對平衡的),而嚴格意義上來講紅黑樹並不是一棵平衡二叉樹,但是因為紅黑樹具有黑平衡的特性,所以可以認為紅黑樹保持的是一種近似的平衡。
從這裡我們也可以分析出向紅黑樹新增,查詢,刪除元素的時間複雜度。在最差的情況下,從根節點到葉子節點的路徑上,紅色節點與黑色節點交替出現,其複雜度為 $O(2logN)$,忽略掉常數項係數,我們可以得到紅黑樹的增刪改查的時間複雜度為 $O(logN)$。
向紅黑樹中新增元素的程式碼實現
接下來我們來看一下如何向紅黑樹中新增元素。
紅黑樹中節點的定義與實現並不難。因為紅黑樹本身是一棵二分搜尋樹,所以節點可以儲存元素值,並且內部有指向左,右孩子的指標,這些和二分搜尋樹中節點的實現是一樣的。不過,紅黑樹的節點有顏色之分,且顏色非紅即黑,我們就額外使用一個 boolean 變數表示節點的顏色:
public class Node<E> {
public E e;
public Node left;
public Node right;
public boolean color;
public Node(E e) {
this.e = e;
left = null;
right = null;
color = true; // true 表示紅色,false 表示黑色
}
}
可以看到,我們設定初始化一個節點的顏色為紅色。其實不難理解,在介紹 2-3 樹時我們就提到了,向 2-3 樹中新增節點的原則是新的節點永遠不會插入到一個空的位置上。而我們在新增節點時,首先要將待新增的節點與其他節點進行融合,如果初始化節點的顏色為紅色,那麼就表示新新增的節點與其他節點進行融合這一步驟。
在捋清向紅黑樹新增一個節點的邏輯之前,我們首先回顧一下向二分搜尋樹中插入一個節點的邏輯:
如果當前二分搜尋樹的根節點為空,那麼新插入的節點就會成為根節點。
如果當前二分搜尋樹的根節點不為空,就讓根作為當前比較的節點:新插入的節點與當前節點進行比較;如果值比當前節點小就要“向左走”,如果值比當前節點大就要“向右走”,然後讓下一層的節點繼續作為當前比較的節點,直至走到應該插入的位置。
下圖為在向當前的二分搜尋樹中新增節點“28”的流程:
Java 程式碼:
/**
* @param e 向二分搜尋樹中新增新的元素
*/
public void add(E e) {
root = add(e, root);
}
/**
* @param e 向二分搜尋樹中新插入的節點
* @param node 當前比較的節點
* @return 返回二分搜尋樹的根節點
*/
private Node add(E e, Node node) {
if (node == null) {
size++;
return new Node(e);
}
if (e.compareTo((E) node.e) < 0) {
node.left = add(e, node.left);
} else if (e.compareTo((E) node.e) > 0) {
node.right = add(e, node.right);
}
return node;
}
我們要滿足紅黑樹的根節點始終為黑色,所以每次插入完一個節點,我們都手動將根節點置為黑色:
public void add(E e) {
root = add(e, root);
root.color = BLACK; // false
}
基於向二分搜尋樹中插入一個節點的邏輯,我們向紅黑樹中新插入一個節點有以下幾種情況:
- 當前插入的節點與 2-節點進行融合
- 當前插入的節點與 3-節點進行融合
如果我們插入到一個 2-節點上,則具有以下兩種情況:
第一種情況為,插入的節點比當前的 2-節點的元素值小。
如果是這種情況,我們直接將新的節點與 2-節點融合為一個 3-節點即可:
第二種情況為,插入的節點比當前的 2-節點的元素值大。
將元素 “42” 插入到 “37” 的右孩子的位置上:
此時,當前並不滿足左傾紅黑樹的定義,我們需要以節點 ”37“ 進行一次 “左旋轉” 操作(左旋轉為逆時針,右旋轉為順時針),並將顏色重置:
為了不失一般性,我在節點 “37” 與節點 “42” 的孩子節點上分別新增了對應的子樹,通過上圖中虛擬碼的邏輯,我們看到“左旋轉”後的樹既保持了紅黑樹的特性,也保持了二分搜尋樹的特性。
左旋轉操作的 Java 程式碼如下:
/**
* 左旋轉:
*
* node x
* / \ / \
* T1 x =========> node T3
* / \ / \
* T2 T3 T1 T2
*
*/
private Node leftRotate(Node node){
Node x = node.right;
// 左旋轉
node.right = x.left;
x.left = node;
x.color = node.color;
node.color = RED;
return x;
}
在討論了新插入的節點與 2-節點進行融合的所有情況後,我們來看一下新插入的節點與 3-節點進行融合的情況。
如果我們插入到一個 3-節點上,則具有以下幾種情況:
第一種情況為,新的節點比 3-節點上的黑色節點的值還要大:
插入節點 “66” 後:
這一步操作相當於我們新插入的節點與 3-節點融合成為了一個 4-節點,對應到 2-3 樹上,我們的操作是這樣的:
接下來,我們需要將當前的 4-節點變成 3 個 2-節點,並讓 “42” 繼續向上融合:
所以,對應地,在紅黑樹中,我們應該將節點 “37” 與 “66” 變為黑色的 2-節點,將節點 “42” 置為繼續與上一個節點融合的紅色節點:
這一步驟的操作叫做 flipColors,顧名思義就是將這三個節點的顏色全部進行翻轉,其程式碼對應如下:
// flipColors
private void flipColors(Node node){
node.color = RED;
node.left.color = BLACK;
node.right.color = BLACK;
}
第二種情況為,新的節點比 3-節點上的紅色節點的值還要小:
節點 “12” 插入後:
此時,我們需要對節點 “42” 進行一次右旋轉:
再進行一次 flipColors:
右旋轉的 Java 程式碼如下:
/**
* 右旋轉:
*
* node x
* / \ / \
* x T2 ===========> y node
* / \ / \
* y T1 T1 T2
*
*/
private Node rightRotate(Node node) {
Node x = node.left;
// 右旋轉
node.left = x.right;
x.right = node;
x.color = node.color;
node.color = RED;
return x;
}
第三種情況為,新的節點比 3-節點上的紅色節點的值要大,但是比黑色節點的值要小:
節點 “40” 插入後:
此時,我們首先對節點 “37” 進行一次左旋轉,然後對 “42” 節點進行一次右旋轉,最後再執行一次 flipColors 操作即可:
總的來看,向紅黑樹中新增元素的操作有如下的邏輯:
向紅黑樹中新增節點的完整 Java 程式碼如下:
public class RBTree<E extends Comparable<E>> {
private static final boolean RED = true;
private static final boolean BLACK = false;
private Node root;
private int size;
public RBTree() {
root = null;
size = 0;
}
public int size() {
return size;
}
public boolean isEmpty() {
return size == 0;
}
/**
* 判斷節點 node 的顏色
*
* @param node
* @return
*/
public boolean isRed(Node node) {
if (node == null)
return false;
return node.color;
}
/**
* @return 返回紅黑樹的根節點
*/
public Node getRoot() {
return root;
}
/**
* 左旋轉:
*
* node x
* / \ / \
* T1 x ===========> node T3
* / \ / \
* T2 T3 T1 T2
*
*/
private Node leftRotate(Node node){
Node x = node.right;
// 左旋轉
node.right = x.left;
x.left = node;
x.color = node.color;
node.color = RED;
return x;
}
/**
* 右旋轉:
*
* node x
* / \ / \
* x T2 ===========> y node
* / \ / \
* y T1 T1 T2
*
*/
private Node rightRotate(Node node) {
Node x = node.left;
// 右旋轉
node.left = x.right;
x.right = node;
x.color = node.color;
node.color = RED;
return x;
}
// 顏色翻轉
private void flipColors(Node node){
node.color = RED;
node.left.color = BLACK;
node.right.color = BLACK;
}
/**
* @param e 向紅黑樹中新增新的元素
*/
public void add(E e) {
root = add(e, root);
root.color = BLACK;
}
/**
* @param e 向紅黑樹中新插入的節點
* @param node 當前比較的節點
* @return 返回紅黑樹的根節點
*/
private Node add(E e, Node node) {
if (node == null) {
size++;
return new Node(e); // 預設插入的是紅色節點
}
if (e.compareTo((E) node.e) < 0) {
node.left = add(e, node.left);
} else if (e.compareTo((E) node.e) > 0) {
node.right = add(e, node.right);
}
if(isRed(node.right) && !isRed(node.left))
node = leftRotate(node);
if(isRed(node.left) && isRed(node.left.left))
rightRotate(node);
if(isRed(node.left) && isRed(node.right))
flipColors(node);
return node;
}
}
那麼至此,向紅黑樹中插入一個節點的操作就已經介紹完畢了。
以上內容就是我對紅黑樹的學習總結。關於向紅黑樹中刪除節點的操作,在本篇文章中將不會涉及。
總結
今天我主要向大家分享了紅黑樹這種資料結構。
我們在介紹紅黑樹之前首先介紹了 2-3 樹。如果理解了 2-3 樹,那麼理解紅黑樹其實並不難。
然後我們介紹了紅黑樹的五大基本特性:
- 每個節點或者為紅色,或者為黑色
- 根節點的顏色一定是黑色
- 每一個葉子節點(最後的空節點)一定是黑色的
- 如果一個節點是紅色的,那麼它的孩子節點都是黑色的
- 從任意一個節點到葉子節點,經過的黑色節點的數量一樣多
並且從 2-3 樹的原理上對紅黑樹這 5 條基本特性進行了分析。
最後,我們介紹瞭如何向紅黑樹中新增一個節點及它的程式碼實現。
好啦,至此為止,這篇文章就到這裡了~歡迎大家關注我的公眾號,在這裡希望你可以收穫更多的知識,我們下一期再見!
本作品採用《CC 協議》,轉載必須註明作者和本文連結