這兩個結構長得都像「先偷懶、真的被逼問了才一次算清」。攤還分析就是它們能偷懶的底氣——單次可能很醜,一整串攤下來很漂亮。

為什麼要學這兩個「用不太到」的東西?

先說實話:Fibonacci Heap 你在正式專案裡大概一輩子不會手寫,Link-Cut Tree 更是競程裡公認最難的結構之一。那為什麼還值得學?

因為它們是攤還分析(amortized analysis)的兩個極致展示。前面你在 Treap 與 Splay Tree 看過 Splay「單次不保證、一串均攤 O(log n)」的玩法,這裡把同一套思路推到更遠:一個把 decrease-key 壓到 O(1),一個在會塌會長的森林上還能查路徑。你懂了這兩個,攤還這件事就算真的通了。

而且它們各自解的痛,是別的結構解不掉的:

  • Fibonacci Heap 解的是「Dijkstra 在稠密圖上被 decrease-key 的 log n 拖垮」。
  • Link-Cut Tree 解的是「靜態樹那套一遇到邊會動就全部作廢」。

一個一個來。

Fibonacci Heap:能拖就拖,被逼問才整理

痛點:稠密圖上,那些 log n 會累死你

跑 Dijkstra 或 Prim,核心迴圈在幹嘛?每次從堆裡挑出最近的點,然後鬆弛它所有的邊——鬆弛成功就要把鄰居的距離改小,也就是 decrease-key

二元堆的 decrease-key 是 O(log n):你把某個節點的值改小了,它得一路上浮回到正確位置。單看一次沒感覺,但鬆弛的總次數是 O(E)——邊有多少條就可能改多少次。在稠密圖上 E 逼近 V²,於是這條式子長這樣:

二元堆 Dijkstra:O((V + E) log V)

              E 條邊每條都吃一個 log V
              稠密圖 E ≈ V²,log V 被乘了 V² 次

那個 log V 被 E 乘起來,就是稠密圖上真正的痛。你會想:decrease-key 有沒有可能不要每次都 O(log n)?

招數:把整理往後拖到不能再拖

Fibonacci Heap 的核心心法就一句:能不整理就不整理,拖到真的要拿最小值時才一次算清。

  • 插入:新節點直接丟進根串列(一圈環形雙向鏈結),不排、不比、不上浮。O(1)。
  • 合併兩個堆:把兩圈鏈結接起來就好。O(1)。
  • decrease-key:把節點改小,如果它比父親還小,直接剪下來丟回根串列——不上浮、不重排。O(1) 均攤。

你看,前面全部都在偷懶、把爛攤子堆著。帳什麼時候算?extractMin 的時候。

根串列(一堆最小堆有序樹,樹根之間沒排序,就這樣堆著):
 
  min

  [3] ↔ [7] ↔ [12] ↔ [4] ↔ ...(環形)
   |           |
  [5]        [9]
   |
  [8]
 
規則只有一條:每棵樹內部 父 ≤ 子;樹根彼此無所謂

帳單到期:Consolidate 一次還清

extractMin 把最小的樹根摘掉後,才做唯一一次認真的整理——按「度數」合併(consolidate):度數相同的兩棵樹,根值大的去當根值小的兒子,一路合到每個度數最多只剩一棵樹。

consolidate:讓每個 degree 最多一棵樹
 
  degree 0 → 1 棵    degree 1 → 1 棵    degree 2 → 1 棵 ...
 
樹的總數壓到 ≤ log_φ(n)  →  extractMin 均攤 O(log n)

前面偷的懶,在這裡一次補回來——這正是攤還的靈魂:便宜的操作很多,貴的操作很少,平均下來每個都便宜。

那個「級聯切割」和名字的由來

decrease-key 直接剪節點有個隱憂:剪來剪去樹會變得又瘦又長,度數的保證就崩了。Fibonacci Heap 用一個標記機制撐住樹的「胖度」:

  • 一個節點第一次被剪走小孩 → 標記起來(marked)。
  • 已標記的節點又被剪走一個小孩 → 它自己也剪掉丟回根串列,而且往上連鎖(級聯切割 / cascading cut)。

這個約束保證了「度數為 k 的樹,至少有 F(k+2) 個節點」——F 是費波那契數列。名字就是這麼來的:不是結構長得像費氏數,是它的大小下界由費氏數擋著。

insert       O(1)       ← 丟根串列就走
merge        O(1)       ← 接鏈結
decreaseKey  O(1) 均攤   ← 剪一刀(可能級聯)
extractMin   O(log n)   ← 這裡一次還清

於是 Dijkstra 的複雜度改寫成 O(E + V log V):E 次 decrease-key 各攤 O(1),V 次 extractMin 各 O(log n)。理論上,這是比較式模型下 Dijkstra 的漂亮下限。

誠實時間:它幾乎沒人用

理論這麼香,為什麼實務上你翻遍生產程式碼幾乎看不到它?

  1. 常數大得離譜。每個 O(1) 背後藏著環形鏈結維護、標記、級聯切割一堆指標操作,隱藏常數把二元堆甩開好幾條街。
  2. cache 極不友善。整個結構靠指標亂跳,快取一路 miss;二元堆是一塊連續陣列,CPU 疼它疼得很。
  3. 現實的圖不夠密。多數圖 E 遠不到 V²,二元堆那個 log V 根本沒被乘爆,反而是常數贏。真要優化,大家也多半選 Pairing Heap——實作簡單一大截,實測還常常更快。

