一、问题:最坏情况分析太悲观

考虑动态数组(Python 的 list、Java 的 ArrayList、C++ 的 vector)的 push

type Vector struct {
    data []int // len(data) == size, cap(data) == capacity
}

func (v *Vector) Push(x int) {
    if len(v.data) == cap(v.data) { // 满了
        newCap := max(1, 2*cap(v.data))
        grown := make([]int, len(v.data), newCap)
        copy(grown, v.data) // Θ(n) 拷贝
        v.data = grown
    }
    v.data = append(v.data, x) // Θ(1)
}

Go 的内建 append 自己就实现了这套倍增策略(增长因子随容量在 2 与 1.25 之间变化),这里手写出来是为了看清代价从哪来。

单次 push 最坏是 Θ(n)。那么 n 次 push 是 Θ(n²) 吗?

不是。 因为扩容极其罕见,而且每次扩容后,下一次扩容要等到元素数量再翻倍。n 次 push 的总代价是 Θ(n),平均每次 Θ(1)。

摊还分析(amortized analysis) 就是用来给出这种"一串操作的总代价"的保证的。

摊还代价的定义:若任意 n 个操作的序列总代价为 T(n),则每个操作的摊还代价为 T(n)/n。

关键性质:这是最坏情况的保证,不含任何概率。 它说的是"无论对手怎么安排操作序列,总代价都不超过这个数"。


二、摊还 ≠ 平均 ≠ 期望

这三个词经常被混用,必须分清:

随机性来源 保证强度 例子
摊还(amortized) 无随机性 对任意操作序列成立 动态数组 push、并查集
平均情况(average-case) 假设输入服从某分布 分布假设不成立就失效 输入随机时的快排
期望(expected) 算法自己掷硬币 对任意输入成立 随机化快排、跳表

⚠️ 说"动态数组 push 平均是 O(1)“是不准确的,正确说法是"摊还 O(1)"——因为这里根本没有概率,它是一个确定性的最坏情况结论。

⚠️ 另一个重要限制:摊还 O(1) 不代表每次都快。 某一次 push 仍然会花 Θ(n)。对硬实时系统(音频、控制、游戏帧循环),这个尖峰可能是不可接受的,需要改用逐步搬迁(incremental resizing) 把拷贝分摊到每次操作里,换取真正的最坏情况 O(1)。


三、方法一:聚合分析(Aggregate Analysis)

思路:直接算出 n 个操作的总代价 T(n),然后除以 n。所有操作摊到同一个摊还代价。

例 1:动态数组的倍增扩容

设初始容量 1,n 次 push。扩容发生在第 1, 2, 4, 8, …, 2^k 次 push(2^k ≤ n)时,第 i 次扩容拷贝 2^(i-1) 个元素。

总代价 = n·Θ(1)              (每次 push 的基本写入)
       + Σ_{i=0}^{⌊log₂n⌋} 2ⁱ  (所有扩容的拷贝)
       = n + (2^(⌊log₂n⌋+1) − 1)
       < n + 2n
       = 3n = Θ(n)

每次 push 摊还 Θ(1)。

关键在于几何级数1 + 2 + 4 + … + n < 2n扩容代价的总和被最后一次扩容支配,而最后一次只有 Θ(n)。

⚠️ 如果按固定增量扩容会怎样? 每次容量 +c:

扩容发生在 c, 2c, 3c, … 处,总拷贝 = c + 2c + … + n = Θ(n²/c) = Θ(n²)
⟹ 摊还代价 Θ(n)

这就是为什么所有标准库都用倍增而不是固定增量。 增长因子取 2 还是 1.5 是次要的工程权衡(1.5 更利于内存复用,MSVC 用 1.5,GCC/Clang 用 2),但必须是几何增长

例 2:二进制计数器递增

k 位计数器从 0 开始,做 n 次 INCREMENT。代价 = 翻转的比特数。

// a[0] 是最低位,返回本次翻转的比特数(即代价)
func Increment(a []byte) int {
    cost, i := 0, 0
    for i < len(a) && a[i] == 1 {
        a[i] = 0 // 把连续的 1 变 0
        i++
        cost++
    }
    if i < len(a) {
        a[i] = 1 // 最低的 0 变 1
        cost++
    }
    return cost
}

