平摊分析

来源:互联网 发布:清华经济管理学院知乎 编辑:程序博客网 时间:2024/05/22 08:12

平摊分析

我们经常在处理数据结构的时间复杂度的时候,大多数操作代价很低,可是由于某些个别操作的代价较高,导致最后求得时间复杂度的上界不是那么的紧凑。在平摊分析中,执行一系列数据结构操作所需要的时间是通过对执行的所有操作求平均而得出的。平摊分析可用来证明在一系列操作中,即使单一的操作具有较大的代价,通过对所有操作求平均后,平均代价还是很小的。平摊分析与平均情况分析的不同之处在于它不牵涉到概率。这种分析保证了在最坏情况下每个操作具有平均性能。平摊分析一般有三种方法:聚集分析,记账方法,势能方法。我们将由一个动态表的例子引入这三种方法,利用平摊分析来分析动态表操作的时间代价。动态表的实现有很多种,堆、栈、哈希等。这里我们采用哈希表hashtable,因为这里有一个哈希因子α,我们利用这个哈希因子α才能更好的分析这三种平摊分析方法。哈希表在之前文章已经详细的讲过,现在就是利用哈希表分析一下这个动态表的插入和删除带来的扩张和收缩的代价。

聚集分析

先来说聚集分析,聚集分析致力于确定一个有n个操作的数据结构的总代价的上界T(n),每个操作的平摊代价为T(n)/n。这个平摊代价对所有的操作都是成立的。对于一个动态表,我们可以随时插入一个元素和删除一个元素。用哈希表来实现动态表,我们当然希望查找越快越好,内存空间越小越好。可是鱼和熊掌不可兼得,所以我们取个折中的办法,一个动态表T的空间大小size[T]是表内元素num[T]的2倍,即size[T]=2*num[T]。此时哈希因子α=1/2<1,可以在O(1)的时间内找到需要的元素,而且浪费的空间不会多于表空间大小的一半。可是随着插入的进行,动态表总有一天会满,那时候再插入元素的时候就会出现错误,无法插入任何元素,你不能依靠删除元素来等待再次插入。此时我们要做的就是先扩张动态表。重新申请内存空间,然后插入。我们要申请一个更大的内存空间,由于要满足α<1,所以新的内存空间是原来的两倍。然后将旧表中的元素复制到新表之中,同时释放旧表空间,插入新的元素。如图所示,表的空间是越来越大。
1
12
123 
1234
1234  5   
随着删除的进行,表中的元素会越来越少,如果删除之后,哈希因子α>=1/2,直接删除就好。可是删除之后,哈希因子α<1/2(下文会讲这是不确切的),我们为了节省内存就要进行动态表的收缩,同样的需要申请内存空间,新申请的空间是原来空间的1/2,然后复制旧表的元素到新表里面。此时我们是先收缩表,然后再删除。如图,表的空间是越来越小。
1234        
123  
12    
1   


我们来具体分析一下,对于一个动态表T来说,插入第i个元素所需要的代价ci是多少呢?如果插入之前当前动态表未满,还有空间,那么插入一个元素的代价ci=1。如果插入之前表已满,扩张表,然后插入的代价ci=(i-1)+1。删除一个元素的代价ci呢?如果删除之后表中元素至少是半满状态,删除的代价ci=1。如果删除之后表中元素不足空间一半,先进行表的收缩,再删除元素,删除代价ci=num[T]-(i-1)+1(见下面)。总的来说动态表的一次操作的代价要么是1,要么是O(n)。
也就是说最坏的情况下动态表一次操作的代价是O(n),那么n次操作的总代价在最坏情况下就是O(n^2)喽!真的是这样吗?这也就是我们开头说的不紧凑的上界,O(n^2)确实是一个上界,可是真的不够紧凑,这里我们要用平摊分析的方法来证明这个确切的上界是O(n)。不相信?不骗人的,继续看吧。

