摊还分析(2)——算法导论(24)

1. 动态表

先来介绍动态表的概念。

我们在使用数组时,通常都是先创建一个大小固定的数组,然后再将数据填充进去。这时难免会遇到创建的数组过小或过大的情况。过小则满足不了存储需求;过大则浪费存储空间。于是我们对普通数组进行包装,创造出一种叫做动态表的数据结构。

所谓动态,就是它能够自动地进行表的扩张(在数组容量不够时)与收缩(在数组容量过大时)。具体地,它通常会采取这样的策略:在插入数据时,若检查到数组容量过小,则会创建一个新的容量较大的数组,然后将原始数组里面的数据复制到新的数组中,最后再执行插入操作;在删除数据时,先执行删除数据操作,然后若检查到数组容量过大,则会创建一个新的容量较小的数组(当然要保证容量足够),最后同样将原始数组里的数据复制到新的数组中。

本篇博客关注于动态表的扩张和收缩问题,将使用摊还分析证明,虽然插入和删除操作可能会引起表的扩张和收缩,从而有较高的实际代价,但它们的摊还代价都是O(1)

需要说明的是,用什么样的数据结构来组织动态表不是固定和重要的,除了以上所说的采用数组的方式,你也可以使用堆、栈或散列表来实现。

2. 表扩张

在介绍表的扩张之前,我们先引入在学习散列表时接触的概念:装载因子α

非空表T的装载因子α(T)定义为表中储存的数据项的数量比上表的规模(槽的数量)。

下面用一小段Java代码给出表的扩张操作:

public class Table<T> {

    private Object[] values;
    private int size; // 表中元素的个数

    public void insert(T value) {
        if (values == null) {
            values = new Object[1];
        }
        if (size == values.length) { // 表的扩张
            Object[] newValues = new Object[size * 2]; // 创建一个容量是之前两倍的数组
            System.arraycopy(values, 0, newValues, 0, values.length); // 拷贝原始数据到新数组
            values = newValues;
        }
        values[size] = value;// 插入新数据
        size++;
    }
}

从代码中我们发现,整个insert操作实际上包含两个"插入"过程,一个是copy原始数据,另一个是插入新数据。我们把每次基本插入操作的代价设定为1,然后用基本插入操作的次数来描述insert操作的运行时间。

下面我们分析对一个空表执行n个insert操作的代价。设第i次操作的代价为ci。如果当前表有空间容纳新的插入项,那么ci=1;如果当前表已填满,则会发生一次扩张,此时需要将旧表里的i1项(因为已经插入了i-1次数据)数据copy到新表里面,还要插入新的数据,因此代价ci=i。一个操作的最坏时间是O(n),因此n次操作总运行时间的上界为O(n2)

和上一篇中的两个例子一样,这个上界也不是紧确的,因为在执行n个操作中,不可能每次操作都遇到最坏情况,即不是每次都需要扩张表。事实上,只有当i1为2的幂时,才需要扩张。

采用聚合分析分析该问题:第i个操作的代价是:

