摊还分析(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(n^2))。虽然这个结论是正确的,但是它却不是一个确界。

从另一个角度考虑,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次时,每次都是最坏情况。事实上,我们可以确切地计算出其时间代价:

[ sum_{i=0}^{k-1}⌊frac{n}{2^i}⌋ < nsum_{i=0}^{infty } frac{1}{2^i}< 2 n ]

因此,对于一个初值为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)。

这是因为(sumlimits_{i=1}^{n}c'_i geq sumlimits_{i=1}^{n}c_i)在任何时刻都成立,其中(c_i)为第i个操作的真实代价,(c_i')为其摊还代价。

2.2 二进制计数器递增

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

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

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

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

3. 势能法

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

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

[c_i' = c_i + Theta(D_i) - Theta(D_{i-1}) ]

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

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

[sum_{i = 1}^n c_i' = sum_{i = 1}^n(c_i + Theta(D_i) - Theta(D_{i-1})) = sum_{i=1}^nc_i + Theta(D_n) - Theta(D_0) ]

做一个移项,变形得:

[sum_{i = 1}^n c_i' - sum_{i = 1}^n c_i = Theta(D_n) - Theta(D_0) ]

如果我们能定义一个势函数(Theta),使得(Theta(D_n) geq Theta(D_0)),则总摊还代价(sum_limits{i = 1}^n c_i')给出了总实际代价(sum_limits{i = 1}^n c_i)的一个上界。

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

3.1 栈操作

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

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

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

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

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

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

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

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

3.2 二进制计数器递增

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

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

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

原文地址:https://www.cnblogs.com/dongkuo/p/5824007.html