插入

对于动态表T的插入操作来说,只有当i-1次操作之后表满,第i次插入的代价才会是i。既然第i次操作要扩张表,说明i-1是2的幂。So插入操作代价一览表,size[i]是第i插入之后的表空间大小,num[i]是第i次插入之后表内元素数量,ci是插入代价。
插入的代价
i
  12   3 5  6   7  8  9  1011121314151617
num[i]
1234567891011121314151617
size[i]
12448888161616161616161632
ci 
123151119111111117
把ci分解一下,因为这里面包含扩张表需要的代价,单纯的插入操作代价是1
单纯插入1111111111111111扩张代价012040000800000016则n个插入操作的总代价对于2的幂来说,第i项肯定是等于前i-1项的和再加1,比如表中17个元素,则最大的2的幂就是2^[lg17]=2^4=16,前面的和是15。而第n项2^[lgn]最大是n,前面所有的2幂小于n,所以1+2+4+..2^[lgn]<2n,也即是n个插入操作的总代价是小于3n的。SO 对于有n个插入操作的动态表的总代价就是O(n)。所以每一个插入操作的代价平摊为O(1)。Nice!这就是聚集分析,虽然个别的插入操作代价很高,但是总的代价平摊到每个操作身上就是很小了。很不错的分析方法。

删除

再来看删除操作,只有当第i-1次删除之后表中元素等于表空间一半,第i次删除的代价才是num[T]-(i-1)+1。也即是num[T]-(i-1)是2的幂,因为只有在删除之前发现表空间是元素的二倍,删除之后才会导致表空间大于表中元素的二倍,表才要收缩。So删除操作代价一览表,size[i]是第i删除之后的表空间大小,num[i]是第i次删除之后表内元素数量,ci是插入代价。
删除的代价
i
  12   3 5  6   7  8  9  1011121314151617
num[i]
161514131211109876543210
size[i]
32161616161616161688884421
ci 
117111111191115132
把ci分解一下,因为这里面包含扩张表需要的代价,单纯的插入操作代价是1

单纯插入1111111111111111收缩代价016000000080004021
则n个删除操作的总代价<3n
SO 对于有n个删除操作的动态表的总代价就是O(n)。所以每一个删除操作的代价平摊为O(1)。Nice!
不过此时我们并不能说对于有n个插入和删除操作的动态表的总代价是O(n)。因为刚才的平摊固然很好,可是如果是这样,插入删除混合进行呢?而不仅仅只是插入或者删除呢?
如果当我插入一个元素的时候发现此时动态表满了,此时要扩张,然后插入,代价是O(n)。然后删除两个元素,删除第一个代价是O(1),删除第二个的时候发现表内元素小于空间一半,要收缩,代价是O(n)。然后我再次插入一个,代价是O(1),再次插入发现表满了,要扩张,插入代价是O(n)。然后再删除两个,导致收缩,插入两个导致扩张.......这样的话,就有O(n)次扩张或收缩,n个插入和删除的操作代价就是O(n^2),平摊到每个操作代价就是O(n)。Are you kidding me?这并不是我们想要的结果,所以我们要改进。
改进的策略就是允许哈希因子α<1/2,具体的说。就是表满仍扩张,但是删除一项导致表元素小于表空间一半的时候我们不收缩,只有当α<1/4的时候,也就是表元素不足空间的1/4时我们再收缩。为什么要这样做,用聚集分析来说就是可以使总体代价降低。这样的好处就是刚才的情况不会再发生,因为如果插入导致扩张之后删除,要删除表中元素至少一半才会导致收缩(α<1/4)。这样的话无论你什么时候删除或插入代价,都不会导致连续的扩张和收缩。总体的代价也就仅仅是几个2的幂加n罢了,也就是说总代价是O(n)。平摊到每一个操作代价就是O(1)。因为插入和删除的次序可以随意的调换,具体的情况不再分析。
聚集分析能够做到的就是求出一个较小的总代价,然后平摊到每一个操作上。对于单独的一个操作,是不能够确定其平摊代价是多少的。而接下来的两种方法可以做到对每一个特定的操作都可以求出其平摊代价。

