Archipelago

Lec 2Fibonacci 堆与均摊分析

讲义完成更新于 2026-09-17

Lec 2 Fibonacci 堆与均摊分析(Fibonacci Heaps & Amortized Analysis)

TL;DR

  • 打破优先队列瓶颈:针对图算法中减小键值(Decrease-Key)远多于提取最小值(Extract-Min)的不对称特征,Fibonacci 堆将减小键值均摊复杂度降低至严格 O(1)O(1)。
  • 惰性延迟合并策略:插入与合并操作仅在根链表中追加单节点,将昂贵的整树合并推迟至提取最小值时批量执行,极大压低常规操作代价。
  • 级联剪切保证度数对数界:通过至多丢失一个孩子节点的打标机制与级联剪切,确保度数为 kk 的节点规模以黄金分割比 ϕ\phi 指数级增长,维持最大度数 D(n)=O(log⁡n)D(n) = O(\log n)。

优先队列与二项堆的局限

优先队列(Priority Queue)是图遍历、网络流与调度系统的核心构件。标准二叉堆(Binary Heap)支持 O(log⁡n)O(\log n) 的插入、删除与减键操作。

优先队列结构InsertFind-MinExtract-MinDecrease-KeyUnion
二叉堆 (Binary Heap)O(log⁡n)O(\log n)O(1)O(1)O(log⁡n)O(\log n)O(log⁡n)O(\log n)O(n)O(n)
二项堆 (Binomial Heap)O(1)∗O(1)^*O(log⁡n)O(\log n)O(log⁡n)O(\log n)O(log⁡n)O(\log n)O(log⁡n)O(\log n)
Fibonacci 堆O(1)O(1)O(1)O(1)O(log⁡n)∗O(\log n)^*O(1)∗O(1)^*O(1)O(1)

注:带 ∗* 的项目表示均摊复杂度(Amortized Complexity)。

在单源最短路径(Dijkstra)与最小生成树(Prim)算法中,算法总共调用 nn 次 Extract-Min 和至多 mm 次 Decrease-Key。若使用经典堆,总时间为 O(mlog⁡n)O(m \log n)。由于稀疏图到稠密图中 mm 可达 Θ(n2)\Theta(n^2),Decrease-Key 的调用频次远超 Extract-Min。如果能将 Decrease-Key 优化至 O(1)O(1),总时间便可直接跃迁为:

T(n,m)=O(m⋅1+n⋅log⁡n)=O(m+nlog⁡n)T(n, m) = O(m \cdot 1 + n \cdot \log n) = O(m + n \log n)

在稠密图(m=Ω(n2)m = \Omega(n^2))中,这一结果直接达到了理论线性的 O(n2)O(n^2)。Fredman 与 Tarjan(1987)正是为了突破这一理论界限而设计了 Fibonacci 堆。

堆结构设计与惰性哲学

堆的表示:森林与双向循环链表

Fibonacci 堆由一组满足**最小堆序(Min-Heap Property)**的有根树构成的森林组成。每个节点维护以下属性:

  • key:节点的键值
  • parent:指向父节点的指针
  • child:指向其子节点双向循环链表中某一节点的指针
  • left、right:指向兄弟节点的双向循环指针
  • degree:当前拥有的子节点数量
  • mark:布尔值,指示该节点自挂载到当前父节点后是否失去过子节点

所有树的根节点链接在同一个双向循环链表(根链表,Root List)中。堆对象维护一个指针 min,恒指向当前具有全局最小键值的根节点。

极端惰性设计

二项堆在每次插入时都立即进行类似于二进制加法的进位合并,这使得插入最坏需要 O(log⁡n)O(\log n)。Fibonacci 堆采取了彻底的**惰性求值(Lazy Evaluation)**思想:

  • Insert(x):创建一个只包含 xx 的单节点树,将其插入根链表,若 x.key<min.keyx.\mathrm{key} < \mathrm{min}.\mathrm{key} 则更新 min。完全不做任何合并,耗时 O(1)O(1)。
  • Union(H1, H2):直接将两个堆的根链表拼接合并为一个环,更新 min 指针。不触碰任何内部节点,耗时 O(1)O(1)。

这种“只记账、不结账”的设计积累了大量的未合并单树。真正的整顿工作被推迟到了不可回避的时刻——提取最小值。

提取最小值与合并整理(Consolidation)

执行 Extract-Min(H) 时:

  1. 移除最小根:将 min 指向的根节点从根链表中摘除,将其所有子节点加入到根链表中,释放父节点指针。
  2. 合并根树(Consolidate):如果此时根链表为空,返回;否则遍历根链表,利用一个度数数组 A[0…D(n)]A[0 \dots D(n)],将具有相同度数的根树两两配对合并。
    • 比较两根的键值,键值较大者作为子节点挂载到较小者的子节点链表下;
    • 较小者的度数加 1;
    • 重复此过程,直至根链表中不再存在任何度数相同的两棵树。
  3. 更新最小值:扫描合并后的所有根节点,重新确定最小根指针 min。

