摊还分析,核算法与势能法

为什么我们需要摊还分析

上篇文章我们提到了算法的时间复杂度分析,给定输入规模,我们分析出算法的耗时,但是这样够了吗?

有时输入规模不是一个静态的值,可能输入是一系列操作,比如在这棵树里先插入结点,再做一个查找,再删除最小值,再与另一棵树合并。(插入,查找,删除最小值,合并)就是一个输入的操作序列。

为了对这类操作序列耗时进行分析,我们引入了摊还分析:n个操作的总耗时除以n。

看到这儿你可能会想,这不还是要分析总时间,和时间复杂度的区别不就除以个n而已嘛。但是对于时间复杂度的分析,我们关心的是最坏情况,假设我们有100个操作A,耗时为1,1个操作B耗时为1000,重点是:如果这个大耗时的操作发生,那么它前一定要发生100个耗时小的操作,也就是必须发生100个A才会发生B。如果我有50个操作(无A),100个操作(无A),101个操作(无B),这时我们分析时间复杂度还要把这个大耗时操作分情况考虑进去。这种情况就是摊还分析大展身手的时候,它的思想就是把大操作的耗时分摊到前面的小铺垫上

摊还分析在分析数据结构的操作耗时上特别有用,接下来我们来看几个例子,他们都来自算法导论:

例1. 用一个列表来实现栈

栈是一种数据结构,它具有后进先出的性质,如果要把数据从栈里弹出去,后加进来的数会被先弹出去。举个例子,洗碗池就是一个栈,碗就是数据,大家吃完饭,按吃完的顺序把碗放入洗碗池里,一个一个地按顺序叠起来。最后放进来的碗会被第一个洗掉,后放进来的碗会被洗掉移出洗碗池,这就叫后进先出。(哈哈哈哈现实中洗碗不是这样的,谁会把它叠起来啊!)

尽管我们一般用的是链表来实现栈,但是这次就用用数组吧。。

目标:从长度为1的数组开始形成一个长度为n的栈。

我们现在有个数组,它的长度固定了,假设它长度为5,就是它能放5个数据,OK,我们前5个数据入栈没有任何问题,可是我想要第6个数据也入栈,怎么办?

假设1个数据入栈耗时为1。

我们创建一个新的数组,长度比5长1,然后把这6个数据入栈。这会耗时为6。

按这种方法:一次就小气地只新建比原来长1的数组,我们想从长度为1的数组开始,做n次入栈操作。因为每次入栈都要一次复制(栈的长度从1长到2,再长到3,…),我们会耗时1+2+3+4+…+n = Θ(n2),摊还分析的时间复杂度就是Θ(n2)/n = Θ(n)

还有一种更聪明的做法,当我们栈满时还要加入元素,我们将栈的长度翻倍,比如从m变到2m,这耗费时间m(这是在原栈上直接翻倍,不是弄一个新的栈出来,所以不会花费时间2m)。所以这种做法有两种操作:
1.翻倍操作,耗时为当前栈的长度
2.入栈操作,耗时为1

所以我们从长度为1的数组开始,入栈n个元素,会出现如下操作序列:(入栈,翻倍,入栈,翻倍,入栈×2,翻倍,入栈×4,翻倍,入栈×8,翻倍…)这个序列也是我们之前提到的,需要有足够多的入栈操作进行铺垫,才会进行耗时多的翻倍操作。

接下来对这种聪明的办法进行摊还分析:
翻倍操作总耗时为1+2+4+8+…+2logn< 2n = O(n)
入栈操作总耗时是n,因为有n个元素要入栈
所以它们总耗时是O(n)+O(n) = O(n)
那么它的摊还代价是O(n) / n = O(1)。

可以看到,进行同一个事情,如果算法不一样,他们的摊还分析会不一样,采用第二个聪明的方法,我们使得摊还到每个操作上的时间代价从O(n)变到了O(1)。

核算法与势能法

为何要引进这两个方法,因为有的时候总代价并不好算,我们需要一些更巧妙的方法来计算摊还代价。我们需要的找的摊还代价最后找的要是实际耗费时间的上界,因为我们关心的是最坏情况。

核算法和势能法其实是一个方法的两种角度,我们进行摊还分析其实核心就在于把耗时长的大操作付出的代价摊还给每个耗时短的小操作,让大家共同承担。

