跳到内容

第 2 卷 / 第 2 章 / 第 1 课

2.1 数组 ​

前置:渐近界、递推式与摊还分析,约 35 分钟。本节把下标、长度、容量、移动量与摊还成本放进同一份动态数组合同。

数组的第一份合同 ​

先分清 size 和 capacity:前者是当前元素数量,后者是现有存储空间能容纳的元素数量。它们看起来只差一个名词,混用以后却会制造不少 bug。

先把“数组”分成三层 ​

抽象数组 ​

抽象数组是一组带整数下标的槽位。只要下标有效,你就能直接读取或改写该槽位。

text
下标:   0      1      2      3      4
       ┌──────┬──────┬──────┬──────┬──────┐
值:   │  10  │  20  │  30  │  40  │  50  │
       └──────┴──────┴──────┴──────┴──────┘

固定长度数组 ​

创建后长度不变。Java 的 int[]、C++ 的 std::array<int, N> 都属于这一类。你可以改元素,不能把长度从 5 改成 6;若要更多槽位,需创建另一个数组并复制。

Python 没有与 Java 原生数组完全相同的内置固定长度容器。tuple 长度不可变,但元素本身也不能替换;list 和 array.array 都能改变长度。因此把 array.array 称为静态数组并不准确;官方文档把它定义成受类型码约束的可变序列。

动态数组 ​

动态数组维护两组数字:

  • size 是当前保存的元素数。
  • capacity 是当前缓冲区能容纳的槽位数。

只要 size < capacity,末尾追加可以直接写下一个空槽。缓冲区满了,容器申请更大的连续槽位区,把旧元素搬过去,再写新元素。

text
size = 3, capacity = 5

       ┌──────┬──────┬──────┬──────┬──────┐
槽位: │  10  │  20  │  30  │ 空闲 │ 空闲 │
       └──────┴──────┴──────┴──────┴──────┘

Python list、Java ArrayList 和 C++ std::vector 都属于动态数组家族,但它们的元素表示、增长策略和失效规则不同。

为什么索引访问是 Θ(1) ​

若每个槽位宽度固定,概念地址可以写成:

text
address(k) = base + k × slot_width

在单位成本 RAM 模型中,这只需要固定数量的算术、边界检查和一次槽位访问,工作量不随数组长度 n 增长,所以是 Θ(1)。

这个公式是模型,不是“一定编译成一条乘法加一条加法”的机器指令保证。编译器、寻址模式、元素表示和运行时都会改变具体指令。

CPython 的 list 实现还多一层细节:连续槽位里存的是对象引用,整数或自定义对象本身可能位于别处。values[k] 仍能常数时间取到第 k 个引用,但不能据此声称所有对象内容连续排列。

可运行的动态数组模型 ​

下面的 Python 3.10+ 类只实现本章需要的接口。它用翻倍策略演示几何增长,返回的移动次数只统计插删时的后缀移动,不含扩容复制和新槽位初始化。翻倍是这个教学模型的选择,不是 Python、Java 或 C++ 的固定增长合同。

读取和 pop 支持负下标;insert 则只接受 0 ≤ i ≤ size,不会像内置列表那样截断越界位置。operator.index 在修改前检查整数下标,避免无效插入先触发扩容。

python
# dynamic_array_demo.py
from operator import index as integer_index

class DynamicArray:
    def __init__(self, initial_capacity: int = 2) -> None:
        initial_capacity = integer_index(initial_capacity)
        if initial_capacity <= 0:
            raise ValueError("initial capacity must be positive")
        self._buffer: list[object | None] = [None] * initial_capacity
        self._size = 0
        self.growths: list[tuple[int, int]] = []

    @property
    def size(self) -> int:
        return self._size

    @property
    def capacity(self) -> int:
        return len(self._buffer)

    def _grow(self) -> None:
        old_capacity = self.capacity
        new_capacity = old_capacity * 2
        new_buffer: list[object | None] = [None] * new_capacity
        for index in range(self._size):
            new_buffer[index] = self._buffer[index]
        self._buffer = new_buffer
        self.growths.append((old_capacity, new_capacity))

    def _check_index(self, index: int) -> None:
        if index < 0 or index >= self._size:
            raise IndexError("index out of range")

    def append(self, value: object) -> None:
        if self._size == self.capacity:
            self._grow()
        self._buffer[self._size] = value
        self._size += 1

    def insert(self, index: int, value: object) -> int:
        index = integer_index(index)
        if index < 0 or index > self._size:
            raise IndexError("insert index out of range")
        if self._size == self.capacity:
            self._grow()

        movements = self._size - index
        for position in range(self._size, index, -1):
            self._buffer[position] = self._buffer[position - 1]
        self._buffer[index] = value
        self._size += 1
        return movements

    def pop(self, index: int = -1) -> tuple[object, int]:
        index = integer_index(index)
        if index < 0:
            index += self._size
        self._check_index(index)

        value = self._buffer[index]
        movements = self._size - index - 1
        for position in range(index, self._size - 1):
            self._buffer[position] = self._buffer[position + 1]
        self._size -= 1
        self._buffer[self._size] = None
        return value, movements

    def __getitem__(self, index: int) -> object:
        index = integer_index(index)
        if index < 0:
            index += self._size
        self._check_index(index)
        return self._buffer[index]

    def to_list(self) -> list[object]:
        return [self._buffer[index] for index in range(self._size)]

