什么是摊还分析?
摊还分析其实就是评价某一个操作的代价,方法就是求某数据结构中一系列操作的平均时间。
摊还分析与概率无关,可以保证某一系列操作在最坏情况下的平均性能。
什么意思呢?举个例子,我们都知道栈这个数据结构,假设现在一个栈支持三种操作:
POP(S) //弹出栈顶元素
PUSH(S,x) //将元素x压如栈中
MultiPop(S,k) //将栈的前k个元素弹出
普通分析:
- 设栈为空栈,那么一系列操作,操作总数为n,分别执行
PUSH(S,x), POP(S), MultiPop(S,k)
操作,所以栈中最大元素数量为n。 - 对于操作
MultiPop(S,k)
,最坏情况是 O ( n ) O(n) O(n),因为容器最多含有n个元素。 - 可以得出,对任意的栈的
MultiPop(S,k)
操作,最差时间复杂度都是 O ( n ) O(n) O(n),那么要进行n个MultiPop(S,k)
的操作,时间复杂度自然是 O ( n 2 ) O(n^2) O(n2) - 这种分析是对的,但是我们从常识得知,当我们第一次取出n个元素的时候,第二次以后的
POP(S)
操作时间复杂度就都是 O ( 1 ) O(1) O(1)了,所以我们的普通分析并不能更准确的求出时间复杂度。于是我们引入了摊还分析。
摊还分析
- 我们的
POP(S)
操作和MultiPop(S,k)
操作只能在非空的栈上操作(也可以在空栈操作,最多返回null,但是这没意义)。也就是说,POP(S)
能执行多少次,在于PUSH(S,x)
了多少次。PUSH(S,x)
了n次,那么POP(S)
就只能操作n次。同理适用于MultiPop(S,k)
- 对于任意的n,任意顺序的上述三种操作最多消耗
O(n)
时间。像一半操作PUSH
一半操作POP
。那么整体来说每个操作所以需时间为 O ( n ) / n = O ( 1 ) O(n)/n = O(1) O(n)/n=O(1)
上面看不懂没关系,下面才是重点。
问题引入
我们知道HashTable的查找时间复杂度是 O ( 1 ) O(1) O(1),最差情况是 O ( n ) O(n) O(n),这里我们认为一个HashTable是一个能够良好的解决冲突的,那么时间复杂度就是常数级别。这里就有一个问题:如何确定HashTable的最优大小?
我们的经验之谈,反正HashTable不管多大,查找时间都是常数级别,那么就让他越大越好咯。这句话本身没错,但是考虑到内存限制,我们不可能让它越大越好。现在语言中提供的API里,一般的解决方法都是给出一个固定大小的HashMap(如容量为8),当这个HashTable被填满或者填充到一定程度的时候,在对它进行动态扩容。一次扩容量为它的二倍。也就是原本为8个单位容量的表,满了后容量会变成16个单位,然后32个单位,64个单位…
上述提到的HashTable,我们会称为“动态表”(dynamic table),那么为什么会以这个速率扩容呢?为什么不每次扩容一个固定值呢?毕竟固定值也好计算,不用再进行二进制逻辑左移位来做到二倍扩充。接下来我们分析内部逻辑。
动态表
我在前面提到,当表中数据满了,会以二倍的容量扩充。
如果我们自己手动实现一个HashTable,会发现扩充其实不那么容易。由于语言的限制,我们没法在原表扩充。比如C++的:
static int size = 1 << 3;
HashTable hashtable = new HashTable(size);
//扩容
size = size << 1;
hashtable = new HashTable(size);
这么做会把原表数据丢掉。所以我们惟一的方法就是:
static int size = 1 << 3;
//原表
HashTable hashtable = new HashTable(size);
//扩容
size = size << 1;
HashTable temp = new HashTable(size);
for key in hashtable:
value = hashtable.get(key);
temp.set(key, value);
那么上述操作用的时间复杂度是多少?很好分析,原表内每个元素都拿出来放进新表,达到复制效果。因此时间复杂度是 O ( n ) O(n) O(n)。
但是问题又来了,上述的操作只发生在表满的情况下,也就是插入时发现插不进去,溢出了,才会触发扩容操作。假设每一次插入都溢出,那么每次插入所需要的时间就是 O ( n ) O(n) O(n),插入n
个数需要的时间就是: n ⋅ O ( n ) = O ( n 2 ) n·O(n) = O(n^2) n⋅O(n)=O(n2),但是我们的实际经验告诉我们,每一次插入都溢出的情况不存在。
所以我们传统的分析方法只能告诉我们最差情况下的时间复杂度,这个时间复杂度并不精准,它的上界太大了,且在许多情况下不能很好的反映出一个算法的优劣,比如我们说快速排序的时间复杂度是 O ( n log n ) O(n\log n) O(nlogn),这里也可以说是 O ( n 10086 ) O(n^{10086}) O(n10086),因为大O定义法就是这么定义的:上界不超过。但是我们能说快排比冒泡还慢么?显然不能。因此我们引入了摊还分析法,也叫平摊分析(Amortized Analysis)来解决上述问题。
现在我们看完了标黄部分,知道了为什么引入摊还分析这个概念(虽然说快排的时间复杂度不严谨,但是不影响理解,凑合看),那么对于我们的动态表,怎么利用摊还分析得到它的时间复杂度呢?
动态表的摊还分析
我们假设一个变量 c i c_i ci,表示第i
个插入到动态表的元素的时间复杂度。那么通过对i
的归类,可以引出如下公式:
c i = { n , i = 2 n a n d n = 1 , 2 , 3...... 1 , i ≠ 2 n a n d n = 1 , 2 , 3.... c_i = \begin{cases}n, i = 2^n\ and \ \ n = 1,2,3...... \\1,i \neq2^n\ and \ \ n = 1,2,3.... \end{cases} ci={
n,i=2n and n=1,2,3......1,i=2n and n=1,2,3....
换句话说,当i
是2的n次方,那么这一步插入需要先扩容,时间复杂度为 O ( n ) O(n) O(n);否则时间复杂度就是 O ( 1 ) O(1) O(1).
我们列一个表出来:
i | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
---|---|---|---|---|---|---|---|---|---|---|
size | 1 | 2 | 4 | 4 | 8 | 8 | 8 | 8 | 16 | 16 |
c i c_i ci | 1 | 2 | 3 | 1 | 5 | 1 | 1 | 1 | 9 | 1 |
component | 1+0 | 1+1 | 1+2 | 1+0 | 1+4 | 1+0 | 1+0 | 1+0 | 1+8 | 1+0 |
上表中,component里面i+j
的意思是,插入耗时与扩容耗时。我们看到插入耗时永远为1,扩容耗时却需要些数学知识:
r e s i z e = ∑ j = 1 log ( n − 1 ) 2 j , j ∈ N + resize=\sum_{j=1}^{\log (n-1)} 2^j, j\in N^+ resize=<