摊还分析(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次时,每次都是最坏情况。事实上,我们可以确切地计算出其时间代价:
因此,对于一个初值为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)。
这是因为\(\sum\limits_{i=1}^{n}c'_i \geq \sum\limits_{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'\)为:
即每个操作的摊还代价为其实际代价与其引起的势能变化的和。
于是,\(n\)个操作的总代价为:
做一个移项,变形得:
如果我们能定义一个势函数\(\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)\)。