(1)ci={i,i1恰为2的幂1,其他

因此n个操作的总代价是:

i=1ncin+j=0lgn2j<n+2n=3n

而单一操作的摊还代价最多为3。

我们可以用核算法来更加直观地考虑为什么每次插入一项数据需要付出3个单位的代价。假设表在某次扩张后容量变为m,此时表中有m2项数据,它们都没有储存任何信用。在进行下一次插入时,我们付出3个单位的代价,1个单位用来支付本次插入;1个单位存储起来用来支付以后移动该数据时的代价;还有1个单位也存储起来用来支付原始的m2项数据中的某项在下一次移动时的代价。这样,在表下一次扩张时,不需要消费额外的代价来支付移动数据这m项数据的代价。

我们还可以用势能法来分析:定义如下的势函数:Θi(T)=2T.sizeT.cap,它表示第i次插入后的势能,T.size表示表中数据的项数,T.cap表示表的大小。易知,Θ0(T)=0;表始终处于等于或超出半满状态,即T.sizeT.cap/2,因此Θi(T)Θ0(T)=0。因此,n个insert操作的总摊还代价给出了总实际代价的上界。

下面分析第i个操作的摊还代价。设capi为第i次操作后表的规模。

  1. 若第i次操作表没有扩张,一次操作本身的实际代价为1;其引起的势能的变化为2。因此一次操作的摊还代价为3。
  2. 若第i次操作表发生了扩张,一次操作本身的实际代价为i;其引起的势能变化为:[2i2(i1)][2×(i1)(i1)]=2(i1)=3i。因此一次操作的摊还代价也为3。

综上所述,一次插入操作的摊还代价为O(1)

3. 表收缩

表收缩过程正好与扩张过程相反,这里不在赘述。

4. 表的扩张与收缩

值得注意的是,因为我们在扩张时,通常是将表的规模扩增为原来的2倍,相应地,你可能也认为我们应该在表中元素个数不足规模的一半,即α<12时,进行表的收缩。遗憾的是,这不是一个好的策略。考虑如下场景,当表在发生一次扩张操作后,我们进行两次删除操作,可以看到,表会在这两次删除操作中的第二次发生收缩;接下来我们再进行两次插入操作,同样,表又会在第二次插入操作时进行扩张。重复上述行为,表会一直进行收缩,扩张。即:删除、删除(收缩)、插入、插入(扩张)、删除、删除(收缩)…这样,对于n个操作,每个操作的摊还代价为Θ(n)

我们改进一下此策略,允许装载因子α小于12,可以想象,选取14作为α的下界是比较合理的,下面我们用势能法证明这点。

首先是势函数的选取,定义势函数为:

(2)Θ(T)={2T.sizeT.cap,α(T)1/2T.cap/2T.size,α(T)<1/2

同样,空表的势为0,;势始终大于0。因此对于n个插入和删除的操作序列,其总摊还代价是总实际代价的上界。

下面我们分析第i个操作的摊还代价。分两种情况讨论。在此之前,我们做如下定义:用ci表示第i个操作的实际代价,用ci表示摊还代价。同样,sizei表示第i次操作后储存的数据项的数量,capi表示第i个操作后表的规模。用αi表示第i个操作后的势。

4.1 第i个操作是插入操作

sizei=sizei1+1

1. 当αi11/2

这种情况与我们在表扩张小节中分析的情况一致,摊还代价ci至多为3。

2. 当αi1<1/2αi<1/2$:

i个操作并不能引起表的扩张,capi=capi1。因此ci=1+[(capi/2sizei)(capi1/2sizei1]=0

3. 当αi1<1/2αi1/2

i个操作并不能引起表的扩张,capi=capi1。因此ci=1+[(2sizeicapi)(capi1/2sizei1)]=3sizeicapicapi1/2,又因为αi1=(i1)/capi1,因此ci=3αi1capi13/2capi1+3<3

因此,当第i个操作是插入操作时,其摊还代价至多为3。

4.2 第i个操作是删除操作

sizei=sizei11

1. 当αi11/2αi>1/2

i个删除操作不会引起表的收缩,capi=capi1,。因此ci=1+[(2sizeicapi)(2sizei1capi1)]=1

2. 当αi11/2αi<1/2

i个删除操作不会引起表的收缩,capi=capi1。因此ci=1+[(capi/2sizei)(2sizei1capi1)]<2

3. 当αi1<1/2

i个操作可能引起表的收缩。

① 若没有引起表的收缩。则ci=1+[(capi/2sizei)(capi1/2sizei1)]=2

② 若引起表的收缩。则ci=(sizei+1)+[(capi/2sizei)(capi1/2sizei1)]=1

因此,当第i个操作是删除操作时,其摊还代价至多为2。

总之,由于每个操作的摊还代价都为常数,在一个动态表上执行任意n个操作的实际运行时间是O(n)

posted @   学数学的程序猿  阅读(1274)  评论(0编辑  收藏  举报
编辑推荐:
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
阅读排行:
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· Manus的开源复刻OpenManus初探
· AI 智能体引爆开源社区「GitHub 热点速览」
· 三行代码完成国际化适配,妙~啊~
· .NET Core 中如何实现缓存的预热?
点击右上角即可分享
微信分享提示