记账方法

接下来我们先用记账方法来分析一下动态表的插入和删除的代价。
在记账方法中,我们化身收费员。对每一个操作收取一定费用(可以相同,也可以不同),用来支付其操作产生的代价,这个收取的费用称为平摊代价。实际操作的代价大小不一,而平摊代价有时也略有不同。如果某个操作的平摊代价超过了实际操作的代价,那么用不完的费用就当作存款存入这个特定的对象之中。如果某个操作的平摊代价低于实际操作所需的代价,那么我们就借用其他的对象之中的存款来解决。存款的目的就是为了补偿那些操作代价高于平摊代价的操作的。这与聚集分析的区别就是聚集分析的所有操作代价都是一样的,而记账方法的操作代价可以不同。既然平摊代价的目的是支付实际操作的代价,那么总的平摊代价肯定是总的实际操作代价的一个上界。因为总的平摊代价肯定是要能够支付所有的操作的啊。总的存款就是两者之差,所以存款不能小于0,你见过负存款吗?
假设对于动态表来说,第一次的插入操作收费是2¥,以后每次操作收费都是3¥(下面会解释为啥第一次是2¥的)。1¥用来单纯的插入或删除,2¥留着做存款,以便支付以后高代价操作的扩张和收缩,复制旧表一个元素到新表的代价是1¥。

插入

先看插入,因为每个操作的存款在以后的操作中会用到,所以呢,每个特定对象的存款是变化的。下图中每个槽代表当前槽的存款。

1
0  
02
00  
002  
0022
0000         
0000  2        

我们可以看到在每次表的扩张以后插入之前,表的所有元素的总存款是0,扩张的代价就是之前所有存款的和。然后插入一个元素,该元素存款是2¥。这说明一个问题,就是说如果我给每一个操作都收费3¥(第一次2¥),则我总能支付所有的操作代价,因为这里维护了一个不变量,那就是每次扩张之后的存款是0,其他时候的存款是大于0的。所以总的平摊代价是实际代价的上界,也就是说n个插入操作的总代价小于3n。如果第一次收取3¥,那就是存款在以后的插入过程中都是+1状态,不影响分析。每个操作平摊代价也可以是4¥、5¥,但是3¥是一个更紧凑的上界。所以n个插入操作的总代价是O(n),每个插入操作的代价是O(1)。

删除

再来看删除,如果还是哈希因子α<1/2的时候就收缩的话,那刚才的尴尬还会碰到。我们分析一下,如果此时表空间是16,表中有9个元素,存款是2,删除2次会导致存款不够支付扩张的,所以就尴尬了。你或许会说,每次收取的费用增加,每次收取10¥,这样不是够支付的了吗?是,不错,这样是可以。但是这样分析不免有些牵强,每次的扩张会剩余好多存款,这就是浪费。而且就如你说的收取10¥,如果此时表空间是1024,表中有513个元素,存款是9,删除2次之后存款还能够支付扩张的吗?你收取的费用不会要随着表空间的变化而变化吧?! 那每次删除的代价估计就是O(n)了。
所以还是当哈希因子小于1/4的时候再收缩,这个时候有足够的删除之后,积攒的存款总和也够收缩的代价了,岂不是很好嘛。
0000  2    
0022    
000 
002 

当有一种以上的操作时,每种操作都可有一个不同的平摊代价(可以相同)。记账方法对操作序列中的某些操作先多收取平摊费用,将多收的部分作为对数据结构中的特定对象上的存款存起来。在该序列中稍后要用到这些存款以补偿那些对它们收费少于其实际代价的操作。

势能方法 

