基於樹實現的數據結構,具有兩個核心特征:
- 邏輯結構:數據元素之間具有層次關系;
- 數據運算:操作方法具有Log級的平均時間復雜度。
因此,樹在文件系統、編譯器、索引以及查找算法中有很廣的應用,本節將以樹-二叉樹-二叉搜索樹-自平衡二叉樹為線索,對樹及其擴展結構進行說明。
- 棧和隊列在遍歷樹結構時的作用
- 使用二叉樹對表達式進行解析
- 二叉搜索樹的排序特征
- 保證最壞情況時間復雜度
- Java中的紅黑樹實例
一、棧和隊列在遍歷樹結構時的作用
軟件是通過數據和算法實現對現實世界的抽象,具有層次關系的數據在現實世界中能找到很多實例,比如:
- 公司組織架構:董事長-CXO-總監-經理-主管-員工;
- 中國行政區域划分:中國-省-市(縣)-街道(小區)-門牌號;
- 汽車產品庫:車-品牌-車系-配置。
因此,它們均可抽象為樹,例如,公司組織架構可以用下圖來描述:
如果對整個公司的人員進行梳理,那么就涉及到對上述架構樹進行遍歷。樹的遍歷指的是按照某種規則,不重復地訪問樹的所有節點的過程。由於樹並非線性數據結構(比如上節所描述的線性表),因此其遍歷根據訪問節點的順序,可划分為不同的方式:深度優先遍歷和廣度優先遍歷。兩者的區別在於:
- 深度優先遍歷會沿着樹的深度遍歷樹的節點,盡可能深的搜索樹的分支;
- 廣度優先遍歷從根節點開始,沿着樹的寬度遍歷樹的節點。
1.1 棧與深度優先遍歷
深度優先遍歷可進一步按照根節點與其左右子節點的訪問先后順序划分為前序遍歷、中序遍歷和后序遍歷。根節點放在左節點的左邊,稱為前序遍歷;根節點放在左節點和右節點的中間,稱為中序遍歷;根節點放在右節點的右邊,稱為后序遍歷。樹的定義通常采用遞歸的方式,即其節點域中包含對自身類的引用,因此樹的遍歷也常用遞歸方式來實現,下面通過偽代碼對上述遍歷方式進行說明。
private void traversal(TreeNode root) {
// 終止條件
if (root == null) {
return;
}
// 1. 前序遍歷
// print(root.getName());
if (root.getlChild() != null) {
traversal(root.getlChild());
}
// 2. 中序遍歷
// print(root.getName());
if (root.getrChild() != null) {
traversal(root.getrChild());
}
// 3. 后序遍歷
// print(root.getName());
}
可見,三種遍歷方式的差別僅在於對根節點與左右子節點的訪問先后順序。針對上述組織架構圖,三種遍歷方式的結果分別為(只有一個子節點時,默認為左節點):
- 前序遍歷:A0-B0-C0-D0-E0-E1-C1-D1-E2-D2-E3-B1-C2-D3-E4-E5-C3-D4-E6
- 中序遍歷:E0-D0-E1-C0-B0-E2-D1-C1-E3-D2-A0-E4-D3-E5-C2-B1-E6-D4-C3
- 后序遍歷:E0-E1-D0-C0-E2-D1-E3-D2-C1-B0-E4-E5-D3-C2-E6-D4-C3-B1-A0
遞歸的本質是通過調用棧實現了局部變量的存儲,而通過在代碼中實例化棧當然也能實現該功能,所以,深度優先遍歷也可采用非遞歸的實現,下面基於LinkedList的棧特性來實現樹的前序遍歷:
private void traversalWithStack(TreeNode root) {
// 1. 初始化棧並將根節點壓棧
Deque<TreeNode> stack = new LinkedList<>();
stack.push(root);
// 2. 循環遍歷直到棧為空
while (!stack.isEmpty()) {
// 3. 取出棧頂節點,並對其域進行訪問
TreeNode head = stack.pop();
print(head.getName());
// 4. 判斷右子節點、左子節點是否為空,將其入隊
if (head.getrChild() != null) {
stack.push(head.getrChild());
}
if (head.getlChild() != null) {
stack.push(head.getlChild());
}
}
}
可以看出,遞歸與非遞歸的實現方式非常類似,只是前者采用方法的調用棧保存本層方法的局部變量,后者采用代碼棧實現(上節已講到LinkedList實現了Deque接口,其包含了棧和隊列的常用操作方法)變量的保存而已。需要注意的是,后者需要先將右子節點進棧再將左子節點進棧。中序遍歷與后序遍歷也可通過非遞歸的方式來實現,讀者可自行理解。
1.2 隊列與廣度優先遍歷
樹的廣度優先遍歷也稱為按層次遍歷,即從根節點開始,一層一層的訪問。實現的核心是通過隊列的入隊和出隊操作,具體如下:
private void layerTraver(TreeNode root) {
// 1. 初始化隊列並將根節點入隊
Queue<TreeNode> queue = new LinkedList<>();
queue.offer(root);
// 2. 循環遍歷隊列直到隊列為空
while (!queue.isEmpty()) {
// 3. 取出頭結點,並對其域進行訪問
TreeNode head = queue.poll();
print(head.getName());
// 4. 判斷左右子節點是否為空,將其入隊
if (head.getlChild() != null) {
queue.offer(head.getlChild());
}
if (head.getrChild() != null) {
queue.offer(head.getrChild());
}
}
}
因此,采用廣度優先遍歷上述架構圖的順序為:A0-B0-B1-C0-C1-C2-C3-D0-D1-D2-D3-D4-E0-E1-E2-E3-E4-E5-E6。
由於樹的非線性結構,從給定的某個節點出發,有多個可以前往的下一個節點,所以在順序計算的情況下,只能推遲對某些節點的訪問——即以某種方式保存起來以便稍后再訪問。常見的做法是采用棧(LIFO)或隊列(FIFO)。在深度優先遍歷中,采用了遞歸的形式來說明三種遍歷方式的區別,其實質可以理解為通過調用棧來實現延遲節點的保存,非遞歸的實現也說明了棧對延遲節點的存儲作用;廣度優先遍歷則采用了隊列來保存這些延遲節點。
二、使用二叉樹對表達式進行解析
二叉樹是編譯器設計領域重要的數據結構之一,比如語法分析過程中使用的語法樹。表達式是編程中最常見的的語法形式,比如定義一個int變量:int x = 3+6*7-(5+9)/2+4,我們能很輕松的算出x=42,可是編譯器是如何計算3+6*7-(5+9)/2+4的呢?
首先,3+6*7-(5+9)/2+4是一個中綴表達式,相應的前綴表達式和后綴表達式分別為(上節中描述了如何通過棧將中綴表達式轉換為后綴表達式以及后綴表達式的計算):
- 前綴表達式:+3-*67+/+5924
- 后綴表達式:367*59+2/4+-+
實際上,使用二叉樹對上述表達式進行解析,就可以得到葉節點為操作數,其他節點為操作符的表達式樹。前綴、中綴和后綴表達式分別對應了表達式樹的前序、中序和后序遍歷。在實際情況中,前綴表達式使用較少,中綴表達式符合人的理解習慣,但對計算機來講,運算規則復雜,不能從左到右順序進行,不利於計算機處理,而后綴表達式則更加適合。
下面演示如何通過后綴表達式來構建表達式樹,這里需要用到棧和二叉樹兩種數據結構,從左到右依次讀取后綴表達式367*59+2/4+-+,如果是數字則直接將其壓入棧中;如果是操作符,則從棧中彈出兩個操作數T1和T2,用該操作符(根節點)和T1(左子樹)、T2(右子樹)組成一個二叉樹,然后將該二叉樹壓入棧中。
-
將3、6、7依次壓入棧中;
-
乘號入棧,從棧中取出6和7,組成二叉樹,並將該樹壓入棧中;
-
將5、9依次壓入棧中;
-
加號入棧,從棧中取出5和9,組成二叉樹,並將該樹壓入棧中,其次將2入棧;
-
除號入棧,從棧中取出二叉樹(5+9)和2,組成新二叉樹,並將該樹壓入棧中,其次將4入棧;
-
加號入棧,從棧中取出二叉樹((5+9)/2)和4,組成新二叉樹,並將該樹壓入棧中;
-
減號入棧,從棧中取出二叉樹(6*7)和((5+9)/2+4),組成新二叉樹,並將該樹壓入棧中;
- 加號入棧,從棧中取出3和二叉樹(6*7-(5+9)/2+4),組成新二叉樹,並將該樹壓入棧中;
表達式樹是將我們原來可以直接由代碼編寫的邏輯以表達式的方式存儲在樹狀的結構里,從而可以在運行時去解析這個樹,然后執行,實現動態的編輯和執行代碼。
三、二叉搜索樹的排序特征
相比於普通二叉樹,二叉搜索樹的關鍵特征是:
- 若任意節點的左子樹不空,則左子樹上所有節點的值均小於它的根節點的值;
- 若任意節點的右子樹不空,則右子樹上所有節點的值均大於它的根節點的值;
- 任意節點的左、右子樹也分別為二叉搜索樹;
- 沒有鍵值相等的節點。
上述特征意味着二叉搜索樹中所有的項都要能夠排序,在Java中,可以用Comparable接口來表示這種性質。正是因為這種排序特征,使其查找、插入的時間復雜度較低。
3.1 查找和插入
查找過程從根節點開始,比較待查找節點的值與根節點值的大小,如果小於,就遞歸查找左子樹;如果大於,就遞歸查找右子樹;如果等於,則查找過程結束。比如在下列二叉樹中搜索32的過程如下:
- 32 > 23,查找右子樹;
- 32 < 35,查找左子樹;
- 32 > 30,查找右子樹;
- 32 == 32,查找過程結束。
把這個過程翻譯成代碼如下:
private boolean searchBST(T t, BinaryNode<T> root) {
// 1.如果被比較節點為空,說明沒有找到匹配項,直接返回false
if (root == null) {
return false;
}
// 2.比較節點值的大小
int compareResult = compare(t, root.getElement());
if (compareResult < 0) {
// 3.如果小於,就遞歸查找左子樹
return searchBST(t, root.getlChild());
} else if (compareResult > 0) {
// 4.如果大於,就遞歸查找右子樹
return searchBST(t, root.getrChild());
} else {
// 5.如果等於,則查找過程結束,返回查找成功
return true;
}
}
整個查找過程形成由根節點開始的一直向下的一條路徑,假定樹的高度為h,那么查找算法的時間復雜度就是O(h)。另外,和遍歷一樣,除了通過遞歸實現元素查找外,也可以通過非遞歸的方式實現,其核心是改變對二叉搜索樹中被比較節點的引用。
private boolean searchBST(T t, BinaryNode<T> root) {
int compareResult;
// 1.當被比較節點不為空且沒找匹配項時,繼續查找
while (root != null && (compareResult = compare(t, root.getElement())) != 0) {
if (compareResult < 0) {
// 2.如果小於,就引用被比較節點的左子樹
root = root.getlChild();
} else {
// 3.如果小於,就引用被比較節點的右子樹
root = root.getrChild();
}
}
// 4.根據被比較節點的最終引用是否為空,判斷是否找到匹配項
return root != null;
}
由於二叉搜索樹的特性,其最小值位於樹的最左側,最大值位於樹的最右側,因此,也可以使用類似上述查找方法進行最小值和最大值的查找。下面是其非遞歸實現:
private BinaryNode<T> findMin(BinaryNode<T> root) {
if (root != null) {
// 1.退出條件:該節點沒有左節點
while (root.getlChild() != null) {
// 2.循環:將該節點引用置為其左子節點
root = root.getlChild();
}
}
return root;
}
private BinaryNode<T> findMax(BinaryNode<T> root) {
if (root != null) {
// 1.退出條件:該節點沒有右節點
while (root.getrChild() != null) {
// 2.循環:將該節點引用置為其右子節點
root = root.getrChild();
}
}
return root;
}
在設計遞歸/循環這類算法時,有兩個關鍵點:
1、遞歸/循環主結構。通過對待求解問題的分解,抽象主問題與子問題之間相同的核心邏輯,這個邏輯就是主結構。以遞歸實現斐波拉契數列 f(n) = f(n-1) + f(n-2)為例,其核心的主結構即為當前項等數列前面兩項的和,比如n=5,那么f(5) = f(4) + f(3) = (f(3) + f(2)) + (f(2) + f(1)) = ……= 5;
2、邊界點表現。遞歸的上升(彈棧)和循環退出是驗證算法在邊界點表現的依據,比如上述查找過程中的退出遞歸的點就是邊界點。
插入操作的關鍵是找到插入點,首先這個插入點一定是葉子節點(相等元素除外),因此,插入就是在查找的基礎上,新增一個葉子節點。以在上述二叉查找樹中插入節點20為例,下圖表示具體的插入過程:
翻譯成代碼如下:
private BinaryNode<T> insert(T t, BinaryNode<T> root) {
// 1.如果root為空,則說明此處為插入點
if (root == null) {
return new BinaryNode<>(t, null, null);
}
// 2.比較節點值的大小
int compareResult = compare(t, root.getElement());
if (compareResult < 0) {
// 3.遞歸調用插入左子樹
root.setlChild(insert(t, root.getlChild()));
} else if (compareResult > 0) {
// 4.遞歸調用插入右子樹
root.setrChild(insert(t, root.getrChild()));
}
return root;
}
可見,插入與查找的核心區別就是對空節點的處理,查找遇到空節點,表示已經查找結束,沒有找到被查節點;插入遇到空節點,表示找到了插入點,於是新增一個節點。
3.2 刪除
在二叉搜索樹中,一個節點的子節點有三種可能:1)無子節點,即該節點為葉子節點;2)有一個子節點;3)有兩個子節點。刪除節點需要針對這三種情況進行不同的處理:
- 首先,刪除葉子節點對其它節點沒有影響,因此,查找到該節點之后,直接刪除;
- 對於有一個子節點的情況,刪除該節點意味着將父節點對其的引用轉接到其子節點上,對此外的節點無影響;
- 刪除有兩個子節點的節點的方法有兩種:a) 從該節點的左子樹中找到最大的元素;b) 從該節點的右子樹中找到最小的元素,並用找到的元素來取代該節點。
下圖以刪除節點35為例,35有兩個子節點,從右子樹中找到最小的元素48,然后用48來代替35所在的位置。
private BinaryNode<T> remove(T t, BinaryNode<T> root) {
// 1.如果root為空,則可刪除節點為null
if (root == null) {
return root;
}
// 2.比較節點值的大小
int compareResult = compare(t, root.getElement());
if (compareResult < 0) {
// 3.在左子樹上遞歸刪除目標節點
root.setlChild(remove(t, root.getlChild()));
} else if (compareResult > 0) {
// 4.在右子樹上遞歸刪除目標節點
root.setrChild(remove(t, root.getrChild()));
} else if (root.getlChild() != null && root.getrChild() != null) {
// 5.找到該節點,並且該節點左右子樹均不空
// 將該節點的值設為右子樹的最小值
root.setElement(findMin(root.getrChild()).getElement());
// 因為最小值沒有左節點,所有刪除操作是前兩種情況之一
root.setrChild(remove(root.getElement(), root.getrChild()));
} else {
// 6.前兩種情況直接改變引用即可
root = (root.getlChild() != null) ? root.getlChild() : root.getrChild();
}
return root;
}
四、保證最壞情況時間復雜度
從上節可知,由N個節點組成的二叉搜索樹,其操作方法時間復雜度為O(h),h為樹的高度。樹的高度依賴於樹的拓撲結構,如果節點均勻分布,則高度為logN;但是,如果遇到插入節點的值依次減少(或增大),則二叉搜索樹退化為鏈表,高度變為N,那么查找、插入和刪除的時間復雜度均為O(N),就失去了二叉搜索樹最核心的時間復雜度優勢。
那么,如何保持二叉搜索樹的高度在最壞情況下依然是logN呢?自平衡二叉樹是通過約束所有葉子的深度趨於平衡達到該目的的。具體實現的方法一般是對不平衡二叉搜索的節點進行旋轉操作,常見的平衡二叉樹類型包括AVL樹、伸展樹、紅黑樹、2-3樹、AA樹等。
以AVL樹為例,要求任何節點的兩個子樹的高度最大差別為1,保證了樹的高度平衡性,因此,查找、插入和刪除的時間復雜度始終保持在logN的水平。在實現上,一般通過對不平衡的樹進行旋轉,使其重新達到平衡,旋轉分為四種場景:
- 造成不平衡的節點為其父節點的左子節點,其父節點為其祖父節點的左子節點,簡稱左左;
上圖中,節點關系:③ < ② < ①,高度為3。通過右旋,將①變為②的右子節點,同時,將②的右子節點變為①的左子節點(根據二叉搜索樹的特征,圖中節點B一定小於節點1)。可見,右旋后依然保持了二叉搜索樹的排序特征,卻使得整體的高度變為2,降低了1。翻譯成代碼如下:
private void rotateRight(Entry<K,V> p) {
if (p != null) {
// 獲取p節點右子節點l
Entry<K,V> l = p.left;
// 將l節點的右子節點設置為p節點的左子節點
p.left = l.right;
// 如果l節點的右子節點不為空,設置p節點為其父節點
if (l.right != null) l.right.parent = p;
// 將p節點的父節點設置為l節點的父節點
l.parent = p.parent;
// p節點的父節點為空,則設置l節點為根節點
if (p.parent == null)
root = l;
// 否則設置p節點的父節點對l節點的引用
else if (p.parent.right == p)
p.parent.right = l;
else p.parent.left = l;
// 改變p節點和l節點的關系
l.right = p;
p.parent = l;
}
}
- 造成不平衡的節點為其父節點的右子節點,其父節點為其祖父節點的右子節點,簡稱右右;
上圖中,節點關系:① < ② < ③,高度為3。通過左旋,將①變為②的左子節點,同時,將②的左子節點變為①的右子節點(根據二叉搜索樹的特征,圖中節點B一定大於節點1)。可見,左旋后依然保持了二叉搜索樹的排序特征,卻使得整體的高度變為2,降低了1。翻譯成代碼如下:
private void rotateLeft(Entry<K,V> p) {
if (p != null) {
// 獲取p節點右子節點r
Entry<K,V> r = p.right;
// 將r節點的左子節點設置為p節點的右子節點
p.right = r.left;
// 如果r節點的左子節點不為空,設置p節點為其父節點
if (r.left != null) r.left.parent = p;
// 將p節點的父節點設置為r節點的父節點
r.parent = p.parent;
// p節點的父節點為空,則設置r節點為根節點
if (p.parent == null)
root = r;
// 否則設置p節點的父節點對r節點的引用
else if (p.parent.left == p)
p.parent.left = r;
else p.parent.right = r;
// 改變p節點和r節點的關系
r.left = p;
p.parent = r;
}
}
- 造成不平衡的節點為其父節點的左子節點,其父節點為其祖父節點的右子節點,簡稱左右;
上圖中,節點關系:① < ③ < ②,高度為3。先通過右旋,將②變為③的右子節點,同時,將③的右子節點變為②的左子節點(根據二叉搜索樹的特征,圖中節點D一定小於節點2)。后通過左旋,將①變為③的左子節點,同時,將③的左子節點變為①的右子節點(根據二叉搜索樹的特征,圖中節點C一定大於節點1)。可見,右左雙旋后依然保持了二叉搜索樹的排序特征,卻使得整體的高度變為2,降低了1。
- 造成不平衡的節點為其父節點的右子節點,其父節點為其祖父節點的左子節點,簡稱右左;
上圖中,節點關系:② < ③ < ①,高度為3。先通過左旋,將②變為③的左子節點,同時,將③的左子節點變為②的右子節點(根據二叉搜索樹的特征,圖中節點C一定大於節點2)。后通過右旋,將①變為③的右子節點,同時,將③的右子節點變為①的左子節點(根據二叉搜索樹的特征,圖中節點D一定小於節點1)。可見,左右雙旋后依然保持了二叉搜索樹的排序特征,卻使得整體的高度變為2,降低了1。
可見,雙旋(左右和右左)其實就是綜合左旋和右旋操作,旋轉的本質是一種在保持二叉搜索樹排序特征的情況下,通過改變節點間的鏈接關系,降低樹的高度的方法。
五、Java中的紅黑樹實例
在Java集合框架中,Map和Set分別有基於樹的實現和基於散列的實現,其實現類如下表所示。
分類 | 基於散列實現 | 基於樹實現 |
---|---|---|
Map | HashMap | TreeMap |
Set | HashSet | TreeSet |
在大多數場景下,基於散列的實現是最好的選擇,除非需要強調元素的順序,才使用基於樹的實現。本節將重點說明如何基於紅黑樹實現TreeMap和TreeSet,HashMap和HashSet將在散列章節中說明。
HashMap/HashSet的擴展類LinkedHashMap/LinkedHashSet也能保持元素的順序,區別在於,TreeMap/TreeSet的順序是基於紅黑樹原理對Key比較實現的,而LinkedHashMap/LinkedHashSet是基於鏈表原理保持元素的插入順序。
在實現上,TreeMap實現了NavigableMap接口,而NavigableMap直接繼承自SortedMap,從字面上就可看出,TreeMap是一種支持節點排序的Map,其排序依據構造方法傳入的Comparator比較器。如果Comparator為空,則默認按照按鍵的自然順序升序進行排序。
List sequence = Arrays.asList("a", "1", "A");
Comparator<String> comparator = new Comparator<String>() {
@Override
public int compare(String o1, String o2) {
return sequence.indexOf(o1) - sequence.indexOf(o2);
}
};
Map<String, String> treeMap = new TreeMap<>(comparator);
treeMap.put("A", "This is A");
treeMap.put("1", "This is 1");
treeMap.put("a", "This is a");
for (Map.Entry<String, String> entry : treeMap.entrySet()) {
System.out.println(entry.getKey() + ":" + entry.getValue());
}
上述實例根據字符在線性表中的順序自定義了比較器,通過是否在TreeMap構造器中使用該比較器,輸出結果順序分為兩種:
- 使用時的輸出順序:“a”,“1”,“A”
- 不使用時的輸出順序:“1”,“A”,“a”
可見,使用比較器時,輸出按照線性表中字符的順序,不使用時則按照字符的自然順序(ASCII碼值)升序排序。實現這種順序區別的關鍵在於,使用TreeMap的put方法進行對象插入時,如果Comparator不為空,則通過Comparator的compare方法實現比較,否則將key強轉為Comparable對象,然后通過其compareTo方法實現比較。
Comparator<? super K> cpr = comparator;
cmp = cpr.compare(key, t.key);
Comparable<? super K> k = (Comparable<? super K>) key;
cmp = k.compareTo(t.key);
TreeMap的put方法其余的實現與上述在二叉搜索樹中插入節點的原理一致,只是TreeMap基於紅黑樹,相比於普通二叉搜索樹,紅黑樹的節點增加了顏色屬性,且取值為黑色或紅色。TreeMap的節點類Entry如下:
static final class Entry<K,V> implements Map.Entry<K,V> {
K key;
V value;
Entry<K,V> left;
Entry<K,V> right;
Entry<K,V> parent;
boolean color = BLACK;
}
在二叉搜索樹的要求之外,紅黑樹增加了如下的額外要求:
- 節點是紅色或黑色。
- 根是黑色。
- 所有葉子都是黑色(葉子是NIL節點)。
- 每個紅色節點必須有兩個黑色的子節點。
- 從任一節點到其每個葉子的所有簡單路徑都包含相同數目的黑色節點。
上述要求4保證了從根到葉子節點的所有路徑上不能有兩個連續的紅色節點,因此,結合要前三點要求得出:最短的路徑全是黑色節點,最長的路徑是紅色和黑色交替。然而第5點要求所有簡單路徑都包含相同數目的黑色節點,所以得出結論:從根到葉子的最長的可能路徑不多於最短的可能路徑的兩倍長。下圖是一顆紅黑樹實例:
相比於AVL樹要求任何節點的兩個子樹的高度最大差別為1,保證高度平衡性,紅黑樹只要求部分達到平衡,降低了對旋轉的要求,因此,當大量數據需要插入和刪除時,AVL樹需要重新平衡的頻率就更高。
具體來講,對於插入操作引起的樹的不平衡,AVL樹和紅黑樹都需要經過兩次旋轉,使得樹重新平衡;而刪除操作引起的不平衡,最壞情況下AVL樹可能需要重新平衡從被刪除節點到根節點的整個路徑,而紅黑樹最多只需要三次旋轉(后續說明),綜合起來看,紅黑樹的統計性能是優於AVL樹的。正是基於這一點,TreeMap才基於紅黑樹實現。
紅黑樹是一種自平衡的二叉搜索樹,因此,其操作方法(插入和刪除)也是在二叉搜索樹操作方法的基礎上,增加了自平衡修復操作來完成的,在使用TreeMap的put和remove方法插入和刪除元素后,分別調用了fixAfterInsertion和fixAfterDeletion,具體包括兩步:旋轉和重新着色,下面將重點分析其實現原理。
5.1 插入節點后的修復
在紅黑樹中,新插入的節點着色為紅色(不增加路徑上的黑色節點),根據其位置、父節點着色等條件,需要根據不同情況對紅黑樹進行修復。為了方便表述,這里對節點名稱做如下約定:新插入的節點定義為N節點,N節點的父節點定義為P節點,P節點的兄弟節點定義為U節點,P節點的父節點(N節點的祖父節點)定義為G節點。
首先,P節點的情況分為三種:
情形1:無P節點。即N節點為根節點,沒有父節點。
修復方式:直接將它設置為黑色以滿足紅黑樹性質2。
情形2:P節點為黑色。
修復方式:由於插入節點總會有兩個黑色的葉子節點(NIL節點),所以不會破壞紅黑樹性質4;另外,由於沒有增加黑色節點,所以紅黑樹性質5依然保持。綜上,不需要對N節點做額外修復。
情形3:P節點為紅色。且P節點為G節點的左子節點
修復方式:由於N、P節點都為紅色,所以首先破壞了紅黑樹性質4,因此需要對其修復。具體的修復方法需要根據U節點和G節點的情況進行細分:
- 情形3.a:P節點和U節點都是紅色
修復方式:將P節點、U節點都設置為黑色,並將G節點設為紅色,以G節點為當前節點,對樹進行遞歸修復。
由於從P節點、U節點到根節點的任何路徑都必須通過G節點,通過上述方式修復后,這些路徑上的黑節點數目沒有改變(原來有葉子和G節點兩個黑色節點,現在有葉子和P兩個黑色節點)。
- 情形3.b:U節點為黑色(或缺失),N節點為P節點的右子節點
修復方式:將P節點設置為當前節點,對P節點進行一次左旋。
由於N、P節點都為紅色,所以旋轉不改變紅黑樹的性質。
- 情形3.c:U節點為黑色(或缺失),N節點為P節點的左子節點
修復方式:將P節點設置為黑色,將G節點設置為紅色,然后對G節點進行一次右旋。
因為通過這N、P、G節點的所有路徑旋轉前都通過G節點,旋轉都通過P節點。旋轉前后,三個節點中都只有唯一的黑色節點,所以保持了紅黑樹的性質5。
下面看下在TreeMap中,fixAfterInsertion方法如何對上述分析過程代碼化。
private void fixAfterInsertion(Entry<K,V> x) {
// 新插入節點着色為紅色
x.color = RED;
// 循環修復由新插入x節點引起的不平衡,直到根節點,或其父節點為黑色
while (x != null && x != root && x.parent.color == RED) {
// x節點的父節點為其祖父節點的左子節點
if (parentOf(x) == leftOf(parentOf(parentOf(x)))) {
// 獲取x節點的叔節點(父節點的兄弟節點)
Entry<K,V> y = rightOf(parentOf(parentOf(x)));
// 對應情形3.a:P節點和U節點都是紅色
if (colorOf(y) == RED) {
// 設置P節點為黑色
setColor(parentOf(x), BLACK);
// 設置U節點為黑色
setColor(y, BLACK);
// 設置G節點為紅色
setColor(parentOf(parentOf(x)), RED);
// 以G節點為當前節點
x = parentOf(parentOf(x));
} else {
// 對應情形3.b:P節點為紅色,U節點為黑色,且N節點為P節點的右子節點
if (x == rightOf(parentOf(x))) {
// 設置P節點為當前節點
x = parentOf(x);
// 左旋當前節點
rotateLeft(x);
}
// 對應情形3.c:P節點為紅色,U節點為黑色,且N節點為P節點的左子節點
// 設置P節點為黑色
setColor(parentOf(x), BLACK);
// 設置G節點為紅色
setColor(parentOf(parentOf(x)), RED);
// 右旋G節點
rotateRight(parentOf(parentOf(x)));
}
// x節點的父節點為其祖父節點的右子節點
} else {
// ……
}
}
// 將根節點設置為黑色
root.color = BLACK;
}
從上述情形3.b和情形3.c可以看出,其實就是對不平衡的樹進行了一次左右雙旋,同樣的,當P節點為G節點的右子節點,N節點為P節點的左子節點時,就需要對不平衡的樹進行右左雙旋。上述代碼中else的部分對其進行了實現,由於原理在上節已經闡述過了,這里不再贅述。相比於AVL樹需要多次旋轉,紅黑樹通過重新着色來保持相對的平衡。
5.2 刪除節點后的修復
前面已經分析過,二叉搜索樹刪除節點分為三種情況:1)無子節點;2)有一個子節點;3)有兩個子節點。紅黑樹加入了顏色屬性后,刪除節點可能破壞紅黑樹的性質,因此需要分情況對其進行分析,而由於刪除紅色節點並不影響紅黑樹的特性,所以這里重點分析刪除黑色節點的情況。
為了方便表述,這里對節點名稱做如下約定:替代被刪除的節點位置的新節點定義為N節點(被刪除的節點右子樹中的最小值節點),N節點的父節點定義為P節點,P節點的兄弟節點定義為U節點,U節點的左、右子節點分別定義為L節點和R節點。
情形1:N節點為紅色
修復方式:直接將其着色為黑色,紅黑樹的性質得到修復;
情形2:N節點是黑色,且位於根節點
修復方式:無需修復,紅黑樹性質保留;
情形3:N節點為黑色,且不位於根節點
- 情形3.a:U節點為紅色,P節點、L節點和R節點均為黑色
修復方式:將U節點設置黑色,P節點設置為紅色,左旋P節點,同時將L節點指定為新的U節點。
- 情形3.b:U節點為黑色,P節點為紅色,L節點和R節點均為黑色
修復方式:將U節點設為紅色,同時指定P節點為新的N節點。
- 情形3.c:U節點為黑色,P節點為紅色,L節點為紅色,R節點為黑色
修復方式:將L節點設為黑色,將U節點設為紅色,右旋U節點,同時將L節點指定為新的U節點。
- 情形3.d:U節點為黑色,P節點為紅色、L節點為任意顏色,R節點為紅色
修復方式:將P節點的顏色賦值給U節點,將P節點設為黑色,將R節點的右子節設為黑色,左旋P節點,設置N為根節點。
下面看下在TreeMap中,fixAfterDeletion方法如何對上述分析過程代碼化。
private void fixAfterDeletion(Entry<K,V> x) {
// 循環修復不平衡,直到根節點,或其N節點為紅色
while (x != root && colorOf(x) == BLACK) {
// N節點為其父節點的左子節點
if (x == leftOf(parentOf(x))) {
// 獲取U節點
Entry<K,V> sib = rightOf(parentOf(x));
// 對應情形3.a:U節點為紅色
if (colorOf(sib) == RED) {
// 設置U節點為黑色
setColor(sib, BLACK);
// 設置P節點為紅色
setColor(parentOf(x), RED);
// 左旋父節點
rotateLeft(parentOf(x));
// 重新指定U節點
sib = rightOf(parentOf(x));
}
// 對應情形3.b:L節點和R節點均為黑色
if (colorOf(leftOf(sib)) == BLACK &&
colorOf(rightOf(sib)) == BLACK) {
// 設置U節點為紅色
setColor(sib, RED);
// 指定P節點為N節點
x = parentOf(x);
} else {
// 對應情形3.c:L節點為紅色,R節點為黑色
if (colorOf(rightOf(sib)) == BLACK) {
// 設置L節點為黑色
setColor(leftOf(sib), BLACK);
// 設置U節點為紅色
setColor(sib, RED);
// 右旋U節點
rotateRight(sib);
// 重新指定U節點
sib = rightOf(parentOf(x));
}
// 對應情形3.d:L節點為任意顏色,R節點為紅色
// 將U節點着色為P節點的顏色
setColor(sib, colorOf(parentOf(x)));
// 將P節點設置為黑色
setColor(parentOf(x), BLACK);
// 將R節點設置為黑色
setColor(rightOf(sib), BLACK);
// 左旋P節點
rotateLeft(parentOf(x));
// 將N節點設置為根節點
x = root;
}
} else {
// ……
}
}
// 設置N節點為紅色
setColor(x, BLACK);
}