数据结构和算法:摊还分析

1 从单次最坏情形到整体代价

我们在分析算法时,几乎总是从最坏情形时间复杂度入手。对于一次操作、一次调用,搞清楚它在最坏输入下需要多少时间,这当然非常重要。但有些时候,如果只盯着单个操作的最坏情形,会得到一些过于悲观的结论。这些结论在数学上并没有错,却无法准确反映算法在"一连串操作"中的实际表现。

考虑一个很简单的例子:从一个空栈开始,依次做若干次 PUSH 和 POP 操作。每个 PUSH 和 POP 显然都是 O(1)O(1)O(1) 的。如果我们这时引入一个新操作 MULTIPOP(k),它从栈顶一次弹出最多 kkk 个元素。一次 MULTIPOP 的最坏情形时间是 O(n)O(n)O(n) ------ 当 kkk 很大且栈中有 nnn 个元素时,它确实可能做 nnn 次 POP。

于是问题出现了:如果有一个由 nnn 个 PUSH、POP 和 MULTIPOP 组成的操作序列,按最坏情形来逐项相加,可能会得出 O(n2)O(n^2)O(n2) 的总时间。这个上界虽然是成立的,却明显夸大了真实情况 ------ 因为每一次 MULTIPOP 弹出的元素个数,不可能超过之前 PUSH 进去的元素总数。换句话说,总代价其实被"弹栈的次数"这个全局资源限制住了。

这种"单次最坏情形乘上操作次数"的估计方法,在有些场景下太过粗糙。我们需要一种更细致的分析方式,它能从整体上刻画一个操作序列的总代价,而不是孤立地看每一次操作。这就是摊还分析(amortized analysis)的出发点。

摊还分析要解决的核心问题是:

给定一个由若干操作组成的序列,某些操作单次可能很昂贵,但发生频率很低,或者其代价会被之前(之后)的操作所"均摊"。我们能否给出一个更紧、更有意义的"每次操作平均代价"上界,并且这个上界在最坏情况下也成立?

这里需要特别强调一点:摊还分析得出的"平均",不是概率意义下的平均情况分析。它不假设输入服从任何分布,也不涉及随机性,而是在最坏的合法操作序列上求平均。换句话说,摊还分析给出的上界是确定性的,它保证:无论操作序列如何(只要是合法的),总代价都不会超过"摊还代价 × 操作次数"。

2 摊还分析的基本思想

设有一个数据结构,支持 mmm 种操作。考虑任意一个由 nnn 个操作组成的序列(nnn 足够大),这些操作按任意顺序施加在初始状态上。记第 iii 次操作的实际代价为 cic_ici,则总代价为

T(n)=∑i=1nci. T(n) = \sum_{i=1}^{n} c_i . T(n)=i=1∑nci.

我们希望给出 T(n)T(n)T(n) 的一个上界,同时为每个操作赋予一个摊还代价 c^i\hat{c}_ic^i,使得对所有可能的操作序列,都满足

∑i=1nc^i≥∑i=1nci, \sum_{i=1}^{n} \hat{c}i \ge \sum{i=1}^{n} c_i , i=1∑nc^i≥i=1∑nci,

并且 ∑i=1nc^i\sum_{i=1}^{n} \hat{c}_i∑i=1nc^i 的形式更便于求和与估计。这样我们就可以用 1n∑c^i\frac{1}{n}\sum \hat{c}_in1∑c^i 作为"每次操作的摊还时间复杂度"。

摊还分析本身并不神秘,核心只有一条:把昂贵操作的代价,分摊到导致它昂贵的那一系列廉价操作上去。关键在于如何形式化这个"分摊",使得分摊之后的每个操作都有一个与数据规模无关(或仅微弱相关)的摊还代价。最常用的有三种方法:聚集分析、记账方法、势能方法。它们本质上等价,但观察视角和操作便利性不同。

下面我们逐一展开这三种方法,然后用若干经典例子加以说明。

3 聚集分析

聚集分析(aggregate analysis)是最直接的一种思路:直接估计 nnn 个操作的总代价 T(n)T(n)T(n) 的上界,然后再除以 nnn,得到每个操作的摊还代价。此时我们令每个操作的摊还代价相等,即 c^i=T(n)/n\hat{c}_i = T(n)/nc^i=T(n)/n。这种方法不在意每次操作"贵不贵",而是对整体求和后取平均。

