本文主要分析了动态表扩张和收缩过程中的摊还代价。

摊还分析

当我们对一个数据结构做一系列的操作时,我们不仅希望知道操作的所有时间,也希望知道每一步操作的平均时间,摊还分析就是计算每一步操作的平均代价。值得注意的是,摊还分析不同于平均情况分析,它并不涉及概率,因此可以保证最坏情况下每个操作的平均性能

假设我们进行 n 步连续的操作,在最坏的情况下总的花费时间为 $T(n)$,则摊还代价为 $T(n)/n$

动态表

在某些应用程序中,我们无法预见会将多少对象保存在表中。我们为一个表分配了固定的内存空间,随后可能出现空间不足的情况。此时,我们必须重新给表分配更大的内存空间,并将原来表中的数据复制到新的空间中。同样,当表中存在大量的空间剩余时,我们可以给它重新分配更小的内存空间。

在这里,我们要考虑的一个核心问题是,重新分配多大的空间才是合理的?如果分配的空间过小,会导致我们需要频繁的分配空间,以适应表中数据的增加,这必然会导致时间性能的上升;但如果分配的空间过大,会导致内存空间的浪费。因此我们需要一个好的策略,实现时间复杂度和空间复杂度的平衡。

表的扩张

我们先考虑只允许向表中插入数据的情况。当我们向一个满的表插入数据时,我们需要扩张表——分配一个包含更多空间的新表。由于我们总是需要表位于连续的内存空间中,因此必须为更大的新表分配一个新的数组,然后将数据从旧表复制到新的表中。

一个常用的分配策略是:新表的空间为旧表的2倍。用C语言写一个简单的动态表。

代码实现

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define Init_size 1

/* define dynamic array */
typedef int datatype;
typedef struct array {
    size_t length, pos;
    datatype *a;
}List;

/* append new element in list */
int append(List *newlist, datatype n) {

    if (newlist->length == newlist->pos) {
        // create new space
        datatype *newspace = calloc(newlist->length*2, sizeof(datatype));
        if (newspace == NULL)
            return 1;
            
        // copy elements from old array to new array
        memcpy(newspace,newlist->a,sizeof(datatype)*newlist->length);
        free(newlist->a);
        newlist->length *= 2;
        newlist->a = newspace;
    }

    newlist->a[newlist->pos++] = n;

    return 0;
}

/* print array information */
void show(List *newlist) {

    printf("space: %lu\nelements: ", newlist->length);
    for (int i = 0; i < newlist->pos; i++) {
        printf("%d ", newlist->a[i]);
    }
    printf("\n");
}


int main(void) {

    int *init = calloc(Init_size, sizeof(datatype));
    List new = {Init_size, 0, init};

    for (int i = 0; i < 8; i++){
        if (append(&new, i) == 1){
            printf("memory error\n");
            break;
        }
        show(&new);
    }

    return 0;
}

程序的运行结果为:

space: 1
elements: 0 
space: 2
elements: 0 1 
space: 4
elements: 0 1 2 
space: 4
elements: 0 1 2 3 
space: 8
elements: 0 1 2 3 4 
space: 8
elements: 0 1 2 3 4 5 
space: 8
elements: 0 1 2 3 4 5 6 
space: 8
elements: 0 1 2 3 4 5 6 7 

摊还分析

假设我们对一个空表执行了 n 次插入操作,我们来计算一下操作的代价。由于表每次扩张,其空间都变为原来的两倍,因此表的实际长度必然为 2 的幂。我们两种情况进行讨论:

  1. 第 i 次操作时表未装满,我们只需要在表中插入新的元素,因此代价为 $c_i=1$。
  2. 假如 $i-1=2$ 的幂,即第 i 次操作时,表已经装满,需要动态扩张,因此我们需要重新分配空间,并在新的表中插入 i 个元素,代价为 $c_i = i$。

所以 n 次操作的总代价为:

$$\sum_{i=1}^{n} c_i \leq n + \sum_{j=0}^{lgn}2^j < n+ 2n = 3n$$

因此,单一操作的摊还代价不会大于 3。这样的设计保证了摊还代价为 $O(1)$。

表的收缩

当我们设计表的收缩策略时,我们希望满足以下条件:

  • 表操作的摊还代价应该有一个常数上界
  • 表空间的利用率应当有一个常数下界,保证空间的有效利用

对于表的收缩策略,我们直观的认为,当插入数据时应当将表的存储空间加倍,那么删除数据使表空间利用率不足一半时,应当将表的规模减半,这样表的空间利用率大于等于 0.5,但这样的设计存在致命的缺陷。我们考虑以下场景。

当表已经处于充满状态时,我们对他进行反复的插入、删除操作。