所以核算法就是,假设你有一个银行账户,你看着这个序列有了初步想法,每次操作来临时,你先存一笔固定的钱,钱的数目你之前已经想好了,可以是3,可以是n,可以是n2这个每次固定存的钱就是摊还代价,然后按操作实际耗时来扣账户里的钱。
这个存钱操作唯一要求就是你的银行账户一直不能为负,因为我们要找的是摊还代价的上界,我们存进去的钱只能比总实际耗时多。假设有一个操作序列,是100个耗时为1的小操作后跟着1个耗时200的大操作,你打算每次存3元进去,小操作只花1元钱,所以你每次银行账户里还剩2元,接下来又来了99个小操作,你发财了,账户里存了200元,恐怖的是耗时200的大操作接踵而至,由于之前的规定,你还是固定存3元进去去硬抗这次大操作,扣了200元后你的账户还剩3元,这是因为吃了之前的老本,你的账户还是正的(也就是摊还总代价还是实际代价的上界),所以摊还代价就是3,是常数时间。这就是摊还分析!固定存3元就代表了一种耗时代价均摊的思想。

关于势能法,也是一样的,只不过它关心的是每一次操作后状态的改变。首先我定义一个势函数,一开始势函数的值是0,之后进行操作都会改变势函数,具体怎么改变是跟我的势函数有关的。我们定义摊还代价如下所示:

c’i = ciii-1
c’i是摊还代价,ci是第i次实际操作的代价,Φii-1是第i次操作后势函数的变化,初始时势函数Φ0=0。
对两边进行从1到n的求和,得出总摊还代价=总时间代价+Φn0

为了使得总摊还代价>总时间代价,Φn>0。

也就是为了使得总摊还代价是总时间代价的上界,我们需要让势函数最后不能为负。就像过山车一样,有的操作是在蓄势,像过山车爬坡,有的操作在放势,像过山车下坡,但是总体来说不管怎么蓄势放势,势函数最后要高于水平线0。

核算法与势能法的难点

这两种方法还是比较巧妙的,但是需要比较强的分析能力和构造能力。核算法难就难在想出我固定存多少钱进银行账户,是存常数?存n元?还是logn元?
势能法难点在于势函数的构造,我需要一个合理的势函数,它的势变化(Φii-1)和那一次操作实际代价(ci)之间的关联最好能够清楚地表现出来,成为我们的摊还代价(c’i)。

接下来举例说明这两种方法的应用

例2. 二进制计数器

我们对一个二进制数从0开始进行每次加1的操作,每翻一位数耗时为1,比如0000+1->0001,我们把最后一位从0翻成了1,耗时1。0101+1->0110,翻了最后两位,耗时为2。
这一系列的加法操作的摊还代价是多少?

1.核算法
我们对每一次的加法操作都把它想成往银行账户里固定存2元,这样我们对于二进制中从0变到1的情况,除了支付它从0变到1的代价,还存好了它从1变到0的代价。比如0000+1->0001,我们账户还有1元,这1元是为了让最后一位1变成0的,
0001+1->0010,我们用第一步的1元偿还了最后一位1变成0,并且我们新投入了两元支付了倒数第二位的0->1,并且还剩余1元,这1元是为了支付倒数第二位的1->0的。
0010+1->0011,0011+1->0100…这个累加操作其实就是使0变成1,1变成0,每次最多只有1个0变成1,我们存进去的2元就是为了满足从0变成1的花费的,并且还存下了1元,已经考虑到了他以后从1变成0了。所以每一位的变化,我们的2元钱都有充分考虑到,我们的银行账户永远不会变负,所以2就是我们的摊还代价。

2.势能法
我们定义势函数 Φ为计数器中1的个数,从定义可知道我们的势函数永远不会为负数,并且Φ0 = 0,c’i = ciii-1 = 2,因为如果我们就是单纯地在末尾的0加上一个1,那么ci=1,Φii-1=1因为整个二进制多了1个1。如果因为我们的加1使得00100111…1(最后m个1)变成了00101000…0(最后m个0),那么ci=m+1因为翻了m+1个数,Φii-1=-(m-1)因为整个二进制计数器少了m-1个1,所以不管什么情况,c’i = ciii-1 = 2。

总结一下
1.引入摊还分析是为了衡量操作序列的平均耗时,因为有些情况耗时高的操作需要耗时低的操作来铺垫,割裂他们来分析时间复杂度没有意义,所以需要用摊还分析把他们合起来均分着来分析。

2.计算摊还代价有三种方法:根据定义来算,核算法,势能法。后两种方法的核心在于通过猜想和构造得到实际操作总代价的上界。

3.核算法强调了一个固定投入多少摊还代价,对于便宜和贵重的操作一视同仁,固定投入多少的决定是它的难点。

4.势能法强调的是每一次实际操作的代价和其引起的势函数变化之间的关系展现,因为我们定义的摊还代价是每一次实际操作的代价(ci)+其引起的势函数变化(Φii-1)。如何定义势函数是它的难点。

猜你喜欢

转载自blog.csdn.net/sinat_41613352/article/details/85065508
今日推荐