
写 Python 这些年我栽过的跟头几乎都和数据结构有关。列表、字典、集合、元组这四件套看起来平平无奇可真正写业务代码时就会发现选错一种容器代码就会越写越别扭用对一种结构一整段逻辑都能跟着清爽起来。这篇是“玩转 Python 核心数据结构”系列的第四篇我打算把日常用得最多的 list、dict、set、tuple 集中过一遍重点聊底层存储逻辑、性能差异、实战选型还有网上教程不会明说的坑。不绕弯子内容全部来自实际跑过代码后的总结适合刚学完 Python 语法、正在写脚本或者已经开始接触工程项目的读者。写代码之前先想清楚“我要用哪种数据结构”这句话听起来像废话但真到动手时很多人都是先写一个 list 再说。实际上数据结构的选择直接决定代码的复杂度和可读性把这一步想明白了后面就是搭积木。1. 内容整体设计与思路拆解1.1 数据结构写代码前最先要做的选择有人说 Python 写起来慢其实大部分时候慢的不是 Python 本身而是数据结构没选对。举个最常见的例子统计一段文本中每个单词的出现次数。新手最容易写出来的方案是两层循环外层遍历所有单词内层遍历已有的统计列表找到就加一找不到就追加一条。这个写法在小数据量下没毛病可一旦文本量换成几十万行日志两层循环的 O(n²) 复杂度会直接把程序拖到卡死。换成字典以后代码只需要一次循环word_count {} for word in text.split(): word_count[word] word_count.get(word, 0) 1字典的查找是哈希表实现的平均 O(1)几十万词的文本跑起来也是瞬间完成。同样的功能数据结构的差异让性能差出了几个数量级这不是 Python 本身的问题而是容器选型的问题。我的建议是接到一个需求后先花两分钟问自己三个问题我需要频繁按键查找吗需要保持顺序吗需要去重吗这三个问题的答案基本能确定该用哪种容器。需求推荐结构核心原因按顺序存储、遍历、追加list动态数组尾插摊还 O(1)按键查找、关联映射dict哈希表平均 O(1) 查找去重、成员判断set哈希表去重天然无序固定数据、多值返回tuple不可变、可哈希、支持解包1.2 四种核心容器的定位差异把 list、dict、set、tuple 想象成四种不同用途的储物工具会好理解很多。list 像一条连续摆放的货架每件商品有固定位置你按下标走过去拿就是 O(1)但在货架中间插队或者抽走最前面的商品后面的全部要挪位置这就是 O(n)。日常做消息队列、遍历展示、临时存储list 都是首选。dict 像一本带索引的通讯录你报出名字直接翻到那一页拿联系方式不用一页页找。凡是“给定一个 key找对应 value”的场景字典都是最优解。set 像一台自动去重的分拣机扔进去重复的东西会自动被过滤掉。当你关心的是“这个东西在不在集合里”而不是“它出现了几次”时set 就派上了用场。tuple 像一份封好的合同内容定下来就不能改。函数要返回多个结果时它就是最轻量的载体。这些定位想清楚了后面写代码就是做选择题而不是每天靠各种奇技淫巧去弥补选错结构带来的麻烦。2. 核心细节解析与实操要点2.1 列表连续内存与动态扩容的真相CPython 里的 list 并不是教科书上那种最简单的数组它本质是一个“动态数组”——底层是一块连续的内存里面存的不是元素本身而是一个个指向真实对象的指针。因为内存连续所以按下标访问时可以直接通过“起始地址 下标 × 指针大小”计算出来随机访问时间复杂度稳定在 O(1)。但连续内存带来一个问题数组长度是固定的不断往里加元素怎么办CPython 的做法是扩容。当 list 的容量不够时它会申请一块更大的内存把老数据整体搬过去然后释放旧空间。扩容系数大概是 1.125 倍所以单次 append 可能是 O(n) 的搬移操作但均摊下来连续 append n 次的总代价是 O(n)平均每次 O(1)。这也是为什么列表的 append 用起来很爽你不需要关心容量问题。真正的性能陷阱在头部操作my_list [1, 2, 3, 4, 5] my_list.insert(0, 0) # O(n)所有元素都要后移 my_list.pop(0) # O(n)所有元素都要前移如果你的代码需要频繁在列表头部插入或删除list 不是好选择。这种情况应该考虑collections.deque它是由双向链表实现的两端操作都是 O(1)。我见过不少同事处理“待办队列”时用 list 加 pop(0)数据量一上来就肉眼可见地卡顿换成 deque 之后立刻解决。切片操作也是一个容易出问题的地方。sub_list my_list[1:]看起来像取子列表但它实际上是浅拷贝——它会创建一个新列表可新列表里的元素依然是指向原对象的引用。如果列表里的元素是不可变对象整数、字符串那么改切片不影响原列表没问题。但如果列表里装的是可变对象比如嵌套子列表或字典那么通过切片修改元素内部的值原列表同样会变。这个“共享引用”的概念非常关键很多 bug 都出在这里。类似地还有一个经典坑——list_of_lists [[]] * 3。这个写法看起来创建了三个独立的空列表实际上它们是同一个对象的三个引用改任意一个另外两个也跟着变。要创建真正独立的列表得用列表推导式list_of_lists [[] for _ in range(3)]2.2 字典哈希表的查找魔法与乱序疑云dict 是 Python 里用得最频繁的数据结构它的底层是哈希表。往字典里存一个键值对时Python 先对 key 调用hash()计算出一个整数哈希值再用这个值定位到表里的某个“槽位”。查找一个 key 时只要计算哈希值、跳到对应槽位就完事了所以平均查找时间是 O(1)。哈希表设计决定了 dict 的 key 必须是“可哈希的”。简单说就是对象的哈希值在其生命周期内不能变。因此不可变类型int、str、tuple、frozenset都可以当 key而 list、dict、set 这种可变类型是不能当 key 的——因为如果列表内容变了它的哈希值没法稳定定位就会失效。这个限制不是 Python 小气而是哈希表原理决定的。说到字典顺序很多人会有疑惑Python 3.6 之前dict 是无序的从 3.6 开始CPython 的实现让 dict 保持了“插入顺序”并在 3.7 版本成为语言规范。这意味着你遍历字典时看到的顺序就是你插入元素的顺序。但这跟“有序字典”排序不是一回事——它不会按 key 自动排序只是记住插入顺序。还有一个细节Python 对字符串和字节的哈希计算默认开启了随机化PYTHONHASHSEED。同一个程序在不同进程里跑同样的插入顺序字典的迭代顺序可能不同。这是因为随机化哈希能防止攻击者构造大量哈希碰撞的数据来拖慢程序属于安全设计。所以别在程序里依赖不同进程中字典哈希值的稳定性。实操中有几个字典的便捷用法值得刻进肌肉记忆from collections import defaultdict, Counter # defaultdict不存在的 key 自动给默认值 count defaultdict(int) count[a] 1 # Counter专为计数设计的字典子类 word_counts Counter(text.split()) top_words word_counts.most_common(10) # 多级嵌套用 defaultdict 的工厂函数 nested defaultdict(lambda: defaultdict(list)) nested[user1][actions].append(click)另外Python 3.9 之后字典合并可以写得非常简洁merged {**dict1, **dict2} # 旧写法 merged dict1 | dict2 # 新写法右边覆盖左边同 key 值判断 key 是否存在于字典里直接用key in my_dict不要用my_dict.get(key)去判断返回值因为如果 value 本身有可能为 None 或 0get 的判断就不可靠了。2.3 集合去重引擎与哈希约束set 的本质可以理解为“没有 value 的字典”底层同样是哈希表。它的核心能力有两个去重和成员判断时间复杂度都是 O(n) 和 O(1)。很多人喜欢先 sort 再去重其实用 set 一趟就能搞定而且还不用动原顺序后面会讲。因为基于哈希表set 里的元素也必须是可哈希的。这意味着元组可以放进集合列表不行。如果确实想把列表放进去需要先转成元组或者用frozenset。列表去重并保持顺序是数据清洗里特别常见的需求。直接set(my_list)能得到去重结果但顺序会被打乱。要保留顺序常见做法是用循环加辅助集合def deduplicate(items): seen set() result [] for item in items: if item not in seen: seen.add(item) result.append(item) return result这个写法的好处是既利用了 set 的 O(1) 查找又不破坏原列表顺序比先排序再去重要快得多。集合运算也非常实用a {1, 2, 3, 4} b {3, 4, 5, 6} a b # 交集 {3, 4} a | b # 并集 {1, 2, 3, 4, 5, 6} a - b # 差集 {1, 2} a ^ b # 对称差 {1, 2, 5, 6}注意set 占用的内存通常比 list 大不少。如果数据量是几十万级别的数字或字符串set 的哈希表开销可能会让内存翻好几倍。在内存敏感的场景下如果能接受排序后去重或者用位图之类的方法可以考虑其他方案。但绝大多数业务场景下set 的便利性远大于那点内存消耗。2.4 元组不可变约束与解包的艺术tuple 是一个常常被低估的结构。表面上看它和 list 的区别只是不能修改但“不可变”这个特性带来了一系列连锁好处。最直接的好处是 tuple 可以哈希。一个由可哈希元素组成的 tuple本身也能被哈希因此可以当作 dict 的 key也可以放进 set。这是 list 完全做不到的。如果你需要用一个组合条件比如“用户ID 日期”作为字典的 key元组就是天然的选择。另一个好处是安全。函数传参时如果你确定某个数据不该被调用方修改用 tuple 而不是 list就从根上杜绝了误改的可能。代码越往后维护越能体会到这种“限制”带来的安全感。namedtuple 是元组的进阶版它让元组拥有了字段名代码可读性提升一截from collections import namedtuple Point namedtuple(Point, [x, y]) p Point(10, 20) print(p.x, p.y) # 按字段访问解包是把元组用得最优雅的方式。函数返回多个值本质就是返回一个元组接收时直接解包def get_user_info(user_id): return user_id, Alice, 28 uid, name, age get_user_info(1)再配合一个不太起眼的星号表达式在不确定长度的场景下非常好用first, *middle, last [1, 2, 3, 4, 5] # first1, middle[2,3,4], last5还有一个很多人容易忽略的细节tuple 的“不可变”只是元素的引用不可变。如果元组里存了一个 list那这个 list 的内容是可变的只是你不能把整个 list 对象换成另一个。类似于“合同上写着甲方是某公司公司内部的人可以换但这家公司不能换掉”。3. 实操过程与核心环节实现3.1 实战一日志聚合分析中的 dict 与 Counter假设有一个用户操作日志文件每一行的格式是“用户ID 操作类型”例如1001 click 1002 view 1001 view 1003 click 1001 click需求是统计每个用户的总操作次数以及每个用户最常做的三种操作。如果按顺序写第一反应可能是读文件、用字典记录次数。但真正处理大文件时要留意一点不要一次性把整个文件读入内存。用文件对象的迭代器逐行处理内存占用就非常稳定。from collections import defaultdict, Counter user_actions defaultdict(Counter) with open(user_log.txt, r, encodingutf-8) as f: for line in f: user_id, action line.strip().split() user_actions[user_id][action] 1 # 输出每个用户的总操作数和 Top3 操作 for user_id, action_counter in user_actions.items(): total sum(action_counter.values()) top_actions action_counter.most_common(3) print(f用户 {user_id}总操作 {total} 次最常操作的 Top3: {top_actions})这里用defaultdict(Counter)是一个非常顺手的组合。defaultdict负责自动创建不存在的用户的 CounterCounter 负责统计各操作类型次数并提供most_common()方法。如果换手动写法需要先判断 user_id 在不在字典里、不在就初始化一个 Counter至少多出三行分支逻辑。而这种结构化的写法能直接反映数据模型每个用户有一组操作次数统计。这种处理方式在处理几 GB 的日志时也不会卡死因为内存里只保留聚合结果用户数 × 操作类型数而不会保留每一行原文。3.2 实战二数据清洗中的去重与排序另一个高频场景是数据清洗。假设有一份订单记录每条记录是一个字典orders [ {order_id: 1001, user_id: U1, created_at: 2024-01-10 10:00:00}, {order_id: 1002, user_id: U2, created_at: 2024-01-10 10:05:00}, {order_id: 1003, user_id: U1, created_at: 2024-01-10 10:10:00}, {order_id: 1004, user_id: U1, created_at: 2024-01-10 10:15:00}, ]需求按 user_id 去重保留每个用户最新的一条订单并按时间倒序输出。一个非常省事的思路是先把数据按时间从早到晚排序再用字典按 user_id 依次覆盖。因为字典的 key 是唯一的后面写入的会覆盖前面的排序后最后写入的就是最新的。sorted_orders sorted(orders, keylambda o: o[created_at]) latest_by_user {} for order in sorted_orders: latest_by_user[order[user_id]] order result sorted(latest_by_user.values(), keylambda o: o[created_at], reverseTrue)这里的关键点在于“覆盖”这个动作。如果你不先按时间排序直接遍历那么最终保留的是遍历顺序中的最后一条可能不是时间最新的一条。先排序、再覆盖就保证了覆盖时机是对的。这种方法比用“set 记录索引”的方式简单得多代码短逻辑也直白。实际项目中我还会顺手把订单时间解析成 datetime 对象再进行排序避免字符串排序在跨年或格式不一致时出现问题。3.3 实战三嵌套结构转换——从 JSON 到表格输出最后一个实战场景非常贴近接口数据处理。假设后端返回了一个多级嵌套的 JSON统计不同地区不同产品类别的销售额data { 2024-01: { 华东: {手机: 12000, 电脑: 9800}, 华北: {手机: 8000, 电脑: 7200}, }, 2024-02: { 华东: {手机: 15000, 电脑: 10200}, 华北: {手机: 9000, 电脑: 6600}, } }现在想按月份输出一张二维表每个地区每个月份的总额。如果直接手动写嵌套循环代码会变得很乱而且需要处理“地区不存在”的情况。用嵌套 defaultdict 会更清晰from collections import defaultdict month_region_total defaultdict(lambda: defaultdict(int)) for month, regions in data.items(): for region, categories in regions.items(): month_region_total[month][region] sum(categories.values()) # 输出 for month, region_data in month_region_total.items(): for region, total in region_data.items(): print(f{month} | {region} | {total})这里的嵌套 defaultdict 是defaultdict传了lambda: defaultdict(int)也就是说当访问一个不存在的月份时自动创建一个新的 defaultdict并且这个新字典的值默认是整数 0。这个组合能省掉大量的if region not in dict判断。有一个细节值得强调写defaultdict(defaultdict(int))是不行的因为defaultdict(int)是一个对象而 default_factory 需要的是一个可调用的工厂函数会在每次缺 key 时调用它来生成默认值。如果你直接传一个已经实例化的 defaultdict所有缺的 key 都会共享同一个字典改一个就全变了。这跟之前提到过的[[]] * 3是同一个坑都属于可变对象默认值共享问题。4. 常见问题与排查技巧实录4.1 高频踩坑速查表下面这个表是我在实际开发中反复遇到过的问题汇总每个都对应一个具体的坑。问题现象根本原因解决办法遍历列表时删除元素结果跳过了某些项遍历过程中列表长度变化导致索引错位改用列表推导式过滤或先记录要删除的下标最后统一删除用[[]] * 3建列表改动一个子列表所有子序列都变三个子列表是同一个对象的引用用[[] for _ in range(3)]字典的 key 是 list报 TypeError可变类型不可哈希改用 tuple 作为 key对 dict 做排序期望按插入序结果变了排序是按 key 或 value不按插入序明确使用items()结合 key 参数排序读大文件时内存直接爆掉用readlines()一次性读入内存用for line in f逐行迭代default_factory 传成已实例化对象出现共享默认值factory 没有传可调用对象用lambda或类名不要用对象修改函数传入的 list 后外部变量也变了list 是可变对象函数内修改影响原数据不想影响原数据时用list.copy()或[:]4.2 实战排查思路与独家建议排查这类数据结构相关的问题最重要的是先明确两个问题这个对象在内存里是同一个吗这个操作的时间复杂度是多少判断“是不是同一个”可以用id()函数打印对象地址。如果你发现两个看似独立的列表地址一样那八成是共享引用问题检查是不是用乘法创建的、是不是切片浅拷贝、是不是赋值而不是 copy 了。判断“时间复杂度”需要在设计阶段就做。写完代码后如果发现运行很慢别急着优化细节先扫一遍有没有把 list 当 dict 用的地方——比如用if item not in my_list做成员判断在几十万数据下就是 O(n²) 级别的灾难换成 set 就能降到 O(n)。还有一个我实测很有用的习惯写代码时同一个数据尽量只用一种容器持有。比如不要一会儿用 list 存用户信息一会儿又转成 dict两种结构之间转换时容易出引用混淆。真需要转换明确在注释里写清楚“这行之后list 不再维护”。内存占用方面可以用sys.getsizeof()对比不同结构在相同数据量下的差距。我实测过存 10 万个整数时list 大概占 0.8 MBset 大概占 4 MB 左右dict 占得更多。所以在只需要成员判断时不要顺手用 dictset 更省。5. 写在最后的个人体会把这四种结构彻底弄明白之后我写代码的习惯变了不少。以前拿到需求先写循环现在会先停下来想想这个数据后面要怎么访问是按键查还是按顺序遍历要不要去重会不会被修改这几个问题问完容器基本就定了代码写起来也顺很多。我自己的感觉是数据结构选对了代码会自动变得简洁很多逻辑不用“硬写”就能表达出来。如果你还在学习阶段建议找几个真实场景练手比如分析一份聊天记录统计高频词、处理 CSV 去重、整理嵌套 JSON 输出成表格。把这些场景跑一遍list、dict、set、tuple 就不再是抽象的概念而是你工具箱里真正随拿随用的工具。下一篇我打算接着写迭代器、生成器和一些进阶技巧正好可以把这一篇提到的内存优化思路继续延展下去。