最后我们看一下势能方法, 它与记账方法的相似之处在于要确定每个操作的平摊代价,且先对某些操作多受平摊费用以补偿以后的不足平摊代价。但是势能方法并不是把存款作为某个特定对象的存款,而是将存款作为数据结构整体的势能来维护。在需要的时候释放出来,支付操作代价,不需要的时候就存储起来。就像是动能和势能的转化一样。
势能函数Φ将每一个数据结构Di的映射一个实数Φ(Di),就是当前的存款总和。如果第i个平摊代价是Ci,操作代价是ci,第i个操作之后Di-1就变成了Di,势能也由Φ(Di-1)变成了Φ(Di)。定义势函数Φ(Di)=Ci-ci+Φ(Di-1),则平摊代价Ci=ci+Φ(Di)-Φ(Di-1)。那么总的平摊代价,如果此时Φ(Dn)>=Φ(D0),则总的平摊代价就是总的操作代价的一个上界。所以只要势函数非负,那么总的平摊代价就是总的操作代价的一个上界。所以我们定义对于所有的i>=1,Φ(Di)>=Φ(D0),Φ(D0)=0不同的势函数会产生不同的平摊代价,所以我们选择势函数的时候要做一些权衡,希望能选择最佳的势函数。
我们用势函数来分析动态表T的插入和删除操作。

插入

先来看插入操作,此时我们定义势函数Φ(T)=2*num[T]-size[T]。扩张前一刻是表满,num[T]=size[T],Φ(T)=num[T],刚好够扩张所需的代价。刚完成扩张的时候num[T]=size[T]/2,Φ(T)=0。由于表至少是半满,所以Φ(T)>=2*size[T]/2-size[T]=0。由于刚才的分析,只要势函数非负,那么总的平摊代价就是总的操作代价的一个上界。
我们具体分析一下第i次插入的平摊代价Ci=ci+Φ(Ti)-Φ(Ti-1),设num[i]是第i次插入操作之后表中的元素,size[i]为第i次插入操作之后表的空间大小。Φ[i]代表第i次操作之后的势能,开始的时候都是0。
如果第i次操作没有扩张表,那么size[i]=size[i-1],num[i]=num[i-1]+1。此时平摊代价
Ci=ci+Φ[i]-Φ[i-1]=1+(2*(num[i-1]+1)-size[i-1])-(2*num[i-1]-size[i-1])=3。如果第i次操作需要扩张表,那么size[i]=2*size[i-1],num[i]=num[i-1]+1,num[i-1]=size[i-1]。此时平摊代价Ci=ci+Φ[i]-Φ[i-1]=num[i]+(2*(num[i-1]+1)-2*size[i-1])-(2*num[i-1]-size[i-1])=3。所以每一个插入操作代价都是常数,那么n个插入操作的总代价就是O(n)。

删除

