跳到主要内容

数组与动态数组

数组连续存储固定大小的槽位,因此位置 ii 的地址可以由基地址和步长计算:

address⁡(i)=base⁡+i⋅stride⁡.\operatorname{address}(i)=\operatorname{base}+i\cdot\operatorname{stride}.

这带来了常数时间的索引访问和良好的空间局部性。

数组连续插入、删除和扩缩容的过程;箭头表示元素复制,空格表示剩余容量。查看清晰大图

从上往下对照每一行:中间插入要移动后面的元素,末尾追加则可能直接利用空位。带星号的操作触发扩缩容,箭头显示元素被复制到新数组。空格属于已分配的容量,不属于当前序列中的元素。

静态与动态​

  • 静态数组的容量固定。
  • 动态数组同时记录长度和容量。容量用尽时,它会分配更大的底层数组并复制已有槽位。

按几何比例扩容,可使末端追加的摊销成本为 O(1)O(1),即使某次扩容本身需要 O(n)O(n)。这是总体保证,不代表每次追加都具有恒定延迟。

操作成本​

操作典型成本
按索引读写O(1)O(1)
顺序遍历O(n)O(n)
末端追加摊销 O(1)O(1)
末端弹出不缩容时 O(1)O(1);合理缩容策略下摊销 O(1)O(1)
在前端或中间插入/删除O(n)O(n) 次移动
搜索无序值O(n)O(n)

二分搜索只有在维持有序且能够随机访问时才是 O(log⁡n)O(\log n);维持顺序可能让更新变得昂贵。

语言边界​

底层类型化数组通常内联存储同质值。在 CPython 中,list 的引用槽位连续存储;所引用的对象可以位于别处,也可以具有不同类型。其他 Python 实现可能采用不同的存储布局。

使用边界​

随机访问、紧凑迭代、排序、矩阵和堆的底层存储适合使用数组。如果频繁移动元素将成为主要成本,就不要用数组前端操作实现队列。

这些复杂度从何而来​

表中按长度为 nn 的序列计算固定大小槽位的操作次数,采用 Open Data Structures 的分析方式。下标运算、移动或比较一个槽位视为常数时间;昂贵的元素比较、对象销毁和分配延迟需要另算。地址公式使用从零开始的下标,有效范围为 00 到 n−1n-1。容量是已分配的存储空间,长度是实际元素数,满足 0≤n≤capacity0 \le n \le \text{capacity};空余槽位不属于有效序列。

假设初始容量为 1,每次扩为两倍。追加八个元素时,三次扩容分别复制 1、2、4 个旧槽位,总共七次复制,加上八次新元素写入。一般而言,从空数组追加 mm 次,所复制容量的几何级数之和小于 2m2m,所以总工作量是 O(m)O(m)。若每次只增加一个槽位,则要复制 1+2+⋯+(m−1)1+2+\cdots+(m-1) 个槽位,总成本变成平方级。两倍扩容只是分析示例,不是 Python 规定的扩容比例。

末端删除无需移动元素。它的 O(1)O(1) 成本以不调整底层容量为前提:会缩容的实现可能在某次删除中复制 O(n)O(n) 个槽位,只能给出摊销保证。等到占用率明显降低再缩容(例如占用四分之一时把容量减半),可避免交替插入、删除不断触发大量复制。

一次具体的插入​

values = [10, 20, 30]
values.insert(1, 15)
assert values == [10, 15, 20, 30]
assert values.pop(1) == 15
assert values == [10, 20, 30]

插入时把 20 和 30 右移,删除时再左移。因此,保存的下标在修改后可能指向另一个元素。底层重新分配内存还可能使指向旧数组的指针失效;Python 对象引用与这种槽位地址不是一回事。Python 越界读取,以及对空列表执行 pop(),都会抛出 IndexError。

来源​

探索关联打开关联网络