def main() -> None:
    values = DynamicArray(2)
    for value in (10, 20, 30, 40):
        values.append(value)
    insert_movements = values.insert(1, 15)
    removed, delete_movements = values.pop(2)

    assert values.growths == [(2, 4), (4, 8)]
    assert insert_movements == 3
    assert (removed, delete_movements) == (20, 2)
    assert values.to_list() == [10, 15, 30, 40]
    assert values.capacity == 8

    for old, new in values.growths:
        print(f"扩容:{old} → {new}")
    print(f"插入移动:{insert_movements}")
    print(f"删除移动:{delete_movements}")
    print(f"内容:{values.to_list()}")
    print(f"容量:{values.capacity}")
    print("数组检查通过")

if __name__ == "__main__":
    main()

运行:

bash
python3 dynamic_array_demo.py

输出:

text
扩容:2 → 4
扩容:4 → 8
插入移动:3
删除移动:2
内容:[10, 15, 30, 40]
容量:8
数组检查通过

这段输出把四类成本分开了:

  • 第三次和第五次写入遇到满缓冲区,触发复制。
  • 在下标 1 插入时,先扩容复制 4 个旧槽位,再右移 3 个后缀元素;输出的 3 只记后一笔。
  • 删除下标 2 时,后面的 2 个元素左移。
  • 删除没有主动缩容,所以容量仍为 8。真实容器是否缩容由它自己的合同决定。

动态数组的操作成本 ​

令插入或删除前的逻辑长度为 n。

操作更精确的工作量常见最坏紧界说明
读取/改写有效下标 i常数Θ(1)不含元素自身操作成本
末尾追加,无扩容常数Θ(1)写一个空槽
末尾追加,发生扩容复制 n 个槽位Θ(n)单次最坏
连续追加总复制量线性摊还 Θ(1)/次从空表、固定初始容量开始,以固定倍数增长
在下标 i 插入移动 n - i 个槽位Θ(n)后缀移动之外,扩容还要复制旧槽位
删除下标 i移动 n - i - 1 个槽位Θ(n)删除末尾不移动后缀
无序按值查找最多比较 n 次Θ(n)最好可首项命中
复制长度 k 的范围复制 k 个槽位Θ(k)同时产生 Θ(k) 输出空间

不扩容时,在下标 i 插入一个元素需要 Θ(n - i + 1) 时间:移动后缀,再写入新值。加上的 1 保留了尾部插入的常数成本;一旦扩容,复制与初始化又把本次成本提高到 Θ(n)。

创建容量为 c 的本例数组,需要初始化 c 个槽位,时间与存储空间均为 Θ(c)。本例不缩容,删除后占用的空间由 capacity 决定,不能只按剩余 size 计算。growths 只是记录扩容的诊断列表。

追加为什么能摊还 Θ(1) ​

从空表和固定初始容量出发,假设每次以固定倍数(大于 1)扩容。用翻倍模型看,连续追加 n 个元素时,历史复制量小于:

text
1 + 2 + 4 + ... < 2n

新缓冲区的初始化量同样构成几何级数,再加每个元素最初写入一次,总工作量是 Θ(n)。把它分摊到 n 次追加,每次为摊还 Θ(1)。

这不依赖随机性,也不表示每次追加的最坏成本都是常数。一次触发扩容的追加仍会复制已有元素。

不要把具体扩容倍数当作语言保证:

  • CPython 可以调整 list 的过度分配策略。
  • Java ArrayList 只公开追加的摊还常数成本,不公开固定增长因子。
  • C++ 标准要求 vector 满足相应复杂度和连续性合同,不指定增长倍数。

搜索、排序与保持有序 ​

无序数组按值查找最坏需要完整扫描,时间 Θ(n)。排序后可以二分查找,最坏时间 Θ(log n),但有两个容易漏掉的成本:

  1. 对没有额外限制的不同元素,比较排序最坏需要 Ω(n log n) 次比较;归并排序能达到 Θ(n log n)。这不是每个输入都要做这么多比较。
  2. 若持续保持数组有序,在位置 i 插入仍要移动后缀,最坏 Θ(n)。

所以“二分查找快”不自动推出“有序数组适合频繁插入”。先看读取与更新的比例。

复制、切片与视图 ​

Python 列表切片会创建浅拷贝:

python
original = [[1], [2], [3]]
copied = original[1:]
copied[0].append(99)

