什么是摊还分析?
摊还分析其实就是评价某一个操作的代价,方法就是求某数据结构中一系列操作的平均时间。
摊还分析与概率无关,可以保证某一系列操作在最坏情况下的平均性能。
什么意思呢?举个例子,我们都知道栈这个数据结构,假设现在一个栈支持三种操作:
POP(S) //弹出栈顶元素
PUSH(S,x) //将元素x压如栈中
MultiPop(S,k) //将栈的前k个元素弹出
普通分析:
- 设栈为空栈,那么一系列操作,操作总数为n,分别执行
PUSH(S,x), POP(S), MultiPop(S,k)操作,所以栈中最大元素数量为n。 - 对于操作
MultiPop(S,k),最坏情况是O(n)O(n)O(n),因为容器最多含有n个元素。 - 可以得出,对任意的栈的
MultiPop(S,k)操作,最差时间复杂度都是O(n)O(n)O(n),那么要进行n个MultiPop(S,k)的操作,时间复杂度自然是O(n2)O(n^2)O(n2) - 这种分析是对的,但是我们从常识得知,当我们第一次取出n个元素的时候,第二次以后的
POP(S)操作时间复杂度就都是O(1)O(1)O(1)了,所以我们的普通分析并不能更准确的求出时间复杂度。于是我们引入了摊还分析。
摊还分析
- 我们的
POP(S)操作和MultiPop(S,k)操作只能在非空的栈上操作(也可以在空栈操作,最多返回null,但是这没意义)。也就是说,POP(S)能执行多少次,在于PUSH(S,x)了多少次。PUSH(S,x)了n次,那么POP(S)就只能操作n次。同理适用于MultiPop(S,k) - 对于任意的n,任意顺序的上述三种操作最多消耗
O(n)时间。像一半操作PUSH一半操作POP。那么整体来说每个操作所以需时间为O(n)/n=O(1)O(n)/n = O(1)O(n)/n=O(1)
上面看不懂没关系,下面才是重点。
问题引入
我们知道HashTable的查找时间复杂度是O(1)O(1)O(1),最差情况是O(n)O(n)O(n),这里我们认为一个HashTable是一个能够良好的解决冲突的,那么时间复杂度就是常数级别。这里就有一个问题:如何确定HashTable的最优大小?
我们的经验之谈,反正HashTable不管多大,查找时间都是常数级别,那么就让他越大越好咯。这句话本身没错,但是考虑到内存限制,我们不可能让它越大越好。现在语言中提供的API里,一般的解决方法都是给出一个固定大小的HashMap(如容量为8),当这个HashTable被填满或者填充到一定程度的时候,在对它进行动态扩容。一次扩容量为它的二倍。也就是原本为8个单位容量的表,满了后容量会变成16个单位,然后32个单位,64个单位…
上述提到的HashTable,我们会称为“动态表”(dynamic table),那么为什么会以这个速率扩容呢?为什么不每次扩容一个固定值呢?毕竟固定值也好计算,不用再进行二进制逻辑左移位来做到二倍扩充。接下来我们分析内部逻辑。
动态表
我在前面提到,当表中数据满了,会以二倍的容量扩充。
如果我们自己手动实现一个HashTable,会发现扩充其实不那么容易。由于语言的限制,我们没法在原表扩充。比如C++的:
static int size = 1 << 3;
HashTable hashtable = new HashTable(size);
//扩容
size = size << 1;
hashtable = new HashTable(size);
这么做会把原表数据丢掉。所以我们惟一的方法就是:
static int size = 1 << 3;
//原表
HashTable hashtable = new HashTable(size);
//扩容
size = size << 1;
HashTable temp = new HashTable(size);
for key in hashtable:
value = hashtable.get(key);
temp.set(key, value);
那么上述操作用的时间复杂度是多少?很好分析,原表内每个元素都拿出来放进新表,达到复制效果。因此时间复杂度是O(n)O(n)O(n)。
但是问题又来了,上述的操作只发生在表满的情况下,也就是插入时发现插不进去,溢出了,才会触发扩容操作。假设每一次插入都溢出,那么每次插入所需要的时间就是O(n)O(n)O(n),插入n个数需要的时间就是: n⋅O(n)=O(n2)n·O(n) = O(n^2)n⋅O(n)=O(n2),但是我们的实际经验告诉我们,每一次插入都溢出的情况不存在。
所以我们传统的分析方法只能告诉我们最差情况下的时间复杂度,这个时间复杂度并不精准,它的上界太大了,且在许多情况下不能很好的反映出一个算法的优劣,比如我们说快速排序的时间复杂度是O(nlogn)O(n\log n)O(nlogn),这里也可以说是O(n10086)O(n^{10086})O(n10086),因为大O定义法就是这么定义的:上界不超过。但是我们能说快排比冒泡还慢么?显然不能。因此我们引入了摊还分析法,也叫平摊分析(Amortized Analysis)来解决上述问题。
现在我们看完了标黄部分,知道了为什么引入摊还分析这个概念(虽然说快排的时间复杂度不严谨,但是不影响理解,凑合看),那么对于我们的动态表,怎么利用摊还分析得到它的时间复杂度呢?
动态表的摊还分析
我们假设一个变量cic_ici,表示第i个插入到动态表的元素的时间复杂度。那么通过对i的归类,可以引出如下公式:
ci={n,i=2n and n=1,2,3......1,i≠2n and n=1,2,3....
c_i = \begin{cases}n, i = 2^n\ and \ \ n = 1,2,3...... \\1,i \neq2^n\ and \ \ n = 1,2,3.... \end{cases}
ci={n,i=2n and n=1,2,3......1,i=2n and n=1,2,3....
换句话说,当i是2的n次方,那么这一步插入需要先扩容,时间复杂度为O(n)O(n)O(n);否则时间复杂度就是O(1)O(1)O(1).
我们列一个表出来:
| i | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|---|
| size | 1 | 2 | 4 | 4 | 8 | 8 | 8 | 8 | 16 | 16 |
| cic_ici | 1 | 2 | 3 | 1 | 5 | 1 | 1 | 1 | 9 | 1 |
| component | 1+0 | 1+1 | 1+2 | 1+0 | 1+4 | 1+0 | 1+0 | 1+0 | 1+8 | 1+0 |
上表中,component里面i+j的意思是,插入耗时与扩容耗时。我们看到插入耗时永远为1,扩容耗时却需要些数学知识:
resize=∑j=1log(n−1)2j,j∈N+
resize=\sum_{j=1}^{\log (n-1)} 2^j, j\in N^+
resize=j=1∑log(n−1)2j,j∈N+
所以扩容时间是一个等比数列,我们算一下:
resize=21+22+...+2log2(n−1)=2∗1−2log2(n−1)1−2=2∗(2log2(n−1)−1)=2n−2
resize = 2^1+2^2+...+2^{\log_2(n-1)} \\=2*\frac{1-2^{\log_2(n-1)}}{1-2}\\=2*(2^{\log_2(n-1)}-1)\\=2n-2
resize=21+22+...+2log2(n−1)=2∗1−21−2log2(n−1)=2∗(2log2(n−1)−1)=2n−2
然后加上必须有的每次插入的时间复杂度:O(n)O(n)O(n),总体的时间复杂度也就是O(3n)=O(n)O(3n) = O(n)O(3n)=O(n)
注意这是插入n个数据,那么对于每一次插入数据,时间复杂度是O(1)O(1)O(1).
有一个细节,那就是我们先求整体复杂度,再求每一步的复杂度。也就是整体复杂度是被某些步骤抬高的,但是这么平均分下来,或者说整体复杂度被平摊下来,摊到各个操作上,每个操作的平均复杂度也就求出来了。这就是聚合分析法的摊还分析的核心思想。
基础的摊还分析有三种,在下面介绍。
聚合分析法(Aggregate Analysis)的摊还分析
看一个新的例子:二进制计数器。
假设现在有一个数组A[0]~A[n-1],每一位代表第i位的值是0还是1。这实际上就是二进制-十进制转换的问题。看代码:
INCREMENT(A)
i=0
while i<k and A[i]==1 //设k-1为二进制最高位
A[i]=0
i=i+1
if i<k
A[i]=1
这是一个简单的计数器加一的操作,那么执行n次的时间复杂度是多少?
考虑到最差情况,每次都循环到最高位,修改最高位的值,循环n次,那么时间复杂度就应该是O(n∗k)O(n*k)O(n∗k)。但是我们知道,这种情况是不存在的。到达最高位后会溢出,达到最高位之前也不用每一步都找到最高位。所以这种时间复杂度的上界并不准确。
我们要求时间复杂度,也就是求A[i]翻转的次数,翻转次数就是子语句执行的次数,也就是时间复杂度。那么我们看,
第一次调用函数,A[0]变化
第二次调用函数,A[0],A[1]变化
第三次调用函数,A[0]变化
第四次调用函数A[0]A[1]A[2]变化,以此类推,找到规律:
A[0]在每次调用的时候都会变化,A[1]每两次调用变化一次,A[2]每四次调用变化一次,则A[i]每2i2^i2i调用变化一次。
所以调用n次函数,总的需要执行的变化为n+n/2+n/4+...+n/2nn+n/2+n/4+...+n/2^nn+n/2+n/4+...+n/2n,这就是一个等比数列求和的问题,过程略,结果为:n∗1−12n1−12=n∗(2−12n−1)n*\frac{1-\frac{1}{2^n}}{1-\frac{1}{2}} = n*(2-\frac{1}{2^{n-1}})n∗1−211−2n1=n∗(2−2n−11),忽略低数量级,得到O(2n)O(2n)O(2n)。
也就是说,n次调用的时间复杂度为O(n)O(n)O(n),每次调用的时间复杂度为O(1)O(1)O(1).
现在,我们知道了聚合分析法,但是这种分析还是有点困难,我们需要找到规律,或者需要一些数学功底。接下来介绍两种基于摊还代价的分析法:核算法和势能法。
核算法(Account/bank Method)的摊还分析
核算法这个名字一言难尽,我最初以为是“核 算法”,后来一想才知道是“核算 法”。反正这个名字起得真的屎。我更愿意叫它“银行家算法”,其实介绍完后,更能体会到这和银行操作如出一辙。
核心思想如下:
- 为每个操作附上一个虚拟的“摊还代价”:ci^\hat{c_i}ci^,比如某个操作需要1元钱的代价
- 为每一个操作付费,这个费用是实际费用,不是摊还费用。但是我们需要使用摊还费用给实际费用付费。
- 多出来的费用存进银行,不够的费用从银行取。
- 银行存款总是非负数(你不能像罗永浩一样欠钱,或者说老罗欠钱后就被限制消费了,同样银行里不能欠钱)
举个例子:我要去买3块橡皮,带了3个5元钱。但是呢,到了商店,这些橡皮有5块的,有8块的,有2块的,那我带了3个5元,买第一块橡皮花了5元,买第二块橡皮花了2元,剩了3元,我存起来,勤俭持家,买第三块橡皮的时候,发现身上钱不够了,需要从银行取之前剩下的3元,买完了。那么平均下来,买个买橡皮的操作代价是5元。在这个例子里,总平摊代价是15元,总实际代价是15元。当然我们也可以带15000元买三块橡皮,但是没有必要,因此,这个核算法主要目的是求最小平摊代价
上动态表的例子。
假设现在是4项已经插入完毕,正准备插入第5项,而我们赋予它的摊还代价为x,这个x包含了每次插入需要的费用(也就是1)加上复制旧表需要平摊的费用。
| i | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| cic_ici | – | – | – | – | x | x | x | x |
我们现在需要让5,6,7,8步的x弥补上复制表所需要的费用,也就是说,复制每个元素用1元钱,复制8个元素需要8元钱。(x−1)+(x−1)+(x−1)+(x−1)≥8(x-1)+(x-1)+(x-1)+(x-1)\ge 8(x−1)+(x−1)+(x−1)+(x−1)≥8,所以x≥3x \ge 3x≥3。
在这个例子中,我们赋予的每个操作的摊还代价就是3。插入n个元素的摊还代价就是O(3n)O(3n)O(3n)。
依然是栈操作的例子:
POP(S) //实际代价为1
PUSH(S,x) //实际代价为1
MultiPop(S,k) //实际代价为k
我们知道,POP命令必须先要PUSH,那么PUSH的摊还代价设为2,一个是他自己的实际代价,另一个是存在自身的信用,用来供POP取用。那么POP的摊还代价就是0,因为他只是从PUSH中取信用。
所以一系列n的PUSH+POP操作的摊还代价是O(2n)O(2n)O(2n),也就是O(n)O(n)O(n)。当然,最严谨的方法还是数学推导:假设POP代价为1,PUSH的实际代价为1,摊还代价为x,x−1≥1x-1\ge1x−1≥1,所以x≥2x\ge2x≥2。
势能法(Potential Method)的摊还分析
我们都知道能量守恒定律,说的是一个没有和外界能量交换的客体,在某一时间段起点和终点,能量保持不变。
能量守恒定律的公式可以表达为E=E末−E初E = E_{末}-E_{初}E=E末−E初
用在摊还分析上,可以表达为:Ci=Ci′+f(i)−f(i−1)C_i =C_i ' + f(i) - f(i-1)Ci=Ci′+f(i)−f(i−1)
所以总摊还为:Ci(总摊还)=Ci′(总实际)+f(i)−f(0)C_i(总摊还)= C_i'(总实际)+ f(i)-f(0)Ci(总摊还)=Ci′(总实际)+f(i)−f(0)
CiC_iCi就是我们要求的摊还结果,Ci′C_i'Ci′是第i步操作的实际代价,f(i)−f(i−1)f(i) - f(i-1)f(i)−f(i−1)指的是从第i-1步到第i步所需要的能量变化。用物理理解一下,设想一个过山车,Ci(总摊还)C_i(总摊还)Ci(总摊还)是总能量,Ci(总实际)C_i(总实际)Ci(总实际)是当前的动能,f(i)−f(0)f(i)-f(0)f(i)−f(0)是改变了的重力势能。整体忽略能量损耗。那么动能的改变量就是势能的改变量。当然能量是连续的,我们这里将能量看做离散的。
嫌麻烦,不想看公式:
我们知道,能量没有负数,最多能量差可以为负。我们这里规定势能不为负数,也就是势能永远大于等于0,如同存款一样。想了想,还是得上公式,因为势能法就需要套公式:
c^i=ci+Φ(Di)−Φ(Di−1)
\hat c_i = c_i + \Phi(D_i) - \Phi(D_{i-1})
c^i=ci+Φ(Di)−Φ(Di−1)
后面的Φ\PhiΦ指的是势能的改变量,cic_ici指的是实际代价,c^i\hat c_ic^i同样是摊还代价。
现在令ΔΦi=Φ(Di)−Φ(Di−1)\Delta \Phi_i = \Phi(D_i) - \Phi(D_{i-1})ΔΦi=Φ(Di)−Φ(Di−1),意思是势能的改变量。那么会有这个改变量和0比较的情况:
- ΔΦi>0\Delta \Phi_i > 0ΔΦi>0:说明摊还代价高于真实代价。类比过山车,摊还代价c^i\hat c_ic^i就像此时刻的总能量,真实代价类似此时刻的动能。势能增加了,是动能转换的,但是总能量不变。这个式子在物理层面就说得通了。
- ΔΦi=0\Delta \Phi_i = 0ΔΦi=0:当前时刻总能量和总动能是一样的,因为势能没有变化
- ΔΦi<0\Delta \Phi_i < 0ΔΦi<0:总能量反而小于总的动能,是因为势能变小了,而动能没变,但是呢,由于离散看待能量,势能变小和动能增加是两个动作,也就是说总能量还未加上增加的动能。此时总能量小于动能。
如果我们对他进行求和:
∑i=1nc^i=∑i=1nci+Φ(Dn)−Φ(D0)
\sum_{i=1}^n \hat c_i = \sum_{i=1}^n c_i + \Phi(D_n) - \Phi(D_0)
i=1∑nc^i=i=1∑nci+Φ(Dn)−Φ(D0)
这就更像一个标准的物理公式了,势能的增加量等于动能的减少量,或者动能的增加量等于动能的减少量。但是有一个标准,那就是一定要让Φ(Dn)−Φ(D0)≥0\Phi(D_n) - \Phi(D_0)\ge 0Φ(Dn)−Φ(D0)≥0,哪怕Φ(Di)−Φ(Di−1)\Phi(D_i) - \Phi(D_{i-1})Φ(Di)−Φ(Di−1)可能小于0.
又到了动态表的例子。
- 第一步:观察Φ(D0)\Phi(D_0)Φ(D0),初始时表的势能为0,因为表是空的,没有插入任何数据。
- 第二步:观察Φ(Dn)\Phi(D_n)Φ(Dn),我们一直没有说,为什么是DnD_nDn而不是其他字母?是因为DDD代表Data Structure,一个数据结构的势能。每一次扩充,让结构的 势能为0,;在两次扩充之间,每次操作积攒势能。到了扩充的时候,每次扩充前积攒的势能正好为扩充使用掉。设第n次插入数据,那么有i=⌈log2n⌉i=\lceil\log_2n\rceili=⌈log2n⌉次扩充。每两次扩充会多出2i−12^{i-1}2i−1个格子,而需要2i−22^{i-2}2i−2个格子的填充来为自己增加势能,所以每填一次格子,势能增加2,每扩充1个格子,势能减少1。现在增加了nnn个数据,也就是填了nnn个格子,增加的势能理应是2n2n2n,扩充了2i2^i2i个格子,所以Φ(Di)=2n−2⌈log2n⌉\Phi(D_i) = 2n-2^{\lceil\log_2 n\rceil}Φ(Di)=2n−2⌈log2n⌉
- 第三步:观察实际代价,在之前(聚合法)我们已经给出
- 第四步:求摊还代价:∑c^i=(2n−2)+2n−2⌈log2n⌉−0\sum \hat c_i = (2n-2) + 2n-2^{\lceil\log_2 n\rceil} - 0∑c^i=(2n−2)+2n−2⌈log2n⌉−0,或者改算分步骤摊还代价:c^n=cn+2n−2⌈log2n⌉−(2(n−1)−2⌈log2n−1⌉)\hat c_n = c_n + 2n-2^{\lceil\log_2 n\rceil} - (2(n-1)-2^{\lceil\log_2 n-1\rceil})c^n=cn+2n−2⌈log2n⌉−(2(n−1)−2⌈log2n−1⌉).
摊还分析用于评价数据结构中操作的平均时间复杂度,不受最坏情况影响。以动态表为例,分析了扩容时的时间复杂度,并通过摊还分析得出每个插入操作的平均复杂度为O(1)。动态表在表满时扩容,扩容操作时间复杂度为O(n),但通过摊还分析,平均每个插入操作的时间复杂度得以平摊。
722

被折叠的 条评论
为什么被折叠?



