Archipelago

Lec 5单调优先队列:Dial 算法与多级桶

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

Lec 5 单调优先队列:Dial 算法与多级桶(Monotone Priority Queues: Dial's Algorithm & Multi-Level Buckets)

TL;DR

  • 单调性突破比较模型下界:利用非负权最短路径(Dijkstra)中提取最小值单调不减与活动键值跨度不超过最大边权 CC 的特性,将基于比较的堆转化为基于直接寻址的桶(Bucket)。
  • Dial 循环数组与空桶跳过难题:Dial 算法以 C+1C+1 大小的循环数组实现 O(m+nC)O(m + nC) 最短路,其核心瓶颈在于线性扫描空桶寻找下一个非空槽。
  • 两级桶到 Denardo-Fox 惰性字典树:通过层级摘要(两级桶达 O(m+nC)O(m + n\sqrt{C}))与惰性展开(不提前下推元素),结合势能分析将最短路复杂度降至 O(m+nlog⁡C/log⁡log⁡C)O(m + n\log C / \log\log C)。

最短路径中的单调性与桶结构

在标准单源最短路径(Dijkstra)算法中,若所有边权均为不超过 CC 的正整数,优先队列表现出两条极为特殊的本质性质:

  1. 单调性(Monotone Property): 从优先队列中弹出的最小值序列 d(u)d(u) 是单调递增(单调不减)的。算法永远不需要将已经弹出的更小值重新插入队列。
  2. 键值跨度有界性(Bounded Key Spread): 在任意时刻,队列中所有有效元素的键值跨度最多为 CC。因为若当前弹出的最小距离为 kk,任何后续通过松弛操作新插入或减小的距离 d(v)=d(u)+w(u,v)≤k+Cd(v) = d(u) + w(u, v) \le k + C。在最小值推进到 k+1k+1 之前,键值大于 k+Ck+C 的元素绝不可能进入队列。

在 RAM 模型下,由于键是整数,我们可以利用间接寻址(Indirect Addressing)将键值直接作为数组索引,从而打破比较排序模型 Ω(log⁡n)\Omega(\log n) 的下界约束。

Dial 算法:循环单层桶

Dial(1969)提出了最朴素的桶排序优先队列方案。

算法机制与循环数组技巧

由于任意时刻同时存在的不同距离键值最多只有 CC 种可能,我们不需要为全局最大距离开辟庞大数组,只需开辟一个大小为 B=C+1B = C + 1 的桶数组 A[0…C]A[0 \dots C],每个槽位挂载一个双向链表。

  • 下标映射:键值为 kk 的节点放入槽位 A[k mod (C+1)]A[k \bmod (C + 1)] 中。
  • Insert(v, d):直接将节点插入对应桶的链表头部,耗时 O(1)O(1)。
  • Decrease-Key(v, d_new):将节点从旧桶链表中摘除,插入新桶链表头部,耗时 O(1)O(1)。
  • Extract-Min():从当前指针所在槽位开始,依次向后扫描数组槽位(遇尾部则环绕回头部),直至找到第一个非空链表,弹出其中一个节点。

复杂度分析与瓶颈

  • 空间复杂度:由于循环数组复用机制,空间严格为 O(n+C)O(n + C)。
  • 时间复杂度:算法执行 mm 次松弛,每次减键耗时 O(1)O(1),共 O(m)O(m)。指针在整个算法运行期间单调前进,至多前进 DD 步(D≤nCD \le nC 为最长最短路径长度)。因此总时间为 O(m+D)≤O(m+nC)O(m + D) \le O(m + nC)。

Dial 算法的瓶颈:当边权上限 CC 较大而图相对稀疏时,大量桶是空的。指针必须一步步线性扫描大片空桶,导致无谓的 CPU 空转。

两级桶与多级分层摘要

为了跳过整片连续的空桶,必须引入摘要(Summary)索引结构。

两级桶架构

将总范围 CC 划分为若干大小为 bb 的连续块(Block),共有 ⌈C/b⌉\lceil C/b \rceil 个块。

  1. 底层桶:每个块包含 bb 个桶;
  2. 顶层摘要表:建立一个大小为 ⌈C/b⌉\lceil C/b \rceil 的数组,记录每个块内当前包含的元素总数。

各操作代价:

  • Insert 与 Decrease-Key:修改底层链表并更新所在块的计数,耗时 O(1)O(1)。
  • Extract-Min:首先在顶层摘要表中向后扫描,找到第一个计数非零的块(跳过整块空桶);然后在该非空块内线性扫描底层桶找到最小元素。

块大小的最优参数平衡

  • 扫描顶层摘要表在整个 Dijkstra 进程中累计至多前进 nC/bnC/b 步;
  • 每次 Extract-Min 在底层块内扫描最多 bb 步,执行 nn 次总耗时 nbnb;
  • 算法总运行时间为:
T(n,m,C)=O(m+nb+nCb)T(n, m, C) = O\left(m + nb + \frac{nC}{b}\right)

为了使总时间最小,对 bb 求导平衡两项:

nb=nCb  ⟹  b2=C  ⟹  b=Cnb = \frac{nC}{b} \implies b^2 = C \implies b = \sqrt{C}

将最优块大小 b=Cb = \sqrt{C} 代入,单源最短路径总复杂度优化为:

O(m+nC)O(m + n\sqrt{C})

三级与多级桶的延伸

自然的想法是将这一分层推广到三层:底层块、中间超级块与顶层摘要。取块大小为 C1/3C^{1/3},总复杂度降为 O(m+nC1/3)O(m + nC^{1/3})。 然而,分层不能无限制加深。随着层数增加,每次插入和减键都必须更新每一层的摘要计数,会导致插入代价线性上升,无法逼近真正的理论极限。

字典树与全分层模型

将分层推到极限,便形成了一棵固定深度为 kk 的基数树或字典树(Trie)。

字典树参数与复杂度

设树的深度为 kk,每个分支节点的度数(分支因子)取 Δ=(C+1)1/k\Delta = (C + 1)^{1/k}。

  • Insert / Decrease-Key:需要沿树下降 kk 层更新各层节点,耗时 O(k)O(k);
  • Extract-Min:需要沿树的每一层扫描找到非空分支,每层最多扫描 Δ\Delta 个子指针,耗时 O(kΔ)=O(kC1/k)O(k \Delta) = O(k C^{1/k});
  • 最短路径总耗时:
O(km+knC1/k)O\left(k m + k n C^{1/k}\right)

令 nC1/k=mn C^{1/k} = m 进行参数平衡:

C=(m/n)k  ⟹  k=log⁡Clog⁡(m/n)C = (m/n)^k \implies k = \frac{\log C}{\log(m/n)}

代入得到总运行时间为:

O(mlog⁡m/nC)O\left(m \log_{m/n} C\right)

字典树的核心缺陷在于:每次插入都急于将元素一直推送至树叶,产生了大量冗余的纵向路径维护。

Denardo-Fox 惰性字典树与势能分析

Denardo 与 Fox(1979)提出了极具创见的惰性字典树(Lazy Trie):不到万不得已,绝不把元素向下层推送。

结构设计与活跃块

在惰性字典树中,深度依然为 kk。但每一层只维护一个当前处于活跃状态的数组块(Active Block):

  • 底层未激活的子树不需要展开分配;
  • 暂未到达其所属时间窗口的元素,直接堆积停留在较高层次的桶中;
  • 每一层维护当前层桶内的元素总计数。

操作流程

  1. Insert(x):元素从根开始向下走,一旦遇到一个尚未激活的非活跃块或到达底层,就停止下降并直接存放在该处的桶中,所在层计数加 1。耗时 O(k)O(k)。
  2. Decrease-Key(x):将元素从原桶中取出。由单调性保证,新键值对应的桶一定是当前活跃分支之前或并行的兄弟分支,只需放入对应桶中,绝不需要向上回退到更高层,耗时 O(1)O(1)。
  3. Extract-Min():
    • 从底层弹出最小元素并更新计数;
    • 若当前底层块已空,向上寻找第一个计数非零的高层桶;
    • 将该高层桶内的所有元素向下展开(Unpack)到下一层的活跃块中,递归向下直至填充到底层。

势能法均摊分析

为什么惰性字典树能够压低时间?核心在于单调性赋予的方向性保证: 在整个生命周期中,任何元素只能沿着树不断向下移动,永远不会向上爬升!

定义每个元素的势能为其当前所在节点到底层的高度(根层高度为 kk,底层高度为 0):

Φ=∑x∈Qheight(x)\Phi = \sum_{x \in Q} \mathrm{height}(x)
  • 初始插入元素时,其势能至多增加 kk;因此将下推的全部潜在代价预先均摊到 Insert 上,使得 Insert 均摊代价为 O(k)O(k);
  • Decrease-Key 绝不增加元素高度,势能不增,均摊代价严格为 O(1)O(1);
  • Extract-Min 展开桶将元素下推所花费的实际工作量,完全被元素高度降低释放的势能抵消!寻找下一个非空桶只需要横向扫描一个分支块,代价仅为 O(C1/k)O(C^{1/k})。

最优参数选取

单源最短路径总时间为:

T=O(m+n(k+C1/k))T = O\left(m + n\left(k + C^{1/k}\right)\right)

选取最优层数参数:

k=2log⁡Clog⁡log⁡C  ⟹  C1/k=2(log⁡C⋅log⁡log⁡C)/(2log⁡C)=log⁡Ck = \frac{2\log C}{\log\log C} \implies C^{1/k} = 2^{(\log C \cdot \log\log C) / (2\log C)} = \sqrt{\log C}

此时 k+C1/k=O(log⁡Clog⁡log⁡C)k + C^{1/k} = O\left(\frac{\log C}{\log\log C}\right),最短路径总时间达到惊人的:

O(m+nlog⁡Clog⁡log⁡C)O\left(m + \frac{n \log C}{\log\log C}\right)

对于 32 位机器整数(C≤232C \le 2^{32}),log⁡Clog⁡log⁡C≈325≈6.4\frac{\log C}{\log\log C} \approx \frac{32}{5} \approx 6.4,几乎等同于常数时间!

最短路径算法复杂度演进全景

优先队列技术最短路径总复杂度关键机制与瓶颈突破
经典二叉堆O(mlog⁡n)O(m \log n)基于比较模型,无法利用键值小整数特性
Fibonacci 堆O(m+nlog⁡n)O(m + n \log n)惰性级联剪切,通用比较模型下的理论极限
Dial 算法O(m+nC)O(m + nC)单层循环数组,瓶颈在于扫描连续空桶
两级桶O(m+nC)O(m + n\sqrt{C})增加单层块摘要,跳过空块
全展开字典树O(mlog⁡m/nC)O(m \log_{m/n} C)沿深 kk 树全量推送,插入代价较高
Denardo-Fox 惰性字典树O(m+nlog⁡Clog⁡log⁡C)O(m + n\frac{\log C}{\log\log C})惰性展开 + 势能摊还,元素单向向下流动
van Emde Boas 树O(m+nlog⁡log⁡C)O(m + n \log\log C)将“找非空桶”递归为自身的 vEB 队列(详见 Lec 6)

我的理解

我的理解

单调性是计算几何与动态规划中破局的杠杆: 通用优先队列必须随时应对任意大小元素的插入与删除,因而受制于信息论的比较下界。而 Dijkstra 的本质是波前扩散(Wavefront Expansion),波前的推进是时间单向不可逆的。

Dial 算法看准了波前厚度不超过 CC 的事实,率先用循环空间完成了物理围栏;而 Denardo-Fox 更是洞悉了“不可逆性”所蕴含的势能红利——只要状态在树上只能单向坠落,我们就可以将未来的下推结算费用提前贴现到插入时刻。这种对问题内在物理特性的极致榨取,正是理论计算机科学区别于通用工程模板的魅力所在。