
先抛个问题你有一个 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 的键也不能放进 settuple 内容不可变如果内部元素都可哈希那整个 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、popO(1)任意位置 insert、pop(i)O(n)因为后面的元素要整体搬动索引访问O(1)成员判断x in lstO(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合并两个 dictd1.update(d2)会就地修改d {**d1, **d2}生成新 dictPython 3.9 之后还可以用d1 | d23.2 setdict 的“只保留键”版本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/tupledict 的键天然不重复。核心操作是按索引取值、判断存在还是按键取关联值按索引访问用 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 inO(n)秒级数据量翻倍耗时线性上涨set inO(1)毫秒级基本不随容量变化dict key in dO(1)与 set 相当但多存了一层值其实不用跑测试复杂度已经能告诉你答案。对于数据量上万之后容器选错的影响会明显体现在耗时上。当然过度优化是另一回事。几十条数据用 list 和 set 都无所谓可读性优先。但一开始就选对容器后期能少很多返工。6. 多年开发里踩过的容器坑以及我沉淀下来的习惯6.1 值得记录的坑第一个坑可变对象作为函数默认参数。def add_item(item, cache[]): cache.append(item) return cache这个代码跑两三次后cache 会把之前调用的结果全带上因为默认参数在函数定义时只创建一次。解决办法是def add_item(item, cacheNone): 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很可能会有意外收获。