AIGC标识 Python 的 list.append 为什么是 O(1)?拆开 CPython 的扩容公式,以及 127.8 毫秒背后的 86 次搬内存

教科书上说 list.append() 是均摊 O(1)。

但直觉上这很反常:list 底层是一块连续内存,加一个元素就得重新分配一块更大的、把旧的拷过去。拷一次是 O(n),拷 n 次不就是 O(n²)?

教科书没错,直觉也没完全错——关键是它不是每次都拷。

先看真实的扩容行为

用 sys.getsizeof 盯住每一次内存变化:

import sys
lst = []
prev = sys.getsizeof(lst)
for i in range(1, 200):
    lst.append(i)
    cur = sys.getsizeof(lst)
    if cur != prev:
        print(len(lst), cur, "容量:", (cur - 56) // 8)
        prev = cur

输出(CPython 3.13.14):

len=1   88    容量 4
len=5   120   容量 8
len=9   184   容量 16
len=17  248   容量 24
len=25  312   容量 32
len=33  376   容量 40
len=41  472   容量 52
len=53  568   容量 64
len=65  664   容量 76
len=77  792   容量 92
len=93  920   容量 108
len=109 1080  容量 128

注意看:它一次多申请一大截。第一次就给了 4 个位置(你只用了 1 个),满了不是加 1 个,而是直接跳到 8。

容量到底怎么算出来的

CPython 源码 Objects/listobject.c 里 list_resize() 的那行是这个:

new_allocated = ((size_t)newsize + (newsize >> 3) + 6) & ~(size_t)3;

翻译成人话:

  • newsize —— 这次需要的最小容量
  • newsize >> 3 —— 额外多给 12.5%(除以 8)
  • + 6 —— 保底多给 6 个
  • & ~3 —— 向下取整到 4 的倍数

逐个验证上面的实测值,全中:

newsize 计算 & ~3 之后 实测容量
5 5+0+6 = 11 8 8 ✅
9 9+1+6 = 16 16 16 ✅
17 17+2+6 = 25 24 24 ✅
25 25+3+6 = 34 32 32 ✅
41 41+5+6 = 52 52 52 ✅
65 65+8+6 = 79 76 76 ✅
77 77+9+6 = 92 92 92 ✅

所以增长比例不是固定的 2 倍(Python 早期版本是),而是前期接近 2 倍、后期收敛到约 1.125 倍。数据量大时更省内存,这是 3.x 之后的改进。

均摊 O(1) 是怎么成立的

既然每次扩容大约多给 12.5%,那么"距离下次扩容"的长度也在按比例增长。数学上的结果就是:n 次 append 的总拷贝量是 O(n),均摊下来每次 O(1)。

实测数一下扩容次数:

def grow_count(n):
    lst, last, c = [], sys.getsizeof([]), 0
    for i in range(n):
        lst.append(i)
        s = sys.getsizeof(lst)
        if s != last:
            c += 1; last = s
    return c

for n in (10, 100, 1000, 10000, 100000, 1000000):
    print(n, grow_count(n))

结果:

n=10       → 3 次
n=100      → 11 次
n=1000     → 28 次
n=10000    → 47 次
n=100000   → 66 次
n=1000000  → 86 次

一百万次 append,只重新分配了 86 次内存。 这就是均摊 O(1) 的实证——拷贝次数不是线性增长的,是对数级的。

实战:预分配还有意义吗

很多优化文章会说"先 [0]*n 再按索引赋值更快"。实测一下:

import time
# A: append 一百万次
st = time.perf_counter()
b = []
for i in range(1_000_000): b.append(i)
t1 = (time.perf_counter() - st) * 1000

# B: 先建好再赋值
st = time.perf_counter()
c = [0] * 1_000_000
for i in range(1_000_000): c[i] = i
t2 = (time.perf_counter() - st) * 1000

结果(3.13.14,本机):

append:     127.8 ms
预分配赋值:  123.2 ms
差距:        3.8%

只快 3.8%。 因为 over-allocation 已经把重新分配的开销摊得几乎看不见了。

所以结论很明确:为了性能去做预分配,基本是白费劲,还会让代码变丑。真正该用的是列表推导式,它连方法调用都省了。

两个反直觉的补充

1. list 会缩容。 很多人以为它只增不减,其实不是:

d = list(range(1000))
print(sys.getsizeof(d))        # 8056
for _ in range(900): d.pop()
print(sys.getsizeof(d))        # 1528  ← 缩了

list_resize 里有一条:如果新长度小于已分配容量的一半,就重新分配一块小的。

2. clear() 会把容量清零。

d.clear()
print(sys.getsizeof(d))        # 56   ← 回到初始状态

所以长期持有又反复清空的大 list,不用担心内存一直挂着。

一句话总结

append 之所以是 O(1),不是因为它不搬内存,而是因为它攒够了才搬一次:新容量 = (需要的 + 需要的/8 + 6) 再向下取整到 4 的倍数,超额配置约 12.5%。

一百万次 append 只触发 86 次重新分配,实测与预分配写法只差 3.8%——所以别再为了"性能"手动预分配了,把精力花在算法上更划算。

posted @ 2026-11-01 09:27  小小张说故事  阅读(5)  评论(0)    收藏  举报