本章主要设计理论分析,感觉第3版讲的不是很好(也有可能是翻译的语句不通顺),这里搬运了知乎上的文章。
- https://zhuanlan.zhihu.com/p/536470404
- https://zhuanlan.zhihu.com/p/577232877
- https://zhuanlan.zhihu.com/p/577594781
- https://zhuanlan.zhihu.com/p/577826672
本章前三节将介绍三种在摊还分析中最常用的方法。16.1 节将介绍聚合分析(aggregate analysis)。16.2 节将介绍核算法(accounting method)。16.3 节将介绍势能法(potential method)。
每个方法都将用两个例子进行测试,一个例子是具有 MULTIPOP 操作的栈,另一个例子是具有 INCREMENT 操作的二进制计数器。
摊还分析可以帮助我们深入理解数据结构,也可以帮助我们优化数据结构设计。
16.1 聚合分析
聚合分析(aggregate analysis):一个有 nnn 个操作的序列运行时间为 T(n)T(n)T(n) ,在最坏情况下,每个操作的平均开销(average cost)或者摊还开销(amortized cost)为 T(n)/nT(n)/nT(n)/n 。
评:因为总的摊还开销与总的实际开销渐近相等,所以每一个操作的摊还开销可以先求总开销再求平均开销。
栈操作(Stack operations)
栈有PUSH(S, x)
和POP(S)
的操作,运行时间均为 O(1)\Omicron(1)O(1)。
现在增加一个MULTIPOP(S, k)
操作,能够删除栈 SSS 顶的 kkk 个对象,伪代码如下:
MULTIPOP(S, k)
while not STACK-EMPTY(S) and k > 0
POP(S)
k = k - 1
如果对有 sss 个对象的栈 SSS 执行 MULTIPOP(S, k)
操作,运行时间为 O(min{s,k})\Omicron(min\{s, k\})O(min{s,k})。
下面分析一个由 nnn 个PUSH
、 POP
和MULTIPOP
操作组成的操作序列在一个空栈上的执行情况。开销至多是 O(n)\Omicron(n)O(n) ,虽然MULTIPOP
操作最坏情况下开销为 O(n)\Omicron(n)O(n) ,但是操作序列中一个操作的平均开销或摊还开销为 O(n)/n=O(1)\Omicron(n)/n=\Omicron(1)O(n)/n=O(1)。
二进制计数器自增(Incrementing a binary counter)
一个 kkk 位二进制计数器自增问题。计数器初始值为 0 ,用比特位数组 A[0..k−1]A[0..k-1]A[0..k−1] 表示计数器。当计数器保存二进制数 xxx 时, xxx 的最低位保存在 A[0]A[0]A[0] ,xxx 的最高位保存在 A[k−1]A[k−1]A[k−1] , x=∑i=0k−1A[i]⋅2ix=\sum_{i=0}^{k-1}A[i] \cdot 2^ix=∑i=0k−1A[i]⋅2i 。 INCREMENT(A, k)
操作的伪代码如下:
INCREMENT(A, k)
i = 0
while i < k and A[i] == 1
A[i] = 0
i = i + 1
if i < k
A[i] = 1
对于一个初始值为 0 的计数器,执行一个由 nnn 个 INCREMENT
操作组成的序列, A[i]A[i]A[i] 会翻转 ⌊n/2i⌋⌊n/2^i⌋⌊n/2i⌋ 次,其中 i=0,1,⋯ ,k−1i=0, 1, \cdots, k−1i=0,1,⋯,k−1 ,所有比特位翻转次数总计为:
∑i=0k−1⌊n2i⌋<n∑i=0∞12i=2n=O(n)
\sum_{i=0}^{k-1}\lfloor \frac{n}{2^i} \rfloor
\lt n \sum_{i=0}^{\infty} \frac{1}{2^i}
=2n
=\Omicron(n)
i=0∑k−1⌊2in⌋<ni=0∑∞2i1=2n=O(n)
即执行一个由 nnn 个 INCREMENT 操作组成的序列最坏情况下运行时间为 O(n)\Omicron(n)O(n),每个操作的平均开销或摊还开销为 O(n)/n=O(1)\Omicron(n)/n=\Omicron(1)O(n)/n=O(1) 。
17.2 核算法
核算法(accounting method):我们给不同的操作分配不同的费用(charge),某些操作的费用可能多于或少于其实际开销(actual cost)。我们将给一个操作分配的费用称为摊还开销(amortized cost)。当某个操作的摊还开销超出其实际开销,我们计算盈余差额(difference),并记录在数据结构的特定对象中,这些差额称为信用(credit)。当某个操作的摊还开销小于其实际开销,我们计算亏欠差额(difference),可以用信用来支付亏欠差额。也就是每个操作的摊还开销可以被分解为实际开销和信用(存入的或者用掉的)。
评:核算法把摊还分析模拟成记账了,amortized 翻译成摊还确实比较合适。信用是不需要提供物资保证,不立即支付现金,而凭信任所进行的,如信用贷款和信用卡透支。
核算法与聚合分析的区别:
- 核算法中不同操作的摊还开销可能不同。
- 聚合分析中不同操作的摊还开销相同。
我们必须谨慎分配每个操作的摊还开销,一个有 nnn 个操作的序列,第 iii 个操作的实际开销为 cic_ici ,摊还开销为 c^i\hat c_ic^i ,有
∑i=1nc^i≥∑i=1nci
\sum_{i=1}^n \hat c_i \ge \sum_{i=1}^n c_i
i=1∑nc^i≥i=1∑nci
数据结构中记录的信用为 ∑i=1nc^i−∑i=1nci\sum_{i=1}^n \hat c_i - \sum_{i=1}^n c_i∑i=1nc^i−∑i=1nci,我们要保证信用始终非负。
评:保证信用始终非负即保证操作序列的摊还开销是操作序列的实际开销的渐近上界。由于摊还开销求得是实际开销的渐近界,所以操作序列执行过程中就可以按照渐近界处理了。
栈操作(Stack operations)
栈操作的实际开销和摊还开销:
operation | actual cost | amortized cost |
---|---|---|
PUSH | 1 | 2 |
POP | 1 | 0 |
MULTIPOP | min{s, k} | 0 |
在设定每个操作的摊还开销时, PUSH
相当于存款,信用增加, POP
和MULTIPOP
相当于取款,信用减少。
评:可以通过栈中对象增加或者减少的数量来计算摊还开销。
- 对于每次
PUSH
操作,栈中增加 1 个对象,所以其 c^−c=1\hat c -c = 1c^−c=1 ,解得 c^=2\hat c = 2c^=2; - 对于每次
POP
操作,栈中减少 1 个对象,所以其 c^−c=−1\hat c -c = -1c^−c=−1 ,解得 c^=0\hat c = 0c^=0; - 对于每次
MULTIPOP
操作,栈中减少 min{s,k}min\{s, k\}min{s,k} 个对象,所以其 c^−c=−min{s,k}\hat c -c = -min\{s, k\}c^−c=−min{s,k},解得 c^=0\hat c = 0c^=0 。
栈的信用与栈中对象个数是相同的。
- 当我们调用
PUSH
时,我们使用其摊还开销来支付 1 的操作费用,剩余的 1 存储为信用。 - 当我们调用
POP
时,我们使用信用来支付 1 的操作费用。 - 当我们调用
MULTIPOP
时,我们使用信用来支付 min{s,k}min\{s,k\}min{s,k} 的操作费用。
下面分析一个由nnn个有PUSH
、POP
和MULTIPOP
操作组成的操作序列在一个空栈上的执行情况。总的摊还开销至多是 2⋅n=O(n)2⋅n=\Omicron(n)2⋅n=O(n),操作序列中一个操作的摊还开销为 O(n)/n=O(1)\Omicron(n)/n=\Omicron(1)O(n)/n=O(1)。
二进制计数器自增(Incrementing a binary counter)
设每翻转一个比特位开销为 1 ,由于计数器初始值为 0 ,设将一个比特位从 0 翻转到 1 摊还开销为 2 ,将一个比特位从 1 重置为 0 摊还开销为 0 。每次自增操作计数器至多有 1 个比特位从 0 翻转到 1 ,所以 nnn 次自增操作,总的摊还开销至多是 2⋅n=O(n)2⋅n=\Omicron(n)2⋅n=O(n),操作序列中一个操作的摊还开销为 O(n)/n=O(1)\Omicron(n)/n=\Omicron(1)O(n)/n=O(1)。
17.3 势能法
势能法(potential method):将预支付的开销(prepaid work)作为“势能(potential energy)”储藏,势能可以释放用于支付未来需要的开销。
对初始数据结构 D0D_0D0 执行 nnn 个操作,第 iii 个操作的实际开销为 cic_ici ,摊还开销为 c^i\hat c_ic^i ,设数据结构 DiD_iDi 为数据结构 Di−1D_{i-1}Di−1 执行第 iii 个操作转化而来。势函数(potential function)为 Φ\PhiΦ ,将数据结构 DiD_iDi 映射到势能 Φ(Di)\Phi(D_i)Φ(Di) ,有
c^i=ci+Φ(Di)−Φ(Di−1)
\hat c_i = c_i + \Phi(D_i)-\Phi(D_{i-1})
c^i=ci+Φ(Di)−Φ(Di−1)
则 nnn 个操作总摊还开销为:
∑i=1nc^i=∑i=1n(c+Φ(Di)−Φ(Di−1))=∑i=1nci+Φ(Dn)−Φ(D0)
\sum_{i=1}^n\hat c_i=\sum_{i=1}^n(c+\Phi(D_i)-\Phi(D_{i-1})) \\
=\sum_{i=1}^nc_i+\Phi(D_n)-\Phi(D_0)
i=1∑nc^i=i=1∑n(c+Φ(Di)−Φ(Di−1))=i=1∑nci+Φ(Dn)−Φ(D0)
如果能定义一个势函数 Φ\PhiΦ ,使得 Φ(Dn)≥Φ(D0)\Phi(D_n)≥\Phi(D_0)Φ(Dn)≥Φ(D0) ,则总摊还开销为总实际开销的渐近上界。
势能法与核算法的区别:
- 势能法将势能和整个数据结构关联。势能法要保证在操作结束后最终势能和初始势能的势能差非负。
- 核算法将信用和每种操作关联。核算法要保证在操作过程中信用始终非负。
评:势能法综合了聚合分析和核算法的优点,求出的总摊还开销为总实际开销的渐近上界的隐藏因子更精确。
栈操作(Stack operations)
初始栈 D0D_0D0 的势能 Φ(D0)=0\Phi(D_0)=0Φ(D0)=0 。由于栈中对象个数始终非负,因此 Φ(Di)≥0\Phi(D_i) \ge 0Φ(Di)≥0。
设状态 Di−1D_{i-1}Di−1 栈中有 sss 个对象。
PUSH
操作的势能为Φ(Di)−Φ(Di−1)=(s+1)−s=1\Phi(D_i)-\Phi(D_{i-1})=(s+1)-s=1Φ(Di)−Φ(Di−1)=(s+1)−s=1,,其摊还代价为 c^i=ci+Φ(Di)−Φ(Di−1)=1+1=2\hat c_i = c_i + \Phi(D_i)-\Phi(D_{i-1})=1+1=2c^i=ci+Φ(Di)−Φ(Di−1)=1+1=2。
同理,POP
操作的摊还开销为0,MULTIPOP
操作的摊还开销为0。
下面分析一个由 nnn 个PUSH
、POP
和MULTIPOP
操作组成的操作序列在一个空栈上的执行情况。总摊还开销至多是 2⋅n=O(n)2⋅n=\Omicron(n)2⋅n=O(n),操作序列中一个操作的摊还开销为 O(n)/n=O(1)\Omicron(n)/n=\Omicron(1)O(n)/n=O(1)。
二进制计数器自增(Incrementing a binary counter)
定义计数器执行 iii 次INCREMENT
操作后的计数器中 1 的个数为势能为 bib_ibi。
假设第 iii 次INCREMENT
操作将 tit_iti 个比特位重置为 0 ,由于至多有一个比特位从 0 翻转到 1 ,因此实际开销 cic_ici 至多为 ti+1t_i+1ti+1。
- 若 bi=0b_i=0bi=0,则第 iii 次操作使得 kkk 个比特位均为 0 ,即 bi−1=ti=kb_{i-1}=t_i=kbi−1=ti=k,即 bi=bi−1−tib_i=b_{i-1} - t_ibi=bi−1−ti。
- 若 bi>0b_i \gt 0bi>0,则 bi=bi−1−ti+1b_i=b_{i-1}-t_i+1bi=bi−1−ti+1。
综上,bi≤bi−1−ti+1b_i \le b_{i-1} - t_i + 1bi≤bi−1−ti+1。
评: bi=0b_i=0bi=0 只有在 kkk 个比特位均为 1 的基础上进行自增发生溢出(overflow)才会出现 kkk 个比特位均为 0 ,也就是特殊情况也要考虑。
Φ(Di)−Φ(Di−1)=bi−bi−1≤bi−1−ti+1−bi−1=1−ti
\Phi(D_i)-\Phi(D_{i-1})=b_i-b_{i-1} \\
\le b_{i-1}-t_i+1-b_{i-1} \\
=1-t_i
Φ(Di)−Φ(Di−1)=bi−bi−1≤bi−1−ti+1−bi−1=1−ti
一个操作的摊还开销为:
c^i=ci+Φ(Di)−Φ(Di−1)≤(ti+1)+(1−ti)=2
\hat c_i = c_i + \Phi(D_i)-\Phi(D_{i-1}) \\
\le (t_i+1)+(1-t_i) \\
=2
c^i=ci+Φ(Di)−Φ(Di−1)≤(ti+1)+(1−ti)=2
由于计数器初始值为 0 ,因此 Φ(D0)=0\Phi(D_0)=0Φ(D0)=0,又对于所有 iii,都有 Φ(Di)≥0\Phi(D_i) \ge 0Φ(Di)≥0,总摊还代价至多是 2⋅n=O(n)2⋅n=\Omicron(n)2⋅n=O(n),操作序列中一个操作的摊还开销为 O(n)/n=O(1)\Omicron(n)/n=\Omicron(1)O(n)/n=O(1)。