AVL 樹
AVL 取自兩位發明者 Adelson-Velsky 與 Landis 的姓氏。插入或刪除後,只要哪裡左右高度差超過 1 就旋轉一下,讓二元搜尋樹不管輸入順序都保持在 log n 高。
走過的路徑檢查平衡失衡/旋轉新加入節點下方:h 高度、平衡因子
一棵會自己保持平衡的二元搜尋樹:每個節點左右子樹的高度差(平衡因子)都在 −1 到 +1 之間。插入或刪除一個數,看它怎麼沿路往上檢查、必要時旋轉。試試「依序插入 1–15」,和二元搜尋樹那一頁的一條斜線比比看。
節點
8
樹高
4
最矮可能的高度
4
這次旋轉幾次
–
亮起來的是這一步執行的程式碼
class AVLNode { left: AVLNode | null = null; right: AVLNode | null = null; height = 1; constructor(public key: number) {}} const height = (n: AVLNode | null) => (n ? n.height : 0);const balanceOf = (n: AVLNode) => height(n.left) - height(n.right); function update(n: AVLNode): void { n.height = 1 + Math.max(height(n.left), height(n.right));} function rotateRight(y: AVLNode): AVLNode { const x = y.left!; y.left = x.right; x.right = y; update(y); update(x); return x;} function rotateLeft(x: AVLNode): AVLNode { const y = x.right!; x.right = y.left; y.left = x; update(x); update(y); return y;} function rebalance(n: AVLNode): AVLNode { update(n); const b = balanceOf(n); if (b > 1) { if (balanceOf(n.left!) < 0) n.left = rotateLeft(n.left!); return rotateRight(n); } if (b < -1) { if (balanceOf(n.right!) > 0) n.right = rotateRight(n.right!); return rotateLeft(n); } return n;} function insert(node: AVLNode | null, key: number): AVLNode { if (!node) return new AVLNode(key); if (key === node.key) return node; if (key < node.key) node.left = insert(node.left, key); else node.right = insert(node.right, key); return rebalance(node);} function remove(node: AVLNode | null, key: number): AVLNode | null { if (!node) return null; if (key < node.key) node.left = remove(node.left, key); else if (key > node.key) node.right = remove(node.right, key); else if (!node.left || !node.right) return node.left ?? node.right; else { let min = node.right; while (min.left) min = min.left; node.key = min.key; node.right = remove(node.right, min.key); } return rebalance(node);}模型假設與範圍
- Big O 與實測步數以頁面列出的基本操作為單位;視覺化的快照、畫圖、程式高亮和輸出成本另計,不是執行時間 benchmark。
什麼時候用
- 需要排序好的資料(依序走訪、範圍查詢、找前一個後一個),而且不能信任輸入順序:資料可能本來就是排好的、或被人刻意排過。
- 查找比修改多很多時:AVL 比紅黑樹平衡得更嚴格,查找稍快,但插入刪除時旋轉稍多。
和其他主題的關係
- 由這些組成
- 二元搜尋樹
時間與空間複雜度(Big O)
| 操作 | 平均 | 最差 |
|---|---|---|
| 查找 樹高保證不超過約 1.44 log₂ n | O(log n) | O(log n) |
| 插入 往上檢查 O(log n) 個節點,但最多只需要一次(單或雙)旋轉 | O(log n) | O(log n) |
| 刪除 刪除可能一路旋轉到根,最多 O(log n) 次 | O(log n) | O(log n) |
| 依序走訪 | O(n) | O(n) |
空間:O(n),每個節點多存一個高度(或平衡因子)
Big O 實測:n 變大時步數怎麼長
數的是:依序插入 0…n−1(二元搜尋樹的最差情況)之後量的
| Big O | n = 1,000 | n = 10,000 | n = 100,000 | 成長倍數:實測(理論) | |
|---|---|---|---|---|---|
| 樹高 | O(log n) | 10 | 14 | 17 | ×1.7 (×1.7) |
| 找一個值平均走幾個節點 | O(log n) | 9.0 | 12.4 | 15.7 | ×1.7 (×1.7) |
| 平均每次插入旋轉幾次 | O(1) | 1.0 | 1.0 | 1.0 | ×1.0 (×1.0) |
n 變成一百倍,樹高從 10 變 17;同樣的輸入,沒有平衡的二元搜尋樹樹高會等於 n。
和其他做法比
| 二元搜尋樹:樹高 | AVL:樹高 | 二元搜尋樹:找一個值走幾個節點 | AVL:找一個值走幾個節點 | |
|---|---|---|---|---|
| 依序插入 | 1,000 | 10 | 500.5 | 9.0 |
| 隨機順序插入 | 22 | 12 | 11.2 | 9.2 |
同樣 1,000 個鍵,兩種插入順序。沒有平衡的二元搜尋樹遇到依序插入就退化成一條線;AVL 不管順序如何,樹高都維持在 10 左右,代價是平均每次插入旋轉約 0.99 次(依序插入時)。
真實世界裡的它
- 語言內建的有序容器多半是紅黑樹(C++ 的 std::map、Java 的 TreeMap),是同一類「自平衡二元搜尋樹」,平衡條件比較寬鬆。
- 記憶體內的索引、需要穩定延遲的排程器與區間查詢;放在磁碟上的索引則改用 B-tree。
取捨與陷阱
- 旋轉時要先更新下面節點的高度,再更新上面的:順序反了,高度就錯了,之後的平衡判斷全部跟著錯。
- 雙旋轉(LR 左右、RL 右左:失衡的節點偏向一邊、它的子節點又偏向另一邊)最容易寫錯:要先看子節點往哪邊偏,偏向內側時得先轉子節點、再轉自己。
- 每個節點都是獨立配置的記憶體,指標多、對 CPU 快取不友善;資料量大或在磁碟上時,B-tree 這種「矮胖」的樹通常更快。