先抛个问题:你有一个 10 万条用户 ID 的列表,现在要判断另一个 1 万个 ID 中有多少存在。用 list 写in判断,很多人不觉得有什么问题;换成 set 之后,同一个脚本从几十秒跑到毫秒级。我第一次做这个替换的时候,甚至怀疑是不是机器性能突然变好了。这件事给我留下的印象特别深——数据容器不是“随便选一个能用就行”的基础课,它直接决定了代码的性能上限、可读性和维护成本。
这篇内容我打算从一个日常开发者的角度,把 Python 内置的数据容器怎么选、怎么用、底层为什么这样设计,以及我实际踩过的坑,一次讲清楚。不写成那种“每个方法列一遍”的 API 手册,而是更接近你身边有个老同事,一边敲代码一边跟你聊。适合刚学完 Python 语法、开始碰实际项目的初学者,也适合写了一段时间但没系统梳理过容器的朋友。
1. 先给数据容器画一张“全家福”
1.1 数据容器到底在解决什么问题
先说底层逻辑。变量只能保存一个值,这是所有编程语言的基本设定。但真实业务里,很少出现“一个变量管一个值”就能跑完的场景。你手里的订单列表、用户详情、去重后的标签集合、接口返回的固定字段,每一种数据形态都对应一个容器类型。
数据容器本质上就是“存放多个数据的组织方式”,不同的组织方式决定了三件事:数据能不能被修改,数据能不能保持顺序,数据能不能被快速查找。这三件事是选型的核心,后面讲到每个容器我都会围绕这三点展开。
Python 内置的核心容器主要是 list(列表)、tuple(元组)、dict(字典)、set(集合),再加上 str(字符串)。严格来说 str 是不可变的字符序列,它也有序列容器的语义。除了内置的,collections 模块里还提供了 defaultdict、Counter、deque、namedtuple 等增强容器,很多是生产环境里的“默认答案”,后面会专门提到。
1.2 一张表看懂四种容器的基本属性
| 容器 | 是否有序 | 是否可变 | 是否允许重复 | 元素是否可哈希 |
|---|---|---|---|---|
| list | 是 | 是 | 是 | 否(不能作为 dict 键 / set 元素) |
| tuple | 是 | 否 | 是 | 若内部元素都可哈希,则可以 |
| dict | 保持插入顺序 | 是 | 键不重复 | 键必须可哈希 |
| set | 无序 | 是 | 不重复 | 元素必须可哈希 |
| str | 是 | 否 | 是 | 否(整体是序列) |
对新手,先解释一下“哈希”这个词。你可以把它理解成对象的一个稳定数字指纹,通过这个指纹,Python 能在内存里快速定位数据。list 的内容会变化,所以哈希值不稳定,不能用它当 dict 的键,也不能放进 set;tuple 内容不可变,如果内部元素都可哈希,那整个 tuple 就可以被哈希。这些属性不是死规则,而是容器设计的结果。理解了每一层设计之后,你写代码时就不需要“背语法”,而是能自然地推断容器行为。
2. list 与 tuple:同源但不同命的两个序列
2.1 list 的动态数组机制,以及为什么尾部追加偶有波动
list 底层是一个动态数组,它保存的是元素的引用,而不是元素本身。所以 list 里可以混装 int、str、dict 等任意对象,代价是每次访问元素都多了一次指针跳转。
动态数组的意思是:容量不是固定的。当 list 容量不够时,会一次性申请更多内存空间,把老数据复制过去。Python 的列表扩容不是一点一点加,而是按比例扩容,具体比例不同版本略有调整,通常预留 12.5% 到 25% 的余量。所以尾部 append 在绝大多数情况下是 O(1),但恰好触发扩容时,就会变成 O(n)。
实际操作中,如果你预先知道列表规模比较大,可以用lst = []再循环 append,也可以先初始化一个足够长的 list,比如lst = [None] * n再按索引写入。后者在需要频繁在特定位置写入时更稳,能避免反复扩容。不过别太焦虑这个,日常几千几万条数据的性能差别并不明显。
list 的核心操作复杂度,值得刻在脑子里:
- 尾部 append、pop:O(1)
- 任意位置 insert、pop(i):O(n),因为后面的元素要整体搬动
- 索引访问:O(1)
- 成员判断
x in lst:O(n),需要逐个比较 - 切片:O(k),返回一个浅拷贝新列表
重点说切片。a[:]复制出来的新列表,列表本身是新的,但里面装的依然是原来那些对象的引用。这句话得好好理解,否则很容易踩到下面这个坑。
2.2 浅拷贝与深拷贝:list 复制里最大的坑
这是 list 新手必踩的坑。用b = a不是拷贝,只是多了一个引用;用b = a[:]或者b = list(a)是浅拷贝,最外层列表和原列表不同,但元素如果是可变对象,改内部依然会影响原列表。
a = [[1, 2], [3, 4]] b = a[:] b[0][0] = 999 print(a) # [[999, 2], [3, 4]]要彻底复制所有层级的对象,得用copy.deepcopy(a)。但 deepcopy 有成本,尤其对象复杂时会慢,所以不要无脑用。实际开发中,我更推荐用列表推导式来创建新结构,例如:
new_list = [item.copy() for item in original_list]这属于显式控制深度,比 deepcopy 快,而且代码意图更清晰。我见过不少线上 bug,就是因为对一段配置数据做浅拷贝,然后改了内层结构,结果别处读配置的模块也收到了脏数据。
2.3 tuple 的“不可变”到底锁住了什么
tuple 常被误解为“不可改的 list”。其实它更强的地方在于:因为不可变,tuple 可以被哈希(只要内部元素也都是可哈希的),可以当作 dict 的键或 set 的元素。list 作为键会直接报TypeError: unhashable type: 'list'。
但“不可变”锁住的是“不能增删、不能替换元素引用”,元素本身如果是可变对象,那元素内部还是可以改的:
t = ([1, 2], 3) t[0].append(4) print(t) # ([1, 2, 4], 3)关键点:不是 tuple 真的没法变,而是它的“引用数组”不可变。
什么时候该用 tuple?我自己的习惯是这么定的:
- 函数返回多个值,本质上返回的就是一个 tuple
- 复合键,比如
(year, month)查询某个月的统计值 - 一组配置项,不想让调用方随意改动
- 具名元组 namedtuple 用来表示一条固定字段的记录
在多返回值场景,人们常写a, b = func(),其实是解包 tuple。用*rest, last = items这种星号解包,在做大数据拆分时也很顺手。
我个人建议:如果某个数据结构要作为 dict 的 key,优先考虑 tuple,而不是用字符串拼接。比如(city, date)就比f"{city}|{date}"语义清晰得多,也避免分隔符冲突。
3. dict 与 set:哈希表的核心逻辑
3.1 为什么 dict 查一个键几乎是瞬时
dict 的底层是哈希表。当你要找d[key]时,Python 先对 key 算哈希值,通过哈希值定位到对应的桶,再比较桶里的键是否相等。所以 dict 的查询平均复杂度是 O(1),和容器容量基本无关。这就是为什么当 list 已经慢到无法忍受时,换成 dict 会像开挂一样。
哈希背后有三个必须知道的概念:
- 哈希函数:把任意对象映射成一个整数,Python 里通过
hash(obj)获取。 - 哈希表:用哈希值定位“桶”的数组结构。
- 哈希碰撞:两个对象的哈希值可能出现重复。Python 遇到碰撞会继续比较键本身,或者用更精细的策略探测下一个可用位置。日常开发不需要太关心碰撞细节,但要留意一个隐蔽的坑:自定义类如果重写了
__eq__却没重写__hash__,该对象会变成不可哈希,放进 dict 键或 set 就会报错。
Python 3.6 之后 dict 的内部实现引入了 compact dict,记录插入顺序的同时降低了内存占用。所以现代 Python 中 dict 是保持插入顺序的,不要再写OrderedDict来做这种基础需求。OrderedDict现在只在需要“移动到末尾”这类操作时才有优势。
dict 常用操作,我总结过一张自己的清单:
d.get(key, default):比d[key]安全,键不存在时返回默认值d.setdefault(key, default):键不存在时先初始化再返回,适合构造嵌套结构d.pop(key, default):删除并返回,比先判断后删更原子for k, v in d.items():遍历时千万别同时修改 dict 的键集合,否则会报RuntimeError: dictionary changed size during iteration- 合并两个 dict:
d1.update(d2)会就地修改,d = {**d1, **d2}生成新 dict;Python 3.9 之后还可以用d1 | d2
3.2 set:dict 的“只保留键”版本
set 的底层也是哈希表,所以成员判断x in s是 O(1),这是用 set 做去重和存在性判断的基本依据。和 dict 一样,set 里的元素必须可哈希,且 set 本身不可哈希,不能作为 dict 的键。
set 的日常价值主要体现在三块:
- 去重:
unique_items = set(items),需要时再转回 list - 集合运算:交集
s1 & s2、并集s1 | s2、差集s1 - s2、对称差集s1 ^ s2 - 快速判断是否存在
这里有个细节:set(items)去重后顺序不保证。Python 3.7+ 里 set 的迭代顺序在实践中大致按插入顺序,但官方不承诺,主要受哈希和碰撞影响,所以别依赖。如果既要保持顺序又要去重,用dict.fromkeys(items)或者手动循环判断。
frozenset是不可变的 set,好处是可以用作 dict 的键或 set 的元素。比如你想表示“一组不可重复标签”作为缓存的键,frozenset 很合适。
从数据量角度来说,set 和 list 的查询差异真的非常明显。我做过一次小测试:10 万元素,在 list 里用in判断 1 万个随机数,大概要几十秒;换成 set 之后几乎瞬时完成。这可不是“少了几毫秒”的差距,而是从“不可用”到“可用”的差距。生产环境里用 set 做幂等判断、黑名单过滤、标签去重,都是很常规的做法。
4. 容器嵌套与实战:业务数据大多是复合结构
4.1 常见嵌套模式与建模思路
真实业务很少只有一个容器,更多时候是“容器套容器”。常见的模式有:
list[dict]:一组记录,每条记录是字段字典,比如 API 返回的用户列表dict[str, list]:分组数据,比如按部门存员工列表dict[tuple, value]:复合键数据,比如(城市, 日期) -> 成交量set[tuple]:去重后的元素组合
嵌套不是越深越好,超过两层就会开始难读。我见过有人写list[list[dict[str, list[dict]]]]这种结构,看起来像数据建模,实际上是在给未来的自己挖坑。处理深度嵌套时,先想着怎么拆变量,或者定义成数据类。工具类的东西后面会提到。
4.2 用 defaultdict 简化分组逻辑
先看一个典型需求:给一堆商品按类别分组。不用 defaultdict 的写法是这样:
grouped = {} for product in products: category = product["category"] if category not in grouped: grouped[category] = [] grouped[category].append(product)用 defaultdict 之后:
from collections import defaultdict grouped = defaultdict(list) for product in products: grouped[product["category"]].append(product)defaultdict会在访问不存在的键时自动调用list()创建一个空列表作为默认值,把“先判断是否存在”这件事交给容器自己处理。同理,统计频次可以用defaultdict(int),计数累加时不需要先初始化。这个类我从入门用到现在,几乎每个项目都离不开它。
4.3 用一个实际例子串起来:统计一篇文章的词频
拿经典场景举例:把文本拆成单词,统计每个单词出现次数,输出出现次数最高的前五个词。
from collections import Counter text = "python python data container container container list" words = text.lower().split() counter = Counter(words) top5 = counter.most_common(5) print(top5)Counter 本质上就是 dict 的增强容器。如果不用 Counter,用 defaultdict(int) 手写统计也能做,但 Counter 额外提供了most_common、elements、更新多个计数等方法,非常契合“计数”场景。
如果想顺便去掉停用词,可以这样写:
important_words = {word for word in words if len(word) > 2 and word not in stopwords} counter = Counter(important_words)这里 set 推导式把“单词重要性判断”这一层业务逻辑和数据容器结合起来。你会发现 list、set、dict、Counter 其实都在围绕同一个目标协作,而不是孤立的语法点。
5. 容器选型:不是“用哪个顺手”,而是“场景适合哪个”
5.1 选容器之前,先问四个问题
我写代码前会快速确认四件事:
- 需要保持元素的插入顺序吗?需要的话优先 list 或 dict;不需要的话 set、dict 都可以,顺序反而不重要。
- 允许重复吗?不允许就考虑 set,允许就 list/tuple;dict 的键天然不重复。
- 核心操作是按索引取值、判断存在,还是按键取关联值?按索引访问用 list,判断存在用 set,按键取关联值用 dict。
- 数据以后会被修改吗?只读、还想作为键,就用 tuple、frozenset;需要频繁增删改,就用 list、dict、set。
这四个问题组合起来,基本能锁定合适的容器。
5.2 不同场景下的取舍建议
需要频繁判断“某个值在不在集合里”,不要用 list。这是最容易优化、收益最大的一个调优点。举个例子:接口幂等,判断请求 ID 是否已经在处理集合里,正确做法是维护一个 set,而不是用一个 list 去in。
需要保持去重结果的有序性时,不要直接 set。可以先遍历原列表,用一个 set 记录已经出现过的元素,再判断是否加入新列表;或者用dict.fromkeys(items)这个技巧。
如果一条记录有固定字段,比如学生有姓名、班级、成绩三个属性。用 tuple 可以,但不够可读;用 dict 可读但字段校验弱;用 namedtuple 或 dataclass 更好。namedtuple 既保留了 tuple 的轻量特性,字段又带名字,代码可读性明显提升:
from collections import namedtuple Student = namedtuple("Student", ["name", "class_name", "score"]) stu = Student("小明", "三年级二班", 92) print(stu.name)需要注意的是,namedtuple 本质是 tuple,不可变;如果需要修改字段,用 dataclass 更合适。
5.3 性能对比带来的启示
前面那个 10 万条 ID 的判断测试,我用三种容器都跑过:
| 容器 | 成员判断复杂度 | 1 万个随机 ID 的耗时表现 |
|---|---|---|
list +in | O(n) | 秒级,数据量翻倍耗时线性上涨 |
set +in | O(1) | 毫秒级,基本不随容量变化 |
dict +key in d | O(1) | 与 set 相当,但多存了一层值 |
其实不用跑测试,复杂度已经能告诉你答案。对于数据量上万之后,容器选错的影响会明显体现在耗时上。
当然,过度优化是另一回事。几十条数据用 list 和 set 都无所谓,可读性优先。但一开始就选对容器,后期能少很多返工。
6. 多年开发里踩过的容器坑,以及我沉淀下来的习惯
6.1 值得记录的坑
第一个坑:可变对象作为函数默认参数。
def add_item(item, cache=[]): cache.append(item) return cache这个代码跑两三次后,cache 会把之前调用的结果全带上,因为默认参数在函数定义时只创建一次。解决办法是def add_item(item, cache=None): cache = [] if cache is None else cache。
第二个坑:循环遍历列表时删除元素。
# 想删除所有偶数,实际结果会漏删 for x in numbers: if x % 2 == 0: numbers.remove(x)因为删除元素后列表长度变化,循环内部索引却继续往下走,会跳过一个元素。更安全的方式是生成新列表:
numbers = [x for x in numbers if x % 2 != 0]第三个坑:dict 遍历时修改键集合。遍历过程中新增或删除键,会触发RuntimeError。解决办法是先把键列表取出来,比如for key in list(d.keys()):,或者用 dict 推导式生成新 dict。
第四个坑:浅拷贝和深拷贝混淆。前面已经详细说过,这里再强调一次:判断容器是否需要深拷贝,要看里面有没有可变对象。如果只是存整数、字符串这类不可变对象,浅拷贝完全够用。
第五个坑:判断容器是否为空。新手经常写if len(lst) > 0或if lst is not None。更 Pythonic 的写法是直接if lst:,因为空容器在布尔上下文里是 False,非空容器是 True。注意if lst is not None和if lst完全不同,前者只判断变量是否存在引用,后者才是在判断内容是否有数据。这两个的差异非常隐蔽,也很容易制造 bug。
6.2 我会主动使用的几个容器技巧
collections 模块是内置容器的扩展,几个常用类都是生产级替代方案:
defaultdict:处理分组和计数,省掉大量“先判断键是否存在”的样板代码Counter:词频统计、计数排序都很方便deque:需要两端操作时比 list 好,左端 append/pop 是 O(1)namedtuple:只读固定字段记录,兼具可读性和 tuple 的轻量特性
如果定义配置常量,又希望外部不能改,可以用MappingProxyType包一层只读视图,比直接暴露 dict 安全。判断多个集合的关系时,用 set 的isdisjoint、issubset、issuperset,语义清晰,也不用写循环。
技术之外,我还想提醒一点:数据容器本身是“理论正确”,但工程上要考虑可读性。如果一个容器嵌套层级太深,不要硬用容器表达对象模型,用 dataclass 会更直观:
from dataclasses import dataclass @dataclass class Order: order_id: str items: list total: float这比写dict(order_id=..., items=[...], total=...)更清晰,类型检查也更好。
6.3 给未来留一条扩展路径
如果将来要处理超级大的数据,Python 内置容器可能不够用。像 numpy 的 ndarray、pandas 的 DataFrame,都是专门为数值型批量数据设计的。它们和内置容器的设计逻辑是一致的:通过更紧凑的内存布局、牺牲一部分灵活性换取速度。这个迁移路径,从掌握 Python 内置数据容器开始,会顺畅很多。
我在实际工作中最大的体会是:数据容器学得好不好,不完全体现在语法是不是熟练,更多体现在写出来的代码是不是干净、跑起来是不是稳定。判断一个容器是否合适,根本标准是,这个数据形态在你的业务里最核心的操作是什么,容器就为这个操作服务。如果这些内容对你有一点帮助,建议你现在就把手头的代码打开,挑一个最常用的 list,想想它到底是该用 list、set、还是 dict,很可能会有意外收获。