平摊分析
首先解释一下平摊分析的目的,之所以会有平摊分析是因为在很多算法或数据结构操作中我们直观的或用一般的方法计算出来的时间上界不够紧凑(也就是估大了),这是因为在一系列的数据结构操作中,有的操作消耗时间大有的消耗时间小,因此提出平摊分析技术计算所有数据结构操作后的平均时间代价以提高算法时间复杂度的紧凑度.另一方面,由于估计算法时间复杂度我们都要估计一个上界,因此无论是一般的方法还是利用平摊分析技术,我们的目标都是估计算法的上界.这点也在聚集分析,记账方法和势能方法中有所体现.要记住:平摊分析不涉及概率,平摊分析保证在最坏情况下,每个操作具有的平均性能.
在平摊分析中,执行一系列数据结构操作所需要的时间是通过对执行的所有操作求平均而得出的。平摊分析可用来证明在一系列操作中,即使单一的操作具有较大的代价,通过对所有操作求平均后,平均代价还是很小的。平摊分析与平均情况分析的不同之处在于它不牵涉到概率。这种分析保证了在最坏情况下每个操作具有平均性能。
本文将讨论平摊分析技术中最常用的三种技术:
- 聚集方法 —— 可以用这种方法确定一个n个操作的序列的总代价的上界T(n)。每个操作的平摊代价可表示为T(n)/n;
- 会计方法 —— 用它可确定每个操作的平摊代价。当有一种以上的操作时,每种操作都可有一个不同的平摊代价。这种方法对操作序列中的某些操作先“多记帐”,将多记的部分作为对数据结构中的特定对象上预付的存款存起来。在该序列中稍后要用到这些存款以补偿那些对它们记的“帐”少于其实际代价的操作。
- 势能方法 —— 它与会计方法的相似之处在于要确定每个操作的代价,且先对某些操作多记帐以补偿以后的不足记帐。这种方法将存数作为数据结构的“势能”来维护,而不是将存款与数据结构中的单个对象联系起来。
我们将用两个例子来说明这三个模型。第一个例子是个栈,它有一个新的操作MULTIPOP,它可一次弹出几个对象。另一个例子是个二进计数器,它利用操作INCREMENT从0开始计数。
在阅读本文时,读者应记住在平摊分析中所记的“帐”只是为了分析之用,它们不应出现在代码中。例如,当在采用会计方法时,如果将一存款赋予一个对象,在代码中就没有必要对其属性credit[x]赋一个相应的值了。
通过做平摊分析而获得的对某种数据结构特性的认识有助于优化设计。例如,后文中我们将用势能方法来分析一个动态扩充和收缩的表。
聚集方法
在平摊分析的聚集方法中,我们要证明对所有的n,n个操作构成的序列在最坏情况下总的时间为T(n)。在最坏情况下,每个操作的平摊代价就为T(n)/n。请注意这个平摊代价对每个操作都是成立的,即使当序列中存在几种类型的操作时也是一样的。后文中要研究的另两种方法—— 会计方法和势能方法——对不同类型的操作则可能赋予不同的平摊代价。
栈操作
在关于聚集方法的第一个例子中,我们要来分析增加了一个新操作的栈。我们知道,栈的两种基本操作 ——PUSH和POP,每个的时间代价都是O(1):
- PUSH(S,x) —— 将对象x压入栈S;
- POP(S) —— 弹出并返回S的顶端元素;
因为这两个操作的运行时间都为O(1),故我们可把每个操作的代价视为1。这样,一个n个PUSH和POP操作的序列的总代价就为n,而这n个操作的实际运行时间就为O(n)。
如果再增加一个栈操作MULTIPOP(S,k),则情况会变得更有趣。该操作去掉S的k个顶端对象,或当它包含少于k个对象时弹出整个栈。在下面的伪代码中,如果当前栈中没有对象则操作STACK-EMPTY返回TRUE,否则它返回FALSE。
MULTIPOP(S,k)
1 while not STACK-EMPTY(S) and k≠0
2 do POP(S)
3 k ← k-1
图1演示了MULTIPOP的一个例子,初始情况见图1(a)。最上面的四个对象由MULTIPOP(S,4)弹出,其结果如(b)中所示。下一个操
作是MULTIPOP(S,7),它将栈清空——如图1(c)中所示——因为余下的对象已经不足七个了。
图1 MULTIPOP作用于栈S上的动作
MULTIPOP(S,k)作用于一个包含s个对象的栈上的运行时间怎样呢?实际的运行时间与实际执行的POP操作数成线性关系,因而只要按PUSH和POP具有抽象代价1来分析MULTIPOP就是够了。代码中while循环执行的次数即从栈中弹比的对象数min(s,k)。对该循环的每一次执行,在第2行中都要调用一次POP。这样,MULTIPOP的总代价即址为 min(s,k),而实际运行时间则为这个代价的一个线性函数。
现在来对作用于一个初始为空的栈上的n个PUSH,POP和MULTIPOP操作构成的序列作个分析。序列中一次MULTIPOP操作的最坏情况代价为O(n),因为栈的大小至多为n。这样,任意栈操作的最坏情况时间就是O(n),而n个操作的总代价就是O(n2),因为MULTIPOP操作可能会有O(n)个,每个的代价为O(n)。虽然这个分析是正确的,但通过分析每个操作的最坏情况代价而得的O(n2)的结论却是不够准确的。
利用平摊分析中的聚集方法,我们可以或的一个考虑到了整个操作序列的更好的上界。事实上,虽然某一次MULTIPOP操作的代价可能较高,但作用于初始为空的栈上的任意一个包含n个PUSH,POP和MULTIPOP操作的序列的代价至多为O(n)。为什么会是这样呢?一个对象在每次被压入栈后至多被弹出一次。所以,在一个非空栈上调用POP的次数(包括在MULTIPOP内的调用)至多等于PUSH操作的次数,即至多为n。对任意的n值,包含n个PUSH,POP和MULTIPOP操作的序列的总时间为O(n)。据此,每个操作的平摊代价为:O(n)/n=O(1)。
我们想再一次强调一下,虽然我们已说明了每个栈操作的平均代价(或平均运行时间)为O(1),但没有用到任何概率推理。实际上是给出了一列n个操作的最坏情况界O(n)。用n来除这个总代价即可得每个操作的平均代价(或说平摊代价)。
二进制计数器
作为聚集方法的另一个例子,考虑实现一个由0开始向上计数的k位二进制计数器的问题。我们用一个数组A[0..k-1](此处length[A]=k)作为计数器。存储在计数器中的一个二进数x的最低位在A[0]中,最高位在A[k-1]中,故
开始时,x=0,故A[i]=0,i=0,1,...,k-1。为将计数器中的值加1(模2k),我们可用下面的过程:
INCREMENT(A)
1 i←0
2 while i<length[A] and A[i]=1
3 do A[i]←0
4 i←i+1
5 if i<length[A]
6 then A[i]←1
这个算法与硬件实现的行波进位计数器基本上是一样的。图2演示了一个二进制计数器从0至16的16次增值的过程。发生翻转而取得下一个值的位都加了阴影。右边示出了位翻转所需的代价。注意总代价始终不超过INCREMENT操作总次数的两倍。在第2-4行中每次while循环的开始,我们希望在位置i处加1。如果A[i]=1,则加1后就将位置i处的数位置为0,并产生一个进位1,它在循环的下一次执行中加到位置i+1上;否则,循环结束;然后,如果i<k,我们知道A[i]=0,故将1加到位置i后,使0变为1,这在第6行中完成。每次INCREMENT操作的代价与被改变值的位数成线性关系。
像在栈的例子中一样,大致分析一下只能得到一个正确但不紧确的界。在最坏情况下, INCREMENT的每次执行要花O(k)时间,此时数组A中包含全1。这样,在最坏情况下,作用于一个初始为零的计数器上的n个INCREMENT操作的时间就为O(nk)。
如果我们分析得更精确一些的话,则可得到n次lNCREMENT操作的序列的最坏情况代价为O(n)。关键是要注意到在每次调用INCREMENT中,并不是所有的位都发生变化:作用于初始为零的计数器上的n次lNCREMENT操作导致A[1]变化了ë n/2 û次。类似地,位A[2]在n次INCREMENT操作中共变化ë n/4 û次。一般地,对i=0,1,..,ë ㏒n û,位A[i]在一个作用于初始为零的计数器上的n次INCREMENT操作的序列中共要翻转ë n/2i û次。对i > ë ㏒n û,位A[i]始终不发生变化。这样,在序列中发生的位翻转的总次数为
由此可知,作用于一个初始为零的计数器上的n次INCREMENT操作的最坏情况时间为O(n),因而每次操作的平摊代价为O(n)/n=O(1)。
Counter | A[7] | A[6] | A[5] | A[4] | A[3] | A[2] | A[1] | A[0] | Total | ||
0 |
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
|
1 |
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 1 |
|
2 |
| 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 3 |
|
3 |
| 0 | 0 | 0 | 0 | 0 | 0 | 1 | 1 | 4 |
|
4 |
| 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 7 |
|
5 |
| 0 | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 8 |
|
6 |
| 0 | 0 | 0 | 0 | 0 | 1 | 1 | 0 | 10 |
|
7 |
| 0 | 0 | 0 | 0 | 0 | 1 | 1 | 1 | 11 |
|
8 |
| 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 15 |
|
9 |
| 0 | 0 | 0 | 0 | 1 | 0 | 0 | 1 | 16 |
|
10 |
| 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 | 18 |
|
11 |
| 0 | 0 | 0 | 0 | 1 | 0 | 1 | 1 | 19 |
|
12 |
| 0 | 0 | 0 | 0 | 1 | 1 | 0 | 0 | 22 |
|
13 |
| 0 | 0 | 0 | 0 | 1 | 1 | 0 | 1 | 23 |
|
14 |
| 0 | 0 | 0 | 0 | 1 | 1 | 1 | 0 | 25 |
|
15 |
| 0 | 0 | 0 | 0 | 1 | 1 | 1 | 1 | 26 |
|
16 |
| 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 31 |
|
图2 在16次INCREMENT操作作用下,一个八位二进制计数器的值从0变到16
会计方法
在平摊分析的会计方法中,我们对不同的操作赋予不同的费值,某些操作的费值比它们的实际代价或多或少。对每一操作所记的费值即其平摊代价。当一个操作的平摊代价超过了它的实际代价时,两者的差值就被作为存款赋给数据结构中一些特定的对象。存款可在以后用于补偿那些其平摊代价低于其实际代价的操作。这样,我们就可将一操作的平摊代价看作为两部分——实际代价与存款(或被储蓄或被使用)。这与聚集方法有很大不同,后者所有操作都具有相同的平摊代价。
在选择操作的平摊代价时是要非常小心的。如果我们希望通过对平摊代价的分析说明每次操作具有较小的最坏情况平均代价,则操作序列的总的平摊代价就必须是该序列的总的实际代价的一个上界。而且,像在聚集方法中一样,这种关系必须对所有的操作序列都成立。这样,与该数据结构相联系的存款始终应该是非负的,因为它表示了总的平摊代价超过总的实际代价的部分。如果允许总的存款为负的话(开始时对某些操作的费值记得过低),则在某一时刻总的平摊代价就会低于总的实际代价。对到该时刻为止的操作序列来说,总的平摊代价就不会是总的实际代价的一个上界。所以,我们必须始终注意数据结构中的总存款不能是负的。
栈操作
为了说明平摊分析中的会计方法,我们再回过头看看栈的例子。各栈操作的实际代价为:
- PUSH 1
- POP 1
- MULTIPOP min(k,s)
其中k为MULTIPOP的一个参数,s为调用该操作时栈的大小。现对它们赋予以下的平摊代价:
- PUSH 2
- POP O
- MULTIPOP O
请注意MULTIPOP的平摊代价是个常数0,而它的实际代价却是个变量。此处所有的三个平摊代价都是O(1),但一般来说所考虑的各种操作的平摊代价会渐近地变化。
现在我们来说明只需要用平摊代价就支付任何的栈操作序列。假设我们用1元钱来表示代价的单位。开始时栈是空的。栈数据结构与在餐馆中一堆迭放的盘子类似。当将一个盘子压入堆上时,我们用1元来支付该压入动作的实际代价,并有1元的存款(记的是2元的帐),将该1元钱放在刚压入的盘子的上面。在任何一个时间点上,堆中每个盘子的上而都有l元钱的余款。
盘中所存的钱是用来预付将盘从栈中弹出所需代价的。当我们在执行了一个POP操作时,对该操作不用收任何费,只要用盘中所存放的余款来支付其实际代价即可。为弹出一个盘子,我们拿掉该盘子上的1元余款,并用它来支付弹出操作的实际代价。这样,在对PUSH操作多收了一点费后,就无需对POP操作收取任何费用。
更进一步,我们对MULTIPOP操作也无需收费。为弹出第一个盘子,我们取出其中的1元余款并用它支付一次POP操作的实际代价。为弹出第二个盘子,再取出该盘子上的1元余款来支付第二次POP操作,等等。这样,对任意的包含n次PUSH,POP和MULTIPOP操作的序列,总的平摊代价就是其总的实际代价的一个上界。又因为总的平摊代价为O(n),故总的实际代价也为O(n)。
二进制计数器的增值
为进一步说明会计方法,我们再来分析一下作用于一个初始为0的二进制计数器上的INCREMENT操作。我们前面已经说过,这个操作的运行时间与发生翻转的位数是成正比的,而位数在本例中即为代价。我们还是用1元钱来表示位数代价(此例中即为某一位的翻转)。
为进行平摊分析,我们规定对将某一位置为1的操作收取2元的平摊费用。当某数位被设定后,我们用2元中的1元来支付置位操作的实际代价,而将另1元存在该位上作为余款。在任何时间点上,计数器中每个1上都有1元余款。这样在将某位复位成0时不用支付任何费用,只要取出该位上的1元余款即可。
现在就可以来确定INCREMENT的平摊代价了。在while循环中复位操作的代价是由有关位上的余款来支付的。在INCREMENT的第6行中至多有一位被复位,所以一次INCREMENT操作的代价至多为2元。又因为计数器中为1的位数始终是非负的,故其中总的余款额也是非负的。对n次INCREMENT操作,总的平摊代价为2n元,即O(n),这就给出了总的实际代价的一个界。
势能方法
平摊分析中的势能方法不是将已预付的工作作为存储在数据结构特定对象中的存款来表示,而是表示成--种“势能”,或“势”,它在需要时可释放出来以支付后面操作的代价。势是与整个数据结构而不是其中的个别对象发生联系的。
我的理解:
1):与记账方法的相似之处:
<i>要确定每个操作的代价.
<ii>.先对某些操作多记帐以补偿以后的不足记帐.
2):与记账方法的不同之处:
<i>.把记账方法的存款表示成一种势或"势能",在需要的时候释放.
<ii>.这种方法将存数作为整个数据结构的“势能”来维护,而不是将存款与数据结构中的单个对象发生联系.
势能方法的工作过程是这样:开始时先对一个初始数据结构D0执行n个操作,对每个i=1,2,..n,设ci为第i个操作的实际代价,Di为对数据结构Di-1作用第i个操作的结果。势函数F将每个数据结构Di映射为一个实数F(Di),即与数据结构Di相联系的势。第i个操作的平摊代价定义为:
(1)
从这个式子可以看出,每个操作的平摊代价为其实际代价加上由于该操作所增加的势。根据等式(1),n个操作的总的平摊代价为:
(2)
如果我们能定义--个势函数F使得F(Dn)≧F(D0),则总的平摊代价就是总的实际代价的一个上界。在实践中,我们并不总是
知道要执行多少个操作,所以,如果要求对所有的i有F(Di)≧F(D0),则就应像在会计方法中一样,保证预先支付。通常为了方便起见,我们定义F(D0)为0,然后再证明对所有i有F(Di)≧O;倘若F(D0)≠0,只要构造势函数F¢,使得F¢(i)=F(Di)-F(D0)即可。
从直觉上看,如果第i个操作的势差F(Di)-F(Di-1)是正的,则平摊代价就表示对第i个操作多收了费,同时数据结构的势也随之增加了。如果势差是负的,则平摊代价就表示对第i个操作的不足收费,这时就可通过减少势来支付该操作的实际代价。
由等式(1)和(2)所定义的平摊代价依赖于所选择的势函数F,不同的势函数可能会产生不同的平摊代价,但它们都是实际代价的上界。在选择一个势函数时常要作一些权衡,可选用的最佳势函数的选择要取决于所需的时间界。
栈操作
为了说明势能方法,我们再一次来研究栈操作PUSH,POP和MULTIPOP。定义栈上的势函数F为栈中对象的个数。开始时我们要处理的是空栈D0,F(D0)=0。因为栈中的对象数始终是非负的,故在第i个操作之后的栈Di就具有非负的势,且有
以F表示的n个操作的平摊代价的总和就表示了实际代价的一个上界。
现在我们来计算各栈操作的平摊代价。如果作用于一个包含s个对象的栈上的第i个操作是个PUSH操作,则势差为
根据等式(1),该PUSH操作的代价为
假设第i个操作是MULTIPOP(S,k),且弹出了k'=min(k,s)个对象。该操作的实际代价为k',势差为
这样,MULTIPOP操作的平摊代价为
类似地,POP操作的平摊代价也是0。
三种栈操作中每一种的平摊代价都是O(1),这样包含n个操作的序列的总平摊代价就是O(n)。因为我们已经证明了F(Di)≧F(D0),故n个操作的总平摊代价即为总的实际代价的一个上界。这样n个操作的最坏情况代价为O(n)。
二进制计数器的增值
作为说明势能方法的另一个例子,我们再来看看二进计数器的增值问题。这---次,我们定义在第i次INCREMENT操作后计数器的势为bi,即第i次操作后计数器中1的个数。
我们来计算一下一次INCREMENT操作的平摊代价。假设第i次INCREMENT操作对ti个位进行了复位。该操作的实际代价至多为ti+1,因为除了将ti个位复位外,它至多将一位置成1。所以,在第i次操作后计数器中1的个数为bi≦bi-1-ti+1,势差为
平摊代价为
如果计数器开始时为0,则F(D0)=0。因为对所有i有F(Di)≧0,故n次INCREMENT操作的序列的总平摊代价就为总的实际代价的一个上界,且n次INCREMENT操作的最坏情况代价为O(n)。
势能方法给我们提供了一个简易方法来分析开始时不为零的计数器。开始时有b0个1,在n次INCREMENT操作之后有bn个1,此处0≦b0,bn≦k。我们可以将等式(2)重写为:
对所有1≦i≦n有ci≦2。因为F(D0)=b0,F(Dn)=bn,n次INCREMENT操作的总的实际代价为:
请注意因为b0≦k,如果我们执行了至少n=W(k)次INCREMENT操作,则无论计数器中包含什么样的初始值,总的实际代价都是O(n)。
动态表
在有些应用中,在开始的时候无法预知在表中要存储多少个对象。可以先为该表分配一定的空间,但后来可能会觉得不够,这样就要为该表分配一个更大的空间,而表中的对象就复制到新表中。类似地,如果有许多对象被从表中删去了,就应该给原表分配一个更小的空间。在后文中,我们要研究表的动态扩张和收缩的问题。利用平摊分析,我们要证明插入和删除操作的平摊代价为O(1),即使当它们引起了表的扩张和收缩时具有较大的实际代价也时一样的。此外,我们将看到如何来保证某一动态表中未用的空间始终不超过整个空间的一部分。
假设动态表支持Insert和Delete操作。Insert将某一元素插入表中,该元素占据一个槽(即一个元素占据的空间)。同样地,Delete将一个元素从表中去掉,从而释放了一个槽。用来构造这种表的数据结构方法的细节不重要,可以选用的有栈,堆或杂凑表结构。我们将用一个数组或一组数组来实现对象存储。
大家将会发现在采用了杂凑表中分析杂凑技术时引入的装载因子概念后会很方便。定义一个非空表T的装载因子a(T)为表中存储的对象项数和表的大小(槽的个数)的比值。对一个空表(其中没有元素)定义其大小为0,其装载因子为1。如果某一动态表的装载因子以一个常数为上界,则表中未使用的空间就始终不会超过整个空间的一常数部分。
我们先开始分析只对之做插入的动态表,然后再考虑既允许插入又允许删除的更一般的情况。
表的扩张
假设一个表的存储空间分配为一个槽的数组,当所有的槽都被占用的时候这个表就被填满了,这时候其装载因子为1。在某些软件环境中,如果试图向一个满的表中插入一个项,就只会导致错误。此处假设我们的软件环境提供了存储管理系统,它能够根据请求来分配或释放存储块。这样,当向一满的表中插入一个项时,我们就能对原表进行扩张,即分配一个包含比原表更多槽的新表,再将原表中各项数据复制到新表中去。
一种常用的启发技术是分配一个比原表大一倍的新表。如果只对表进行插入操作,则表的装载因子总是至少为1/2,这样浪费掉的空间就始终不会超过表的总空间的一半。
在下面的伪代码中,我们假设对象T表示一个表,域table[T]包含了一个指向表的存储块的指针,域num[T]包含了表中的项数,域size[T]为表中总的槽数。开始时,表是空的:num[T]=size[T]=0
Insert(T, x)
1 if size[T]=0
2 then
给
table[T]
分配一个槽的空间
3 size[T]←1
4 if num[T]=size[T]
5 then
分配一个有
2*size[T]
个槽的空间的新表
6
将
table[T]
中所有的项插入到新表中
7
释放
Table[T]
8 table[T]
指向新表的存储块地址
9 size[T]←2*size[T]
10
将
x
插入
table[T]
11 num[T]←num[T]+1
请注意,这里有两种“插入”过程:Insert过程本身与第6行和第l0行中的基本插入。可以根据基本插入的操作数来分析Insert的运行时间,每个基本插入操作的代价为1。假淀Insert的实际运行时间与插入项的时间成线性关系,使得在第2行中分配初始表的开销为常数,而第5行与第7行中分配和释放存储的开销由第6行中转移表中所有项的开销决定。我们称第5-9行中执行then语句的事件为一次扩张。
现在我们来分析一下作用于一个初始为空的表上的n次Insert操作的序列。设第i次操作的代价ci,如果在当前的表中还有空间(或该操作是第一个操作),则ci=1,因为这时我们只需在第10行中执行一次基本插入操作即可。如果当前的表是满的,则发生一次扩张,这时ci=i;第10行中基本插入操作的代价1再加上第6行中将原表中的项夏制到新表中的代价i-1。如果执行了n次操作,则一次操作的最坏情况代价为O(n),由此可得n次操作的总的运行时间的上界O(n)。
但这个界不很紧确,因为在执行n次Insert操作的过程中并不常常包括扩张表的代价。特别地,仅当i-1为2的整数幕时第i次操作才会引起一次表的扩张。实际上,每一次操作的平摊代价为O(1),这一点我们可以用聚集方法加以证明。第i次操作的代价为
由此,n次Insert操作的总代价为
因为至多有 次操作的代价为1,而余下的操作的代价就构成了一个几何级数。因为n次Insert操作的总代价为3n,故每次操作的平摊代价为3。
通过采用会计方法,我们可以对为什么一次Insert操作的平摊代价会是3有一些认识。从直觉上看,每一项要支付三次基本插入操作:将其自身插入现行表中,当表扩张时对其自身的移动,以及对另一个在扩张表时已经移动过的另一项的移动。例如,假设刚刚完成扩张后某一表的大小为m,那么表中共有m/2项,且没有“存款”。对每一次插入操作要收费3元。立即发生的基本插入的代价为1元,另有1元放在刚插入的元素上作为存款,余下的1元放在已在表中的m/2个项上的某一项上作为存款。填满该表另需要m/2次插入,这样,到该表包含了m个项时,该表已满,每一项上都有1元钱以支付在表扩张期间的插入。
也可以用势能方法来分析一系列n个Insert操作,我们还将在后文中用此方法来设计一个平摊代价为O(1)的Delete的操作。开始时我们先定义一个势函数F,在完成扩张时它为0,当表满时它也达到表的大小,这样下一次扩张的代价就可由存储的势来支付了。函数
(4)
是一种可能的选择。在刚刚完成一次扩张后,我们有num[T]=size[T]/2,于是有F(T)=0,这正是所希望的。在就要做一次扩张前,有num[T]=size[T],于是F(T)=num[T],这也正是我们希望的。势的初值为0,又因为表总是至少为半满,num[T]≧size[T]/2,这就意味着F(T)总是非负的。所以,n次Insert操作的总的平摊代价就是总的实际代价的一个上界。
为了分析第i次Insert操作的平摊代价,我们用numi来表示在第i次操作后表中所存放的项数,用sizei表示在第i次操作之后表的大小,Fi表示第i次操作之后的势。开始时,num0=O,size0=0和F0=0。
如果第i次Insert操作没有能触发一次表的扩张,则numi=numi-1+1,sizei=sizei-1,且该操作的平摊代价为
如果第i次操作确实触发了一次扩张,则numi=numi-1+1,sizei = 2sizei-1 = 2numi-1=2(numi -1),且该操作的平摊代价为
图3画出了numi,sizei和Fi的各个值。在第i次操作后对这些量中的每一个都要加以计算。图中红线表示numi,紫线表示sizei,蓝线表示Fi。注意在每一次扩张前,势已增长到等于表中的项目数,因而可以支付将所有元素移到新表中去的代价。此后,势降为0,但一但引起扩张的项目被插入时其值就立即增加2。
图3 对表中项目数numi,表中的空位数sizei,以及势Fi作用n次Insert操作的效果
表扩张和收缩
为了实现Delete操作,只要将指定的项从表中去掉即可。但是,当某一表的装载因子过小时,我们就希望对表进行收缩,使得浪费的空间不致太大。表收缩与表扩张是类似的:当表中的项数降得过低时,我们就要分配一个新的、更小的表,而后将旧表的各项复制到新表中。旧表所占用的存储空间则可被释放,归还到存储管理系统中去。在理想情况下,我们希望下面两个性质成立:
- 动态表的装载因子由一常数作为下界;
- 各表操作的平摊代价由一常数作为下界。
另外,我们假设用基本插入和删除操作来测度代价。
关于表收缩和扩张的一个自然的策略是当向表中插入一个项时将表的规模扩大一倍,而当从表中删除一项就导致表的状态小干半满时,则将表缩小一半。这个策略保证了表的装载因子始终不会低于1/2,但不幸的是,这样又会导致各表操作具有较大的平摊代价。请考虑一下下面这种情况:我们对某一表T执行n次操作,此处n为2的整数幂。前n/2个操作是插入,由前面的分析可知其总代价为O(n)。在这一系列插入操作的结束处,num[T]=size[T]=n/2。对后面的n/2个操作,我们执行下面这样一个序列:I,D,D,I,I,D,D,I,I,……,其中I表示插入,D表示删除。第一次插入导致表扩张至规模n。紧接的两次删除又将表的大小收缩至n/2;紧接的两次插入又导致表的另一次扩张,等等。每次扩张和收缩的代价为Θ(n),共有Θ(n)次扩张或收缩。这样,n次操作的总代价为Θ(n2),而每一次操作的平摊代价为Θ(n)。
这种策略的困难性是显而易见的:在一次扩张之后,我们没有做足够的删除来支付一次收缩的代价。类似地,在一次收缩后,我们也没有做足够的插入以支付一次扩张的代价。
我们可以对这个策略加以改进,即允许装载因子低于1/2。具体来说,当向满的表中插入一项时,还是将表扩大一倍,但当删除一项而引起表不足1/4满时,我们就将表缩小为原来的一半。这样,表的装载因子就以常数1/4为下限界。这种做法的基本思想是使扩张以后表的装载因子为1/2。因而,在发生一次收缩前要删除表中一半的项,因为只有当装载因子低于1/4时方会发生收缩。同理,在收缩之后,表的装载因子也是1/2。这样,在发生扩张前要通过扩张将表中的项数增加一倍,因为只有当表的装载因子超过1时方能发生扩张。
我们略去了Delete的代码,因为它与Insert的代码是类似的。为了方便分析,我们假定如果表中的项数降至0,就释放该表所占存储空间。亦即,如果num[T]=0,则size[T]=0。
现在我们用势能方法来分析由n个Insert和Delete操作构成的序列的代价。先完义一个势函数F,它在刚完成一次扩张或收缩时值为0,并随着装载因子增至1或降至1/4而变化。我们用a(T)= num[T]/size[T]来表示一个非空表T的装载因子。对一个空表,因为有num[T]=size[T]=0,且a(T)=1,故总有num[T]=a(T)*size[T],无论该表是否为空。我们采用的势函数为
(5)
请注意,空表的势为0;势总是非负的。这样,以F表示的一列操作的总平摊代价即为其实际代价的--个上界。
在进行详细分析之前,我们先来看看势函数的某些性质。当装载因子为1/2时,势为0。当它为1时,有size[T]=num[T],这就意味着F(T)=num[T],这样当因插入一项而引起一次扩张时,就可用势来支付其代价。当装载因子为1/4时,我们有size[T]=4*num[T],它意味着F(T)=num[T],因而当删除某个项引起一次收缩时就可用势来支付其代价。图4说明了对一系列操作势是如何变化的。
图4 对表中的项目数numi、表中的空位数sizei及势Fi
作用由n个Insert和Delete操作构成的操作序列的效果
图4中红线表示numi,紫线表示sizei,蓝线表示Fi。注意在每一次扩张前,势已增长到等于表中的项目数,因而可以支付将所有元素移到新表中去的代价。类似地,在一次收缩之前,势也增加到等于表中的项目数。
为分析n个Insert和Delete的操作序列,我们用 来表示第i次操作的实际代价,ci表示其参照F的平摊代价,numi表示在第i次操作之后表中存储的项数,sizei表示第i次操作后表的大小,ai表示第i次操作后表的装载因子,Fi表示第i次操作后的势。开始时,num0=O,size0=0,a0=1,F0=0。
我们从第i次操作是Insert的情况开始分析。如果ai-1≥1/2,则所要做的分析就与对表扩张的分析完全一样。无论表是否进行了扩张,该操作的平摊代价ci都至多是3。如果ai-1<1/2,则表不会因该操作而扩张,因为仅当ai-1=1时才发生扩张。如果还有ai<1/2,则第i个操作的平摊代价为
如果ai-1< 1/2,但ai≥1/2,那么
因此,一次Insert操作的平摊代价至多为3。
现在我们再来分析一下第i个操作是Delete的情形。这时,numi=numi-1-1。如果ai-1< 1/2,我们就要考虑该操作是否会引起一次收缩。如果没有,则sizei=sizei-1,而该操作的平摊代价则为
如果ai-1< 1/2且第i个操作触发一次收缩,则该操作的实际代价为ci=numi+1,因为我们删除了一项,移动了numi项。这时,sizei/2 = sizei-1/4 = numi+1,而该操作的平摊代价为
当第i次操作为Delete且ai-1≥1/2时,其平摊代价仍有一常数上界。具体的分析从略。
总之,因为每个操作的平摊代价都有一常数上界,所以作用于一动态表上的n个操作的实际时间为O(n)。