第五章 摊还分析
1. 什么是摊还分析?
对一个数据结构(例如栈)执行n个操作,其中有的操作代价高,有的操作代价低,有些操作代价中等。而摊还分析是用来评价程序中的一个操作序列的平均代价,有时可能某个操作的代价特别高,但总体上来看也并非那么糟糕,可以形象的理解为把高代价的操作“分摊”到其他操作上去了,要求的就是均匀分摊后的平均代价。(参考了这篇博文:http://www.cnblogs.com/hezhihao/p/4185816.html)
要强调一点就是,摊还分析中赋予对象的信用或者费用,仅仅是用来分析而已,不需要也不应该出现在程序中。它并不涉及不同输入出现的概率,可以保证最坏情况下每个操作的平均性能。
摊还分析有三种常用的技术:聚合分析,核算法,势能法。
首先看个例子,现在有三种操作,push(s),pop(s),mutlipop(s,k),push(s),统称为栈操作。 push(s)每次只能压一个数据,所以规定操作的代价为1,pop(s)每次只能弹一个数据,所以也规定操作的代价为1,而mutlipop(s,k),内部实现的是一个循环弹出,每执行一次的代价为k(k<n,n为栈的最大容量)。那么现在问题来了,我想分析下执行n次栈操作最坏情况下的时间复杂度是多少?第一反应应该就是这样想的,mutlipop(s,k)的代价最高,最高位k=n;
执行n次的最坏情况当然就是o(n2),当实际上并非如此。
2. 聚合分析,核算法,势能法
聚合分析要求我们要总体看问题,首先mutlipop(s,k)也是个弹栈的操作,当栈里有数据的时候才执行有效,所以上述提到的o(n2)是不科学的,而push(s),pop(s)的代价是1,可想而知最坏的情况当然是前n-1次操作都是压栈,而最后一次才执行mutlipop(s,n-1),这样的代价也只有2n-2,时间复杂度为o(n),平均下来每个操作的摊还代价就为o(1)了。
核算法比较好理解,进行摊还分析时,摊还的代价有可能多于实际的代价,也有可能少于实际的代价,多于实际代价的差额会存进一个数据结构中,称为信用,而当遇到少于实际代价的时候就可以用这些信用来填充了。注意这些什么算法提供的只是思路,求出一系列操作代价的上界的思路,具体的做法还是要自己思考的。
同样是压栈的例子,我们可以赋给Push(s)操作的摊还代价是2,相当于自己使用了1,而压进去的必定会弹出,剩下的1就作为弹出时的费用,这样pop(s)和mutlipop(s,k)的摊还代价就为0,这样做有什么意义?试着现在思考下题目中的问题(标红色的部分),那么可想最糟糕的情况无非就是所有操作都是Push(s),代价为2n,并不像聚合代价那样需要考虑其他两个操作(摊还代价为0),时间复杂度为O(n)。
势能法,势能法其实核算法有点相似,也有预付差额的,但不叫信用,而叫势能,势能法是从整体上看的,不像核算法那样具体到某个操作,而是整体的势能。势能法定义了一条公式:
Ci(摊还)=Ci(实际)+f(Di)-f(Di-1) (1)
累加可得总摊还代价的公式为;
Ci(总摊还)= Ci(总实际)+ f(Di)-f(D0) (2)
其中Ci 为每步操作的代价,f(Di)表示执行了第i个操作后的势能,那个这个公式就可以理解为 第i步操作的摊还代价等于第i步操作的实际代价加上从第i-1步操作到第i步操作的势能变化,理解这个后,再来看栈操作的例子。
同样Push(s)、pop(s)的代价为1, mutlipop(s,k)的代价为k,我们规定入栈一个元素势能加1,弹出一个元素势能减1,那么f(Di)永远为非负,而f(D0)等于0,再根据上面的公式(2)即可知道这个又这里就可以确定总摊还代价是总实际代价的上界,所以现在要求的就是总摊还代价。根据公式(1)可以得到Push(s)的摊还代价为2,pop(s)的摊还代价为0,mutlipop(s,k)的摊还代价也为0 (因为弹栈势能要减,刚好和代价抵消,也可以看做势能都用来支付代价了,所以下降了),那么又回到了核算法了,可得时间复杂度为O(n)。
3. 一些例子
(1) 二进制计数器
聚合分析要求整体看待问题,那么对于二进制计数器考虑总体情况的话,最低位第0为每次加1时都会翻转一次,所以n次的加法操作,第0位总共翻转n次。对于第1位,每2次的加法操作才会翻转一次,所以n次的加法操作共有n/2次翻转,以此类推。
核算法:对于一次置位操作(将该位值置为1的操作),我们设其摊还代价为2。在进行一次置位操作时,我们花1个单位的代价来支付其本身的实际代价,剩余1单位作为信用,用来支付将来可能的复位操作(将该位值置为0的操作)的代价。这样就保 证了在任何时刻的信用值是非负的。因此,总实际代价的上界为总摊还代价。而在一次increment操作中,只进行一次置位操作,因此总摊还代价O(n)。
势能法:首先将每一步的势能f(Di)定义为二进制计数器中,位上是1的位数。显然f(Di)≥f(D0)=0。再分析摊还代价,对于一次加1操作,总是将k位置为0,将一位置为1(即k个复位操作,一个置位操作), 所以势能变化是 -k+1 , 摊还代价在势能法中是 实际代价和势能变化之和,故为 k+1 + (-k+1) =2; 所以n次加1操作的总摊还代价是O(N), 因此实际的最坏情况是O(n)。
(2) 表扩张
问题定义:假设有个表(比如哈希表)只允许插入,当插入数据发现表满了,会自动新建一个表大小为原来的两倍,然后把已有的数据复制过去,再插入,在问题就是要求这个程序的代价,准确来说是摊还代价。
下面部分参考了这篇博文:http://blog.csdn.net/zhongkelee/article/details/44563355
(a) 聚合分析
定义第i个插入操作的代价 ci 为:
如上表格所示,我们将插入一个元素的代价看成1,假设一开始表的大小为1,没有数据。如果表格没有被插满,此时每一次插入元素的代价为1;如果表格满了,易知此时表格的大小都为2的幂次,这时插入一个元素的代价就是原有的元素插入新表中的代价k(假设原表满时有k个元素)加上新插入的这1个元素(比如上图中的第二步插入,因为表满了需要将表的长度扩张为2,然后要将表中原来的元素和现在要插入的元素都要插到新表中,所以代价是2),所以上表可以用下面的形式表示:
则总代价是:
所以,摊还代价(每个操作的平均性能)就是:
(b) 核算法
核算法用于动态表时,它赋予每个插入操作3元,即,其中1元用于支付当前的插入操作,剩下的2元存入用于后续的表格翻倍处理。当表格翻倍时,1元用于移动最新项,也就是刚才插入操作的元素把这1元保留做自己的信用;另1元用于移动旧项,也就是刚才插入操作的元素把这1元捐赠给了原本就在表中但没有信用的旧元素。(要注意原本已经在表中的元素是没有信用可用的,表翻倍时要移动他们到新表中的)
这样一来,每当表格翻倍时,原来表格中的数据所持有的信用刚好可以满足自己的插入需求,m大小的表格中,m/2的元素没有信用,而且已经完成插入操作,仿佛又回到了最初的形态,有点像递归。如此一来,每个插入操作被赋予3元的操作代价就可以满足无限次的扩展表格的需求。如下图所示:
核算法分析这个问题时,关键点在于存入的信用永远都是非负的,因而摊还代价的总和提供了实际代价的一个上界。
(c) 势能法
我们定义势能函数为:
那么第i个插入操作的摊还代价为:
对于上式情况一:
对于上式情况二:
结论:在一个动态表上执行任意n个操作的实际运行时间为O(n),其中每个操作的摊还代价的上界是一个常数。