平摊分析
在平摊分析中,执行一系列数据结构操作所需要的时间是通过对执行的所有操作求平均而得出的。平摊分析可用来证明在一系列操作中,即使单一的操作具有较大的代价,通过对所有操作求平均后,平均代价还是很小的。平摊分析与平均情况分析的不同之处在于它不牵涉到概率。这种分析保证了在最坏情况下每个操作具有平均性能。
本文将讨论平摊分析技术中最常用的三种技术:
- 聚集方法可以用这种方法确定一个n个操作的序列的总代价的上界T(n)。每个操作的平摊代价可表示为T(n)/n; ——
- 会计方法 用它可确定每个操作的平摊代价。当有一种以上的操作时,每种操作都可有一个不同的平摊代价。这种方法对操作序列中的某些操作先“多记帐”,将多记的部分作为对数据结构中的特定对象上预付的存款存起来。在该序列中稍后要用到这些存款以补偿那些对它们记的“帐”少于其实际代价的操作。 ——
- 势能方法它与会计方法的相似之处在于要确定每个操作的代价,且先对某些操作多记帐以补偿以后的不足记帐。这种方法将存数作为数据结构的“势能”来维护,而不是将存款与数据结构中的单个对象联系起来。 ——
我们将用两个例子来说明这三个模型。第一个例子是个栈,它有一个新的操作
MULTIPOP
,它可一次弹出几个对象。另一个例子是个二进计数器,它利用操作
INCREMENT
从
0
开始计数。
在阅读本文时,读者应记住在平摊分析中所记的
“
帐
”
只是为了分析之用,它们不应出现在代码中。例如,当在采用会计方法时,如果将一存款赋予一个对象,在代码中就没有必要对其属性
credit[x]
赋一个相应的值了。
聚集方法
在平摊分析的聚集方法中,我们要证明对所有的
n
,
n
个操作构成的序列在最坏情况下总的时间为
T(n)
。在最坏情况下,每个操作的平摊代价就为
T(n)/n
。请注意这个平摊代价对每个操作都是成立的,即使当序列中存在几种类型的操作时也是一样的。后文中要研究的另两种方法
—— 会计方法
和
势能方法——
对不同类型的操作则可能赋予不同的平摊代价。
栈操作
在关于聚集方法的第一个例子中,我们要来分析增加了一个新操作的栈。我们知道,栈的两种基本操作
——PUSH
和
POP
,每个的时间代价都是
O(1)
:
- PUSH(S,x) —— 将对象x压入栈S;
- POP(S) —— 弹出并返回S的顶端元素;
因为这两个操作的运行时间都为
O(1)
,故我们可把每个操作的代价视为
1
。这样,一个
n
个
PUSH
和
POP
操作的序列的总代价就为
n
,而这
n
个操作的实际运行时间就为
O(n)
。
如果再增加一个栈操作
MULTIPOP(S,k)
,则情况会变得更有趣。该操作去掉
S
的
k
个顶端对象,或当它包含少于
k
个对象时弹出整个栈。在下面的伪代码中,如果当前栈中没有对象则操作
STACK-EMPTY
返回
TRUE
,否则它返回
FALSE
。
MULTIPOP(S,k)
1 while not STACK-EMPTY(S) and k≠0
2 do POP(S)
3 k ← k-1
图
1
演示了
MULTIPOP
的一个例子,初始情况见图
1(a)
。最上面的四个对象由
MULTIPOP(S,4)
弹出,其结果如
(b)
中所示。下一个操
作是 MULTIPOP(S,7) ,它将栈清空 —— 如图 1(c) 中所示 —— 因为余下的对象已经不足七个了。
作是 MULTIPOP(S,7) ,它将栈清空 —— 如图 1(c) 中所示 —— 因为余下的对象已经不足七个了。
图
1 MULTIPOP
作用于栈
S
上的动作
MULTIPOP(S
,
k)
作用于一个包含
s
个对象的栈上的运行时间怎样呢?实际的运行时间与实际执行的
POP
操作数成线性关系,因而只要按
PUSH
和
POP
具有抽象代价
1
来分析
MULTIPOP
就是够了。代码中
while
循环执行的次数即从栈中弹比的对象数
min(s,k)
。对该循环的每一次执行,在第
2
行中都要调用一次
POP
。这样,
MULTIPOP
的总代价即址为
min(s,k)
,而实际运行时间则为这个代价的一个线性函数。
现在来对作用于一个初始为空的栈上的
n
个
PUSH
,
POP
和
MULTIPOP
操作构成的序列作个分析。序列中一次
MULTIPOP
操作的最坏情况代价为
O(n)
,因为栈的大小至多为
n
。这样,任意栈操作的最坏情况时间就是
O(n)
,而
n
个操作的总代价就是
O(n2)
,因为
MULTIPOP
操作可能会有
O(n)
个,每个的代价为
O(n)
。虽然这个分析是正确的,但通过分析每个操作的最坏情况代价而得的
O(n2)
的结论却是不够准确的。
利用平摊分析中的聚集方法,我们可以或的一个考虑到了整个操作序列的更好的上界。事实上,虽然某一次
MULTIPOP
操作的代价可能较高,但作用于初始为空的栈上的任意一个包含
n
个
PUSH
,
POP
和
MULTIPOP
操作的序列的代价至多为
O(n)
。为什么会是这样呢?一个对象在每次被压入栈后至多被弹出一次。所以,在一个非空栈上调用
POP
的次数
(
包括在
MULTIPOP
内的调用
)
至多等于
PUSH
操作的次数,即至多为
n
。对任意的
n
值,包含
n
个
PUSH
,
POP
和
MULTIPOP
操作的序列的总时间为
O(n)
。据此,每个操作的平摊代价为:
O(n)/n=O(1)
。
我们想再一次强调一下,虽然我们已说明了每个栈操作的平均代价
(
或平均运行时间
)
为
O(1)
,但没有用到任何概率推理。实际上是给出了一列
n
个操作的最坏情况界
O(n)
。用
n
来除这个总代价即可得每个操作的平均代价
(
或说平摊代价
)
。
二进制计数器
作为聚集方法的另一个例子,考虑实现一个由
0
开始向上计数的
k
位二进制计数器的问题。我们用一个数组
A[0..k-1](
此处
length[A]=k)
作为计数器。存储在计数器中的一个二进数
x
的最低位在
A[0]
中,最高位在
A[k-1]
中,故
开始时,
x=0
,故
A[i]=0
,
i=0,1,...,k-1
。为将计数器中的值加
1(
模
2k)
,我们可用下面的过程:
INCREMENT(A)
1 i←0
2 while i<length[A] and A[i]=1
3 do A[i]←0
4 i←i+1
5 if i<length[A]
6 then A[i]←1
这个算法与硬件实现的行波进位计数器基本上是一样的。图
2
演示了一个二进制计数器从
0
至
16
的
16
次增值的过程。发生翻转而取得下一个值的位都加了阴影。右边示出了位翻转所需的代价。注意总代价始终不超过
INCREMENT
操作总次数的两倍。在第
2-4
行中每次
while
循环的开始,我们希望在位置
i
处加
1
。如果
A[i]=1
,则加
1
后就将位置
i
处的数位置为
0
,并产生一个进位
1
,它在循环的下一次执行中加到位置
i+1
上;否则,循环结束;然后,如果
i<k
,我们知道
A[i]=0
,故将
1
加到位置
i
后,使
0
变为
1
,这在第
6
行中完成。每次
INCREMENT
操作的代价与被改变值的位数成线性关系。
像在栈的例子中一样,大致分析一下只能得到一个正确但不紧确的界。在最坏情况下,
INCREMENT
的每次执行要花
O(k)
时间,此时数组
A
中包含全
1
。这样,在最坏情况下,作用于一个初始为零的计数器上的
n
个
INCREMENT
操作的时间就为
O(nk)
。
如果我们分析得更精确一些的话,则可得到
n
次
lNCREMENT
操作的序列的最坏情况代价为
O(n)
。关键是要注意到在每次调用
INCREMENT
中,并不是所有的位都发生变化:作用于初始为零的计数器上的
n
次
lNCREMENT
操作导致
A[1]
变化了
ë
n/2
û
次。类似地,位
A[2]
在
n
次
INCREMENT
操作中共变化
ë
n/4
û
次。一般地,对
i=0,1,..,
ë
㏒
n
û
,位
A[i]
在一个作用于初始为零的计数器上的
n
次
INCREMENT
操作的序列中共要翻转
ë
n/2i
û
次。对
i >
ë
㏒
n
û
,位
A[i]
始终不发生变化。这样,在序列中发生的位翻转的总次数为
由此可知,作用于一个初始为零的计数器上的
n
次
INCREMENT
操作的最坏情况时间为
O(n)
,因而每次操作的平摊代价为
O(n)/n=O(1)
。
Counter Value |
A[7]
|
A[6]
|
A[5]
|
A[4]
|
A[3]
|
A[2]
|
A[1]
|
A[0]
|
Total Cost | ||
0
|
|
0
|
0
|
0
|
0
|
0
|
0
|
0
|
0
|
0
|
|
1
|
|
0
|
0
|
0
|
0
|
0
|
0
|
0
|
1
|
1
|
|
2
|
|
0
|
0
|
0
|
0
|
0
|
0
|
1
|
0
|
3
|
|
3
|
|
0
|
0
|
0
|
0
|
0
|
0
|
1
|
1
|
4
|
|
4
|
|
0
|
0
|
0
|
0
|
0
|
1
|
0
|
0
|
7
|
|
5
|
|
0
|
0
|
0
|
0
|
0
|
1
|
0
|
1
|
8
|
|
6
|
|
0
|
0
|
0
|
0
|
0
|
1
|
1
|
0
|
10
|
|
7
|
|
0
|
0
|
0
|
0
|
0
|
1
|
1
|
1
|
11
|
|
8
|
|
0
|
0
|
0
|
0
|
1
|
0
|
0
|
0
|
15
|
|
9
|
|
0
|
0
|
0
|
0
|
1
|
0
|
0
|
1
|
16
|
|
10
|
|
0
|
0
|
0
|
0
|
1
|
0
|
1
|
0
|
18
|
|
11
|
|
0
|
0
|
0
|
0
|
1
|
0
|
1
|
1
|
19
|
|
12
|
|
0
|
0
|
0
|
0
|
1
|
1
|
0
|
0
|
22
|
|
13
|
|
0
|
0
|
0
|
0
|
1
|
1
|
0
|
1
|
23
|
|
14
|
|
0
|
0
|
0
|
0
|
1
|
1
|
1
|
0
|
25
|
|
15
|
|
0
|
0
|
0
|
0
|
1
|
1
|
1
|
1
|
26
|
|
16
|
|
0
|
0
|
0
|
1
|
0
|
0
|
0
|
0
|
31
|
|
图
2
在
16
次
INCREMENT
操作作用下,一个八位二进制计数器的值从
0
变到
16
会计方法
在平摊分析的会计方法中,我们对不同的操作赋予不同的费值,某些操作的费值比它们的实际代价或多或少。对每一操作所记的费值即其平摊代价。当一个操作的平摊代价超过了它的实际代价时,两者的差值就被作为存款赋给数据结构中一些特定的对象。存款可在以后用于补偿那些其平摊代价低于其实际代价的操作。这样,我们就可将一操作的平摊代价看作为两部分
——
实际代价与存款
(
或被储蓄或被使用
)
。这与聚集方法有很大不同,后者所有操作都具有相同的平摊代价。
在选择操作的平摊代价时是要非常小心的。如果我们希望通过对平摊代价的分析说明每次操作具有较小的最坏情况平均代价,则操作序列的总的平摊代价就必须是该序列的总的实际代价的一个上界。而且,像在聚集方法中一样,这种关系必须对所有的操作序列都成立。这样,与该数据结构相联系的存款始终应该是非负的,因为它表示了总的平摊代价超过总的实际代价的部分。如果允许总的存款为负的话
(
开始时对某些操作的费值记得过低
)
,则在某一时刻总的平摊代价就会低于总的实际代价。对到该时刻为止的操作序列来说,总的平摊代价就不会是总的实际代价的一个上界。所以,我们必须始终注意数据结构中的总存款不能是负的。
栈操作
- PUSH 1
- POP 1
- MULTIPOP min(k,s)
其中
k
为
MULTIPOP
的一个参数,
s
为调用该操作时栈的大小。现对它们赋予以下的平摊代价:
- PUSH 2
- POP O
- MULTIPOP O
请注意
MULTIPOP
的平摊代价是个常数
0
,而它的实际代价却是个变量。此处所有的三个平摊代价都是
O(1)
,但一般来说所考虑的各种操作的平摊代价会渐近地变化。
现在我们来说明只需要用平摊代价就支付任何的栈操作序列。假设我们用
1
元钱来表示代价的单位。开始时栈是空的。栈数据结构与在餐馆中一堆迭放的盘子类似。当将一个盘子压入堆上时,我们用
1
元来支付该压入动作的实际代价,并有
1
元的存款
(
记的是
2
元的帐
)
,将该
1
元钱放在刚压入的盘子的上面。在任何一个时间点上,堆中每个盘子的上而都有
l
元钱的余款。
盘中所存的钱是用来预付将盘从栈中弹出所需代价的。当我们在执行了一个
POP
操作时,对该操作不用收任何费,只要用盘中所存放的余款来支付其实际代价即可。为弹出一个盘子,我们拿掉该盘子上的
1
元余款,并用它来支付弹出操作的实际代价。这样,在对
PUSH
操作多收了一点费后,就无需对
POP
操作收取任何费用。
更进一步,我们对
MULTIPOP
操作也无需收费。为弹出第一个盘子,我们取出其中的
1
元余款并用它支付一次
POP
操作的实际代价。为弹出第二个盘子,再取出该盘子上的
1
元余款来支付第二次
POP
操作,等等。这样,对任意的包含
n
次
PUSH
,
POP
和
MULTIPOP
操作的序列,总的平摊代价就是其总的实际代价的一个上界。又因为总的平摊代价为
O(n)
,故总的实际代价也为
O(n)
。
二进制计数器的增值
为进一步说明会计方法,我们再来分析一下
作用于一个初始为0的二进制计数器上的INCREMENT操作
。我们前面已经说过,这个操作的运行时间与发生翻转的位数是成正比的,而位数在本例中即为代价。我们还是用
1
元钱来表示位数代价
(
此例中即为某一位的翻转
)
。
为进行平摊分析,我们规定对将某一位置为
1
的操作收取
2
元的平摊费用。当某数位被设定后,我们用
2
元中的
1
元来支付置位操作的实际代价,而将另
1
元存在该位上作为余款。在任何时间点上,计数器中每个
1
上都有
1
元余款。这样在将某位复位成
0
时不用支付任何费用,只要取出该位上的
1
元余款即可。
现在就可以来确定
INCREMENT
的平摊代价了。在
while
循环中复位操作的代价是由有关位上的余款来支付的。在
INCREMENT
的第
6
行中至多有一位被复位,所以一次
INCREMENT
操作的代价至多为
2
元。又因为计数器中为
1
的位数始终是非负的,故其中总的余款额也是非负的。对
n
次
INCREMENT
操作,总的平摊代价为
2n
元,即
O(n)
,这就给出了总的实际代价的一个界。
势能方法
平摊分析中的势能方法不是将已预付的工作作为存储在数据结构特定对象中的存款来表示,而是表示成
--
种
“
势能
”
,或
“
势
”
,它在需要时可释放出来以支付后面操作的代价。势是与整个数据结构而不是其中的个别对象发生联系的。
势能方法的工作过程是这样:开始时先对一个初始数据结构
D0
执行
n
个操作,对每个
i=1,2,..n
,设
ci
为第
i
个操作的实际代价,
Di
为对数据结构
Di-1
作用第
i
个操作的结果。势函数
F
将每个数据结构
Di
映射为一个实数
F
(Di)
,即与数据结构
Di
相联系的势。第
i
个操作的平摊代价定义为:
(1)
从这个式子可以看出,每个操作的平摊代价为其实际代价加上由于该操作所增加的势。根据等式
(1)
,
n
个操作的总的平摊代价为:
(
2
)
如果我们能定义
--
个势函数
F
使得
F
(Dn)
≧
F
(D0)
,则总的平摊代价就是总的实际代价的一个上界。在实践中,我们并不总是
知道要执行多少个操作,所以,如果要求对所有的 i 有 F (Di) ≧ F (D0) ,则就应像在会计方法中一样,保证预先支付。通常为了方便起见,我们定义 F (D0) 为 0 ,然后再证明对所有 i 有 F (Di) ≧ O ;倘若 F (D0)≠0 ,只要构造势函数 F¢ ,使得 F¢ (i)= F (Di)- F (D0) 即可。
知道要执行多少个操作,所以,如果要求对所有的 i 有 F (Di) ≧ F (D0) ,则就应像在会计方法中一样,保证预先支付。通常为了方便起见,我们定义 F (D0) 为 0 ,然后再证明对所有 i 有 F (Di) ≧ O ;倘若 F (D0)≠0 ,只要构造势函数 F¢ ,使得 F¢ (i)= F (Di)- F (D0) 即可。
从直觉上看,如果第
i
个操作的势差
F
(Di)-
F
(Di-1)
是正的,则平摊代价
就表示对第
i
个操作多收了费,同时数据结构的势也随之增加了。如果势差是负的,则平摊代价就表示对第
i
个操作的不足收费,这时就可通过减少势来支付该操作的实际代价。
由等式
(1)
和
(2)
所定义的平摊代价依赖于所选择的势函数
F
,不同的势函数可能会产生不同的平摊代价,但它们都是实际代价的上界。在选择一个势函数时常要作一些权衡,可选用的最佳势函数的选择要取决于所需的时间界。
栈操作
为了说明势能方法,我们再一次来研究栈操作
PUSH
,
POP
和
MULTIPOP
。定义栈上的势函数
F
为栈中对象的个数。开始时我们要处理的是空栈
D0
,
F
(D0)=0
。因为栈中的对象数始终是非负的,故在第
i
个操作之后的栈
Di
就具有非负的势,且有
以
F
表示的
n
个操作的平摊代价的总和就表示了实际代价的一个上界。
现在我们来计算各栈操作的平摊代价。如果作用于一个包含
s
个对象的栈上的第
i
个操作是个
PUSH
操作,则势差为
根据等式
(1)
,该
PUSH
操作的代价为
假设第
i
个操作是
MULTIPOP(S,k)
,且弹出了
k'=min(k,s)
个对象。该操作的实际代价为
k'
,势差为
这样,
MULTIPOP
操作的平摊代价为
类似地,
POP
操作的平摊代价也是
0
。
三种栈操作中每一种的平摊代价都是
O(1)
,这样包含
n
个操作的序列的总平摊代价就是
O(n)
。因为我们已经证明了
F
(Di)
≧
F
(D0)
,故
n
个操作的总平摊代价即为总的实际代价的一个上界。这样
n
个操作的最坏情况代价为
O(n)
。
二进制计数器的增值
作为说明势能方法的另一个例子,我们再来看看二进计数器的增值问题。这
---
次,我们定义在第
i
次
INCREMENT
操作后计数器的势为
bi
,即第
i
次操作后计数器中
1
的个数。
我们来计算一下一次
INCREMENT
操作的平摊代价。假设第
i
次
INCREMENT
操作对
ti
个位进行了复位。该操作的实际代价至多为
ti+1
,因为除了将
ti
个位复位外,它至多将一位置成
1
。所以,在第
i
次操作后计数器中
1
的个数为
bi
≦
bi-1-ti+1
,势差为
平摊代价为
如果计数器开始时为
0
,则
F
(D0)=0
。因为对所有
i
有
F
(Di)
≧
0
,故
n
次
INCREMENT
操作的序列的总平摊代价就为总的实际代价的一个上界,且
n
次
INCREMENT
操作的最坏情况代价为
O(n)
。
势能方法给我们提供了一个简易方法来分析开始时不为零的计数器。开始时有
b0
个
1
,在
n
次
INCREMENT
操作之后有
bn
个
1
,此处
0
≦
b0,bn
≦
k
。我们可以将等式(
2
)重写为:
对所有
1
≦
i
≦
n
有
ci
≦
2
。因为
F
(D0)=b0
,
F
(Dn)=bn
,
n
次
INCREMENT
操作的总的实际代价为:
请注意因为
b0
≦
k
,如果我们执行了至少
n=
W
(k)
次
INCREMENT
操作,则无论计数器中包含什么样的初始值,总的实际代价都是
O(n)
。
动态表
在有些应用中,在开始的时候无法预知在表中要存储多少个对象。可以先为该表分配一定的空间,但后来可能会觉得不够,这样就要为该表分配一个更大的空间,而表中的对象就复制到新表中。类似地,如果有许多对象被从表中删去了,就应该给原表分配一个更小的空间。在后文中,我们要研究表的动态扩张和收缩的问题。利用平摊分析,我们要证明插入和删除操作的平摊代价为
O(1)
,即使当它们引起了表的扩张和收缩时具有较大的实际代价也时一样的。此外,我们将看到如何来保证某一动态表中未用的空间始终不超过整个空间的一部分。
假设动态表支持
Insert
和
Delete
操作。
Insert
将某一元素插入表中,该元素占据一个槽(即一个元素占据的空间)。同样地,
Delete
将一个元素从表中去掉,从而释放了一个槽。用来构造这种表的数据结构方法的细节不重要,可以选用的有栈,堆或杂凑表结构。我们将用一个数组或一组数组来实现对象存储。
大家将会发现在采用了杂凑表中分析杂凑技术时引入的装载因子概念后会很方便。定义一个非空表
T
的装载因子
a(T)
为表中存储的对象项数和表的大小
(
槽的个数
)
的比值。对一个空表(其中没有元素)定义其大小为
0
,其装载因子为
1
。如果某一动态表的装载因子以一个常数为上界,则表中未使用的空间就始终不会超过整个空间的一常数部分。
我们先开始分析只对之做插入的动态表,然后再考虑既允许插入又允许删除的更一般的情况。
表的扩张
假设一个表的存储空间分配为一个槽的数组,当所有的槽都被占用的时候这个表就被填满了,这时候其装载因子为
1
。在某些软件环境中,如果试图向一个满的表中插入一个项,就只会导致错误。此处假设我们的软件环境提供了存储管理系统,它能够根据请求来分配或释放存储块。这样,当向一满的表中插入一个项时,我们就能对原表进行扩张,即分配一个包含比原表更多槽的新表,再将原表中各项数据复制到新表中去。
一种常用的启发技术是分配一个比原表大一倍的新表。如果只对表进行插入操作,则表的装载因子总是至少为
1/2
,这样浪费掉的空间就始终不会超过表的总空间的一半。
在下面的伪代码中,我们假设对象
T
表示一个表,域
table[T]
包含了一个指向表的存储块的指针,域
num[T]
包含了表中的项数,域
size[T]
为表中总的槽数。开始时,表是空的:
num[T]=size[T]=0
Insert(T, x)
1 if size[T]=0
2 then
给
table[T]
分配一个槽的空间
3 size[T]←1
4 if num[T]=size[T]
5 then
分配一个有
2*size[T]
个槽的空间的新表
6
将
table[T]
中所有的项插入到新表中
7
释放
Table[T]
8 table[T]
指向新表的存储块地址
9 size[T]←2*size[T]
10
将
x
插入
table[T]
11 num[T]←num[T]+1
请注意,这里有两种
“
插入
”
过程:
Insert
过程本身与第
6
行和第
l0
行中的基本插入。可以根据基本插入的操作数来分析
Insert
的运行时间,每个基本插入操作的代价为
1
。假淀
Insert
的实际运行时间与插入项的时间成线性关系,使得在第
2
行中分配初始表的开销为常数,而第
5
行与第
7
行中分配和释放存储的开销由第
6
行中转移表中所有项的开销决定。我们称第
5-9
行中执行
then
语句的事件为一次扩张。
现在我们来分析一下作用于一个初始为空的表上的
n
次
Insert
操作的序列。设第
i
次操作的代价
ci
,如果在当前的表中还有空间(或该操作是第一个操作),则
ci=1
,因为这时我们只需在第
10
行中执行一次基本插入操作即可。如果当前的表是满的,则发生一次扩张,这时
ci=i
;第
10
行中基本插入操作的代价
1
再加上第
6
行中将原表中的项夏制到新表中的代价
i-1
。如果执行了
n
次操作,则一次操作的最坏情况代价为
O(n)
,由此可得
n
次操作的总的运行时间的上界
O(n)
。
但这个界不很紧确,因为在执行
n
次
Insert
操作的过程中并不常常包括扩张表的代价。特别地,仅当
i-1
为
2
的整数幕时第
i
次操作才会引起一次表的扩张。实际上,每一次操作的平摊代价为
O(1)
,这一点我们可以用聚集方法加以证明。第
i
次操作的代价为
由此,
n
次
Insert
操作的总代价为
因为至多有
次操作的代价为
1
,而余下的操作的代价就构成了一个几何级数。因为
n
次
Insert
操作的总代价为
3n
,故每次操作的平摊代价为
3
。
通过采用
会计方法
,我们可以对为什么一次
Insert
操作的平摊代价会是
3
有一些认识。从直觉上看,每一项要支付三次基本插入操作:将其自身插入现行表中,当表扩张时对其自身的移动,以及对另一个在扩张表时已经移动过的另一项的移动。例如,假设刚刚完成扩张后某一表的大小为
m
,那么表中共有
m/2
项,且没有
“
存款
”
。对每一次插入操作要收费
3
元。立即发生的基本插入的代价为
1
元,另有
1
元放在刚插入的元素上作为存款,余下的
1
元放在已在表中的
m/2
个项上的某一项上作为存款。填满该表另需要
m/2
次插入,这样,到该表包含了
m
个项时,该表已满,每一项上都有
1
元钱以支付在表扩张期间的插入。
也可以用
势能方法
来分析一系列
n
个
Insert
操作,我们还将在后文中用此方法来设计一个平摊代价为
O(1)
的
Delete
的操作。开始时我们先定义一个势函数
F
,在完成扩张时它为
0
,当表满时它也达到表的大小,这样下一次扩张的代价就可由存储的势来支付了。函数
(4)
是一种可能的选择。在刚刚完成一次扩张后,我们有
num[T]=size[T]/2
,于是有
F
(T)=0
,这正是所希望的。在就要做一次扩张前,有
num[T]=size[T]
,于是
F
(T)=num[T]
,这也正是我们希望的。势的初值为
0
,又因为表总是至少为半满,
num[T]
≧
size[T]/2
,这就意味着
F
(T)
总是非负的。所以,
n
次
Insert
操作的总的平摊代价就是总的实际代价的一个上界。
为了分析第
i
次
Insert
操作的平摊代价,我们用
numi
来表示在第
i
次操作后表中所存放的项数,用
sizei
表示在第
i
次操作之后表的大小,
F
i
表示第
i
次操作之后的势。开始时,
num0=O
,
size0=0
和
F
0
=0
。
如果第
i
次
Insert
操作没有能触发一次表的扩张,则
numi=numi-1+1
,
sizei=sizei-1
,且该操作的平摊代价为
如果第
i
次操作确实触发了一次扩张,则
numi=numi-1+1
,
sizei = 2sizei-1 = 2numi-1=2(numi -1)
,且该操作的平摊代价为
图
3
画出了
numi
,
sizei
和
F
i
的各个值。在第
i
次操作后对这些量中的每一个都要加以计算。图中红线表示
numi
,紫线表示
sizei
,蓝线表示
F
i
。注意在每一次扩张前,势已增长到等于表中的项目数,因而可以支付将所有元素移到新表中去的代价。此后,势降为
0
,但一但引起扩张的项目被插入时其值就立即增加
2
。
图
3
对表中项目数
numi
,表中的空位数
sizei
,以及势
F
i
作用
n
次
Insert
操作的效果
表扩张和收缩
为了实现
Delete
操作,只要将指定的项从表中去掉即可。但是,当某一表的装载因子过小时,我们就希望对表进行收缩,使得浪费的空间不致太大。表收缩与表扩张是类似的:当表中的项数降得过低时,我们就要分配一个新的、更小的表,而后将旧表的各项复制到新表中。旧表所占用的存储空间则可被释放,归还到存储管理系统中去。在理想情况下,我们希望下面两个性质成立:
- 动态表的装载因子由一常数作为下界;
- 各表操作的平摊代价由一常数作为下界。
另外,我们假设用基本插入和删除操作来测度代价。
关于表收缩和扩张的一个自然的策略是当向表中插入一个项时将表的规模扩大一倍,而当从表中删除一项就导致表的状态小干半满时,则将表缩小一半。这个策略保证了表的装载因子始终不会低于
1/2
,但不幸的是,这样又会导致各表操作具有较大的平摊代价。请考虑一下下面这种情况:我们对某一表
T
执行
n
次操作,此处
n
为
2
的整数幂。前
n/2
个操作是插入,由前面的分析可知其总代价为
O(n)
。在这一系列插入操作的结束处,
num[T]=size[T]=n/2
。对后面的
n/2
个操作,我们执行下面这样一个序列:
I,D,D,I,I,D,D,I,I
,
……
,其中
I
表示插入,
D
表示删除。第一次插入导致表扩张至规模
n
。紧接的两次删除又将表的大小收缩至
n/2
;紧接的两次插入又导致表的另一次扩张,等等。每次扩张和收缩的代价为
Θ(n)
,共有
Θ(n)
次扩张或收缩。这样,
n
次操作的总代价为
Θ(n2)
,而每一次操作的平摊代价为
Θ(n)
。
这种策略的困难性是显而易见的:在一次扩张之后,我们没有做足够的删除来支付一次收缩的代价。类似地,在一次收缩后,我们也没有做足够的插入以支付一次扩张的代价。
我们可以对这个策略加以改进,即允许装载因子低于
1/2
。具体来说,当向满的表中插入一项时,还是将表扩大一倍,但当删除一项而引起表不足
1/4
满时,我们就将表缩小为原来的一半。这样,表的装载因子就以常数
1/4
为下限界。这种做法的基本思想是使扩张以后表的装载因子为
1/2
。因而,在发生一次收缩前要删除表中一半的项,因为只有当装载因子低于
1/4
时方会发生收缩。同理,在收缩之后,表的装载因子也是
1/2
。这样,在发生扩张前要通过扩张将表中的项数增加一倍,因为只有当表的装载因子超过
1
时方能发生扩张。
我们略去了
Delete
的代码,因为它与
Insert
的代码是类似的。为了方便分析,我们假定如果表中的项数降至
0
,就释放该表所占存储空间。亦即,如果
num[T]=0
,则
size[T]=0
。
现在我们用势能方法来分析由
n
个
Insert
和
Delete
操作构成的序列的代价。先完义一个势函数
F
,它在刚完成一次扩张或收缩时值为
0
,并随着装载因子增至
1
或降至
1/4
而变化。我们用
a
(T)= num[T]/size[T]
来表示一个非空表
T
的装载因子。对一个空表,因为有
num[T]=size[T]=0
,且
a
(T)=1
,故总有
num[T]=
a
(T)*size[T]
,无论该表是否为空。我们采用的势函数为
(5)
请注意,空表的势为
0
;势总是非负的。这样,以
F
表示的一列操作的总平摊代价即为其实际代价的
--
个上界。
在进行详细分析之前,我们先来看看势函数的某些性质。当装载因子为
1/2
时,势为
0
。当它为
1
时,有
size[T]=num[T]
,这就意味着
F
(T)=num[T]
,这样当因插入一项而引起一次扩张时,就可用势来支付其代价。当装载因子为
1/4
时,我们有
size[T]=4*num[T]
,它意味着
F
(T)=num[T]
,因而当删除某个项引起一次收缩时就可用势来支付其代价。图
4
说明了对一系列操作势是如何变化的。
图
4
对表中的项目数
numi
、表中的空位数
sizei
及势
F
i
作用由 n 个 Insert 和 Delete 操作构成的操作序列的效果
作用由 n 个 Insert 和 Delete 操作构成的操作序列的效果
图
4
中红线表示
numi
,紫线表示
sizei
,蓝线表示
F
i
。注意在每一次扩张前,势已增长到等于表中的项目数,因而可以支付将所有元素移到新表中去的代价。类似地,在一次收缩之前,势也增加到等于表中的项目数。
为分析
n
个
Insert
和
Delete
的操作序列,我们用
来表示第
i
次操作的实际代价,
ci
表示其参照
F
的平摊代价,
numi
表示在第
i
次操作之后表中存储的项数,
sizei
表示第
i
次操作后表的大小,
a
i
表示第
i
次操作后表的装载因子,
F
i
表示第
i
次操作后的势。开始时,
num0=O
,
size0=0
,
a
0
=1
,
F
0
=0
。
我们从第
i
次操作是
Insert
的情况开始分析。如果
a
i-1
≥1/2
,则所要做的分析就与对表扩张的分析完全一样。无论表是否进行了扩张,该操作的平摊代价
ci
都至多是
3
。如果
a
i-1
<1/2
,则表不会因该操作而扩张,因为仅当
a
i-1
=1
时才发生扩张。如果还有
a
i
<1/2
,则第
i
个操作的平摊代价为
如果
a
i-1
< 1/2
,但
a
i
≥1/2
,那么
因此,一次
Insert
操作的平摊代价至多为
3
。
现在我们再来分析一下第
i
个操作是
Delete
的情形。这时,
numi=numi-1-1
。如果
a
i-1
< 1/2
,我们就要考虑该操作是否会引起一次收缩。如果没有,则
sizei=sizei-1
,而该操作的平摊代价则为
如果
a
i-1
< 1/2
且第
i
个操作触发一次收缩,则该操作的实际代价为
ci=numi+1
,因为我们删除了一项,移动了
numi
项。这时,
sizei/2 = sizei-1/4 = numi+1
,而该操作的平摊代价为
当第
i
次操作为
Delete
且
a
i-1
≥1/2
时,其平摊代价仍有一常数上界。具体的分析从略。
总之,因为每个操作的平摊代价都有一常数上界,所以作用于一动态表上的
n
个操作的实际时间为
O(n)
。