结论先行:list 的 append 敢叫「均摊 O(1)」,靠的是一张预先多要的内存表------容量不够时不是刚好要 1 个就申请 1 个,而是按公式多要一大块(n + n/8 + 6,再向下取到 4 的倍数),把十几次后续 append 的搬迁费一次付清。代价是内存白借:1 万元素的 list,系统可能实际揣着 10800 个位置。而账单的另一面:insert(0) 和 pop(0) 每次都实打实搬动整张表,实测在 10 万元素的 list 上比 append 慢 500 倍。这篇用 7 组实验把过度分配的账算清楚。
---# P## 1. 空 list 就有 56 字节,append 一个就跳到 88
sys.getsizeof([]) 返回 56------这是 list 对象头加一个空指针数组的固定开销。有意思的是第一个 append:python>>> import sys>>> l = []>>> sys.getsizeof(l)56>>> l.append(1)>>> sys.getsizeof(l)88ython 的 list 一次 append,容量从 0 直接跳到 4(56 + 4×8 = 88)。CPython 从不为「刚好够」买单------第一次就多要了 3 个空位。
2. 容量增长表:4 → 8 → 16 → 24 → 32 → 40 → 52 → 64实测连续 append,getsizeof 只在容量跳变时变化,规律非常整齐:| append 到第几个 | getsizeof | 容量((size-56)/8) | 每步增幅 ||---|---|---|---|| 1 | 88 | 4 | --- || 5 | 120 | 8 | +100% |append 为什| 9 | 184 | 16 | +100% |
| 17 | 248 | 24 | +50% || 25 | 312 | 32 | +33% || 33 | 376 | 40 | +25% || 41 | 472 | 52 | +30% || 53 | 568 | 64 | +23% || 65 | 664 | 76 | +19% || 77 | 792 | 92 | +21% || 93 | 920 | 108 | +17% |么是均摊 O(1)规律不在增幅,而在跳变点越来越稀疏:前 4 个元素扩容 0 次之外的频繁期,到 90 多个元素才只扩了 11 次。这正是「均摊 O(1)」的来源------n 次 append 触发的搬移总次数是 O(n),摊到每次头上就是常数。
3. 那张表的公式:n + n/8 + 6,再抹掉零头CPython 的 list_resize 用的是同一个公式,新容量 = 需要容量 + 需要容量/8 + 6,再向下取到 4 的倍数:textnew_allocated = (n + (n >> 3) + (n < 9 ? 3 : 6)) & ~3?拆开过度分配那张三个细节值得掰开看:
- +6 是低频增长的压舱石 :大表每次只多要 12.5%,但至少多 6 个位置,避免大表频繁为 1 个元素扩容;- & ~3 对齐到 4 的倍数 :实测第 17 次 append 时公式算出 25,实际容量 24------25 & ~3 = 24。对齐让内存分配器出手更整齐;- 小表(<9)用 +3 不用 +6 :1 个元素也至少要给 4 个位置,给小表留出绝对增幅空间。## 4. append 本身多少钱?100 纳秒,跟长度基本无关实测不同规模的纯 append 循环:表,看容量翻倍背后append 的均摊是「借来的」,insert(0) 的昂贵是「现结的」。在 10 万元素的 list 上各做 1000 次操作:
| 操作 | 总耗时 | 每次 | 复杂度 ||---|---|---|---||insert(0, x)| 57 795.7 μs | 57.8 μs | O(n) 全表 memmove ||pop(0)| 29 720.8 μs | 29.7 μs | O(n) 全表 memmove ||pop()| 125.5 μs | 0.125 μs | O(1) || appen一次 insert(0) = 460 次 append 的时间 。memmove 搬的是 80 万字节的指针块,CPU 再快也快不过物理定律。想用 list 当队列?请用collections.deque,它两头都是 O(1)。
6. 内存账单:list 存 100 万个整数,指针 7 MB,对象 26 MB| 容器 | 100 万个整数的内存 ||---|---|| list | 7 MB 指针数组 + 约 26 MB int 对象体 || array('q') | 7 MB(定宽 C 整数,无对象头) |d 次数 | 每list 的每个元素是指针 ,指向堆上的 int 对象------小整数(-5 到 256)有缓存,大一点的每个 28 字节起。数据本身有界的场景(像素、采样、矩阵),定宽容器省的不只是那 26 MB,还有缓存局部性。
7. 预分配到底省多少?15%知道 n 的时候,[0] * n 一次到位 vs 空 list append n 次,实测 100 万元素:| 方式 | 耗时 ||---|---|| [0]*n + 下标赋值 | 105.6 ms || 空 list + append n 次 | 123.4 ms |次耗时 |
|---省下的 18 ms 就是历次扩容的 memmove 总账 。但注意量级:过度分配的设计已经让 append 足够便宜,预分配是锦上添花,不是数量级的差别 ------除非你在写热点循环。
---一句话收尾:list 的 append 均摊 O(1) 是拿内存换时间------按 n + n/8 + 6 预借容量,把十几次搬迁并成一次付清;而 insert(0) 把这张借条全额现付,10 万元素上每次 57.8 微秒。知道容量表的存在,才知道什么时候该预分配、什么时候该换 deque。 |---|| 1 000 | 99.4 ns || 100 000 | 99.9 ns || 1 000 000 | 121.3 ns |从一千次到一百万次,单次成本几乎不动 ------这就是均摊的含义:扩容的搬运费被摊薄到可以忽略。唯一能看出变化的是百万次级:121 ns 比 99 ns 高了 22%,那是缓存装不下 8 MB 指针数组的锅,不是算法的锅。## 5. 反面教材:insert(0) 每次都真金白银地搬全表的暗账