本文主要是介绍Amortized Analysis 摊还分析,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!
Amortized Analysis
摊还分析考察一个操作序列中所执行的所有操作的平均时间,来评价操作的代价。这个操作序列中也许某一操作的代价很高,但因为还有其他操作,所以这些操作的平均代价并没有那么高。
本文将首先将这种代价分析方式与最坏情况时间复杂度和平均时间复杂度两种方式进行区分,然后通过一篇其他人的博文说明摊还分析的三种方法,并对三种方法进行简要总结,最后对摊还分析方法进行实践。
对比
摊还分析是相对于最坏情况时间复杂度分析、平均时间复杂度分析的另一种时间复杂度分析。它的bound介于两者之间。
这样的评价方式相比于最坏情况复杂度要更接近于实际情况,因为这个“代价较高的操作”往往不会经常发生,比如栈操作中的Multi-pop(k),虽然它会花费k个时间,但在它之前需要有k个push这个操作才能进行,也就是说无法一直进行这个操作。
而相比较于平均时间复杂度分析,摊还分析不考虑概率问题,它考虑的是最坏情况下每个操作的平均代价。
摊还分析方法
转载自:codeplayer`s blog
先来直观的介绍一下什么是摊还分析:在摊还分析中,我们求数据结构的一个操作序列中所执行的所有操作的平均时间,来评价操作的代价。这样,我们就可以说明一个操作的平均代价是很低的,即使序列中某个单一操作的代价很高。摊还分析不同于平均情况分析,它不涉及概率,它可以保证最坏情况下每个操作的平均性能。
在学习摊还分析的时候要注意,在摊还分析中赋予对象的费用仅仅是用来分析而已,不需要也不应该出现在程序中。通过做瘫痪分析,通常可以获得对某种特定数据结构的认识,这种认识有助于优化设计。
聚合分析
使用聚合分析,我们可以证明对所有n,一个n个操作的序列最坏情况下花费的总时间为T(n)。因此,在最坏情况下,每个操作的平均时间,或摊还代价为T(n)/n。注意,此摊还代价是适用于每个操作的,即使序列中有多种类型操作。下面通过两个例子来了解一下聚合分析。
栈操作
经典的栈操作,这里不过多叙述,它支持三种操作:
- PUSH(S, x),压入对象x。
- POP(S),弹出一个对象。
- MULTIPOP(S,k),弹出k个对象,如果栈中对象的数量少于k,则将所有对象弹出。
我们来分析一下n个这三种操作在一个空栈上的运行时间。一个MULTIPOP操作的最坏情况时间为О(n),因为栈的大小为最大为n,PUSH和POP最坏情况时间均为1,假设序列中的有О(n)个MULTIPOP操作,所以我们通过分析每个操作的最坏情况时间得到操作序列的最坏情况时间为О(n^2),但是这不是一个确界。
通过使用聚合分析,考虑整个序列的n个操作,可以得到更好的上界。实际上虽然MULTIPOP操作的最坏情况时间很高,但是在一个空栈上执行PUSH、POP、MULTIPOP的操作序列,代价最多是О(n)。因为我们将一个对象压入到栈后,最多只将其弹出一次,所以一个非空的栈,可以执行的POP次数最多为n。因此上述操作序列最多花费О(n)时间,而一个操作的平均时间为О(n) / n = О(1)。在聚合分析中,我们将每个操作的摊还代价设定为平均代价。所以,这三种操作的摊还代价均为О(1)。
这里我们并未使用概率分析就证明了一个栈操作的平均代价。我们实际上得出的是一个n个操作序列的最坏情况运行时间О(n),再除以n得到了每个操作的平均代价,或者说摊还代价。
二进制计数器递增
一个k位二进制计数器递增的问题的例子。简单来说就是使用二进制来计数,并将这个二进制数放到一个数组中,可以使用一个INCREMENT的操作来对这个二进制数增加1。A.length = k,将最低位保存在A[0]中,最高位保存在A[k-1]中。INCREMENT的伪代码如下:
INCREMENT
i = 0
while i < A.length and A[i] == 1A[i] = 0i = i+1
if i < A.lengthA[i] = 1
在2~4行while循环时,我们希望将1加在第i位上。如果A[i]=1,那么加1操作会将第i位翻转为0,并产生一个进位——在一次循环中将1加到i+1位上。否则循环结束,此时若i 与上一个例子类似,粗略的分析会的到一个正确但是不紧的界。最坏情况下翻转数组上所有的位,INCREMENT执行一次花费Θ(k)时间,因此n个INCREMENT的最坏时间为О(nk)。但是通过摊还分析,我们可以得到最坏运行时间О(n),因为不可能每次INCREMENT都翻转所有的位。实际上,对一个初值为0的计数器,执行n个INCREMENT的过车给你中,A[i]会翻转n/(2^i)次。所以我们可以得到的执行翻转操作的总数为: 2n(1−12k−1)<2n
所以可以得到n个INCREMENT操作序列的最坏情况时间为О(n),摊还代价为О(n)/n = О(1)。
核算法
用核算法进行摊还分析时,我们对不同操作赋予不同费用,赋予某些操作的费用可能多于或少于实际代价。我们将赋予一个操作的费用成为它的摊还代价。当一个操作的摊还代价超出其实际代价时,我们将差额存入数据结构中的特定对象,存入的差额成为信用。对于后续操作中摊还代价小于实际代价的情况,信用可以用来支付差额。
我们必须小心地选择操作的摊还代价。如果我们希望通过分析摊还代价来证明每个操作的平均代价的最坏情况很小,就应确保操作序列的总摊还代价给出了序列总真实代价的上界。而且,这种关系必须对所有操作序列都成立。数据结构中存储的信用恰好等于总摊还代价与总实际代价的差值。数据结构所关联的信用必须一直非负值,如果在某个步骤,我们允许信用为负值,那么当时的总摊还代价就会低于总实际代价,对于到那个时刻为止的操作序列,总摊还代价就不再是总实际代价的上界了。
栈操作
为了说明摊还分析的核算法,再次使用栈的例子。我们赋予PUSH、POP、MULTIPOP如下的摊还代价:
PUSH 2
POP 0
MULTIPOP 0
PUSH时我们将1元的代价支付PUSH操作的实际代价,将剩余的1元存为信用,这1元实际上是作为将来被POP时代价的预付费。当执行一个POP时,并不缴纳任何费用,而是使用存储的信用来支付其实际代价,对于MULTIPOP也是一样的。因为栈中元素的数量总是非负的,所以可以保证信用也非负的。因此,对任意n个PUSH、POP、MULTIPOP组成的序列,总摊还代价О(n)为总实际代价的上界。
二进制计数器递增
在这个例子中,对一次置位操作,我们设其摊还代价为2元,用1元支付实际代价,1元存为信用,用来支付将来复位操作的代价。在任何时刻,计数器中任何为1的位都存有1元信用,这样在复位的时候,我们就不需要支付任何费用了。
INCREMENT过程一次最多置位一次,因此摊还代价最多为2美元,计数器中1的个数永远不会为负,所以对于n个INCREMENT操作的总摊还代价О(n)是总实际代价的上界。
势能法
势能法摊还分析并不预付代价表示为数据结构中特定对象的信用,而是表示为“势能”,将势能释放即可用来支付未来操作的代价。我们将势能于整个数据结构而不是特定对象相关联。
工作方式如下。对一个初始数据结构D0执行n个操作,对每个i=1,2,…,n,令ci为第i个操作的实际代价,令Di为数据结构Di-1执行第i个操作得到的结果数据结构。势函数Φ将每个数据结构Di映射到一个实数Φ(Di),此值即为关联到数据结构Di的势。第i个操作的摊还代价^ci用势函数Φ定义为:^ci = ci + Φ(Di) - Φ(Di-1)
所以每个操作的摊还代价等于其实际代价加上此操作引起的势能变化。则n个操作的总摊还代价为 : Ci(总摊还)= Ci(总实际)+ f(Di)-f(D0)
不同的势函数会产生不同的摊还代价,但摊还代价仍未实际代价的上界。在选择势函数时,我们常常发现可以做出一定的权衡,是否使用最佳势函数依赖于对时间界的要求。
两个例子还是盏和二进制计数器,不过势能法分析起来使用公式计算较多,在这里公式写起来不太方便就不详细叙述了。总体思想就是将预支付的代价添加的整个数据结构的势能中,将势能释放即可支付未来操作的代价。
三种方法的简要总结
聚合分析是简单地通过理解来得出一系列操作所需要的总代价,并由此得出每个操作的平均代价。 核算法则是通过“精妙地设计每个操作的摊还代价”,来获得整体的摊还代价。势能法则与核算法相似,只不过是从整体考虑势。
从经验要求度来说,聚合分析高于核算法高于势能法。所以在我看来,虽然理解上聚合分析最简单,其次是核算法,势能法最复杂,但从操作上说,顺序是反过来的。不妨以这样的思路去对待这三种方法:
首先掌握的是势能法(找到“势能”所对应的对象,再通过它来获得每个操作的摊还代价,最后得出n个操作的代价),在熟练之后,我们可以直接根据经验得到每个操作的摊还代价,也就掌握了核算法,最后进一步熟练,我们就可以直接理解得到n个操作的代价了,也就是掌握了聚合分析。
实践
证明
Splay Trees连续的M次操作,最多花费O(MlogN)的时间
我们利用势能法来证明。
记 S(i) 为 i 节点的后裔的个数(包括自身),R(i) 为 i 节点的秩,即为logS(i)。令势能函数为
记 Ri(X) 为旋转前X的秩, Rf(X) 为旋转后X的秩。
下面我们分别分析Zig, Zig-Zag, Zig-Zig三种情况下的旋转的摊还成本。一次访问调整的摊还成本是旋转摊还成本的和。
Zig
单次旋转下实际成本为 1,势能变化 Rf(X)+Rf(P)−Ri(X)−Ri(P) (因为A, B, C, D四棵子树节点数均未变化,所以其中的秩都未变化,势能变化,即秩变化,就是X和P的秩的变化)
故Zig情况的摊还成本为 1+Rf(X)+Rf(P)−Ri(X)−Ri(P)
又 Rf(P)<Rf(X),Ri(P)>Ri(X)
摊还成本 <1+2(Rf(X)−Ri(X))
又 Rf(X)>Ri(X)
故摊还成本 <1+3(Rf(X)−Ri(X))
Zig-Zag
之字形旋转实际成本为2,
势能变化 Rf(X)+Rf(P)+Rf(G)−Ri(X)−Ri(P)−Ri(G)
故摊还成本为 2+Rf(X)+Rf(P)+Rf(G)−Ri(X)−Ri(P)−Ri(G)
而 Ri(G)=Rf(X)
故摊还成本为 2+Rf(P)+Rf(G)−Ri(X)−Ri(P)
又 Sf(P)+Sf(G)<Sf(X)⇒Rf(P)+Rf(G)<2Rf(X)−2
if a+b< c and a, b are integer
loga+logb≤2logc−2
证明略
故摊还成本 < 2Rf(X)−Ri(X)−Ri(P)
又 Ri(P)>Ri(X)
故摊还成本 < 2Rf(X)−2Ri(X)
又 Rf(X)>Ri(X)
故摊还成本 <3(Rf(X)−Ri(X))
Zig-Zig
一字形旋转实际成本为2,
势能变化 Rf(X)+Rf(P)+Rf(G)−Ri(X)−Ri(P)−Ri(G)
故摊还成本为 2+Rf(X)+Rf(P)+Rf(G)−Ri(X)−Ri(P)−Ri(G)
而 Ri(G)=Rf(X)
故摊还成本为 2+Rf(P)+Rf(G)−Ri(X)−Ri(P)
又 Si(X)+Sf(G)<Sf(X)⇒Ri(X)+Rf(G)<2Rf(X)−2
⇒Rf(G)<2Rf(X)−Ri(X)−2
故摊还成本 < 3Rf(X)−2Ri(X)−Ri(P)
又 Ri(P)>Ri(X)
故摊还成本 < 3Rf(X)−3Ri(X)
故一次调整,摊还成本为 1+3(Rf(Root)−Ri(X))
为O(logN)
故Splay trees连续的M次操作最坏情况是M次访问并调整,而每次调整的摊还时间复杂度是O(logN),故M次操作的摊还时间复杂度为O(MlogN)
这篇关于Amortized Analysis 摊还分析的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!