单次最坏 Θ(k)(0111…1 → 1000…0)。但看每一位翻转的频率

位  0:  每 1 次递增翻转一次   →  n     次
位  1:  每 2 次翻转一次       →  n/2   次
位  2:  每 4 次翻转一次       →  n/4   次
位  i:  每 2ⁱ 次翻转一次      →  n/2ⁱ  次
                              ────────────
                       总计 < n·Σ(1/2ⁱ) = 2n

总代价 Θ(n),摊还每次 Θ(1)。 ∎ 又是几何级数。


四、方法二:记账法(Accounting Method)

思路:给每种操作指定一个摊还代价(可以大于或小于真实代价)。多收的部分作为信用(credit) 存在数据结构的某个对象上,将来给昂贵操作买单。

必须满足的条件:任何时刻,总信用不能为负

对任意前缀操作序列:Σ 摊还代价 ≥ Σ 真实代价

只有这样,摊还代价才是真实总代价的上界。

例:动态数组

给每次 push 收费 3 元

1 元  ── 付这次自己写入元素的成本
1 元  ── 存起来,将来搬自己时用
1 元  ── 存起来,将来搬"容量前半段那个没钱的老元素"时用

为什么正好是 3? 看扩容那一刻:容量从 m 变成 2m 时,需要搬 m 个元素。这 m 个元素中,有 m/2 个是上次扩容之后新加进来的(它们各自存了 2 元),有 m/2 个是更老的(它们的存款已在上次扩容时花光)。新元素的 2m/2 = m 元存款正好覆盖 m 次搬运。✓

摊还代价 = 3 = Θ(1)。

例:栈的 MULTIPOP

支持三种操作:PUSH(Θ(1))、POP(Θ(1))、MULTIPOP(k)(弹出 min(k, size) 个,代价 Θ(min(k,size)))。

单次 MULTIPOP 最坏 Θ(n)。记账法:

操作 真实代价 摊还代价
PUSH 1 2(1 元付压栈,1 元存在这个元素身上)
POP 1 0(用元素自带的存款付)
MULTIPOP(k) min(k,size) 0(每个被弹元素用自己的存款付)

每个元素最多被弹出一次,而它入栈时已预付了弹出费用。 总信用永不为负 ✓。n 个操作总代价 ≤ 2n = Θ(n)。∎

这是摊还分析最核心的直觉:一个元素只能被"处理"有限次,那么就在它进来的时候预付。 这个思路在并查集(第 23 讲)、KMP 的失配指针(第 33 讲)里都会重现。


五、方法三:势能法(Potential Method)

最强大也最常用的方法。思路:定义一个把数据结构状态映射到实数的势能函数(potential function) Φ,它度量"结构中积攒了多少将来要付的债”。

设 D₀ 是初始结构,Dᵢ 是第 i 个操作后的结构,cᵢ 是第 i 个操作的真实代价。定义

摊还代价  ĉᵢ  =  cᵢ  +  Φ(Dᵢ) − Φ(D_{i−1})
                        └──── 势能变化 ────┘

n 个操作的总摊还代价:

Σ ĉᵢ = Σ cᵢ + Φ(Dₙ) − Φ(D₀)          (望远镜求和,中间项全消)

因此,只要保证 Φ(Dₙ) ≥ Φ(D₀)(通常取 Φ(D₀) = 0 且 Φ ≥ 0),就有 Σ ĉᵢ ≥ Σ cᵢ,摊还代价是真实代价的上界。

   代价
    │   真实代价 cᵢ ▄
    │              █           ▄ 势能被消耗(扩容)
    │  ▁▁▁▁▁▁▁▁▁▁▁▁█▁▁▁▁▁▁▁▁  ← 摊还代价 ĉᵢ 保持平稳
    │  ░░░░░░░░░░░░           势能 Φ 缓慢积累
    └────────────────────► 操作序号

例 1:栈的 MULTIPOP

取 Φ(D) = 栈中元素个数。 显然 Φ(D₀) = 0,Φ ≥ 0 ✓。

操作 cᵢ ΔΦ ĉᵢ = cᵢ + ΔΦ
PUSH 1 +1 2
POP 1 −1 0
MULTIPOP(k),弹出 k′ 个 k′ −k′ 0