再来看删除,因为如果还是表中元素小于一半就收缩的话,意思就是哈希因子α<1/2时收缩。由于哈希因子α=num[i]/size[i]<1/2,此时2*num[i]-size[i]<0,势函数的值是负的了,那就不能保证总的平摊代价是总的操作代价的上界了。我们没有删除足够多元素,存够足够多的势能,没有办法支付收缩的代价。所以我们要等到表中元素小于1/4的时候才收缩,此时势函数定义也发生了变化。当哈希因子α<1/2时,Φ(T)=size[T]/2-num[T],当需要收缩的时候α=1/4,收缩之前的时候Φ(T)=num[T],足够支付操作的代价了。先收缩再删除,收缩之后的α=1/2,Φ(T)=0。所以Φ(T)>=0。当哈希因子α>=1/2的时候我们还用之前的势函数Φ(T)=2*num[T]-size[T]积攒势能,Φ(T)>=0。这样势函数始终非负,所以总的平摊代价是总的操作代价的一个上界。
势能函数:α<1/2,Φ(T)=size[T]/2-num[T];α>=1/2,Φ(T)=2*num[T]-size[T]; 这样的话我们就重新分析一下插入的代价,然后再分析删除的代价,α[i]是第i次操作之后的α值。分析第i次插入的情况:如果α[i]>=1/2,此时插入和之前的分析是一模一样的,平摊代价Ci=3。如果α[i-1]<1/2,α[i]=1/2,不扩张。此时num[i]=num[i-1]+1,size[i]=size[i-1] 则平摊代价
Ci=ci+Φ[i]-Φ[i-1]=1+(2*(num[i-1]+1)-size[i-1])-(size[i-1]/2-num[i-1])=3*num[i-1]-(3/2)*size[i-1]+3 。
由于α[i-1]<1/2num[i-1]/size[i-1]<1/2,num[i-1]<(1/2)*size[i-1],3*num[i-1]<(3/2)*size[i-1],即 Ci<3。
如果α[i-1]<1/2,α[i]<1/2,不扩张。此时num[i]=num[i-1]+1,size[i]=size[i-1],则平摊代价Ci=ci+Φ[i]-Φ[i-1]=1+(size[i-1]/2-(num[i-1]+1))-(size[i-1]/2-num[i-1])=0。所以一次插入操作的平摊代价至多是3。分析第i次删除的情况如果1/4<α[i-1]<1/2,1/4<=α[i]<1/2,不收缩。此时num[i]=num[i-1]-1,size[i]=size[i-1],则平摊代价
Ci=ci+Φ[i]-Φ[i-1]=1+(size[i-1]/2-(num[i-1]-1))-(size[i-1]/2-num[i-1])=2。α[i-1]=1/4,α[i]<1/4,要收缩。此时num[i]=num[i-1]-1,size[i]=size[i-1]/2, ci=num[i-1],num[i-1]=size[i-1]/4=size[i]/2 则平摊代价Ci=ci+Φ[i]-Φ[i-1]=num[i-1]+(size[i-1]/2-(num[i-1]-1))-(size[i-1]/2-num[i-1])
=num[i-1]+(num[i-1]-(num[i-1]-1))-(2*num[i-1]-num[i-1])=1。

如果α[i-1]=1/2,α[i]<1/2num[i]=num[i-1]-1,size[i]=size[i-1],平摊代价

Ci=ci+Φ[i]-Φ[i-1]=1+(size[i-1]/2-(num[i-1]-1))-(2*num[i-1]-size[i-1])=2-3*num[i-1]-size[i-1]/2<2。如果α[i-1]>1/2,α[i]>=1/2。num[i]=num[i-1]-1,size[i]=size[i-1],平摊代价Ci=ci+Φ[i]-Φ[i-1]=1+(2*(num[i-1]-1)-size[i-1])-(2*num[i-1]-size[i-1])=-1 <0。所以一次删除操作的平摊代价至多是2。所以作用于动态表上的n个操作的实际代价是O(n)。势能方法并不是把存款作为某个特定对象的存款,而是将存款作为数据结构整体的势能来维护。在需要的时候释放出来,支付操作代价,不需要的时候就存储起来。如果势能始终非负,那么总的平摊代价就是总的操作代价的一个上界。平摊分析给数据结构的性能分析提供了一个抽象概念,不关注个体,关注集体产生的代价。在平摊分析中,执行一系列数据结构操作所需要的时间是通过对执行的所有操作求平均而得出的。平摊分析可用来证明在一系列操作中,即使单一的操作具有较大的代价,通过对所有操作求平均后,平均代价还是很小的。
     3种方法,每一个都有特定的情况,或者简单,或者精确。任何一个方法都可以解决我们的问题,但有时候这个好,那个不好。关键是你要明白如何用不同的方法解决不同的问题。平摊分析就说完了。(表格显示的有问题////
转载请注明出处http://blog.csdn.net/liangbopirates/article/details/9906405

原创粉丝点击