第一个插入操作会导致表的扩张,空间会扩张为原来的2倍,此时我们再进行删除操作,表会收缩至原来的1/2,而接下来的操作又会引起表的扩张。以此类推,每个操作的摊还代价为 $O(n)$。

我们对此策略进行改进:

当删除操作导致表的利用率不到1/4时,我们将表的空间收缩为原来的1/2。

这样做使得表的空间利用率不会低于1/4,同时所有操作的摊还成本依然为 $O(1)$。

python代码分析

python的list使用相似的扩张和收缩策略,我们先对他做简单的研究

>>> a = []
>>> sys.getsizeof(a)
72

我们先创建一个空表 a,可以看到空表占72 bytes,接着我们对空表.append(1),看一下表 a 所占用空间的变化。

>>> b = [1]   # create a new list
>>> sys.getsizeof(b)  # a list with one element occupy 80 bytes
80
>>> a.append(1)
>>> sys.getsizeof(a)
104

我们先创建一个只有一个元素的list,发现它占据了80个bytes,由此可知,每个element占据8个bytes,接着我们对表a 做.append(1),发现 a 的空间变为了 104个bytes,和我们之前预想的不同。

那么python具体是如何实现动态表的扩张的呢,这时候我们需要RTFS——python的源代码

static int
array_resize(arrayobject *self, Py_ssize_t newsize)
{
    char *items;
    size_t _new_size;
    ...
    /* Bypass realloc() when a previous overallocation is large enough
       to accommodate the newsize.  If the newsize is 16 smaller than the
       current size, then proceed with the realloc() to shrink the array.
    */

    if (self->allocated >= newsize &&
        Py_SIZE(self) < newsize + 16 &&
        self->ob_item != NULL) {
        Py_SIZE(self) = newsize;
        return 0;
    }

    if (newsize == 0) {
        PyMem_FREE(self->ob_item);
        self->ob_item = NULL;
        Py_SIZE(self) = 0;
        self->allocated = 0;
        return 0;
    }

    /* This over-allocates proportional to the array size, making room
     * for additional growth.  The over-allocation is mild, but is
     * enough to give linear-time amortized behavior over a long
     * sequence of appends() in the presence of a poorly-performing
     * system realloc().
     * The growth pattern is:  0, 4, 8, 16, 25, 34, 46, 56, 67, 79, ...
     * Note, the pattern starts out the same as for lists but then
     * grows at a smaller rate so that larger arrays only overallocate
     * by about 1/16th -- this is done because arrays are presumed to be more
     * memory critical.
     */

    _new_size = (newsize >> 4) + (Py_SIZE(self) < 8 ? 3 : 7) + newsize;
    items = self->ob_item;
    /* XXX The following multiplication and division does not optimize away
       like it does for lists since the size is not known at compile time */
    if (_new_size <= ((~(size_t)0) / self->ob_descr->itemsize))
        PyMem_RESIZE(items, char, (_new_size * self->ob_descr->itemsize));
    else
        items = NULL;
    if (items == NULL) {
        PyErr_NoMemory();
        return -1;
    }
    self->ob_item = items;
    Py_SIZE(self) = newsize;
    self->allocated = _new_size;
    return 0;
}

从这一段代码中,我们可以看出python的内存分配策略。我们来解释一下这条代码。

  • newsize:操作后,实际需要存储的元素个数
  • _new_size:操作后,这条 list 实际分配的内存空间
  • self->allocated:未操作前,list实际分配的内存空间
  • Py_size(self):上一次进行内存分配时,list内存储数据的数量

首先,python会判断内存空间是否足够,且利用率够高:

if (self->allocated >= newsize &&
        Py_SIZE(self) < newsize + 16 &&
        self->ob_item != NULL) {
        Py_SIZE(self) = newsize;
        return 0;
    }

假如之前分配的内存空间已经够用,且存储元素数量和之前相比没有明显的减少,则不改变内存空间。

如果不满足以上条件,python就重新计算所需要的内存空间:

_new_size = (newsize >> 4) + (Py_SIZE(self) < 8 ? 3 : 7) + newsize;

当我们对一个空列表进行插入操作时,内存会以以下形式扩张

self->allocated Py_size(self) newsize _new_size
0 0 1 0+3+1=4
4 1 2 不变
4 2 3 不变
4 3 4 不变
4 4 5 0+3+5=8

$~$

内存空间的增长序列为:0, 4, 8, 16, 25, 34, 46, 56, 67, 79, …

假如Py_SIZE(self) >= newsize + 16,说明此时实际存储的元素个数相比之前,明显减少,需要收回一部分内存,举例来说:

self->allocated Py_size(self) newsize _new_size
20 20 4 0+7+4=11

$~$

新的内存空间小于之前的内存空间,从而收回了一部分内存。