2026/10/20 10:10:22

Python列表、字典、集合性能对比:从底层原理到实战选型

Python列表、字典、集合性能对比:从底层原理到实战选型 1. 从一个真实的性能翻车现场说起几年前我接手过一个数据处理模块逻辑本身不复杂从一批日志记录里提取用户ID判断某个ID是否已经出现过没出现过就记录下来。原始实现用的是列表代码写起来很顺手if uid not in seen_list一行搞定。测试环境跑几千条数据毫无压力上线之后数据量涨到几十万条整个模块的耗时从几百毫秒直接飙到几十秒CPU占用拉满接口超时告警一个接一个。当时第一反应是数据库慢查了半天SQL和索引没问题。又怀疑是网络IO排查下来也不是。最后用性能分析工具一跑热点函数清清楚楚地指向了那个not in判断。把列表换成集合之后同样的数据量耗时从几十秒降到了不到一秒。这个教训让我意识到字典、集合和列表之间的性能差异不是教科书上的一句查找复杂度不同就能带过的它直接决定了你的代码在生产环境里是能跑还是不能跑。这篇内容就是围绕这个主题展开的。我会把列表、字典、集合这三种最常用的内置数据结构放在一起从底层实现原理讲到实际场景中的选型策略再配合可复现的性能测试代码让你不仅知道该用哪个更明白为什么用这个以及用错了会怎样。不管你是刚学编程的新手还是写了几年代码但没深究过底层机制的老手应该都能从中找到对自己有用的东西。需要提前说明的是本文讨论的是Python语言中的list、dict和set其他语言虽然实现细节不同但核心思路是相通的。所有测试代码都可以直接复制运行建议你边看边动手验证效果比纯读要好得多。2. 三种结构的底层存储机制到底差在哪2.1 列表一块连续内存上的顺序排列列表的底层实现本质上是一个动态数组。当你创建一个列表时解释器会在内存中分配一块连续的空间元素一个挨着一个存放。每个元素在内存中的位置可以通过起始地址 索引 × 元素大小直接计算出来这就是为什么lst[5]这种按索引访问的操作快得离谱——它不需要从头遍历一步就能定位到目标。但连续存储也带来了两个限制。第一按值查找必须逐个比对。你手里只有一个值想知道它在不在列表里解释器只能从第一个元素开始一个一个拿出来跟你给的值比较直到找到或者遍历完。这就是in操作在列表上是O(n)复杂度的根本原因。第二插入和删除可能触发大量元素搬移。在列表头部插入一个元素后面所有元素都得往后挪一位删除中间某个元素后面的元素得往前补位。只有在尾部追加append才是高效的因为动态数组通常会预留一些额外空间偶尔触发扩容时才会整体搬迁。Python的列表还有一个特点值得注意它存储的是元素的引用指针而不是元素本身的值。也就是说一个列表里可以同时放整数、字符串、对象因为每个槽位存的只是一个指向实际数据的地址。这个设计让列表非常灵活但也意味着每次比较操作实际上是在比较两个对象的引用或值比纯数值比较要多一层开销。2.2 字典哈希表支撑的键值映射字典的核心是哈希表。当你往字典里插入一个键值对时解释器首先对键计算哈希值然后通过某种映射规则把哈希值转换成一个数组下标把值存到那个位置。查找的时候同样先算哈希值直接跳到对应位置取出值整个过程与字典里有多少个键基本无关平均时间复杂度是O(1)。这里的关键在于哈希函数的质量和冲突处理机制。不同的键有可能算出相同的哈希值这叫哈希冲突。Python采用的是开放寻址法当目标位置已经被占用时会按照一定的探测序列寻找下一个空槽。如果哈希函数设计得好冲突概率很低查找效率就接近理想的O(1)。但如果大量键的哈希值集中在少数几个位置性能就会退化。Python 3.6之后字典还做了一个重要优化保持插入顺序。底层实现上它把条目存储和索引分开索引数组只存条目在条目数组中的位置条目数组按插入顺序排列。这个改动不仅让字典的内存布局更紧凑还让遍历顺序变得可预测对调试和序列化都很友好。还有一个实际开发中容易忽略的点字典的键必须是可哈希的。整数、字符串、元组元素都可哈希时都可以做键但列表不行因为列表是可变的哈希值会变。这个限制看起来是麻烦实际上是保证哈希表正确性的必要条件。2.3 集合只有键没有值的字典集合和字典的关系非常近可以理解为只存键、不存值的字典。底层同样是哈希表同样依赖哈希值来定位元素同样要求元素可哈希。所以集合的成员判断in操作平均也是O(1)插入和删除也是O(1)。集合和字典最大的区别在于集合不存储额外的值只关心某个元素是否存在。这个特性让集合在去重和成员判断场景下特别高效。比如你有一个包含十万条记录的列表想快速去重转成集合再转回列表通常比手动遍历判断快得多。集合还有一个字典没有的能力集合运算。并集、交集、差集、对称差集这些操作底层都有专门的优化实现比用循环手动计算快很多。当你需要比较两组数据的重叠情况时集合运算往往是最优解。不过集合也有它的代价。第一它不保证元素的顺序虽然CPython的实现中整数集合有时看起来有序但这是实现细节不能依赖。第二它比列表占用更多内存因为哈希表需要预留空槽来减少冲突通常会有30%到50%的空间浪费。第三集合的元素也必须是可哈希的这意味着你不能把列表或字典放进集合里。3. 用可复现的测试代码量化性能差距3.1 测试环境与方法的说明光讲理论不够直观我写了一套测试代码你可以直接复制运行。测试环境是Python 3.11普通笔记本电脑不同机器上具体数字会有差异但数量级的关系是一致的。测试方法上有一点需要说明我用了timeit模块来做计时它会自动多次运行取平均值比手动掐表准确得多。另外为了避免单次测试的偶然性每个操作我都跑了多轮取中位数作为参考值。数据规模从一千到一百万不等这样能看出性能差距随数据量增长的变化趋势。import timeit def benchmark(func, number100): 运行基准测试并返回平均耗时毫秒 total timeit.timeit(func, numbernumber) return (total / number) * 1000 # 准备不同规模的数据 sizes [1000, 10000, 100000, 1000000]3.2 成员查找差距最悬殊的战场成员查找是三种结构性能差异最明显的地方。测试逻辑很简单构造一个包含N个元素的数据结构然后查找一个不存在的元素最坏情况看需要多长时间。for n in sizes: data_list list(range(n)) data_set set(range(n)) data_dict {i: None for i in range(n)} target -1 # 不存在的元素触发最坏情况 t_list benchmark(lambda: target in data_list, number100) t_set benchmark(lambda: target in data_set, number1000) t_dict benchmark(lambda: target in data_dict, number1000) print(f规模 {n}: 列表 {t_list:.4f}ms, 集合 {t_set:.4f}ms, 字典 {t_dict:.4f}ms)实测结果大致是这样的数据规模列表查找集合查找字典查找1,0000.02ms0.0001ms0.0001ms10,0000.2ms0.0001ms0.0001ms100,0002ms0.0001ms0.0001ms1,000,00020ms0.0001ms0.0001ms列表的查找耗时随数据量线性增长而集合和字典基本是一条水平线。在百万级数据下差距达到了两百倍。这还只是单次查找如果你的代码在一个循环里反复做成员判断累积的差距会非常恐怖。注意集合和字典的查找是平均O(1)不是绝对O(1)。如果哈希函数被恶意构造或者数据分布极端不均匀最坏情况下会退化到O(n)。但在正常使用场景下这个风险可以忽略。3.3 插入与删除列表尾部快中间慢插入和删除的情况要分场景讨论。列表在尾部追加元素非常快均摊O(1)和集合、字典的插入速度在同一量级。但在头部或中间插入删除列表需要搬移后续元素性能会急剧下降。# 尾部追加测试 def append_to_list(): lst [] for i in range(10000): lst.append(i) # 头部插入测试 def insert_to_list_head(): lst [] for i in range(10000): lst.insert(0, i) # 集合插入测试 def add_to_set(): s set() for i in range(10000): s.add(i)实测下来往空列表尾部追加一万个元素大约需要0.5ms往集合里添加一万个元素大约0.8ms差距不大。但如果是往列表头部插入一万个元素耗时会飙升到50ms以上因为每次插入都要把已有的所有元素往后挪一位总搬移次数是n(n-1)/2量级。删除也是类似的道理。lst.pop()删除尾部元素很快lst.pop(0)删除头部元素很慢。集合和字典的删除操作则不受位置影响都是O(1)。3.4 内存占用列表最省字典最费性能不只是速度内存占用同样重要。我用sys.getsizeof测了三种结构在不同规模下的内存开销数据规模列表集合字典1,0008KB32KB36KB10,00080KB524KB295KB100,000800KB4MB5.2MB列表的内存占用是最紧凑的因为它只需要连续存放指针。集合和字典因为哈希表需要预留空槽通常负载因子控制在三分之二左右加上每个条目还要存储哈希值等元信息内存开销明显更大。在数据量达到百万级时字典的内存占用可能是列表的六到八倍。这个差异在内存敏感的场景下很关键。比如你需要在内存里缓存大量数据用列表可能放得下换成字典就可能触发内存不足。选型的时候不能只看速度得综合考虑。4. 什么场景该用哪种结构一张决策表4.1 按操作类型选型把常见操作和三种结构对应起来可以整理出这样一张速查表操作需求首选结构理由按位置索引访问列表O(1)随机访问其他两种做不到判断元素是否存在集合/字典O(1) vs 列表的O(n)去重集合一行代码搞定底层优化好保持插入顺序且需要索引列表字典3.7也保序但不支持索引键值映射查询字典这是字典的本职工作频繁在头部插入删除集合/字典或collections.deque列表头部操作是O(n)需要集合运算集合并交差运算有专门优化存储大量数据且内存紧张列表内存占用最紧凑4.2 几个容易判断错的边界场景有些场景不是非黑即白的需要多想一步。场景一数据量很小的时候用列表行不行行。当元素个数在几十个以内时列表的线性查找和集合的哈希查找差距微乎其微用列表反而更省内存、代码更简单。性能优化要看数据规模不要为了O(1)而O(1)。场景二需要同时去重和保序怎么办集合去重会打乱顺序列表保序但去重慢。标准做法是用字典的fromkeys方法list(dict.fromkeys(data))既去重又保持原顺序而且底层是哈希表速度很快。场景三字典的键需要是复杂对象怎么办如果键是自定义对象必须实现__hash__和__eq__方法。只实现__eq__不实现__hash__对象会变成不可哈希的没法做字典键。反过来如果两个对象相等但哈希值不同字典会出现找不到的诡异现象。场景四集合里的元素是元组元组里又有列表呢不行。元组本身可哈希的前提是它包含的所有元素都可哈希。列表不可哈希所以包含列表的元组也不可哈希不能放进集合。这是新手常踩的坑报错信息是TypeError: unhashable type: list。5. 我踩过的坑和总结出的实战经验5.1 哈希冲突导致的性能悬崖前面一直说集合和字典是O(1)但这是有前提的哈希函数能把键均匀地映射到不同的槽位。如果大量键的哈希值相同或者集中在少数几个值上所有操作都会退化成链表遍历性能直接崩盘。我遇到过一次真实案例用自定义对象做字典的键但__hash__方法写成了返回一个固定值。结果所有对象都哈希到同一个槽位字典变成了一个超长的链表查找耗时从微秒级涨到了毫秒级。排查的时候用hash()函数打印了几个键的哈希值发现全都一样问题立刻定位。避免这个坑的办法很简单自定义对象的哈希值要基于那些参与相等判断的字段来计算。Python提供了hash()函数可以组合多个字段的哈希值或者直接用元组的哈希class User: def __init__(self, uid, name): self.uid uid self.name name def __hash__(self): return hash((self.uid, self.name)) def __eq__(self, other): return isinstance(other, User) and (self.uid, self.name) (other.uid, other.name)5.2 在循环里反复构造集合的隐形开销集合查找快但构造集合本身是有成本的。如果你在一个循环里每次都重新构造一个集合来做成员判断构造的开销可能比查找省下来的时间还多。# 错误示范每次循环都构造集合 for item in data: if item in set(reference_list): # 每次都重新构造 process(item) # 正确做法循环外构造一次 reference_set set(reference_list) for item in data: if item in reference_set: process(item)这个坑很隐蔽因为代码逻辑是对的结果也是对的只是慢。用性能分析工具才能发现热点在set()构造上。养成习惯任何在循环里不变的东西都提到循环外面。5.3 字典遍历时修改键的陷阱字典在遍历过程中如果改变了大小增删键会抛出RuntimeError: dictionary changed size during iteration。这个限制是为了保证遍历的正确性因为哈希表的扩容或缩容会打乱元素的存储位置。如果确实需要在遍历时删除元素有两个办法。一是先收集要删除的键遍历结束后统一删除to_delete [k for k, v in d.items() if should_remove(v)] for k in to_delete: del d[k]二是遍历字典的副本for k in list(d.keys()):。但要注意副本是浅拷贝如果字典很大这个操作本身有内存和时间开销。第一种方法更稳妥。5.4 列表转集合去重时丢失顺序的补救用set()去重是最快的但顺序没了。如果顺序重要用dict.fromkeys()data [3, 1, 2, 3, 1, 4] unique_ordered list(dict.fromkeys(data)) # 结果: [3, 1, 2, 4]这个技巧在Python 3.7上稳定可用因为字典保证插入顺序。它的时间复杂度是O(n)和直接用集合去重一样快但多了保序的好处。我在处理日志去重、用户行为序列去重时经常用这招。5.5 大数据量下的内存优化思路当数据量到了千万级字典的内存占用可能成为瓶颈。几个实用的优化方向第一能用集合就不用字典。如果只需要判断存在性不需要存值集合比字典省内存。第二考虑用数组替代列表。如果列表里存的都是同类型的数值array模块或者numpy数组的内存效率比普通列表高得多。第三分块处理。不要一次性把所有数据加载到内存里分批处理每批用完就释放。配合生成器使用内存占用可以控制在常数级别。第四用__slots__减少对象开销。如果字典的值是自定义对象给类加上__slots__可以省掉每个实例的__dict__内存能省不少。6. 把性能意识变成肌肉记忆写了这么多核心其实就几句话列表适合按位置访问和保持顺序字典适合键值映射集合适合去重和成员判断。但知道这几句话和真正在写代码时条件反射地选对结构中间还差着大量的实践。我的建议是在日常开发中养成一个习惯每当你写下in判断、去重逻辑或者键值查找时停下来想一秒钟——这里的数据规模有多大如果超过几百条列表的线性查找会不会成为瓶颈如果会换成集合或字典的成本高不高这一秒钟的思考能帮你避开很多上线后才暴露的性能问题。另外不要凭感觉猜性能。我见过太多人凭直觉优化结果改了半天反而更慢。用timeit测一下用性能分析工具跑一下让数据说话。上面那些测试代码你可以直接拿去改换成你自己的数据结构和操作几分钟就能得到答案。最后说一个我自己的体会性能优化不是越早越好但数据结构选型是越早越好。因为改数据结构往往意味着改接口、改调用方、改测试用例成本远高于写代码时多想一步。在动手之前花五分钟想清楚数据怎么存、怎么查、怎么改比事后花五小时重构要划算得多。