摊还分析
简介
在分析算法时间复杂度的时候,往往有以下三种:
- 最坏情况分析 (Worst-case Analysis)
- 平均情况分析 (Average-case Analysis)
- 摊还分析 (Amortized Analysis)
一个算法的最坏运行时间给出了运行时间的上界;在某些特定情况下,也会对算法的平均运行时间感兴趣,但是平均情况分析的范围有限,因为对于特定的问题,算法输入的分布并不明显,很多时候常常假定给定规模(比如假设输入数组大小为 n n n)的所有输入具有相同的可能性,即输入的分布为均匀分布。
不同于最坏情况和平均情况分析只是针对某个单独的操作,摊还分析是求一个操作序列中所有操作的平均时间,即使某个单一操作的代价很高,但是只要它的平均代价低即可。不同于平均情况分析,摊还分析不涉及概率,它可以保证最坏情况下每个操作的平均性能。
初学时这段话不理解很正常,建议先看明白聚合分析中的案例后,再回过头来理解这段话
下面首先讲述摊还分析中最常用的三种技术,即聚合分析、核算法和势能分析,最后再对动态表进行摊还分析
本文内容是在学习了《算法导论》对摊还分析的讲解,总结出来的
聚合分析 (Aggregate Analysis)
所谓聚合分析,就是证明一个 n n n 个操作的序列最坏情况下花费的总时间为 T ( n ) T(n) T(n),然后我们就可以得到最坏情况下每个操作的平均代价,即摊还代价为 T ( n ) / n T(n)/n T(n)/n。
注意这里的摊还代价 T ( n ) / n T(n)/n T(n)/n 是适用于所有的操作的,哪怕序列中有不同类型的操作。而后面的核算法和势能法,可能会对不同类型的操作赋予不同的摊还代价。
栈操作
下面考虑对一个栈 S S S 的三种操作:
- Push ( S , x ) \text{Push}(S, x) Push(S,x): 将 x x x 压入栈中
- Pop ( S , x ) \text{Pop}(S, x) Pop(S,x): 从栈顶弹出元素
- MultiPop ( S , k ) \text{MultiPop}(S, k) MultiPop(S,k): 从栈顶弹出 k k k 元素
下面是 MultiPop ( S , k ) \text{MultiPop}(S, k) MultiPop(S,k) 的伪代码实现
MultiPop(S, k):
while not S.is_empty() and k > 0:
Pop(S)
k = k - 1
下面来分析一个由 n n n 个 Push \text{Push} Push, Pop \text{Pop} Pop 和 MultiPop \text{MultiPop} MultiPop 组成的操作序列在一个空栈上的最坏运行时间。由于 MultiPop \text{MultiPop} MultiPop 的最坏运行时间为 O ( n ) O(n) O(n) (因为栈大小最大为 n n n),因此一个 n n n 个操作的序列的最坏情况代价为 O ( n 2 ) O(n^2) O(n2)。
但是这不是一个好的上界,通过聚合分析,我们可以得到一个更好的上界。 MultiPop \text{MultiPop} MultiPop 是由若干个 Pop \text{Pop} Pop 组成,在一个空栈上运行一个 n n n 个操作的序列,我们可以 Pop \text{Pop} Pop 的次数(包括 MultiPop \text{MultiPop} MultiPop 中的 Pop \text{Pop} Pop)最多和 Push \text{Push} Push 的数目一样,而 Push \text{Push} Push 的次数最多为 n n n, 因此最终 Push \text{Push} Push 和 Pop \text{Pop} Pop(包括 MultiPop \text{MultiPop} MultiPop 中的 Pop \text{Pop} Pop)的总次数最多为 2 n 2n 2n,故一个空栈上运行一个 n n n 个操作的序列的最坏情况代价为 O ( n ) O(n) O(n)。所以摊还代价为 O ( n ) / n = O ( 1 ) O(n)/n=O(1) O(n)/n=O(1)。
在聚合分析中,每个操作的平均代价即为摊还代价,因此所有三种栈操作的摊还代价都为 O ( 1 ) O(1) O(1)
所谓聚合分析,就是从整体上去求一个 n n n 个操作的序列在最坏情况下花费的总时间,具体怎么求,需要根据具体操作来进行分析,没有一个固定的思路
二进制计数器递增
下面来看一个 k k k 位二进制计数器递增的问题,计数器的初值为0。我们用一个位数组 A [ 0.. k -1 ] A[0..k\text{-1}] A[0..k-1] 作为计数器,其中 A . l e n g t h = k A.length=k A.length=k。当计数器中保存的二进制值为 x x x 时, x x x 的最低位保存在 A [ 0 ] A[0] A[0] 中,最高位保存在 A [ k -1 ] A[k\text{-1}] A[k-1] 中。下面是二进制计数器递增的算法。
Increment(A):
i = 0
while i < A.length and A[i] == 1:
A[i] = 0
i = i + 1
if i < A.length:
A[i] = 1
下面考虑对初值为0的计数器执行 n n n 个 Increment \text{Increment} Increment 操作的的最坏情况代价。显然该代价的一个上界为 O ( n k ) O(nk) O(nk),但是可以得到一个更好的上界。
一次 Increment \text{Increment} Increment 操作的运行时间和翻转(0变为1,或者1变为0)的二进制位的数目呈线性关系,因此下面考虑对初值为0的计数器执行 n n n 个 Increment \text{Increment} Increment 操作,数组 A A A 中的位最多翻转的次数。我们可以手动执行一下 Increment \text{Increment} Increment 操作,不难发现, A [ 0 ] A[0] A[0] 每次调用 Increment \text{Increment} Increment 都会翻转, A [ 1 ] A[1] A[1] 每两次调用才会翻转一次, A [ 2 ] A[2] A[2] 每四次调用才会翻转一次,…。因此对于 n n n 个 Increment \text{Increment} Increment 操作, A [ i ] A[i] A[i] 翻转的次数为 ⌊ n / 2 i ⌋ \lfloor n/2^i\rfloor ⌊n/2i⌋,因此数组 A A A 中的位最多翻转的次数为 ∑ i = 0 k − 1 ⌊ n / 2 i ⌋ < n ∑ i = 0 ∞ 1 2 i = 2 n \sum_{i=0}^{k-1}\lfloor n/2^i\rfloor<n\sum_{i=0}^{\infty}\frac{1}{2^i}=2n ∑i=0k−1⌊n/2i⌋<n∑i=0∞2i1=2n。
因此初值为0的计数器执行 n n n 个 Increment \text{Increment} Increment 操作的的最坏情况代价为 O ( n ) O(n) O(n),每个操作的摊还代价为 O ( 1 ) O(1) O(1)。
核算法 (Accounting Method)
使用核算法进行摊还分析时,需要给不同的操作赋予不同的费用,这个费用称为摊还代价。需要注意的是,一个操作的摊还代价可能多于或者少于它的实际代价。对于一系列操作,我们需要为每个操作缴纳它的摊还代价,当该操作的摊还代价大于其实际代价的时候,多交的费用会存入到特定的地方,这一部分多出来的费用称为 信用(credit)
;当该操作的摊还代价小于其实际代价的时候,少交的费用会从信用中扣除。
我们必须谨慎地选择各个操作的摊还代价。如果我们希望通过分析摊还代价来证明每个操作的平均代价的最坏情况很小,就应该确保操作序列的总摊还代价给出了序列总实际代价的上界,也就是说,在为每个操作缴纳它的摊还代价的时候,我们需要确保,当该操作的摊还代价小于其实际代价时,信用中有足够的费用来弥补少交的费用。如果用
c
i
c_i
ci 表示第
i
i
i 个操作的实际代价,用
c
i
^
\hat {c_i}
ci^ 表示第
i
i
i 个操作的摊还代价,则对任意一个长度为
n
n
n 的操作序列,对任意的
1
≤
k
≤
n
1 \leq k \leq n
1≤k≤n,都有
∑
i
=
1
k
c
i
^
≥
∑
i
=
1
k
c
i
\sum_{i=1}^k \hat{c_i} \ge \sum_{i=1}^k {c_i}
i=1∑kci^≥i=1∑kci
从上式中可以得出,在我们给每个操作逐个缴纳它的摊还代价时,信用必须一直为非负值。
使用核算法进行摊还分析,首先需要为每个操作赋予对应的摊还代价;然后需要证明,总摊还代价一直不小于总实际代价,即信用一直为非负值,在这过程中一般需要先明确各操作的实际代价;最后再计算这 n n n 个操作的总摊还代价,即可得到这 n n n 个操作的总实际代价的一个上界。
栈操作
首先,我们可以确定每个操作的实际代价,如下所示
Push \text{Push} Push: 1
Pop \text{Pop} Pop: 1
MultiPop \text{MultiPop} MultiPop: min ( k , s ) \min(k, s) min(k,s), k k k 是弹出元素的数目, s s s 是调用时栈的大小
然后我们定义每个操作的摊还代价,如下所示
Push \text{Push} Push: 2
Pop \text{Pop} Pop: 0
MultiPop \text{MultiPop} MultiPop: 0, k k k 是弹出元素的数目, s s s 是调用时栈的大小
下面我们需要证明,在为每一个操作缴纳摊还代价的时候,信用一直为非负值。
每次我们
Push
\text{Push}
Push 一个元素的时候,都会缴费2元(
Push
\text{Push}
Push 操作的摊还代价为2),由于
Push
\text{Push}
Push 操作的实际代价是1,因此,每一个栈中的元素都会有额外的1元存储在信用里。对于
Pop
\text{Pop}
Pop 操作和
MultiPop
\text{MultiPop}
MultiPop 操作,我们不需要为其缴费(因为它们的摊还代价都是0),对于
Pop
\text{Pop}
Pop 操作,它会弹出栈顶的元素,由于每一个栈中的元素都会有额外的1元存储在信用里,所以此时信用一定可以支付少交的费用(
Pop
\text{Pop}
Pop 的实际代价是1,少交了1元);对于
MultiPop
\text{MultiPop}
MultiPop 操作,它是由若干个
Pop
\text{Pop}
Pop 组成,同样的道理,在它每次调用
Pop
\text{Pop}
Pop 弹出当前栈顶的元素时,此时信用一定可以支付少交的费用。因此,在为每一个操作缴纳摊还代价的时候,信用一直为非负值,也就是说,总摊还代价一直不小于总实际代价。
接下来我们计算任意
n
n
n 个
Push
\text{Push}
Push、
Pop
\text{Pop}
Pop 和
MultiPop
\text{MultiPop}
MultiPop 操作组成的序列的总摊还代价,显然其总摊还代价为
O
(
n
)
O(n)
O(n),因此总实际代价也为
O
(
n
)
O(n)
O(n)。
二进制计数器递增
一次
Increment
\text{Increment}
Increment 操作是由若干个置位和复位所组成,置位和复位的实际代价均为 1,因此这里将翻转(置位或者复位 )的位数作为该操作的实际代价。然后定义置位的摊还代价为 2,复位的摊还代价为 0。
下面我们需要证明,在为每一个操作缴纳摊还代价的时候,信用一直为非负值。
一次
Increment
\text{Increment}
Increment 操作是由若干个置位和复位所组成,当我们置位的时候,会缴纳 2 元,多出来的 1 元会存储在信用里,也就是说,每一个值为 1 的比特位,都会有对应的 1 元存储在信用中;当我们复位的时候,我们不需要缴纳费用,由于每一个值为 1 的比特位都会有对应的 1 元存储在信用中,所以此时信用一定可以支付少交的这 1 元。所以信用一直为非负值,即总摊还代价一直不小于总实际代价。
接下来我们计算任意
n
n
n 个
Increment
\text{Increment}
Increment 操作组成的序列的总摊还代价。由
Increment
\text{Increment}
Increment 的算法可知,一次
Increment
\text{Increment}
Increment 操作最多会置位一次,由于复位的摊还代价为 0,也就是说,一次
Increment
\text{Increment}
Increment 操作的摊还代价最多为 2,因此总摊还代价为
O
(
n
)
O(n)
O(n),总实际代价也为
O
(
n
)
O(n)
O(n)。
势能法 (Potential Method)
动态表 (Dynamic Array)
对于某些程序,我们无法事先知道它会有多少个对象存储在表中。我们为表分配了一定的内存空间,随后可能发现不够用,于是必须为其重新分配更大的空间,然后将所有对象从原表中复制到新的空间中。类似地,如果从表中删除了很多对象,可能为其重新分配一个更小的内存空间就是值得的。可以使用摊还分析证明,虽然插入和删除操作可能会引起表扩张或者收缩,从而有较高的实际代价,但是它们的摊还代价都是 O ( 1 ) O(1) O(1)。
C++的vector就是采用了这种技术