全部 O(1)。∎ 比记账法更机械、更不易出错。

例 2:二进制计数器

取 Φ(D) = 计数器中 1 的个数 bᵢ。

第 i 次 INCREMENT 把 tᵢ 个 1 变成 0,再把 1 个 0 变成 1(若未溢出):

cᵢ  = tᵢ + 1
ΔΦ = bᵢ − b_{i−1} = (b_{i−1} − tᵢ + 1) − b_{i−1} = 1 − tᵢ
ĉᵢ = (tᵢ + 1) + (1 − tᵢ) = 2 = Θ(1)          ∎

tᵢ 被完美消掉了——这就是好的势能函数的标志。

例 3:动态数组(含收缩)

只考虑扩容时,取 Φ(D) = 2·size − capacity(刚扩容后 size = capacity/2,Φ = 0;填满时 size = capacity,Φ = capacity)。

不触发扩容的 push:c = 1,ΔΦ = 2 ⟹ ĉ = 3。 触发扩容的 push(扩容前 size = capacity = m):

c  = m + 1                                (拷贝 m 个 + 写入 1 个)
ΔΦ = (2(m+1) − 2m) − (2m − m) = 2 − m
ĉ  = (m + 1) + (2 − m) = 3 = Θ(1)         ∎

⚠️ 收缩策略的陷阱:抖动

如果规定"size 降到 capacity/2 以下就减半容量",考虑这个操作序列:

数组刚好半满 → push(触发扩容,拷贝 n 个)
             → pop (触发收缩,拷贝 n 个)
             → push(又扩容)→ pop(又收缩)→ …

每次操作都是 Θ(n),摊还退化成 Θ(n)。 这叫抖动(thrashing)

正确做法:留出滞后区(hysteresis)——size 降到 capacity/4 才收缩到 capacity/2。这样每次扩容或收缩之后,都需要至少 capacity/4 次操作才能触发下一次,几何级数论证重新成立,摊还回到 Θ(1)。

这是本讲最有工程价值的一条结论:任何"自动伸缩"的机制都必须有滞后区,否则会在阈值处抖动。 这条规律同样适用于线程池、连接池、自动扩缩容。


六、三种方法的选择

方法 适用场景 优点 缺点
聚合法 所有操作代价相同、序列结构简单 最直观 不能给不同操作不同摊还代价
记账法 能找到自然的"谁为谁买单" 直觉清晰 需要构造性洞察,易出错
势能法 通用,尤其是复杂结构 机械、可验证、最强 势能函数需要猜

怎么猜势能函数? 问一句:“结构里现在积攒了多少’将来会很贵’的东西?”

结构 势能函数
元素个数
二进制计数器 1 的个数
动态数组 2·size − capacity
斐波那契堆 树的棵数 + 2×被标记节点数
伸展树(Splay) Σ log(子树大小)

七、课程中会再遇到摊还分析的地方

讲次 结构 摊还结论
第 5 讲 动态数组 append 摊还 O(1)
第 7 讲 散列表再散列 insert 摊还 O(1)
第 18 讲 B 树 分裂/合并的摊还次数 O(1)
第 23 讲 并查集 每操作摊还 O(α(n))
第 33 讲 KMP 失配指针总移动量 O(n)

随堂自测

  1. 用一句话说明"摊还 O(1)“与"平均 O(1)“的区别。哪一个不依赖任何假设?
  2. 若动态数组每次扩容 +100 个位置(而非翻倍),n 次 append 的总代价是多少?摊还代价呢?
  3. 用势能法证明:栈操作序列(PUSH/POP/MULTIPOP)中每个操作摊还 O(1)。写出你的 Φ 并验证 Φ ≥ 0。
  4. 一个 k 位二进制计数器同时支持 INCREMENT 和 DECREMENT。证明摊还代价不再是 O(1),给出一个使摊还退化为 Θ(k) 的操作序列。
  5. 为什么动态数组的收缩阈值取 capacity/4 而不是 capacity/2?构造 capacity/2 时的最坏序列。
  6. 某结构的操作序列中,每 √n 次操作会有一次 Θ(n) 的重建,其余操作 Θ(1)。摊还代价是多少?
  7. “摊还 O(1) 意味着不会有卡顿”——这句话对吗?在什么场景下它会造成实际问题,怎么办?