摊还分析(1)——算法导论(23)

摊还分析(amortized analysis)是一种分析一个操作序列中所执行的所有操作的平均时间分析方法。与一般的平均分析方法不同的是,它不涉及概率的分析,可以保证最坏情况下每个操作的平均性能。

下面介绍瘫痪分析中的最常用的三种技术。

1. 聚合分析

1.1 栈操作

先来看对栈进行操作的例子。

通常,栈能够进行push(S, x)pop(S)操作,其时间复杂度均为O(1)。现在定义一个新的操作multipop(S, k),它删除栈S栈顶的k个元素(若不足k个,则全部弹出为止)。下面是操作multipop(S, k)的伪代码:

while not stack-empty(S) and k > 0
	pop(s)
    k = k - 1

可以看出,multipop的时间代价为min(s, k),其中s是栈中元素的个数。

下面我们分析一个由n个push、pop和multipop组成的操作在一个空栈上的执行情况。

简单地,根据上面对multipop操作时间代价(min(s, k))的分析,我们很快得出,一个multipop操作的最坏时间是O(n),因为栈的大小是n,因此n个操作的序列的最坏时间代价是O(n2)。虽然这个结论是正确的,但是它却不是一个确界。

从另一个角度考虑,pop操作的次数至多与push操作的次数相等,而push操作的次数至多为n次。因此一个由n个push、pop和multipop组成的操作的时间代价最多为O(n),分摊到每一个操作的时间代价就为O(n) / n = O(1)。

1.2 二进制计数器递增

我们再来看二进制计数器递增的例子。

二进制计数器是用一个数组来表示一个数,即数组每一个槽充当二进制数的一个数位,数组的第一个槽为最低位。

其递增算法的思路是:首先将最低位记为当前位。判断当前位是否为1,若是,则将该位置为0,当前位右移一位(进位操作);不断重复以上过程直至当前位的值不为1或者当前位“溢出”。最后,如果没有“溢出”,就将当前位的值置为1。

下面是python实现代码:

def increment(num):
    i = 0
    while i < len(num) and num[i] == 1:
        num[i] = 0
        i += 1
    if i < len(num):
        num[i] = 1

假设这个数组长度位k。根据上面的代码,我们可以发现,increment操作的最坏时间代价是 O(k),这在所有位上都是1时发生。因此,我们可以粗略地做出判断:对初值位0的计数器执行n个increment操作的时间代价位O(nk)。

和前面一样,事实上,这个上界是不确切的。因为increment操作不会在连续执行n次时,每次都是最坏情况。事实上,我们可以确切地计算出其时间代价:

i=0k1n2i<ni=012i<2n

因此,对于一个初值为0的计数器,执行一个n个increment操作的序列的最坏时间代价是O(n),分摊到每一个操作的时间代价为O(1)。

2. 核算法

在用核算法(accounting method)进行摊还分析时,我们对不同操作赋予不同的费用,它可能多于或少于其实际代价,我们称其为摊还代价

当一个操作的摊还代价超出其实际代价时,我们将差额存入数据结构的特定对象,存入的差额称为信用。对于后续操作中摊还代价小于其实际代价的情况,信用可以用来支付差额。

2.1 栈操作

回到栈操作的例子,其中各操作的实际代价为:

操作 实际代价
push 1
pop 1
multipop min(k, s)

我们为这些操作赋予如下的摊还代价:

操作 摊还代价
push 2
pop 0
multipop 0

在每次进行push操作时,我们花费2个单位的代价,1个单位用来支付其本身的实际代价,另1个单位可作为信用保存;当对栈中的任何一个元素进行pop(multipop也属于pop)操作时,可用该元素在push时储存的信用来支付差额。这样就保证了在任何时刻的信用值是非负的

因此,总实际代价的上界为总摊还代价,即为O(n)。

这是因为i=1ncii=1nci在任何时刻都成立,其中ci为第i个操作的真实代价,ci为其摊还代价。

