平摊分析(摊还分析)
我们有时候会有一个算法,或者只是单纯的一系列操作,当我们需要将这一些操作计算一个平均代价,但是又不涉及概率的问题,我们就可以使用平摊分析。
就比如一个月的账单,可能每一天都是正常的一日三餐,但是有一个周末出去玩花的钱可能会很多,
如果你想计算一下这一个月平均每一天消费的上界,最简单的方式就是找最大消费的那天(出去玩),但是很明显这样是不合理的,但我们又没有概率支撑,不知道有多大的概率出去玩。这时就需要用到平摊分析了。
三种方法
聚集法
看名字就知道了,这个方法也是很简单粗暴的,就是将所有的操作加起来,然后除以操作数,得到的结果就是平摊代价。
会计法
会计每天对着什么,是账本,所以我们的想法是找一个账本将额外支付的代价记录下来。
额外的代价?
我们实际上是清楚每一个操作的代价的,但是我们在这里还需要自己定义一个平摊代价(也就是最后我们要找的),
有的操作平摊代价比实际代价要大,这时我们就需要将多出来的部分记录在账本上;
有的却比实际低,这时我们就可以通过划掉账本的一部分记录来抵消,只要我们保证账本非负,那么我们最开始的平摊代价就一定是实际代价的一个上界。
这里的会计法看起来很像对一个已知的平摊代价进行证明,其实不完全是这样的,这种方法的首先其实是对平摊代价进行猜测,然后会计法只是一种验证。
势能法
没错,和物理中的势能是差不多的东西。这种方法和会计法其实还是很像的,都是利用存储的方式。这里我们将存储的余额叫做势能。
对于势能,我们给出两个关键点
- 每一个实际代价 ci 都将数据结构从Di-1改变为Di,所以我们的平摊代价就可以定义为:
ci’ = ci + φ(Di) - φ(Di-1) 【φ代表势能函数】 - 如果平摊代价要比实际代价大,那么我们就说势能增加,否则势能降低。
所以我们将所有操作的平摊代价相加,会发现后面的势能函数基本上消没了。
也就是平摊代价的和 = 实际代价和+φ(Dn)-φ(D0),我们假设φ(D0)为0,这和物理的零势能面相似。
如果我们能证明φ(Dn)为正,那么我们就可以说明我们的平摊代价一定是实际代价的一个上界。
势能和会计的不同点
还记得高中的物理中,能量分析部分有一个公式,左边是面向过程,将每一部分加在一起;右边是面向结果,只需要判断整体的能量变化(具体记不清楚不敢乱叫)。
有一说一,其实会计和势能就是这样的,会计要求的是每时每刻账本为正,势能只需要保证最后势能为正就行。
因此,我们一般常用的还是势能法,相对简单。
势能法看着也像是用来对猜测进行验证的,但其实它也可以用来计算平摊代价,接下来我们看几个例子。
栈操作
我们已知的栈操作只有压栈和出栈,每一个的代价都是1,没什么好说的。
这次我们加一个操作:MULTIPOP(S,k),将栈顶的k个对象去掉,如果没有k个就清空整个栈。
我们可以分析出,该操作的实际代价为min{s,k},其中s为栈顶元素个数。
先用聚集法
我们将n步操作加在一起,但是我们都不知道每一种操作的个数啊。
别急,我们知道栈内元素的个数一定是小于等于n的,因为我们每一次只能压入一个元素,而我们取出元素的个数也一定小于n,所以整体下来最多也就压入n个、拿出n个(而且一定达不到),那么平均下来每一步的平摊代价就是2。
会计法
这里我们先对操作估计一个平摊代价,其中压栈代价为2,出栈代价为0。
感觉很奇怪,解释一下就好了。
首先,我们的账本就是整个栈,没错这就是账本。
当我们每一次压入一个元素,账本上就多了一项,平摊代价2支付了压栈的代价1并将剩下的部分存储在栈内;
当我们弹出一个元素,刚好账本上少了一个元素,拿来支付代价刚刚好。
因为栈内的元素个数非负,所以我们保证了估计的平摊代价的合理性。
势能法
势能法一定要先给出势能函数,这里的势能函数为栈内元素个数。
每一个实际代价为1,但是压栈时势能函数加一,按照我们上面的公式,平摊代价为2;
同理,出栈的平摊代价为0。
(这个例子就是势能法计算平摊代价,你说是我猜完了证明也行吧)
二进制计数器
看着特别高端,其实就是初始一串零,每一次进行加一。
000->001->010->……
先给出实现的伪代码:
INCREMENT(A)
i=0
while i<length[A] and A[i]=1
A[i]=0;
i=i+1;
If i<length[A]
A[i]=1
也就是说,每一次加一都是从最后一位开始,碰到1就翻转成0,碰到0或者溢出才结束。结束时需要判断一下,如果有需要就将0翻转成1,这和常识都相符。
这里面的操作是翻转0和翻转1,但我们要算平摊代价的操作是每一步的平摊代价。
但我们每一步的代价应该怎么算?
我们默认每一次的翻转,不论是0到1还是1到0都一样,代价都是1。
第一步是1,第二步则是2,第三步是1,第四步是3……
乍一看,真的找不到规律,那么应该怎么算?
这里我们看的不是每一步的代价,而是每一位的代价。
通过观察,我们可以看到对于第一位,是每一次都进行翻转,而第二位则是每两次进行一次翻转,依次进行,我们将整体进行求和,然后除以n,结果是小于2的,这里就不算了。
所以,我们说,此处的平摊代价为2。
会计法
这次我们的账本变成了数组,或者是字符串。
我们假设0->1的代价为2,一个支付实际代价,另一个存储在帐本上;
1->0的代价是0,账本上少了一个1,刚好用来支付代价。
和上面一样,1的个数非负,所以我们的平摊代价估计的很合理。
然后我们对整体来说,总代价和一定是小于2n的,所以我们给出的平摊代价为2。
(实际上我们只需要知道是O(1),所以也不需要特别精确)
势能法
还是先给出势能函数,这里的势能为计数器中1的个数。
计数器中1的个数也是非负的。
假设我们在低i次操作将a个位置从1翻转成0,最多将一位翻转为1。(可能在最后一位溢出了么)
所以我们的势能差小于等于1-a,而我们的实际代价是小于等于a+1的,求和后平摊代价小于2。
(在这道题中,我们如果将初始的计数器不置零,那么对会计法来说就会有一点烦,因为要保证每一步判断账本是否为空;但是势能法只需要看最后的情况,所以简单了很多。)
动态表的扩张和收缩
这里我们主要讲的是扩张操作。
什么是动态表
想象成一个数组,假设其大小为size_T,其中的元素个数为num_T,可以用来存储东西,先不考虑移除。
当这个数组满了的时候,我们将这个数组扩大一倍,使其能接着存储。
这里就有一个装载因子的概念了,为num_T/size_T,代表的是这个表的存储元素比例,按照我们上述的操作,能保证装填因子大于等于0.5。
接下来我们就要看操作了。
如果是正常的操作,就是存入一个元素,代价为1;
但如果很不幸碰到了元素个数满了,还需要将整个表进行扩张操作,这时的代价为1+a(a为扩张代价)
我们可以推断出的是,只有在a那一步,我们才需要扩展a,而a为2i,其中i为自然数。(包括0)
实际代价:
在2i步(i为自然数),代价为2i+1;剩下的部分,代价为1。
聚集分析:
首先将每一步都取一个1出来,然后将剩下的等比数列求和,
我们得到的平摊代价为3。
会计法
这次不能取元素个数为账本了,因为账本只增不减,明显不合理。
我们假设平摊代价为3,每一次的压入我们将1作为必要代价支付,同时将2的代价存储在表内没有对应代价为位置,当所有位置都有存款了,那么就需要将表进行扩张,同时所有的存款清空。
(第一步会特殊一点)
说着可能有一点复杂,但是实际上还是很简单的。
很明显,1元素个数非负,我们的代价是合理的。
势能法
我们的势能函数需要满足以下两条性质:
- 刚扩充完,φ(T)=0
- 表满时,φ(T)=size(T)
这两个条件不是必须,但是这样的条件能使我们的函数更加合理。
想一下么,我们建立势能函数的目的不就是为了保证我们在进行大代价操作时的代价能被之前的势能(或者说是存款)抵消,上述的条件刚好卡着我们的目的。
所以我们的给出的势能函数为2*num_T - size_T。
我们还是假设平摊代价为3。
对于不扩张的操作,势能增加2,实际代价为1,按照公式平摊代价为3;
对于需要扩张的操作,size_T扩大为两倍,num_T加一(加一千万别忘了),
所以势能差为2(num_T+1) - 2size_T - [2num_T - size_T] = 2-size_T,而我们的实际代价为1+size_T,求和仍为3,得证。(或者说我们不给出之前的假设,这时就是通过这些步骤算出平摊代价为3)
收缩操作
一直在讲扩张的问题,这里补充一下收缩,收缩是因为删除元素引起的,我们为了保证表不至于太空旷,将表的装载因子定义为[1/4,1]。
也就是说当我们装满了的时候,也就是装载因子为1,我们就需要进行扩张;
如果因为删除过多导致装载因子为1/4,就需要将表收缩为之前的一半。
这两个操作很明显都能保证装载因子为1/2。
因为我们的大代价操作有扩张和收缩两个,而这两个操作结束都能保证装载因子为1/2,而此时的势能函数最优应当为0,所以我们定义的势能函数是这样的:
类似于动态表的问题
之前见过一个和这个很像的问题,叫做翻转栈的数据结构,是bill提出的,该数据结构支持一种操作Flip_push(),先正常压栈,如果发现栈内元素个数为2的幂次,那么就将栈进行翻转。
举例:(1)⇒(2,1)⇒(2,1,3)⇒(4,3,1,2)【右边为栈顶】
仔细分析一下,每一次都有压栈的操作,代价为1,每2i步,我们还有一个2i的代价,其实就是和动态表相同。
其中,会计法我们采取和动态表相同的记账方式,每一步平摊代价为3,先支付1的代价入栈,然后将剩下两个存储在没有存款的元素上,当每一个元素都有存款就进行翻转,同时将存款清空;
势能法的势能函数为2(num_T-size_T),但这次的定义产生了变化。
我们num_T的定义还是之前的定义,但是size_T为上一次翻转的元素的个数。
当我们刚好需要翻转时,size_T和num_T相同,势能函数为0;而即将翻转的时候是势能函数最大,这样的定义也算合理。