3 摊还分析
什么是摊还分析
摊还分析(Amortized Analysis)关注的是一系列操作的平均最坏情况代价,而不是单次操作的最坏代价。
一个直观的比喻:看一个人的一生而非某一天。某天犯了大错(代价很高),但摊开到整个人生中,单次 " 失利 " 并不显得那么严重。
与其他分析方式的区别
| 分析方式 | 关注对象 | 是否使用概率 |
|---|---|---|
| 最坏情况分析 | 单次操作的理论最坏代价 | 否 |
| 平均情况分析 | 所有可能输入下的期望代价 | 是(需要假设输入分布) |
| 摊还分析 | 一系列操作中单次操作的平均最坏代价 | 否 |
关键区分:
- 摊还分析 ≠ 最坏时间复杂度(后者看单次,前者看 nnn 次操作的平均)
- 摊还分析 ≠ 平均情况分析(后者考虑输入的概率分布,前者对所有输入都成立)
- 摊还分析不使用概率,它给出的是确定性的上界
引入示例:动态数组
Java ArrayList 的扩容机制
ArrayList 本质是一个连续内存的动态数组。分配多少空间是一个两难问题:容量过小导致频繁扩容,容量过大浪费内存。
ArrayList 的策略:
- 初始容量:首次插入时分配默认值 C0=10C_0 = 10C0=10
- 扩容触发:当
size == capacity时 - 扩容倍数:新容量 ≈ 旧容量 × 1.5(下取整)
当触发扩容时:分配 1.5 倍大的新表 → 复制旧表所有元素 → 释放旧表。
代价分析(以 1.5 倍扩容为例):
| 第 iii 次插入 | 1 | 2 | ... | 10 | 11 | 12 | ... | 15 | 16 |
|---|---|---|---|---|---|---|---|---|---|
| 容量 | 10 | 10 | ... | 10 | 15 | 15 | ... | 15 | 22 |
| 代价 cic_ici | 1 | 1 | ... | 1 | 11 | 1 | ... | 1 | 16 |
大多数插入代价为 1,偶尔一次扩容代价很高。
动态表的 2 倍扩容(教材 17.4 节)
这是教材中的标准示例,扩容倍数为 2:
| iii | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|---|
| 容量 | 1 | 2 | 4 | 4 | 8 | 8 | 8 | 8 | 16 | 16 |
| cic_ici | 1 | 2 | 3 | 1 | 5 | 1 | 1 | 1 | 9 | 1 |
扩容发生在第 1、2、3、5、9 次插入时,代价分别为 1、2、3、5、9(等于 "1 + 旧容量 ")。
三种摊还分析方法
一、聚合法(Aggregate Method)
最直接的方法:先算出 nnn 次操作的总代价上界 T(n)T(n)T(n),再除以 nnn 得到每次操作的摊还代价。
以 2 倍扩容的动态表为例,nnn 次插入的总代价:
T(n)=∑i=1nci=n+∑j=0⌊log2n⌋2j<n+2n=3nT(n) = \sum_{i=1}^{n} c_i = n + \sum_{j=0}^{\lfloor \log_2 n \rfloor} 2^j < n + 2n = 3nT(n)=i=1∑nci=n+j=0∑⌊log2n⌋2j<n+2n=3n
第一项 nnn 是每次插入本身的代价。第二项是所有扩容的复制代价之和:1+2+4+⋯+2⌊logn⌋<2n1 + 2 + 4 + \cdots + 2^{\lfloor \log n \rfloor} < 2n1+2+4+⋯+2⌊logn⌋<2n。
所以每次操作的摊还代价 = T(n)/n<3T(n)/n < 3T(n)/n<3,即 O(1)O(1)O(1)。
二、核算法(Accounting Method)
核心思想是 " 预收费 ":给每次操作分配一个摊还代价(可以高于或低于实际代价),多收的部分作为 " 存款 " 存在数据结构上,日后用于支付高代价操作。
只要保证任何时刻存款余额 ≥ 0,总摊还代价就是总实际代价的上界:
∑i=1nc^i≥∑i=1nci\sum_{i=1}^{n} \hat{c}i \geq \sum{i=1}^{n} c_ii=1∑nc^i≥i=1∑nci
2 倍扩容的例子
给每次插入收取 3 元:
- 1 元:支付本次插入
- 2 元:存入 " 银行 ",预留给未来扩容时的元素迁移
扩容时需要把旧表所有元素复制到新表。每个旧元素的迁移费用由它自己插入时预存的 2 元支付:1 元负责移动自己,另 1 元负责 " 帮 " 一个在上次扩容前就存在的老元素。
具体过程:大小为 8 的表插入前 4 项后存款耗尽,插入第 5 项时收 3 元、存 2 元。插入到第 8 项时积累 8 元存款。插入第 9 项触发扩容(移动 8 个旧元素),8 元存款刚好用完。如此反复。存款余额始终 ≥ 0。
所以每次插入的摊还成本为 3,即 O(1)O(1)O(1)。nnn 次插入总代价至多 3n=O(n)3n = O(n)3n=O(n)。
1.5 倍扩容(ArrayList)
每次插入收取 4 元(或 5 元以完全覆盖奇数扩容的零头):
- 1 元用于本次插入
- 3 元预存:扩容时从容量 NNN 扩到 1.5N1.5N1.5N,需迁移 NNN 个元素。其中 0.5N0.5N0.5N 个是上次扩容后新插入的(新项),NNN 个是旧项。由于旧项数量约为新项的 2 倍,3 元里 1 元给新项、2 元给旧项,刚好平衡。
由于整数下取整的原因,遇到奇数容量扩容时会有 1 元的微小亏空。解决办法:当容量为奇数时额外收 1 元补上,或直接把每次收费提到 5 元。无论取 4 还是 5,单次代价都是 O(1)O(1)O(1)。
三、势能法(Potential Method)
与核算法类似,但把 " 存款 " 抽象为整个数据结构的势能函数 Φ\PhiΦ。
定义:
- DiD_iDi 为第 iii 次操作后数据结构的状态
- 第 iii 次操作的摊还代价 c^i=ci+Φ(Di)−Φ(Di−1)\hat{c}i = c_i + \Phi(D_i) - \Phi(D{i-1})c^i=ci+Φ(Di)−Φ(Di−1)
总摊还代价:
∑i=1nc^i=∑i=1nci+Φ(Dn)−Φ(D0)\sum_{i=1}^{n} \hat{c}i = \sum{i=1}^{n} c_i + \Phi(D_n) - \Phi(D_0)i=1∑nc^i=i=1∑nci+Φ(Dn)−Φ(D0)
只要 Φ(Di)≥Φ(D0)\Phi(D_i) \geq \Phi(D_0)Φ(Di)≥Φ(D0) 对所有 iii 成立(通常取 Φ(D0)=0\Phi(D_0) = 0Φ(D0)=0 且 Φ(Di)≥0\Phi(D_i) \geq 0Φ(Di)≥0),总摊还代价就是总实际代价的上界。
2 倍扩容的例子
定义势函数 Φ(Di)=2⋅num−size\Phi(D_i) = 2 \cdot \text{num} - \text{size}Φ(Di)=2⋅num−size,其中 num 是当前元素个数,size 是当前表容量。
- 普通插入(未扩容):ci=1c_i = 1ci=1,num 增 1 而 size 不变,ΔΦ=2\Delta\Phi = 2ΔΦ=2。摊还代价 c^i=1+2=3\hat{c}_i = 1 + 2 = 3c^i=1+2=3。
- 扩容插入:ci=1+old_sizec_i = 1 + \text{old\_size}ci=1+old_size,num 增 1 而 size 翻倍。扩容前 num = size,扩容后 Φ\PhiΦ 从 2⋅size−size=size2 \cdot \text{size} - \text{size} = \text{size}2⋅size−size=size 变为 2(size+1)−2⋅size=22(\text{size}+1) - 2\cdot\text{size} = 22(size+1)−2⋅size=2。ΔΦ=2−size\Delta\Phi = 2 - \text{size}ΔΦ=2−size。摊还代价 c^i=(1+size)+(2−size)=3\hat{c}_i = (1 + \text{size}) + (2 - \text{size}) = 3c^i=(1+size)+(2−size)=3。
两种情况下摊还代价都是 3。nnn 次插入总代价 ≤3n=O(n)\leq 3n = O(n)≤3n=O(n)。
1.5 倍扩容(ArrayList)
类似地定义势函数后推导,每次操作的摊还代价为 4,nnn 次操作总代价为 O(n)O(n)O(n)。
三种方法对比
| 方法 | 思路 | 特点 |
|---|---|---|
| 聚合法 | 先求总代价,再平均 | 最直观,但不同操作不能有不同的摊还代价 |
| 核算法 | 给每种操作定价,多收的存起来 | 灵活,但需要证明存款始终 ≥ 0 |
| 势能法 | 定义势函数,用势差抵消代价 | 最形式化,势能作用于整个数据结构而非特定对象 |
三种方法得到的结论相同,只是分析角度不同。聚合法最简单,势能法最精确也最通用。