示例:含 MULTIPOP 的栈操作

设栈初始为空,操作序列由 PUSH、POP 和 MULTIPOP 组成。我们知道:

  • PUSH:实际代价 1。
  • POP:实际代价 1。
  • MULTIPOP(k):实际代价 min⁡(k,m)\min(k, m)min(k,m),其中 mmm 为当前栈中元素个数。

最坏情形下,单次 MULTIPOP 可能为 O(n)O(n)O(n),但总代价的估计不需要被这一点吓倒。关键观察是:每个元素最多被 PUSH 一次,POP 一次(无论是通过 POP 还是 MULTIPOP 间接弹出)。因此在一个长度为 nnn 的操作序列中,PUSH 的总次数不超过 nnn,从而被弹出的元素总数也不会超过 PUSH 的总次数。所以所有 POP 和 MULTIPOP 中实际弹出的总次数 ≤\le≤ 所有 PUSH 的次数 ≤n\le n≤n。于是总代价

T(n)=(PUSH 总次数)+(弹出总次数)≤n+n=2n. T(n) = (\text{PUSH 总次数}) + (\text{弹出总次数}) \le n + n = 2n . T(n)=(PUSH 总次数)+(弹出总次数)≤n+n=2n.

由此每个操作的摊还代价为 T(n)/n≤2T(n)/n \le 2T(n)/n≤2,即 O(1)O(1)O(1)。这个结论是确定性的,并且不依赖任何概率假设。如果只看单次 MULTIPOP,很可能会误以为整个序列的代价是 O(n2)O(n^2)O(n2)。

聚集分析的优点是简单、直观,但它对操作不加区分,给所有操作分配同样的摊还代价。在某些复杂场景中,不同类型操作之间差异很大,强行用一个平均值掩盖这些差异并不方便,这时就需要更精细的工具。

4 记账方法

记账方法(accounting method)更像一种"预付费"的思维。我们为每种操作预先规定一个摊还代价 c^i\hat{c}_ic^i,其值可以不同于实际代价。当 c^i\hat{c}_ic^i 大于实际代价 cic_ici 时,多余的"费用"被当作存款(credit) 存入数据结构中的特定对象上;当某个操作的实际代价大于其摊还代价时,就用之前积累的存款来支付差额。

为了保证这个方案可行,必须满足一个前提:对于任何操作序列,总存款始终非负,即不允许"赊账"。换句话说,对任意前缀序列,

∑i=1kc^i−∑i=1kci≥0. \sum_{i=1}^{k} \hat{c}i - \sum{i=1}^{k} c_i \ge 0 . i=1∑kc^i−i=1∑kci≥0.

如果能找到一组合理的摊还代价并证明存款非负,那么 ∑c^i\sum \hat{c}_i∑c^i 就是总代价的一个上界,摊还代价也就成立。

示例:栈操作的另一种解读

还是以 PUSH、POP、MULTIPOP 为例。在记账方法中,我们这样分配摊还代价:

  • PUSH:摊还代价为 2。
  • POP:摊还代价为 0。
  • MULTIPOP:摊还代价为 0。

直觉是这样的:每次 PUSH 一个元素时,除了支付自己 1 单位的实际代价,还多付 1 单位作为"预存款",这笔存款记在这个元素上。当该元素将来被弹出时(无论是 POP 还是 MULTIPOP),弹出的实际代价就由它身上携带的存款来支付,不再需要额外的摊还代价。

现在检验存款非负:栈中的每个元素恰携带 1 单位存款。每次 PUSH 存入 1 单位;每次弹出(POP 或 MULTIPOP 中的一次弹出)消费 1 单位。只要栈中元素数量非负,总存款就非负。因此摊还代价的设置是合法的。于是每次操作的摊还代价为 O(1)O(1)O(1)(确切地说,PUSH 为 2,其余为 0),总摊还代价不超过 2n2n2n。

记账方法的优势在于我们可以给不同操作分配不同额度,把存款绑定到具体对象上,直观地解释"谁在为谁付钱"。在很多数据结构(如伸展树、斐波那契堆)的分析中,这种解释非常自然。

