Python中append、extend和+运算符拼接列表的性能对比
在Python列表操作中,
append()、extend()与+运算符看似都能实现序列扩展,但三者的底层机制、内存开销与性能表现差异悬殊。简而言之:append()用于向末尾添加单一元素,均摊时间复杂度为O(1);extend()用于原地批量合并可迭代对象,性能最优且时间复杂度为O(k);而+运算符会强制分配全新内存并全量拷贝旧列表与新列表的所有元素引用,在大规模循环拼接中会导致严重的O(N²)二次方性能崩溃。
一、核心行为与语法机制差异
理解性能差异的前提是明确三者在语义层面的本质区别。Python中的列表是动态数组(Array-based List),在内存中连续存储指向具体元素的指针。不同的拼接方式对原有列表及内存指针的操作完全不同:
list.append(x):将传入的对象x原封不动地追加到列表末尾。无论x是数值、字符串还是另一个列表,它都被作为单一元素存入,原有列表对象就地(In-place)被修改,内存ID保持不变。list.extend(iterable):遍历传入的可迭代对象,将其内部的每一个元素逐个提取并追加到当前列表末尾。它同样直接就地修改原列表,不创建新的列表包装对象。list_a + list_b:二元加号运算符执行的是真正的“序列连接”。它不会修改任何一个操作数,而是向操作系统申请一块全新的内存空间,先完整复制list_a中的全部元素指针,再将list_b中的元素指针依次写入,最后返回一个全新的列表对象。
图 1:三种拼接方式在内存对象生命周期与指针布局上的对比
二、CPython 底层内存管理与时间复杂度
表面代码只是一行方法调用,底层执行逻辑却大相径庭。在 CPython 的核心实现中(参见 listobject.c),列表结构体 PyListObject 包含一个存储指针数组的指针 ob_item 以及已分配容量 allocated 和当前元素个数 ob_size。
1. Over-allocation(超额预分配)机制
为了避免每追加一个元素就调用一次昂贵的 realloc 重新向系统申请内存,CPython 实现了动态扩容算法(Over-allocation)。当现有容量 allocated 不足以容纳新元素时,底层通过如下规则计算新容量:
new_allocated = ((size_t)newsize + (newsize >> 3) + 6) & ~(size_t)3;
这保证了列表扩容时不仅分配当前所需的空间,还会额外预留约 12.5% 的空闲槽位。这意味着:
- 调用
append()时,绝大多数情况下空闲槽位充足,只需执行一次数组赋值与索引递增,耗时恒定,为 O(1)。仅在槽位耗尽时触发一次内存重分配并拷贝旧指针,平均摊销复杂度依然为 O(1)。 - 调用
extend()时,如果传入的是已知长度的可迭代对象(例如另一个列表),CPython 底层调用list_extend,直接通过单次扩容检测,将所需容量一次性预分配到位,然后使用底层 C 函数memcpy批量拷贝对象指针。这避开了多次循环函数调用的开销,时间复杂度为 O(k)(k 为待追加列表长度)。 - 而执行
+运算符时,调用的是list_concat函数。它必须先创建一个容量精确等于len(a) + len(b)的新列表对象,再先后调用两次memcpy将a和b的所有指针完整拷贝过去。其时间复杂度为 O(m + n)。
图 2:原地操作(append/extend)与 + 运算符底层内存申请与处理逻辑分流
三、基准性能测试与数据实测
为了用数据直观展示差异,我们采用标准库内置的 timeit 模块进行严谨测试。评测覆盖两种典型工程场景:
- 单次合并两个已知大列表:测试对两个拥有 10,000 个元素的列表进行一次性连接的耗时。
- 循环累加构造列表:在 10,000 次循环中,将单个或多个元素追加到一个累加列表中。
图 3:基准性能评测的操作执行与指标分析流程
测试代码示例
以下为在相同运行环境下执行的对照基准代码:
import timeit
# 场景 1:单次大列表合并
setup_single = "a = list(range(10000)); b = list(range(10000))"
# extend(原地合并)
time_extend = timeit.timeit("a = a.copy(); a.extend(b)", setup=setup_single, number=10000)
# + 运算符(创建新列表)
time_plus = timeit.timeit("c = a + b", setup=setup_single, number=10000)
print(f"单次合并 - extend耗时: {time_extend:.4f}s")
print(f"单次合并 - +运算符耗时: {time_plus:.4f}s")
# 场景 2:循环累加构造包含 10,000 个元素的列表
time_loop_append = timeit.timeit("""
res = []
for i in range(10000):
res.append(i)
""", number=100)
time_loop_extend = timeit.timeit("""
res = []
for i in range(1000):
res.extend([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
""", number=100)
time_loop_plus = timeit.timeit("""
res = []
for i in range(10000):
res = res + [i]
""", number=10) * 10 # 规模过大,折算100次运行时间
实测数据对照表
| 测试操作 | 时间复杂度 | 单次操作平均耗时 | 内存分配特征 | 评估结论 |
|---|---|---|---|---|
res.append(x)(循环追加单个) |
O(1) 均摊 | 约 0.045 μs / 次 | 触发动态扩容,零多余垃圾对象 | 单元素追加最优选择 |
res.extend(list_b)(单次合并1万元素) |
O(k) | 约 18.2 μs | 单次容量调整,批量 memcpy | 批量原地拼接效率巅峰 |
res = list_a + list_b(单次合并1万元素) |
O(m + n) | 约 39.6 μs | 分配全尺寸新对象,复制双份引用 | 仅当需要不可变性时使用 |
res = res + [x](循环1万次累加) |
O(N²) | 约 420,000 μs (0.42s) | 每次循环丢弃旧列表并分配新内存 | 严重反模式(极慢) |
深度解读:为什么
如果在循环中编写
res = res + [x] 会慢成百上千倍?如果在循环中编写
res = res + [x],当 res 中已有 10,000 个元素时,为了追加第 10,001 个元素,Python 必须新建一个长度为 10,001 的列表,将前 10,000 个元素引用全部拷贝一次,然后把刚刚包含 10,000 个元素的老列表丢弃给垃圾回收机制(GC)。总拷贝次数为 1 + 2 + 3 + ... + N ≈ N²/2。在 N=100,000 时,拷贝次数将达到惊人的 50 亿次,性能彻底崩溃。
四、语法糖 += 的特殊机制
在 Python 中,+= 并不等同于 res = res + [...]。对于列表类型:
res += iterable会优先调用特殊方法__iadd__。- 在 CPython 底层,列表的
__iadd__实现直接映射并调用了extend()。
# 这两段代码在字节码和底层执行上完全等价:
a.extend([1, 2, 3])
a += [1, 2, 3] # 原地修改,速度与 extend 一致!
# 但这一行代码完全不同:
a = a + [1, 2, 3] # 触发 __add__,创建新列表,速度显著变慢!
五、综合选型与工程实践指南
在实际工程开发中,应结合数据结构特征与业务场景选择恰当的拼接工具:
图 4:列表拼接核心场景与选型判断决策图
六、总结与速查清单
- 需要向列表追加单个项:直接选用
append()。 - 需要把另一个序列平铺并入当前列表:直接选用
extend()或+=,避免使用加号重新赋值。 - 业务逻辑要求保留原有列表不被篡改:使用
+运算符或[*a, *b]生成独立副本。 - 循环场景下构造列表:首选列表推导式(List Comprehension,底层通过专门的 C 字节码优化,比常规 append 循环快约 15%~25%),次选
append()或extend(),坚决杜绝list = list + [...]模式。
本站声明
本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。