2.2 二进制计数器递增

同理,我们也可以用核算法来分析上述二进制计数器递增的例子。

因为increment操作的时间效率与当前数组中为1的位数成正比,因此我们将翻转的位数作为操作的代价。

对于一次置位操作(将该位值置为1的操作),我们设其摊还代价为2。在进行一次置位操作时,我们花1个单位的代价来支付其本身的实际代价,剩余1单位作为信用,用来支付将来可能的复位操作(将该位值置为0的操作)的代价。这样就保证了在任何时刻的信用值是非负的。这样就保证了在任何时刻的信用值是非负的

因此,总实际代价的上界为总摊还代价。而在一次increment操作中,只进行一次置位操作,因此总摊还代价O(n)。

3. 势能法

核算法不同,势能分析并不是将预付代价表示为数据结构中特定对象的信用,而是表示为“势能”,简称“势”。释放势能即可用来支付未来操作代价。我们将势能与整个数据结构而不是特定对象相关联。

假设我们对一个初始数据结构D0执行n个操作。对每一个i=1,2,...,n,用ci表示第i个操作的实际代价,Di表示在数据结构Di1上执行第i个操作得到的结果数据结构。势函数Θ将每个数据结构Di映射到一个实数Θ(Di),此值即为关联到数据结构Di的势;并且定义第i个操作的摊还代价ci为:

ci=ci+Θ(Di)Θ(Di1)

每个操作的摊还代价为其实际代价与其引起的势能变化的和

于是,n个操作的总代价为:

i=1nci=i=1n(ci+Θ(Di)Θ(Di1))=i=1nci+Θ(Dn)Θ(D0)

做一个移项,变形得:

i=1ncii=1nci=Θ(Dn)Θ(D0)

如果我们能定义一个势函数Θ,使得Θ(Dn)Θ(D0),则总摊还代价i=1nci给出了总实际代价i=1nci的一个上界。

由于我们不是总能知道要执行多少个操作,因此无法直接保证Θ(Dn)Θ(D0)。但是如果我们将势函数的选择条件变得严格,使得Θ(Di)Θ(D0)对于所有的i都成立,那么就可以保证。

3.1 栈操作

我们再次回到栈操作的例子。这次我们采用势能法来分析该问题。

对于势函数的选取,我们选择将栈映射到其内部元素个数的函数,即Θ(Di)i次操作栈时,栈中元素的个数。对于初始的空栈,有Θ(D0)=0;并且由于栈中元素不可能为负,因此Θ(Di)0=Θ(D0)

因此,以上Θ函数定义的n个操作的总摊还代价为总实际代价的一个上界。

有了以上Θ函数的定义,我们便可以计算栈上各种操作的摊还代价。

对于push操作,其实际代价为1,其引起的势能变化也为1,因此其摊还代价为2。

对于pop操作,同理可得其摊还代价为0。

对于一次multipop操作,它会将k=min(k,s)个对象弹出,即其实际代价为k;引起的势差变化为k,因此其摊还代价也为0。

综上,每个操作的摊还代价都为O(1),n个操作的总摊还代价为O(n),总实际代价最坏为O(n)

3.2 二进制计数器递增

同样我们再用势能法来分析二进制计数器递增的例子。

与上面相似,我们将Θ(Di)表示为二进制计数器中,位上是1的位数。显然Θ(Di)Θ(D0)=0。因此,总摊还代价为总实际代价的一个上界。

再分析总摊还代价。对于一次increment操作,其实际代价为k次复位操作和1次置位操作,为k+1;其引起的势差变化为k+1,因此摊还代价为(k+1)+(k+1)=2n个操作的总摊还代价为O(n),因此总实际代价最坏为O(n)

posted @   学数学的程序猿  阅读(6926)  评论(3编辑  收藏  举报
编辑推荐:
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析
阅读排行:
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?
点击右上角即可分享
微信分享提示