5 势能方法

势能方法(potential method)是形式上最灵活、应用也最广泛的一种摊还分析手段。它把数据结构整体看作一个物理系统,为每个"状态"定义一个势能(potential)Φ\PhiΦ。势能函数将数据结构的某种配置映射到一个实数,通常要求初始状态势能为 0,且对于任意合法状态势能非负。

设第 iii 次操作前数据结构的势能为 Φi−1\Phi_{i-1}Φi−1,操作后变为 Φi\Phi_iΦi。定义第 iii 次操作的摊还代价为

c^i=ci+(Φi−Φi−1). \hat{c}i = c_i + (\Phi_i - \Phi{i-1}) . c^i=ci+(Φi−Φi−1).

这里 Φi−Φi−1\Phi_i - \Phi_{i-1}Φi−Φi−1 表示势能的增量。如果我们把总摊还代价求和,会发生巧妙的抵消:

∑i=1nc^i=∑i=1nci+Φn−Φ0. \sum_{i=1}^n \hat{c}i = \sum{i=1}^n c_i + \Phi_n - \Phi_0 . i=1∑nc^i=i=1∑nci+Φn−Φ0.

由于 Φ0=0\Phi_0 = 0Φ0=0 且通常保持 Φn≥0\Phi_n \ge 0Φn≥0,我们有 ∑c^i≥∑ci\sum \hat{c}_i \ge \sum c_i∑c^i≥∑ci。因此只要能够找到合适的势函数,使得每一次操作的摊还代价 c^i\hat{c}_ic^i 都有一个较小的上界,就能完成摊还分析。

势能方法的美妙之处在于:它把"代价如何分摊"的问题转换成了"如何定义势函数"的问题。而势函数的定义往往与我们关心的某种"不均衡程度"有关,例如数据结构的规模、树的平衡度、未完成工作的数量等。

示例:用势能方法分析栈操作

令势函数 Φ\PhiΦ 等于当前栈中元素个数。显然 Φ≥0\Phi \ge 0Φ≥0,空栈时 Φ=0\Phi = 0Φ=0。

  • PUSH:实际代价 c=1c = 1c=1,势能增量 ΔΦ=1\Delta\Phi = 1ΔΦ=1,因此 c^=2\hat{c} = 2c^=2。
  • POP:实际代价 c=1c = 1c=1,势能增量 ΔΦ=−1\Delta\Phi = -1ΔΦ=−1,因此 c^=0\hat{c} = 0c^=0。
  • MULTIPOP(k):弹出 m′=min⁡(k,m)m' = \min(k, m)m′=min(k,m) 个元素,实际代价 c=m′c = m'c=m′,势能增量 ΔΦ=−m′\Delta\Phi = -m'ΔΦ=−m′,因此 c^=0\hat{c} = 0c^=0。

所有摊还代价均为 O(1)O(1)O(1),与前两种方法结论一致。势能方法的计算就像记账方法的代数版本:存款变成了势能,绑定对象变成了全局状态。

以上三种方法,最终给出的结论是一样的,具体选择哪一种往往取决于个人习惯和问题特点。聚集分析简朴,记账方法直观,势能方法则更具代数上的普遍性。

6 摊还分析的应用

摊还分析的价值,只有在那些"偶尔昂贵,但长期便宜"的数据结构上才能充分体现出来。下面选三个有代表性的应用逐步展开,它们同时也是理解这三种方法的经典素材。

6.1 二进制计数器

考虑一个 kkk 位二进制计数器,初始值为 0。唯一允许的操作是 INCREMENT:从最低位开始,遇到 1 就翻成 0 并进位,直到遇到第一个 0 翻成 1 停止。单次 INCREMENT 的最坏情形代价是 O(k)O(k)O(k),即所有位都翻转(例如从 011...1 变成 100...0)。如果我们连续做 nnn 次 INCREMENT,按最坏估计会是 O(nk)O(nk)O(nk)。但直觉告诉我们:大多数时候进位很快就停止了。