print(original)  # [[1], [2, 99], [3]]

切片复制了引用,没有递归复制所指对象。复制 k 个引用需要 Θ(k) 时间和新列表空间。

不同语言的范围 API 可能返回副本或视图。Java subList 是由原列表支持的视图;C++ 迭代器对描述范围本身不复制元素。使用这些 API 前要查清修改传播、生命周期与失效条件。

二维数据不一定是一块矩形内存 ​

这段 Python 看似创建三行,实际只创建一个内层列表,再把同一引用放了三次:

python
wrong = [[0] * 3] * 3
wrong[0][0] = 7
print(wrong)  # [[7, 0, 0], [7, 0, 0], [7, 0, 0]]

用列表推导式创建独立行:

python
matrix = [[0] * 3 for _ in range(3)]
matrix[0][0] = 7
print(matrix)  # [[7, 0, 0], [0, 0, 0], [0, 0, 0]]

Python list-of-lists 和 Java arrays-of-arrays 都是嵌套引用结构,不能直接套用“整块矩阵按行连续”的结论。C、C++ 原生多维数组以及 NumPy 等数值库有各自明确的布局合同。分析行优先或列优先遍历前,先确认实际表示。

连续槽位与缓存局部性 ​

数组的相邻槽位让处理器更容易利用空间局部性和预取。顺序扫描常常比追逐分散节点更适合缓存。这里说“常常”是有意的:

  • 缓存行大小和层级因处理器而异。
  • CPython list 连续的是对象引用,被引用对象仍可能分散。
  • 元素尺寸、访问步长、分支、分配器和数据规模都会影响结果。

因此本章不提供“快多少倍”或固定内存延迟。复杂度用来判断增长,基准测试用代表真实负载的数据比较实现。缓存细节可继续阅读 Vol. 3:缓存与存储器层次。

三种语言里的对应物 ​

需求PythonJava 17+C++
通用动态序列listArrayList<E>std::vector<T>
固定长度约定不改长度;无完全对应内置类型T[]std::array<T, N>
紧凑同类数值array.array原生数值数组std::vector<T> / std::array<T, N>
两端频繁进出collections.dequeArrayDeque<E>std::deque<T>

这里的 std::vector<T> 连续元素模型要求 T 不是 bool;vector<bool> 可以压缩存位,不能当作连续的 bool 对象数组。

几个可查的合同:

  • Python 官方教程说明 list.insert(0, value) 和 pop(0) 会移动其他元素,并推荐队列使用 collections.deque。array.array 是可变的同类数值序列。
  • Java ArrayList 公开常数时间索引与摊还常数追加。ensureCapacity 可能减少渐进式重分配,但容量不是列表大小。
  • C++ vector::reserve(n) 保证在大小超过当前容量前不因后续插入而重分配。发生重分配时,元素引用、指针和迭代器都会失效;没有重分配时,插入点及其后的元素引用、指针和迭代器也会失效。标准草案的 vector 容量规则与修改器给出了这些条件。

这些容器默认都不因为“像数组”就自动获得线程安全。并发读写要遵循对应 API 的同步合同。

常见错误怎么定位 ​

症状先检查什么
IndexError / IndexOutOfBoundsException有效读取范围是 0 ≤ i < size
插入后元素顺序错乱是否从后向前右移,避免覆盖未搬元素
删除后末尾还留着引用是否清空逻辑末尾槽位,避免无意保留对象
预留容量后读到了空值reserve/capacity 不会增加逻辑 size
二维列表改一行却多行变化是否用乘法重复了同一个行对象
C++ 保存的指针突然无效操作是否触发 vector 重分配或移动后缀

动手推导移动与扩容 ​

  1. 给教学版 DynamicArray 增加 set(index, value),并让越界规则与 __getitem__ 一致。
  2. 推导在长度 n 的数组下标 i 插入连续 m 个元素时,需要移动多少旧元素、写入多少新元素。
  3. 设计一个不自动缩容的理由,再设计一个需要缩容的负载;比较内存占用与复制次数。
  4. 分别用 Python list-of-lists 和一个明确连续布局的数值数组做行/列遍历基准。先写布局假设,再解释结果。

核对三笔成本 ​

在长度 n 的动态数组下标 i 插入一个元素,需要移动 n-i 个旧元素;删除同一位置则移动 n-i-1 个。把教学实现的容量从 2 扩到 4、8,分别记录单次复制量,再用整段追加序列解释为什么摊还成本仍为 Θ(1)。

接着测试切片究竟返回副本还是视图,并构造一次二维列表行别名。索引访问快来自布局与成本模型,中间修改贵也来自维持同一套连续下标;两者不是互相矛盾的口诀。

继续学习:链表 ​

链表靠链接维持顺序。下一节会说明,“改两条链接”只有在调用者已经握着正确节点或前驱时才是常数操作。

继续学习链表

Built with VitePress | Software Systems Atlas