本节摘要:list 是可变序列,tuple 是不可变序列。本节从 CPython 的内存布局讲两者的真实差异:list 的过度分配策略让 append 均摊 O(1)、但始终比 tuple 更占内存;tuple 因不可变而可哈希、可作字典键、可被解释器常量化。选型从此有据可依。
同一万个元素,装进 list 和装进 tuple,谁更省内存?空口无凭,直接测:
>>> import sys >>> lst = [i for i in range(1000)] >>> tup = tuple(range(1000)) >>> sys.getsizeof(lst), sys.getsizeof(tup) (8056, 8040)
数量级差别不大,但再看看"追加一千次"与"一次建好"的对比,以及空容器:
>>> grown = [] >>> for i in range(1000): grown.append(i) >>> sys.getsizeof(grown) 9016 # 追加式增长,多花了约 12% >>> sys.getsizeof([]), sys.getsizeof(()) (56, 40)
空 tuple 比 list 少 16 字节。这 16 字节是什么?是 list 对象头里多出来的两个字段:当前长度与分配容量。这个差异直指两种容器的本质分歧。
list 底层是一个指针数组(回忆 1.2 节:元素是指向各自 PyObject 的指针,而非值本身)。append 时若容量已满,CPython 按"增长序列"重新申请更大的数组,并故意多要一点,摊薄后续 append 的搬连性能:
>>> data = [] >>> sizes = [] >>> for i in range(12): ... data.append(i) ... sizes.append(sys.getsizeof(data)) >>> sizes [88, 88, 88, 88, 88, 120, 120, 120, 152, 152, 184, 184]
观察容量跳变的节奏:不是每次翻倍,而是按约 1.125 倍的渐进序列增长。这就是"均摊 O(1)"的来源——偶发的扩容搬运转嫁给平时的小额冗余。

对应到操作选型上:
| 操作 | list | 说明 |
|---|---|---|
x[i] 取值 |
O(1) | 指针数组直接寻址 |
x.append(v) |
均摊 O(1) | 靠过度分配 |
x.insert(0, v) |
O(n) | 全体后移 |
v in x |
O(n) | 线性扫描,没有索引可走 |
sorted(x) |
O(n log n) | 混合排序(Timsort) |
在百万级 list 上做 in 测试,你会明显感到吃力——那是第 2.2 节集合与字典登场的前奏。
tuple 省掉的容量字段从何而来?因为它永不改变长度,无需扩容策略。但"不可变"要精确理解——是 tuple 的结构不可变,不是元素对象不可变:
>>> t = (1, [2, 3]) >>> t[1].append(4) # 完全合法!改的是内层 list 对象 >>> t (1, [2, 3, 4]) >>> t[0] = 9 # 这才被禁止 Traceback (most recent call last): File "<stdin>", line 1, in <module> TypeError: 'tuple' object does not support item assignment
包含 list 的 tuple 因此不可哈希:
>>> hash(t) TypeError: unhashable type: 'list'
哈希的要求是"内容不变则哈希不变"。tuple 只保证自己对直接元素的身份不变;若元素本身可变,哈希就失去意义,解释器诚实地拒绝。要当字典键,用全不可变的 tuple(或 frozenset)。
tuple 的两个隐藏红利:
dis 里可见;list 则每次运行时重建。>>> def min_max(nums): ... return min(nums), max(nums) # 打包成 tuple >>> lo, hi = min_max([3, 1, 4, 1, 5]) # 解包 >>> lo, hi (1, 5)
解包还支持星号收集:first, *rest = nums——第 3 章会看到它的函数参数版本。
我的经验法则:默认 list,只在"表达固定结构"时用 tuple。典型场景:
a, b = b, a;反过来,把"恰好不用修改"的普通数据序列写成 tuple 并没有性能红利——遍历与下标访问两者同价,差异只在创建与内存冗余。为了"快"而用 tuple 多半是误传。
⚠️ 常见坑三连:其一,单元素 tuple 必须带逗号
(1,),(1)只是个括号表达式;其二,tuple(x)与x[:]都是浅拷贝,嵌套结构照旧共享;其三,循环中用+=拼接 list 是 O(n²) 反模式,改成收集后extend或用推导式。
💡 关键直觉:list 是"正在施工的数组",tuple 是"浇筑完成的记录"。选哪个,取决于你希望这段数据是过程还是事实。
排序值得多看两眼。内置的 sorted 函数与列表的 sort 方法都采用 Timsort——一种混合归并排序,最坏 O(n log n)、最好 O(n)(对近乎有序的序列线性扫过),并且稳定:相等元素的相对次序保持不变。稳定性不是学术洁癖,它是多级排序的实现基石。想先按部门再按薪资排,只需反过来排序两次:先按薪资排,再按部门排稳定排序,同部门内部的薪资次序就自动保留了下来。另一个常被忽略的参数是 key:它对每个元素只调用一次(不像 cmp 风格的接口每次比较都回调),传函数比传 lambda 略快,而 operator 模块的 itemgetter 与 attrgetter 又比手写 lambda 更快更省——这些微优化在万级以上列表里能看出差别,小数据则无所谓。反转排序用 reverse=True 而不是先排再切片倒置,前者省一次完整拷贝。
sys.getsizeof 显示 list 比 tuple 多出容量字段;append 式增长再多付一成冗余。in O(n);高频成员测试留给 set。下一节把"可哈希"这条线索变成主角——dict 如何用一次哈希把查找做到常数级。