聚集分析 :观察每个比特位的行为。最低位每次 INCREMENT 都翻转,次数为 nnn;第 1 位每两次翻转一次,次数 ⌊n/2⌋\lfloor n/2 \rfloor⌊n/2⌋;第 2 位每四次翻转一次,次数 ⌊n/4⌋\lfloor n/4 \rfloor⌊n/4⌋......总翻转次数为

∑i=0k−1⌊n2i⌋≤n∑i=0∞12i=2n. \sum_{i=0}^{k-1} \left\lfloor \frac{n}{2^i} \right\rfloor \le n \sum_{i=0}^{\infty} \frac{1}{2^i} = 2n . i=0∑k−1⌊2in⌋≤ni=0∑∞2i1=2n.

因此 nnn 次操作总代价 O(n)O(n)O(n),摊还每次 O(1)O(1)O(1)。

记账方法 :规定每次 INCREMENT 的摊还代价为 2 元。当把一个 0 翻成 1 时,实际花费 1 元,剩余的 1 元作为存款记在这个比特位上;将来这个 1 被翻回 0 时,就用之前存的 1 元来支付翻转代价。因为任何时刻计数器中 1 的个数非负,存款总额非负,方案可行。摊还代价 O(1)O(1)O(1) 得证。

势能方法 :令势函数 Φ\PhiΦ 等于计数器中 1 的个数。一次 INCREMENT 中,设它将 ttt 个 1 翻成 0,再将一个 0 翻成 1。实际代价 c=t+1c = t+1c=t+1。势能变化:减少了 ttt 个 1,增加 1 个 1,净变化 ΔΦ=1−t\Delta\Phi = 1 - tΔΦ=1−t。于是摊还代价 c^=(t+1)+(1−t)=2\hat{c} = (t+1) + (1-t) = 2c^=(t+1)+(1−t)=2。所有操作的摊还代价恒为 2,总摊还代价 2n2n2n。

6.2 动态表

很多程序需要维护一个可动态扩张的数组(如 C++ 的 vector、Java 的 ArrayList)。当元素数量超出当前容量时,需要分配更大的空间并把旧元素复制过去。显然,一次扩张的代价是 O(m)O(m)O(m)(mmm 为当前元素数),但扩张并不经常发生。我们希望证明:从空表开始,连续 nnn 次插入的摊还代价是 O(1)O(1)O(1)。

假设扩张策略为:当表满时,将容量扩大为原来的 2 倍。设第 iii 次插入的实际代价为 cic_ici:如果没有触发扩张,ci=1c_i = 1ci=1;如果触发扩张且当前有 mmm 个元素,ci=m+1c_i = m+1ci=m+1(复制 mmm 个旧元素并插入新元素)。采用势能方法分析。

定义势函数 Φ(T)=2⋅(元素数)−容量\Phi(T) = 2 \cdot (\text{元素数}) - \text{容量}Φ(T)=2⋅(元素数)−容量,要求其始终非负。事实上,在每次扩张之后,容量恰好等于元素数的 2 倍,此时 Φ=0\Phi = 0Φ=0;随着插入,元素数上升而容量不变,势能逐渐增大,最高可达接近容量值。扩张前瞬间 Φ≈\Phi \approxΦ≈ 容量,扩张后归零。这个势函数把即将到来的扩张代价预先存储了起来。

  • 无扩张时:ci=1c_i = 1ci=1,元素数加 1,容量不变,ΔΦ=2\Delta\Phi = 2ΔΦ=2,于是 c^i=3\hat{c}_i = 3c^i=3。
  • 有扩张时:设扩张前元素数为 mmm,容量也为 mmm,扩张后容量 2(m+1)2(m+1)2(m+1)。扩张操作将 mmm 个旧元素复制到新空间,然后插入新元素,ci=m+1c_i = m+1ci=m+1。扩张前势能 Φbefore=2m−m=m\Phi_{\text{before}} = 2m - m = mΦbefore=2m−m=m;扩张后元素数 m+1m+1m+1,容量 2m+22m+22m+2,势能 Φafter=2(m+1)−(2m+2)=0\Phi_{\text{after}} = 2(m+1) - (2m+2) = 0Φafter=2(m+1)−(2m+2)=0。ΔΦ=−m\Delta\Phi = -mΔΦ=−m。于是 c^i=(m+1)−m=1\hat{c}_i = (m+1) - m = 1c^i=(m+1)−m=1。

