摊还分析/平摊分析(Amortized Analysis):从白痴到入门

什么是摊还分析?

摊还分析其实就是评价某一个操作的代价,方法就是求某数据结构中一系列操作的平均时间。
摊还分析与概率无关,可以保证某一系列操作在最坏情况下的平均性能。

什么意思呢?举个例子,我们都知道栈这个数据结构,假设现在一个栈支持三种操作:

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) nO(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=<

  • 35
    点赞
  • 114
    收藏
    觉得还不错? 一键收藏
  • 6
    评论
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值