跳到主要內容

資料結構

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

主題 · AVL 樹

AVL 樹

AVL 取自兩位發明者 Adelson-Velsky 與 Landis 的姓氏。插入或刪除後,只要哪裡左右高度差超過 1 就旋轉一下,讓二元搜尋樹不管輸入順序都保持在 log n 高。

5h1 010h2 +120h3 +125h1 030h4 +135h1 040h2 050h1 0
走過的路徑檢查平衡失衡/旋轉新加入節點下方: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 比紅黑樹平衡得更嚴格,查找稍快,但插入刪除時旋轉稍多。

和其他主題的關係

由這些組成
二元搜尋樹
延伸閱讀
B-tree跳躍串列

時間與空間複雜度(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 On = 1,000n = 10,000n = 100,000成長倍數:實測(理論)
樹高O(log n)101417×1.7 (×1.7)
找一個值平均走幾個節點O(log n)9.012.415.7×1.7 (×1.7)
平均每次插入旋轉幾次O(1)1.01.01.0×1.0 (×1.0)

n 變成一百倍,樹高從 10 變 17;同樣的輸入,沒有平衡的二元搜尋樹樹高會等於 n。

和其他做法比

二元搜尋樹:樹高AVL:樹高二元搜尋樹:找一個值走幾個節點AVL:找一個值走幾個節點
依序插入1,00010500.59.0
隨機順序插入221211.29.2

同樣 1,000 個鍵,兩種插入順序。沒有平衡的二元搜尋樹遇到依序插入就退化成一條線;AVL 不管順序如何,樹高都維持在 10 左右,代價是平均每次插入旋轉約 0.99 次(依序插入時)。

真實世界裡的它

  • 語言內建的有序容器多半是紅黑樹(C++ 的 std::map、Java 的 TreeMap),是同一類「自平衡二元搜尋樹」,平衡條件比較寬鬆。
  • 記憶體內的索引、需要穩定延遲的排程器與區間查詢;放在磁碟上的索引則改用 B-tree。

取捨與陷阱

  • 旋轉時要先更新下面節點的高度,再更新上面的:順序反了,高度就錯了,之後的平衡判斷全部跟著錯。
  • 雙旋轉(LR 左右、RL 右左:失衡的節點偏向一邊、它的子節點又偏向另一邊)最容易寫錯:要先看子節點往哪邊偏,偏向內側時得先轉子節點、再轉自己。
  • 每個節點都是獨立配置的記憶體,指標多、對 CPU 快取不友善;資料量大或在磁碟上時,B-tree 這種「矮胖」的樹通常更快。

LeetCode 練習