紅黑樹
用紅、黑兩種顏色和幾條規則,保證最長的路徑不超過最短路徑的兩倍:比 AVL 樹寬鬆,插入和刪除時旋轉比較少,所以 Java 的 TreeMap、C++ 的 map、Linux 的排程器都用它。
紅節點黑節點走過的路徑這一步處理的節點
三條規則:根是黑的;紅節點不能有紅色子節點;從任何節點往下到空位,每條路徑經過的黑節點一樣多。合起來保證最長路徑不超過最短的兩倍。插入一個數,看它屬於哪一種情況。
節點
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 樹比較矮,查找稍快;只要查找、不需要順序時,雜湊表更快。
和其他主題的關係
- 由這些組成
- 二元搜尋樹
時間與空間複雜度(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 O | n = 500 | n = 5,000 | n = 50,000 | 成長倍數:實測(理論) | |
|---|---|---|---|---|---|
| 樹高 | O(log n) | 11 | 15 | 19 | ×1.7 (×1.7) |
| 平均查找讀幾個節點 | O(log n) | 8.2 | 11.6 | 15.0 | ×1.8 (×1.7) |
n 變成一百倍,樹高從 11 變 19:每多一倍的鍵只多大約一層。
和其他做法比
| 1,000 個,依序 | 1,000 個,隨機 | 10,000 個,依序 | 10,000 個,隨機 | |
|---|---|---|---|---|
| 樹高:紅黑樹 | 17 | 12 | 24 | 16 |
| 樹高:AVL | 10 | 12 | 14 | 16 |
| 平均查找讀幾個節點:紅黑樹 | 9.41 | 9.25 | 12.89 | 12.65 |
| 平均查找讀幾個節點:AVL | 8.99 | 9.23 | 12.36 | 12.57 |
| 每次插入的旋轉:紅黑樹 | 0.98 | 0.57 | 1.00 | 0.58 |
| 每次插入的旋轉:AVL | 0.99 | 0.65 | 1.00 | 0.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 矮:只有在寫入多的時候它才划算。