平衡樹的世界不是只有「嚴格保證」一條路。有時候你要的不是最壞情況的鐵飯碗,而是好寫、好擴展。

為什麼還需要它們?AVL / 紅黑樹不夠嗎?

AVL 和紅黑樹確實保證每次操作 O(log n),理論上無懈可擊。問題在實作:你有沒有真的手寫過紅黑樹的刪除?那個「兄弟節點是紅是黑、姪子在內側還是外側」的 case 分析,能寫到你懷疑人生,而且一個 case 漏掉就是難 debug 的隱性 bug。

更麻煩的是區間操作。想在一個平衡序列裡「把第 3 到第 7 個元素整段翻轉」,AVL 和紅黑樹那套嚴格旋轉幾乎沒辦法優雅擴展。

Treap 和 Splay Tree 是兩條「不硬幹」的路,各自放棄了一點東西換來簡潔:

  • Treap 放棄「確定的平衡」,改用隨機——期望 O(log n),但實作只需 split / merge 兩招。
  • Splay Tree 乾脆連平衡都不主動維護,改成「每次用完就把節點搬到根」的自我調整——單次不保證,但均攤 O(log n)。

Treap:把平衡交給骰子

Treap 的招數是讓每個節點多抽一張隨機號碼牌(priority)。key 遵守 BST 性質(左 < 根 < 右),priority 遵守 Heap 性質(父 > 子)。兩個約束一疊加,樹的形狀就等同於「照隨機順序插入的 BST」——期望樹高 O(log n)。

節點 = (key, priority)
  key 走 BST:左 < 根 < 右
  pri 走 Heap:父 > 子
 
      (30,91)
     /       \
  (20,45)   (50,72)
  /
(10,23)
 
中序遍歷 key:10, 20, 30, 50  ✓(BST 成立)
父 pri > 子 pri:91>45, 91>72, 45>23  ✓(Heap 成立)

隨機的 priority 就是平衡的來源:你沒辦法刻意構造一組輸入把它搞歪,因為歪不歪是骰子決定的,跟你的插入順序無關。

一切都是 Split 與 Merge

Treap 的美在於:插入、刪除全都由兩個基礎操作拼出來。

  • Split(root, k):把樹按 key 拆成兩棵——左樹所有 key ≤ k,右樹 key > k。
  • Merge(L, R)(要求 L 所有 key < R 所有 key):比較兩根的 priority,大的當新根,遞迴合併剩下的。
// 插入 = split + 建新節點 + merge 兩次
void insert(int key) {
    Node[] parts = split(root, key);
    Node newNode = new Node(key);   // priority 隨機生成
    root = merge(merge(parts[0], newNode), parts[1]);
}
 
// 刪除 = split 兩次挖出目標 + merge 把兩側接回(中間丟掉)
void delete(int key) {
    Node[] leftMid = split(root, key - 1);
    Node[] midRight = split(leftMid[1], key);
    // midRight[0] 就是 key 那個節點,直接丟棄
    root = merge(leftMid[0], midRight[1]);
}

隱式鍵 Treap:Treap 真正的殺手鐧

把 key 拿掉,改用子樹大小來隱式定義「第幾個元素」,Treap 就變成一個支援 O(log n) 分裂/合併的序列

[1, 2, 3, 4, 5] 上可以 O(log n) 做:
  - 在任意位置插入 / 刪除
  - 區間翻轉(reverse 第 i~j 個)
  - 區間搬移

這是很多競程題的核心武器,也是 AVL / 紅黑樹再怎麼硬旋都做不漂亮的地方。

Splay Tree:常用的自己浮上來

Splay Tree 的比喻是桌上那疊書:每次抽一本看完,順手放回最上面。常翻的自然浮到頂,下次一伸手就拿到。它什麼平衡資訊都不存(沒有高度、沒有顏色),只有一條鐵律:

每次 search / insert / delete 後,把操作的那個節點「伸展」到根。

它不保證單次 O(log n),但保證連續 m 次操作均攤 O(m log n)。真正的甜蜜點是存取不均勻的工作負載——符合 80/20 法則時,那 20% 常用資料自動快取在根附近。

伸展的三種姿勢

把節點 x 一路旋到根,看它跟父、祖父的相對位置分三種:

Zig(x 是根的直接子):單旋一次就到頂
Zig-Zig(x 與父同側):先旋祖父、再旋父
Zig-Zag(x 與父異側):先旋父、再旋祖父(等同兩次單旋)

為什麼 Zig-Zig 不能拆成兩次 Zig?

這是整個 Splay Tree 均攤分析的命門。如果 Zig-Zig 你圖方便做成「連兩次單旋」,在一條歪成鏈狀的樹上,均攤會退化回 O(n)——等於白忙。先旋祖父再旋父這個順序,才能讓路徑上的節點深度整體減半,撐住均攤 O(log n)。這一步是 Splay Tree 唯一「反直覺但不能省」的地方。

// Split:splay(key) 後根就是 key,左右子樹天然分好
Node[] split(int key) {
    splay(findNode(key));
    Node right = root.right;
    root.right = null;
    return new Node[]{root, right};
}

兩棵樹放在一起看

TreapSplay TreeAVL / 紅黑樹
平衡怎麼來隨機 priority每次存取後伸展到根嚴格旋轉 / 著色
時間保證期望 O(log n)均攤 O(log n)確定 O(log n)
單次最壞O(n)(機率極低)O(n)O(log n)
實作難度簡單(split/merge)中等複雜
殺手應用隱式鍵做區間操作存取不均、Link-Cut Tree 的基礎系統函式庫、穩定保證

選型的一句話:要最壞情況的鐵保證去用紅黑樹;要好寫又能玩區間用 Treap;要吃到存取局部性的紅利用 Splay。三者不是誰取代誰,是三種對「平衡」這件事的不同妥協。

🎬 互動視覺化Treap 與 Splay Tree 動畫 — 看 Treap 的隨機 priority 怎麼自動把樹壓矮、Splay 的 zig-zig/zig-zag 怎麼把剛存取的節點一路旋到根,比讀旋轉圖快得多。


嚴格平衡樹像考試作答,每一步都要對;Treap 和 Splay 像實戰——一個賭機率、一個賭局部性,賭對了就又快又好寫。

接下來往哪走