☰
Python列表、字典、集合性能对比:从底层原理到实战选型
2026/10/20 10:10:19 网站建设 项目流程

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, number=100): """运行基准测试并返回平均耗时(毫秒)""" total = timeit.timeit(func, number=number) 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, number=100) t_set = benchmark(lambda: target in data_set, number=1000) t_dict = benchmark(lambda: target in data_dict, number=1000) print(f"规模 {n}: 列表 {t_list:.4f}ms, 集合 {t_set:.4f}ms, 字典 {t_dict:.4f}ms")

实测结果大致是这样的:

数据规模列表查找集合查找字典查找
1,0000.02ms0.0001ms0.0001ms
10,0000.2ms0.0001ms0.0001ms
100,0002ms0.0001ms0.0001ms
1,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,0008KB32KB36KB
10,00080KB524KB295KB
100,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测一下,用性能分析工具跑一下,让数据说话。上面那些测试代码你可以直接拿去改,换成你自己的数据结构和操作,几分钟就能得到答案。

最后说一个我自己的体会:性能优化不是越早越好,但数据结构选型是越早越好。因为改数据结构往往意味着改接口、改调用方、改测试用例,成本远高于写代码时多想一步。在动手之前花五分钟想清楚数据怎么存、怎么查、怎么改,比事后花五小时重构要划算得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询