跳到主要內容

資料結構

資料怎麼排,決定了哪些操作便宜

主題 · 紅黑樹

紅黑樹

用紅、黑兩種顏色和幾條規則,保證最長的路徑不超過最短路徑的兩倍:比 AVL 樹寬鬆,插入和刪除時旋轉比較少,所以 Java 的 TreeMap、C++ 的 map、Linux 的排程器都用它。

81219313841
紅節點黑節點走過的路徑這一步處理的節點

三條規則:根是黑的;紅節點不能有紅色子節點;從任何節點往下到空位,每條路徑經過的黑節點一樣多。合起來保證最長路徑不超過最短的兩倍。插入一個數,看它屬於哪一種情況。

節點
6
樹高(同樣順序的 AVL)
4 (3)
黑高度
2
累計旋轉(AVL)
3 (4)
亮起來的是這一步執行的程式碼
class RBNode {
left: RBNode | null = null;
right: RBNode | null = null;
parent: RBNode | null = null;
red = true; // new nodes start red
constructor(public key: number) {}
}
class RedBlackTree {
root: RBNode | null = null;
insert(key: number): void {
let parent: RBNode | null = null;
let node = this.root;
while (node) {
if (key === node.key) return;
parent = node;
node = key < node.key ? node.left : node.right;
}
const z = new RBNode(key);
z.parent = parent;
if (!parent) this.root = z;
else if (key < parent.key) parent.left = z;
else parent.right = z;
this.fixAfterInsert(z);
}
private fixAfterInsert(z: RBNode): void {
while (z.parent && z.parent.red) {
const p = z.parent;
const g = p.parent!;
const uncle = p === g.left ? g.right : g.left;
if (uncle && uncle.red) {
p.red = false;
uncle.red = false;
g.red = true;
z = g;
} else if (p === g.left) {
if (z === p.right) { z = p; this.rotateLeft(z); }
z.parent!.red = false;
g.red = true;
this.rotateRight(g);
} else {
if (z === p.left) { z = p; this.rotateRight(z); }
z.parent!.red = false;
g.red = true;
this.rotateLeft(g);
}
}
this.root!.red = false;
}
private rotateLeft(x: RBNode): void {
const y = x.right!;
x.right = y.left;
if (y.left) y.left.parent = x;
this.replace(x, y);
y.left = x;
x.parent = y;
}
private rotateRight(y: RBNode): void {
const x = y.left!;
y.left = x.right;
if (x.right) x.right.parent = y;
this.replace(y, x);
x.right = y;
y.parent = x;
}
// Puts `next` where `old` hung from its parent.
private replace(old: RBNode, next: RBNode): void {
next.parent = old.parent;
if (!old.parent) this.root = next;
else if (old === old.parent.left) old.parent.left = next;
else old.parent.right = next;
}
}

模型假設與範圍

  • Big O 與實測步數以頁面列出的基本操作為單位;視覺化的快照、畫圖、程式高亮和輸出成本另計,不是執行時間 benchmark。

什麼時候用

  • 需要有序的鍵值對(依序走訪、找前後一個、範圍查詢),又會一直插入刪除:紅黑樹每次修正最多轉兩三次,最差情況的寫入比 AVL 便宜。
  • 讀遠多於寫、而且每一層的差距都要計較時,AVL 樹比較矮,查找稍快;只要查找、不需要順序時,雜湊表更快。

和其他主題的關係

由這些組成
二元搜尋樹
延伸閱讀
AVL 樹B-tree

時間與空間複雜度(Big O)

操作平均最差
查找
樹高不超過 2·log₂(n+1)
O(log n)O(log n)
插入
最多兩次旋轉;換色可能往上 O(log n) 層
O(log n)O(log n)
刪除
最多三次旋轉(這裡沒有示範)
O(log n)O(log n)

空間:O(n),每個節點多一個位元存顏色

Big O 實測:n 變大時步數怎麼長

數的是:隨機順序插入後,樹高與平均每次查找讀的節點數

Big On = 500n = 5,000n = 50,000成長倍數:實測(理論)
樹高O(log n)111519×1.7 (×1.7)
平均查找讀幾個節點O(log n)8.211.615.0×1.8 (×1.7)

n 變成一百倍,樹高從 11 變 19:每多一倍的鍵只多大約一層。

和其他做法比

1,000 個,依序1,000 個,隨機10,000 個,依序10,000 個,隨機
樹高:紅黑樹17122416
樹高:AVL10121416
平均查找讀幾個節點:紅黑樹9.419.2512.8912.65
平均查找讀幾個節點:AVL8.999.2312.3612.57
每次插入的旋轉:紅黑樹0.980.571.000.58
每次插入的旋轉:AVL0.990.651.000.71

同一組鍵、同樣的插入順序,實際量出來的。紅黑樹允許比較不平衡(高度上限約 2·log₂ n),所以 10,000 個依序插入時樹高 24、AVL 只有 14;平均查找差距不大(12.6 對 12.6,隨機順序)。插入時兩者旋轉次數其實差不多(隨機順序每次 0.58 對 0.71);紅黑樹的保證在最差情況:插入最多轉兩次、刪除最多三次,AVL 刪除可能一路轉到根,而且紅黑樹不用存高度。

真實世界裡的它

  • Java 的 TreeMap/TreeSet、C++ 的 std::map/std::set(GCC 和 LLVM 的實作)都是紅黑樹;Java 8 起的 HashMap 在單一桶子碰撞太多時也改用紅黑樹。
  • Linux 核心到處用紅黑樹:CFS(Completely Fair Scheduler,完全公平排程器)和後來的 EEVDF 排程器依執行時間排序行程,記憶體管理用它找虛擬記憶體區段。

取捨與陷阱

  • 自己實作時最容易錯的是旋轉後忘了更新父指標,或把「三角形」和「一直線」的方向弄反;寫一個檢查三條規則的函式,每次插入後都跑一次。
  • 刪除的情況是插入的兩倍(被刪的黑節點留下一個「雙重黑色」要往上推),這裡沒有示範;面試很少要求手寫。
  • 紅黑樹不比 AVL 矮:只有在寫入多的時候它才划算。

LeetCode 練習