减小键值与级联剪切(Cascading Cut)

当一个节点 xx 的键值减小后,若其新键值小于其父节点 yy,最小堆序遭到破坏。为了恢复堆序,必须对树形结构进行调整。

为什么不能随意剪切?

若直接将 xx 剪切下来放到根链表,连续的剪切会导致某个高度极大的节点失去大量子节点,从而变成一条狭长的退化长链。长链的度数极小而节点数极多,这会彻底破坏“度数为 kk 的节点子树规模关于 kk 指数级递增”的数学保证,导致最大度数 D(n)D(n) 退化为 Θ(n)\Theta(n),令后续 Extract-Min 复杂度恶化为 O(n)O(n)。

标记位与级联剪切规则

Fibonacci 堆规定:每个非根节点至多只能失去一个孩子节点。 具体规则如下:

  1. 节点成为根节点时,其标记位重置为 mark = false。
  2. 当节点 xx 作为非根节点第一次失去某个子节点时,将其标记设置为 mark = true。
  3. 当已被标记(mark == true)的节点 xx 失去第二个子节点时,立即触发级联剪切(Cascading Cut):
    • 将 xx 从其父节点 yy 的子链表中剪切下来,加入到根链表中,并将 x.markx.\mathrm{mark} 重置为 false;
    • 递归对其原父节点 yy 执行级联剪切检查:若 yy 未被打标则打标;若 yy 已被打标则同样将 yy 剪切至根链表并继续向上回溯。

这一规则限制了任何非根节点子树的“萎缩”程度。

树规模下界与最大度数证明

子节点度数引理

设 xx 为任意节点,将其当前的所有子节点按照挂载到 xx 的先后时间顺序排列为 y1,y2,…,yky_1, y_2, \dots, y_k。

引理:对于第 ii 个子节点 yiy_i(i≥2i \ge 2),其当前度数满足:

degree(yi)≥i−2\mathrm{degree}(y_i) \ge i - 2

证明:当 yiy_i 被链接为 xx 的子节点时,xx 此前至少已经挂载了 y1,…,yi−1y_1, \dots, y_{i-1} 共 i−1i - 1 个子节点,因此当时 xx 的度数至少为 i−1i - 1。因为只有度数相同的根树才会合并,所以 yiy_i 在挂载瞬间的度数也至少为 i−1i - 1。在挂载之后,yiy_i 至多因为级联剪切失去 1 个子节点(若失去 2 个则自身早已被剪切)。因此 yiy_i 目前保留的子节点度数至少为 (i−1)−1=i−2(i - 1) - 1 = i - 2。对于 i=1i=1,显然 degree(y1)≥0\mathrm{degree}(y_1) \ge 0。

斐波那契数列下界与最大度数

设 SkS_k 为度数为 kk 的任意节点的最小可能子树规模。显然 S0=1,S1=2S_0 = 1, S_1 = 2。对于 k≥2k \ge 2:

Sk≥1+∑i=1kSdegree(yi)≥1+S0+∑i=2kSi−2=2+∑j=0k−2SjS_k \ge 1 + \sum_{i=1}^k S_{\mathrm{degree}(y_i)} \ge 1 + S_0 + \sum_{i=2}^k S_{i-2} = 2 + \sum_{j=0}^{k-2} S_j

利用数学归纳法易证:

Sk≥Fk+2S_k \ge F_{k+2}

其中 FmF_m 为标准斐波那契数(F0=0,F1=1,F2=1,F3=2,F4=3,…F_0=0, F_1=1, F_2=1, F_3=2, F_4=3, \dots)。由斐波那契数列的通项公式可知:

Fk+2≥ϕk(ϕ=1+52≈1.618)F_{k+2} \ge \phi^k \quad \left( \phi = \frac{1 + \sqrt{5}}{2} \approx 1.618 \right)

因此,在包含 nn 个节点的 Fibonacci 堆中,任何节点的度数 kk 必须满足:

n≥Sk≥ϕk  ⟹  k≤log⁡ϕnn \ge S_k \ge \phi^k \implies k \le \log_\phi n

这证明了堆中任意节点的最大度数 D(n)=O(log⁡n)D(n) = O(\log n)。

势能法均摊复杂度推导

势能函数设计

定义 Fibonacci 堆 HH 的势能函数为:

Φ(H)=t(H)+2m(H)\Phi(H) = t(H) + 2 m(H)

其中:

  • t(H)t(H):根链表中树的棵数(根节点总数);
  • m(H)m(H):整座森林中被标记(mark == true)的节点总数。

