docs/basic/amortized-analysis.md
前置知识:时间复杂度
本页面将介绍均摊复杂度的基础知识.
均摊分析(Amortized Analysis)是一种用于分析算法和动态数据结构性能的技术.它不仅仅关注单次操作的成本,还通过评估一系列操作的平均成本,为整体性能提供更加准确的评估.均摊分析不涉及概率,且只能确保最坏情况性能的每次操作耗费的平均时间,并不能确认系统的平均性能.在最坏情况下,均摊分析通过将高成本操作的开销分摊到低成本操作上,确保整体操作的平均成本保持在合理范围内.
均摊分析通常采用三种主要分析方法:聚合分析、记账分析和势能分析.这些方法各有侧重,分别适用于不同的场景,但它们的共同目标是通过均衡操作成本,优化数据结构在最坏情况下的整体性能表现.
考虑一个可扩展的数组,例如 C++ 中的 vector,其初始容量为 $m = 1$.每次插入新元素时,如果数组已满,则需要将数组的大小加倍,然后将原数组中的元素复制到新数组中,最后插入新元素.
接下来,将以动态数组的插入操作为例,通过聚合分析、记账分析和势能分析三种方法,分析其均摊成本.
聚合分析(Aggregate Analysis)通过计算一系列操作的总成本,并将其平均到每次操作上,从而得出每次操作的均摊时间复杂度.
以动态数组为例,首先,可以得到插入操作的两个关键成本:
所以,为了计算 n 次插入操作的总成本,可以将其分开为两部分计算:
因此,该数组总的插入成本为 $O(n)$,均摊到每次操作的成本为 $O(1)$.即使在最坏情况下,平均每次插入操作的成本依然是常数时间.
记账法(Accounting Method)通过为每次操作预先分配一个固定的均摊成本来确保所有操作的总成本不超过这些预分配的成本总和.记账法类似于一种 费用前置支付 的机制,其中较低成本的操作会存储部分费用,以支付未来高成本的操作.
以动态数组为例,可以为每次插入操作分配一个固定的均摊成本,以确保在需要扩容时已经预留了足够的费用.
费用分配:
费用使用:
以下是一个具体的示例:
初始状态:
arr = [1, 2, 3, 4] // 初始数组
amount = [2, 2, 2, 2] // 每个元素预存的费用
// 第一轮扩容:数组已满,需要扩容
arr = [1, 2, 3, 4, null, null, null, null] // 扩容后数组
amount = [2, 2, 0, 0, 0, 0, 0, 0] // 3, 4的费用用于支付扩容
// 继续插入新元素,直至再次满载
arr = [1, 2, 3, 4, 5, 6, 7, 8] // 继续填充数组
amount = [2, 2, 0, 0, 2, 2, 2, 2] // 新插入的元素同样预存费用
// 第二轮扩容:数组再次满载,需要更大的空间
arr = [1, 2, 3, 4, 5, 6, 7, 8, null, null, null, null, null, null, null, null] // 扩容后数组
amount = [2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] // 5, 6, 7, 8的费用用于支付扩容
以上过程表明,每次插入操作所存储的均摊成本足够支付未来的扩容操作,从而确保了每次操作的均摊成本维持在 $O(1)$.
势能分析(Potential Method)通过定义一个势能函数(通常表示为 $\Phi$),度量数据结构的 潜在能量,即系统状态中的预留资源,这些资源可以用来支付未来的高成本操作.势能的变化用于平衡操作序列的总成本,从而确保整个算法的均摊成本在合理范围内.
首先,定义 状态 $S$ 为某一时刻数据结构的状态,该状态可能包含元素数量、容量、指针等信息,其中定义初始状态为 $S_0$,即未进行任何操作时的状态.
其次,定义势能函数 $\Phi(S)$ 用于度量数据结构状态 $S$ 的势能,其满足以下两个性质:
对于每个操作,其均摊成本 $\hat{c}$ 定义为:
$$ \hat{c} = c + \Phi(S') - \Phi(S) $$
其中 $c$ 为操作的实际成本,$S$ 和 $S'$ 分别表示操作前后的数据结构状态.该公式表明,均摊成本等于实际成本加上势能的变化.如果操作增加了势能(即 $\Phi(S') > \Phi(S)$),则均摊成本上升;如果操作消耗了势能(即 $\Phi(S') < \Phi(S)$),则均摊成本下降.
我们可以通过势能函数来分析一系列操作的总成本.设 $S_1, S_2, \dots, S_m$ 为从初始状态 $S_0$ 开始,经过 $m$ 次操作后产生的状态序列,$c_i$ 为第 $i$ 次操作的实际开销,那么第 $i$ 次操作的均摊成本 $p_i$ 为:
$$ p_i = c_i + \Phi(S_i) - \Phi(S_{i-1}) $$
因此,$m$ 次操作的总时间花销为:
$$ \sum_{i=1}^m c_i = \sum_{i=1}^m p_i + \Phi(S_0) - \Phi(S_m) $$
由于 $\Phi(S) \geq \Phi(S_0)$,总时间花销的上界为:
$$ \sum_{i=1}^m p_i \geq \sum_{i=1}^m c_i $$
因此,若 $p_i = O(T(n))$,则 $O(T(n))$ 是均摊复杂度的一个上界.
以动态数组 vector 的插入操作为例,定义如下的势能函数 $\Phi(h)$:
$$ \Phi(h) = 2n - m $$
其中,$n$ 是数组中的元素数量,$m$ 是数组的当前容量.这个势能函数反映了数组中剩余可用空间的数量,即当前容量和实际使用空间之间的差异.
插入操作(无需扩容):
插入操作(触发扩容):
通过上述分析可以看出,尽管扩容操作的实际成本较高,但由于势能函数的设计,整体均摊成本仍然保持在常数级别 $O(1)$.
堆栈操作是均摊分析的经典应用场景之一.假设堆栈 S 支持以下三种操作:
| 操作 | 说明 | 实际成本 $c_i$ |
|---|---|---|
S.push(x) | 将元素 x 入栈 | $1$ |
S.pop() | 弹出栈顶元素 | $1$ |
S.multi-pop(k) | 弹出栈顶 k 个元素 | $O(\min{\lvert S\rvert, k})$ |
我们将通过聚合分析、记账分析和势能分析三种方法来分析这些堆栈操作的均摊成本.
聚合分析将计算所有操作的总成本,并将其平均分摊到每个操作上,从而得出均摊成本.
push(x) 操作,每次的成本为 $O(1)$,因此总成本为 $O(n_{push})$.pop() 操作,每次的成本为 $O(1)$,总成本为 $O(n_{pop})$.multi-pop(k) 操作,尽管每次的实际成本为 $O(\min(\lvert S \rvert, k))$,但这些操作弹出的元素数量不会超过之前 push(x) 的元素数量,因此总成本仍受 $n_{push}$ 的约束.由于总操作次数 $n = n_{push} + n_{pop} + n_{multi-pop} \leq 2 \times n_{push}$,所以总成本为 $O(n_{push}) = O(n)$,每次操作的均摊成本为 $O(n)/n = O(1)$.
记账分析为每次 push(x) 操作预留一部分费用,以支付未来可能的 pop() 或 multi-pop(k) 操作.
S.push(x):假设每次 push(x) 操作的均摊成本为 $2$,其中 $1$ 单位用于当前操作,另 $1$ 单位存储为费用,用于支付未来的 pop() 或 multi-pop(k) 操作.S.pop():实际成本为 $1$,但由于之前的 push(x) 操作已为其预存了 $1$ 单位费用,因此均摊成本为 $0$.S.multi-pop(k):每个弹出的元素的实际成本为 $1$,可以由之前该元素的 push(x) 操作预存的费用支付,因此均摊成本为 $0$.通过以上分析,入栈操作预存的费用足以支付未来该元素的出栈操作,因此每次操作的均摊成本为 $O(1)$.
势能分析定义了一个势能函数来衡量堆栈的状态,并利用势能的变化来平衡操作成本.
S.push(x):每次 push(x) 操作增加堆栈中的元素数量,势能增加 $1$,因此均摊成本为 $1 + 1 = 2$.S.pop():每次 pop() 操作减少堆栈中的元素数量,势能减少 $1$,因此均摊成本为 $1 - 1 = 0$.S.multi-pop(k):multi-pop(k) 操作弹出 $k$ 个元素,势能减少 $k$,因此均摊成本为 $k - k = 0$.通过以上势能函数设计,push(x) 操作的均摊成本为 $2$,而 pop() 和 multi-pop(k) 操作的均摊成本为 $0$.因此,所有堆栈操作的均摊成本均为 $O(1)$.