无论哪种情形,c^i\hat{c}_ic^i 均为常数级别(3 或 1)。所以插入操作的摊还代价为 O(1)O(1)O(1)。

类似地,可以分析当装载因子低于某一阈值时触发收缩操作,保证插入和删除的摊还代价都为 O(1)O(1)O(1)(这里不再展开)。

6.3 斐波那契堆概览

斐波那契堆支持插入、合并、减小关键字等操作,其中除删除最小元操作外,其余操作的摊还时间均为 O(1)O(1)O(1),删除最小元的摊还时间为 O(log⁡n)O(\log n)O(logn)。其分析大量运用了势能方法:势函数通常取为"堆中树的棵数 + 2 × 标记结点数"。各种操作引起的势能变化,恰好抵消了其中某些步骤的高昂实际代价。虽然斐波那契堆的细节较多,但核心思路与前面如出一辙:通过势函数把"将来要付出的代价"提前分摊到"当前看似便宜"的操作上。

正因为斐波那契堆拥有如此优秀的摊还界,它被用于图算法(如 Dijkstra 算法、Prim 算法)中,能将理论上界进一步压缩。

7 注意事项与常见误解

最后有必要澄清几个容易模糊的问题。

第一,摊还分析不是平均情况分析。平均情况分析需要假设输入的概率分布,给出期望代价;而摊还分析考虑的是最坏操作序列下的平均,结果是确定的、无随机性的。两者不可混淆。

第二,摊还代价不能简单看作"实际代价的平摊"。在某些操作序列中,个别操作的摊还代价可能高于实际代价,也可能低于实际代价,但序列整体上,总摊还代价不会低于总实际代价。这正是"记账"和"势能"设计时要保证的。

第三,势函数不必唯一。同一种数据结构,可以采用不同的势函数,只要满足非负且能导出理想的摊还界就行。在数学推导上,势能方法是更通用的框架,聚集分析和记账方法都可以视为它的特例(选择特定势函数或特定存款分配方式)。

8 小结

摊还分析为我们提供了一套在"序列"层面审视算法代价的语言和工具。它最初产生于对某些操作序列总代价的精细估计,但很快成为数据结构设计中的常规分析手段:在设计一个数据结构时,允许偶尔付出较大代价,只要我们能把这笔代价分摊到一系列廉价操作上,进而保证整个系统的长期效率。

通过聚集分析、记账方法和势能方法这三种角度,我们可以灵活地对各种操作序列给出紧致的上界。二进制计数器和动态表的例子虽然简单,却充分展现了摊还分析的精髓;而斐波那契堆等高级数据结构则证明,这种分析方法在现代算法设计中远非可有可无。

掌握摊还分析,不仅是对分析工具箱的丰富,也会潜移默化地影响我们设计数据结构时的思维方式:有时,"允许某些步骤慢一些"反而能得到整体更优的结构。下一章我们将看到,许多高效图算法和高级优先队列的设计,正是建立在这种思维之上的。

相关推荐
curry____3039 小时前
邻接矩阵 和 领接表 和 链式前向星对比
数据结构·c++·算法
caibixyy9 小时前
springboot+quartz 单机和集群使用示例-【备份任务】
java·quartz
invicinble9 小时前
对于spring的bean应该有哪些领域的认识
java·后端·spring
通信小呆呆9 小时前
维度分数傅里叶时频图 + 图神经网络:突破传统时频分析的目标识别与杂波抑制新框架
人工智能·神经网络·算法
梦想的旅途29 小时前
实现企微外部群主动发送接口:从 0 到 1 实现主动给客户发送的业务实战
java·开发语言·企业微信
是宇写的啊9 小时前
博客系统-小项目
java·数据库·spring boot·mybatis
csdn_aspnet9 小时前
C++ 算法 LeetCode 编号 70 - 爬楼梯
开发语言·c++·算法·leetcode
he___H9 小时前
leetcode100-合并区间
java·数据结构·算法
nbsaas-boot9 小时前
Drools 规则引擎实战:原理、规则语法、数据库动态规则与企业级玩法
java·数据库·python