由于 t(H)≥0,m(H)≥0t(H) \ge 0, m(H) \ge 0,初始空堆 Φ(H0)=0\Phi(H_0) = 0 且任意时刻 Φ(H)≥0\Phi(H) \ge 0。因此均摊代价的上界是真实总操作时间的有效上界。

操作均摊代价计算

操作的均摊代价定义为:c^i=ci+Φ(Hi)−Φ(Hi−1)\hat{c}_i = c_i + \Phi(H_i) - \Phi(H_{i-1})。

1. Insert(x)

  • 实际代价 c=O(1)c = O(1);
  • 增加了一个根节点:Δt=+1\Delta t = +1;标记节点未变:Δm=0\Delta m = 0;
  • 势能变化 ΔΦ=1\Delta \Phi = 1;
  • 均摊代价:c^=O(1)+1=O(1)\hat{c} = O(1) + 1 = O(1)。

2. Union(H1, H2)

  • 实际代价 c=O(1)c = O(1);
  • 根链表拼接,总根数和标记数均不变:ΔΦ=0\Delta \Phi = 0;
  • 均摊代价:c^=O(1)\hat{c} = O(1)。

3. Extract-Min(H)

  • 设提取前根树数量为 tt。实际代价包含遍历 O(D(n))O(D(n)) 个子节点和扫描合并 t+D(n)t + D(n) 个根节点,实际耗时 O(D(n)+t)O(D(n) + t);
  • 合并整理后,每个度数至多保留 1 棵树,因此最终根数至多为 D(n)+1D(n) + 1;
  • 根数变化:Δt≤D(n)+1−t\Delta t \le D(n) + 1 - t;标记数未增加:Δm≤0\Delta m \le 0;
  • 势能变化:ΔΦ≤D(n)+1−t\Delta \Phi \le D(n) + 1 - t;
  • 均摊代价:
c^=O(D(n)+t)+(D(n)+1−t)=O(D(n))=O(log⁡n)\hat{c} = O(D(n) + t) + (D(n) + 1 - t) = O(D(n)) = O(\log n)

tt 项完全被势能下降抵消!

4. Decrease-Key(x, k)

  • 假设减小键值触发了 cc 次级联剪切(剪切了 xx 以及向上连续剪切了 c−1c-1 个已标记祖先节点);
  • 实际代价为遍历链表的 O(c)O(c);
  • 变化统计:
    • 产生了 cc 棵新根树加入根链表:Δt=+c\Delta t = +c;
    • 剪切下来的 cc 个节点标记被重置为 false(减少了至少 c−1c-1 个原标记节点);
    • 最顶层停止处的父节点可能由未标记变为标记(增加至多 1 个标记);
    • 净标记变化:Δm≤−(c−1)+1=−c+2\Delta m \le -(c - 1) + 1 = -c + 2;
  • 势能变化:
ΔΦ=Δt+2Δm≤c+2(−c+2)=−c+4\Delta \Phi = \Delta t + 2\Delta m \le c + 2(-c + 2) = -c + 4
  • 均摊代价:
c^=O(c)+(−c+4)=O(1)\hat{c} = O(c) + (-c + 4) = O(1)

级联剪切的级联耗时完全由此前标记时积累的势能买单,均摊恒为 O(1)O(1)!

理论突破与工程取舍

Fibonacci 堆是理论算法设计中的一座丰碑,成功将 Dijkstra 和 Prim 算法推至 O(m+nlog⁡n)O(m + n \log n)。

然而在工业工程实践中(例如标准库实现),Fibonacci 堆极少被直接采用:

  1. 常数因子巨大:每个节点维护 4 个指针、1 个键、1 个度数和 1 个布尔标记,内存开销大且存在大量的指针间接寻址与缓存不友好(Cache-Miss)。
  2. 非均摊的最坏情况波动:单次 Extract-Min 或 Decrease-Key 可能消耗 O(n)O(n) 最坏时间,对实时性敏感系统不友好。
  3. 替代结构的崛起:配对堆(Pairing Heap)在实践中速度显著超越 Fibonacci 堆;而在特定整数场景下,基数堆与多级桶更为高效。

我的理解

我的理解

势能法的本质是构建“时间银行”: Fibonacci 堆的设计哲学是均摊分析的典范。算法在平时(插入、合并、打标)极其懒惰,只做最微小的指针重连,但通过势能函数中的 2m(H)2 m(H) 向“时间银行”预存了代币。当发生昂贵操作(级联剪切或大批量根合并)时,每一笔耗时的遍历都必然伴随着标记的消除或根的配对,从而直接支取预存的代币冲抵开销。

标记机制的精妙之处在于“一次宽恕”:允许节点失去一个孩子(适应动态调整),但在失去第二个孩子时立即进行激进修复(保证度数的对数界)。这一局部松弛与全局约束的张力平衡,正是理论计算机科学最深邃的智慧之一。