所以定位講清楚:Fibonacci Heap 是理論里程碑,是「decrease-key 的攤還下限能到哪」這個問題的答案,不是你工具箱裡真的會抽出來用的那把。學它,是學那套「懶惰 + 攤還」的思考。

痛點:邊會加會刪,靜態那套全廢

換個場景。你有一片森林,要回答「A 到 B 的路徑上,最大值是多少 / 總和是多少」「A 跟 B 連不連得到」。

如果樹是固定的,這題不難:重鏈剖分(HLD)配線段樹就能 O(log n) 查路徑。但問題是——邊會動。今天拆掉一條邊(森林裂成兩半),明天又接一條新邊(兩棵樹併成一棵)。HLD 那套重鏈是預先算死的,樹一改形狀,整個剖分就作廢,你只能全部重算。

這就是動態樹的痛:結構本身在變,任何「預先算好」的方案都站不住。

招數:把樹拆成一條條路徑,每條丟給 Splay Tree

Link-Cut Tree 的想法是把每棵樹拆成若干「偏好路徑(preferred path)」——大致就是最近走過的那條主幹——而每一條偏好路徑,用一棵 Splay Tree 來維護(叫輔助樹)。沒錯,就是你在 59 學過那個「用完就把節點旋到根」的自我調整樹,它在這裡是核心零件。

原始樹:                偏好路徑分解:
        1
       / \              路徑 1-2-4  → Splay Tree A
      2   3             路徑 5       → Splay Tree B
     / \   \            路徑 3-6     → Splay Tree C
    4   5   6
                        輔助樹之間用「路徑父指標」串起來

輔助樹裡的節點按深度排序——中序遍歷剛好就是這條路徑上從淺到深的順序。每個節點順手掛上路徑聚合資訊:

value  這個節點自己的值
sum    這條路徑上所有值的和
max    這條路徑上的最大值
rev    反轉懶標記(換根時用)

於是「查 A 到 B 路徑的最大值」就變成「把這條路徑湊成一棵輔助樹,讀根上的 max」。

access:一切的地基

所有操作都建在一個核心動作 access(v) 上——它把「v 到樹根」這一整段變成一條偏好路徑,收進同一棵輔助樹。剩下的操作都是它的組合:

makeRoot(v):  access(v) 再把路徑反向 → v 變成新的根
link(u, v):   makeRoot(u); 把 u 的父指標指向 v → 兩棵樹接起來
cut(u, v):    makeRoot(u); access(v); 斷開它們之間那條邊 → 樹裂開
findRoot(v):  access(v); 在輔助樹裡找最左節點(最淺 = 原樹的根)

link 和 cut,字面上就是這結構名字的由來:能連、能斷。而每個操作都靠 Splay 的均攤把成本壓在 O(log n)——Splay 那套「把碰過的節點旋到根」在這裡不只是加速,是整個複雜度分析的支柱。

access / makeRoot   O(log n) 均攤
link / cut          O(log n) 均攤
findRoot            O(log n) 均攤
路徑查詢(sum / max)O(log n) 均攤

為什麼它被叫「最難的結構之一」

Link-Cut Tree 難,不是單點難,是它把好幾層抽象疊在一起:Splay 的伸展、路徑分解的攤還分析、虛邊實邊的轉換、還有懶標記下傳。任何一層沒吃透,寫出來的東西就會在某個 case 悄悄錯掉,而且極難 debug。

所以學習路線別跳:先把 Splay Tree 弄熟(回 59)、再懂 HLD 的靜態路徑查詢、最後才碰 LCT。跳級硬上,多半是浪費時間。

兩把武器收在一起看

表面上一個是堆、一個是樹,八竿子打不著。但把它們放一起,共同的靈魂就浮出來了——都是攤還分析壓成本壓到極致的產物:一個把 decrease-key 壓到 O(1),一個在會動的森林上把路徑查詢壓到 O(log n)。

Fibonacci HeapLink-Cut Tree
解什麼痛稠密圖 Dijkstra 的 decrease-key 太貴動態森林上查路徑 / 連通性
核心手法懶惰整理,extractMin 才 consolidate偏好路徑分解,每條丟給 Splay
招牌操作decrease-key O(1) 均攤link / cut O(log n) 均攤
名字由來樹大小下界是費氏數能 link、能 cut
定位理論里程碑,實務幾乎不用動態樹神器,競程壓箱底大招
前置HeapSplay Tree

選型的一句話:Fibonacci Heap 你大概只會在論文和面試題裡遇到它,真要優化 decrease-key 場景,Pairing Heap 更務實;Link-Cut Tree 則是「樹會動、還要查路徑」時唯一能優雅解掉的工具,難歸難,該用的時候沒有替代品。

🎬 互動視覺化Fibonacci Heap 與 Link-Cut Tree 動畫 — 看 Fibonacci Heap 怎麼把插入全堆進根串列、到 extractMin 才一次 consolidate,以及 Link-Cut Tree 的 access 怎麼把一條路徑「旋」進同一棵輔助樹,比讀懶惰合併和偏好路徑的文字快得多。


一個賭「反正你很少問我要最小值」,一個賭「你碰過的路徑等等還會再碰」——兩個都在賭,賭的都是攤還:單次醜沒關係,一整串算下來漂亮就贏了。

接下來往哪走

  • Treap 與 Splay Tree — Splay Tree 是 Link-Cut Tree 的核心零件,先把它弄熟再回來
  • Heap — Fibonacci Heap 的對照組:普通二元堆怎麼做,log n 又貴在哪
  • Union-Find — 靜態連通性的對照:只加不刪的時候,其實用不到 LCT 這麼重的傢伙