跳到内容

元数据卡

  • 前置知识:ch14-greedy(复杂度直觉)
  • 预计时间:25 分钟
  • 核心难度:进阶
  • 完成标志:能用三种方法解释"为什么 ArrayList 的 add 是摊销 O(1)"

你的进度

上一站你走进了复杂度暗区——有些问题根本没法快。这章恰好相反:有些操作最坏情况很慢,但放在更大的时间窗口里看,其实很快。你每天用的 list.append 就是如此。可选深入,不影响主线。

你的任务 学会三种摊还分析方法:聚合分析、记账法、势能法。用动态数组扩容解释每种方法。


动态数组扩容

动态数组满了之后,需要分配 2 倍空间并把旧数据誊抄过去。一万条记录、每条几微秒等于几百毫秒。但大多数 append 是 O(1),你不能因为"偶尔慢一次"就抛弃它。

python
class DynamicArray:
    def __init__(self):
        self.arr = [None]; self.cap = 1; self.size = 0
    def append(self, val):
        if self.size == self.cap:
            self.cap *= 2
            new = [None] * self.cap
            for i in range(len(self.arr)):
                new[i] = self.arr[i]
            self.arr = new
        self.arr[self.size] = val
        self.size += 1
# 运行: python3 dynarr.py

方法 1:聚合分析

总代价除以操作次数。初始容量 1,每次翻倍,n 次插入总代价约 n + (1+2+4+...+n/2) = 2n。平均 O(1)。

方法 2:记账法

每次插入预收摊还代价 3。普通插入(实际=1)存 2 信用;扩容时(实际=旧容量+1)消费信用。余额始终非负。

python
def accounting(n):
    cap, bal = 1, 0
    for i in range(1, n+1):
        amortized = 3
        if i > cap:
            actual = cap + 1; cap *= 2
        else:
            actual = 1
        bal += amortized - actual
    print(f"余额={bal} (应>=0)")

accounting(16)
# 预期: 余额始终为正

方法 3:势能法

定义势能 Phi = 2*size - capacity。摊还代价 = 实际代价 + Phi_new - Phi_old。

扩容时:实际=capacity+1, Phi_new-Phi_old=2-(capacity) => 摊还=3。不扩容时:摊还=3。无论是否扩容都是 3。

python
def potential(n):
    size, cap, phi = 0, 1, 0
    for i in range(1, n+1):
        prev = phi
        if size == cap:
            actual = cap + 1; cap *= 2
            phi = 2 * (size + 1) - cap
        else:
            actual = 1
            phi = 2 * (size + 1) - cap
        size += 1
        amortized = actual + phi - prev
        if i <= 3 or i == n:
            print(f"size={size}: 实际={actual} 摊还={amortized}")

potential(16)
# 扩容和不扩容摊还都是 3

案例:二进制计数器

从 0 递增到 n,总翻转次数永远 = 2n - 1,摊还 O(1)。

python
def count_flips(n):
    total = 0
    for i in range(1, n+1):
        total += (i & -i).bit_length()
    return total

for n in [1,2,4,8,16,32,64]:
    f = count_flips(n)
    print(f"n={n:4d}: 总翻转={f:5d} 均摊={f/n:.4f}")
# 均摊趋近于 2

速查表

数据结构摊还代价方法
动态数组 push_backO(1)聚合/记账/势能
二进制计数器 INCREMENTO(1)聚合(总翻转=2n-1)
并查集 find(双优化)O(alpha(n))势能法
splay 树操作O(log n)势能法

常见陷阱

卡点真相
"摊还=平均情况分析"不对。摊还是对全部操作取平均,不假设输入分布
"摊还 O(1)=每次 O(1)"错。某些操作可能很慢,但被快的次数稀释
"信用值随便定"必须数学证明余额永不至负

通关挑战

一个栈,支持 push(O(1))和 multipop(k)(O(k))。用势能法证明:连续 n 次操作摊还 O(1)。提示:势能 = 栈中元素数量。

旅人笔记

你能向非技术人员解释"最坏 200ms,但平均每次 1 微秒"时,摊还就在帮你说真话。面试中能流利解释"为什么 ArrayList.add 是 O(1)",面试官知道你不仅会用还懂原理。


下一步:高级数据结构

下一站,拿着这把新武器去面对布隆过滤器、并查集、线段树、Trie 这些更复杂的数据结构。

看高级数据结构 ->

Built with VitePress | Software Systems Atlas