Python set/dict 哈希表性能退化:从 O(1) 到 O(n²) 的隐患与排查
2026/9/7 12:56:30 网站建设 项目流程

调试爬虫程序的时候,我遇到过一件怪事:代码逻辑没有问题,网络请求也正常,但一个set去重操作却越来越慢。从几千条 URL 增加到几万条 URL,耗时的增长完全不是线性,而是直接往二次方向狂奔。后来定位到根源,问题不是出在爬虫本身,而是 Python 的 set 在特定条件下会暴露出quadratic-time performance,也就是二次时间性能。

很多人对 Python 的setdict的第一印象是“查得快、插入快、去重方便”,认为它们是常数时间复杂度的代名词。这个印象不能算错,但只适用于“通常情况”。一旦键的哈希行为异常、容器不断扩容、或者操作被嵌套进一个外层循环,整体时间复杂度就可能从 O(n) 变成 O(n²),甚至更糟。

这篇文章我想聊清楚三件事:set/dict 的 O(1) 承诺到底是怎么来的,真实项目里哪些写法会让它退化成二次复杂度,以及当性能已经劣化时,你可以按什么顺序去定位和排查。最后会给你一套可复用的判断框架,方便你在自己的代码里做预防。

1. 先搞清楚 set 和 dict 的常数时间承诺到底是怎么来的

要理解退化,必须先理解正常情况。Python 的setdict底层都建立在哈希表之上。哈希表最核心的承诺是:通过哈希函数把任意对象映射成一个整数,再用这个整数定位到内部数组的某个位置,于是插入、查找、删除的平均时间复杂度都是 O(1)。

1.1 哈希表基础:hash、桶、负载因子

我们可以把哈希表想象成一个有编号的货架。每个对象要存放时,先算一次hash(),得到一个整数,然后根据这个整数决定放在哪个格子里。

def simple_hash_example(value): return hash(value)

CPython 内部并不是拿hash值直接当数组下标用的,而是会再经过一次掩码运算,把它映射到数组大小范围内的一个索引。这个映射关系由当前的表容量决定。

哈希表不能无限制地往里塞数据,否则格子会被占满。因此当插入的数据量达到某个阈值时,就会触发扩容。这个阈值通常用“负载因子”来衡量:

概念含义
桶 / slot哈希表内部数组中可存放元素的位置
负载因子已用桶数量 / 总桶数量
扩容当负载因子超过阈值时,申请更大数组并重新插入所有键
重哈希扩容后所有键的索引要基于新容量重新计算

在 CPython 的实现里,扩容之后表的容量通常会变大,而所有元素的相对位置也会改变。这个扩容过程本身需要遍历已有元素,复杂度是 O(n)。如果只在增长到很大时偶尔扩容一次,整体平均仍然接近 O(1)。但如果你的程序反复触发扩容,或者在一个循环里反复构建大表,成本就会被反复放大。

1.2 CPython 中经典实现:开放寻址与稀疏表

CPython 的 dict 和 set 不是用“数组 + 链表”实现的,而是用“开放寻址法”。也就是说,当两个键算出来的索引冲突时,Python 会按照一套规则继续探测下一个空位,而不是在同一个索引下挂一个链表。

这种设计的优点是对缓存友好,内存也更紧凑。但它有一个值得注意的副作用:当表变得越来越满时,探测序列会变长,插入和查找的成本也会明显上升。所以 CPython 通常会保持表比较稀疏,宁可浪费一点内存,也不让表装得太满。

这也是为什么你在实际工程里会观察到:一个 set 或者 dict 在元素较少时速度很快,但当元素数量连续翻倍、表不断扩容时,单次操作偶尔会出现一次明显的卡顿。如果这种卡顿发生在一个大循环里,整体耗时就会相当难看。

1.3 为什么说“通常 O(1)”而不是“绝对 O(1)”

官方文档和大多数教科书都会说 set/dict 的平均时间复杂度是 O(1)。但这里的“平均”有前提:

  • 键的哈希值分布足够均匀。
  • 键对象是不可变的,且__hash__方法稳定。
  • 哈希表没有被恶意或偶然地写入大量哈希值相同的键。
  • 操作本身不会触发频繁的扩容和重哈希。

只要这些条件被破坏,“平均 O(1)”就会退化成“最坏 O(n)”。当你在代码里用一个大循环反复做这类操作时,总复杂度就可能是 O(n²)。

这其实是集合和字典这类数据结构里最容易被忽略的一部分:你没有写错语法,也没有犯很低级的性能错误,只是底层的数据结构在特定输入下产生了退化。

2. 真正触发 O(n²) 的几种常见路径

很多人以为二次复杂度只会出现在“循环套循环”的写法里。其实 set/dict 本身就可能成为二次复杂度的来源。下面这几条路径是我在实际项目和调试中经常遇到的。

2.1 失控的哈希碰撞:当“唯一”键不再唯一

最典型的场景是大量键拥有相同或相近的哈希值。如果把哈希表的每个索引想象成车位,碰撞就是多辆车抢同一个车位。开放寻址法会去找附近的其他空位,但如果很多键的哈希值撞在一起,找车位的时间就会越来越长。

Python 内置的字符串、整数、元组在正常情况下哈希分布做得不错,但并不意味着你不需要担心。比如当你把自定义对象作为 set 或 dict 的键时,如果__hash__返回一个常量,那么所有实例都会落在同一个桶区域。插入 N 个这样的键,可能就变成 O(n²)。

class BadKey: def __hash__(self): return 1 def __eq__(self, other): return self is other bad_set = set() for i in range(10000): bad_set.add(BadKey())

上面这段代码不会报错,但运行时间会随range规模增长变得异常慢。原因不是 set 本身差,而是我们给哈希表提供了灾难级的输入。

2.2 循环里反复 resize:动态扩容带来的重哈希

哈希表为了保持性能,会按负载因子动态扩容。扩容本身不是问题,问题在于你在循环里不断插入大量数据,而且中途没有任何删除操作。如果数据量一直在增长,扩容就会周期性发生。

周期性扩容带来的不是一次额外的 O(n),而是多次。N 次插入过程中,扩容的总代价大约为 O(n),分摊到每次插入是 O(1)。这通常是可以接受的。但如果你在一个外层循环里,每一轮都构建一个新的 set 或 dict,并且构建的规模也在成倍增长,那总耗时就会变成二次。

一个典型的反面模式是:

result = [] for i in range(n): s = set() for j in range(i): s.add(j) result.append(len(s))

这里外层循环每次都会重建一个规模递增的 set,每次重建都包含扩容和重哈希,整体复杂度是 O(n²)。虽然单看内部循环,插入是 O(1),但外层多了一层累加成本。

2.3 不可变类型与错误 hash 实现:自定义对象做 key 的陷阱

把自定义对象放进 set 或作为 dict 的 key,是很常见的需求。但很多人没有注意到,dict/set 对 key 有两个硬性要求:

  • __hash__必须在对象的生命周期内保持不变。
  • __eq__相等的两个对象,__hash__也必须相等。

如果违背了第一条,你可能遇到“对象在字典里,却根据它找不到值”的神奇问题。如果违背了第二条,哈希表就会把相等对象放到不同位置,导致查找失败或性能下降。

更隐蔽的问题是,自定义对象如果没有重写__hash__,默认是基于对象 id 实现的。两个内容完全相同的对象,__eq__如果重写成按内容比较,但__hash__仍基于 id,那么它们即使相等也拥有不同哈希值。这在逻辑上是错误的,也会让哈希表无法正常工作。

推荐的做法是让自定义对象使用不可变字段来计算哈希,或者直接使用dataclass(frozen=True)NamedTuplefrozenset等内置结构。

2.4 深层 set/dict 嵌套和数据倍增:组合爆炸

还有一种二次退化不是来自哈希表本身,而是来自数据结构的组合方式。比如你在一个 dict 的 value 里又放了一个 set,外层循环每处理一条数据都要在外层 dict 里做一次查询,同时在内层 set 里做一次更新。

如果外层有 n 条记录,内层平均也有 n 条记录,那么整体就是 O(n²)。这种情况下,即使每次哈希操作都很快,总规模仍然会失控。

users = {} for user_id, tags in raw_data: current = users.setdefault(user_id, set()) for tag in tags: current.add(tag)

这段代码本身很常见,但如果你在raw_data上再套一层循环去处理多批数据,复杂度就会线性叠加成二次。

2.5 误用 in、交集、并集在嵌套循环里

set 的in操作是 O(1),但如果把in放进一个遍历所有元素的循环里,整体就是 O(n)。如果外层也是循环,两个 set 的规模都是 n,那么:

a = set(...) b = set(...) for x in a: if x in b: ...

这段代码看起来没什么问题,但它的复杂度是 O(len(a))。只有当你要遍历的是 a 的每个元素,而 b 的查询是常数时间时,才是 O(n)。可如果你写成:

for x in a: for y in b: if x == y: ...

那就完全退化成 O(n²) 了。有人会狡辩说“但我在用 set 啊”,可 set 的子集判断不是用==去两两比较的,它应该用a & ba.intersection(b)或者x in b

3. 用实际案例复现“看起来线性、实际二次”的过程

理论说完了,下面用几个最小例子来复现。这样你可以直接复制到自己的环境里验证。

3.1 一个最简单的复现脚本:set.add 循环 vs range

先用内置整数键做一个对照组:

import time def make_set(n): s = set() for i in range(n): s.add(i) return s for n in [10000, 20000, 40000, 80000]: start = time.perf_counter() make_set(n) cost = time.perf_counter() - start print(f"n={n}, cost={cost:.4f}s")

在我的环境里,这组数据基本会呈现线性增长。因为整数对象的哈希值就是它自身,分布非常均匀,哈希表可以保持低碰撞。这里是安全区间。

接着把__hash__返回常量的BadKey换成同样的循环:

class BadKey: def __hash__(self): return 1 def __eq__(self, other): return self is other def make_bad_set(n): s = set() for i in range(n): s.add(BadKey()) return s for n in [1000, 2000, 4000, 8000]: start = time.perf_counter() make_bad_set(n) cost = time.perf_counter() - start print(f"n={n}, cost={cost:.4f}s")

n 翻一倍,耗时大约涨四倍,这就是典型的二次复杂度。同一个数据结构,只是因为哈希分布质量不同,性能就从“几乎不耗时的线性增长”变成了“肉眼可见的失控”。

这个实验也说明,set/dict 的 O(1) 并不是无条件成立的。它的前提是哈希值分布足够好。

3.2 自定义对象未实现__hash__造成碰撞

再来看一个更日常的例子。定义一个普通类,只重写__eq__,不重写__hash__

class User: def __init__(self, name): self.name = name def __eq__(self, other): return self.name == other.name

Python 会认为User("a") == User("a"),但两者的哈希值默认基于对象 id,因此把一个User放入 set 后再用另一个相等的User去查询,会得到 False。

a = User("alice") b = User("alice") s = {a} print(b in s) # False

这就是一个典型的“相等对象哈希不一致”问题。它不仅会导致逻辑错误,还会让 set/dict 在做去重时产生“重复数据无法合并”的现象,数据量一大,哈希表内部还会产生大量实际冲突,进一步拖慢性能。

修复方式很简单:要么让对象不可变并实现__hash__,要么用NamedTuplefrozen dataclass

3.3 在批量、爬虫、量化场景中的影响

你可能觉得自定义对象做 key 不常用,但实际工程里很容易踩到类似问题。比如爬虫中用 set 去重 URL,如果 URL 不是字符串而是某种封装对象,封装类的哈希实现写得不好,去重性能就会退化。再比如把股票交易数据按时间戳聚合到多个 dict 中,如果时间戳存在多种表示方式,你用 tuple 做 key 时没处理好类型统一,也会导致重复构建和哈希冲突。

举个爬虫场景的例子:

# 爬虫伪代码,仅演示结构 seen_urls = set() for page in range(1, 10000): url = f"https://example.com/list/{page}" seen_urls.add(url)

如果 page 是字符串,性能正常;但如果这里你 hash 的对象是一个自定义 Response 对象,并且__hash__实现不理想,那整体去重的耗时就会非常可观。

量化交易场景同理,当你用一个复杂结构作为 dict 的 key,比如(timestamp, symbol),如果 tuple 元素本身包含自定义对象,那么这个自定义对象的__hash__质量会直接影响整体聚合性能。

这些场景的共同特点是:数据规模不大,但操作频率高;单个操作看起来都是 O(1),但累积起来就成了性能瓶颈。

3.4 如何测量并定位:timeit、perf_counter、cProfile

如果怀疑 set/dict 退化了,先不要盲目优化。先用量化工具确认复杂度是否符合预期。

最基础的方法是记录不同输入规模下的耗时,观察增长倍数:

输入规模耗时1耗时2增长倍数
nt1t2-
2n2倍左右4倍左右线性?二次?
4n4倍左右16倍左右线性?二次?

如果输入规模翻倍后耗时翻倍,说明基本是线性;耗时翻四倍,就要怀疑是二次。这个方法虽然粗糙,但定位很快。

进一步定位可以用cProfile看函数调用耗时占比,也可以用tracemalloc看内存增长是否异常。如果发现某个add__hash____eq__调用特别多,说明碰撞和比较成本已经上来了。

import cProfile cProfile.run("make_bad_set(4000)")

输出里会显示__hash__被调用了多少次,以及set_add的累计耗时。如果__hash____eq__的调用次数远超元素个数,基本可以判定发生了碰撞。

4. 工程上可以怎么避免退化

知道问题在哪,接下来就是怎么在工程上避免。我的建议不是“不要用 set/dict”,而是“在合适的时候用,并且把输入和自定义行为管好”。

4.1 优先控制输入规模和 hash 质量

如果一块逻辑的输入规模本来就在可控范围内,而且用的是 Python 内置 str、int、tuple 做键,那么大多数情况下 set/dict 的性能都不用操心。你需要担心的场景是:

  • 输入规模会持续增长,且你不知道上限。
  • 键是自定义对象,且不能保证哈希质量。
  • 同一个数据结构会被频繁重建。
  • 你正在一个外层循环里不断向同一个容器追加数据。

如果命中其中一两条,建议在数据入口处先做一次校验或归一化。比如把 URL 先转成字符串再放入 set,把时间字段统一成同一种格式再放入 tuple。

4.2 对自定义对象实现稳定且分布良好的__hash____eq__

如果你确实需要自定义对象作为键,最稳妥的方式是让对象不可变,并基于所有参与相等比较的字段来计算哈希。

from dataclasses import dataclass @dataclass(frozen=True) class Point: x: int y: int points = set() points.add(Point(1, 2)) print(Point(1, 2) in points) # True

frozen=True的 dataclass 会自动实现正确的__eq____hash__,同时保证字段不可变。这个方案基本能避开大多数自定义对象哈希陷阱。

如果不想用 dataclass,用NamedTuple也是可接受的:

from typing import NamedTuple class Point(NamedTuple): x: int y: int

这两种方式都是工程上更稳的选择。

4.3 使用namedtuplefrozensetdataclass(frozen=True)等内置不可变结构

有些人的代码里喜欢用 list 或 dict 作为 key,然后发现TypeError: unhashable type: 'list'。他们可能改用一个 tuple 代替,但 tuple 里的元素如果是 list,也仍然不可哈希。

正确的做法是把可变结构转成不可变结构:

  • list -> tuple
  • dict -> frozenset(dict.items()) 或重新设计映射
  • set -> frozenset

这样做不仅可以避免报错,还可以让哈希值分布更稳定,减少碰撞风险。

4.4 避免在小循环里反复创建大 set/dict

这是一个常见的工程坏味道。例如每一批数据处理时,你都新建一个包含大量映射关系的 dict,然后在这个循环里反复查询。正确的做法是把这个映射表提升到循环外部,只构建一次。

# 坏味道:每次循环都重新构建 for chunk in chunks: mapping = build_mapping(chunk) for item in chunk: if item in mapping: ... # 更合理:先构建一次映射 mapping = build_mapping(all_items) for chunk in chunks: for item in chunk: if item in mapping: ...

后者把构建成本从“每批一次”降成“全局一次”,如果批次很多,性能提升会非常明显。

4.5 尽量用生成器和流式处理,避免全量聚合

如果数据的最终目的是逐条输出或聚合,不要一开始就全量塞进一个大 dict/set。能用生成器处理就优先用生成器。生成器可以减少中间容器的大小,也降低内存和哈希表扩容压力。

比如统计日志中的 key 频次,你确实需要 dict 来计数,但你可以先做“小窗口聚合”,再定期合并结果。这比把所有记录一次性装入内存更稳妥,尤其是当数据规模达到百万级时。

5. 一套可复用的排查框架:当代码突然变慢时先查什么

如果代码已经变慢了,不建议一上来就怀疑哈希函数。先按下面的顺序排查,能从外到内快速定位问题。

5.1 第一层:看增长曲线,判断是线性还是二次

找出最耗时的循环,把输入规模缩小到 1/10,再扩大到相同数量级,记录三到五个数据点。

规模耗时
10k0.25s
20k0.98s
40k3.90s

如果增长倍数接近 4 倍,说明大概率是二次复杂度。这时再看耗时压在哪个数据结构上。

5.2 第二层:检查键的 hash 值分布

如果怀疑 set/dict 本身有问题,直接打印一部分键的 hash 值,观察有没有大量重复或聚集:

for sample in keys[:100]: print(hash(sample))

如果大量 hash 值落在相同的低位,说明冲突概率很高。如果所有结果都是同一个数字,那已经属于灾难性碰撞了。

这里要留意:Python 的strbytes等内置类型在较新版本里引入了随机化种子,不同进程之间的 hash 值会变化,这是正常现象。我们要看的不是数值本身,而是分布是否均匀。

5.3 第三层:检查 resize 和内存分配

如果 set/dict 的元素数量巨大,可以在运行时用sys.getsizeof观察容器占用:

s = set() for i in range(100000): s.add(i) print(sys.getsizeof(s))

如果内存突然跳变,说明触发了扩容。单独一次扩容不是大问题,但如果代码里反复创建新容器,内存分配和释放本身也会拖慢速度。可以使用tracemalloc查看内存增长是否集中在某处。

5.4 第四层:检查自定义对象和库的写法

如果你的键不是基本类型,优先看这几个文件里的__hash____eq__是否实现正确。很多第三方库会自定义内部对象,不一定针对哈希性能做过优化。当你把它们作为 key 使用时,问题就转移到了你的代码层。

可以用inspect查看对象是否重写了__hash__

import inspect from your_module import YourClass print(inspect.getsource(YourClass.__hash__))

如果一个类没有显式定义__hash__,它通常继承自 object,这种哈希在作为数据键时往往是次优的。

5.5 最后:考虑换用替代结构

如果以上步骤都查过,仍然无法把复杂度压下来,可以考虑换数据结构:

场景替代方案
去重数量极大bloomfilter/ Redis Set
计数统计collections.Counter/defaultdict(int)
有序去重sortedcontainers.SortedSet
大数据量聚合数据库索引 / 外部排序 / 流式聚合

但注意,替代方案也有自己的适用边界,不要为了避开一个坑而掉进另一个坑。先用基准测试验证再替换。

6. 一个边界:不是所有 O(n²) 都要优化

最后想补充一个容易被忽略的点。发现二次复杂度,不代表你立刻要改成某种高级结构。技术文章很容易让人产生“我以后要严格审查每一个 set/dict”的焦虑,但工程实践更讲究成本效益。

6.1 哪些情况其实可以放心

如果数据规模很小,比如一次最多几百条,那么即使退化到 O(n²),耗时也还是毫秒级。这种情况下,维护代码的清晰度比强行优化更重要。

  • 脚本只在本地运行,输入规模受控。
  • 数据是配置参数,数量固定。
  • 临时调试代码,不需要长期维护。
  • 加入边界保护,比如if len(items) > 10000:再切换策略。

只要数据规模的上限能被控制住,二次复杂度不一定构成问题。

6.2 优化前先量化:过早优化是万恶之源

在你决定重写数据结构之前,先用五分钟做一项测量。真实项目里,全量聚合往往不是唯一的瓶颈。如果数据从数据库读取、网络传输、磁盘 IO 已经占用了 90% 的时间,那么 CPU 内部的哈希碰撞可能根本不值得优化。

我的建议是:先把最耗时的函数摘出来,做一个最小基准测试,然后决定要不要动手。优化完再跑一次同样的基准,用数据对比替代感觉判断。

6.3 长期建议:把性能边界当成 API 契约的一部分

如果你写的模块会被别人调用,最好在注释或文档里写明:

  • 这个函数期望的输入规模大概是多少。
  • 如果数据量超过某个阈值,建议用什么方式处理。
  • 自定义 key 是否要求实现__hash__

这看起来只是小细节,但能帮未来维护者少走很多弯路。

最后说几句

Python 的 set 和 dict 是日常开发里最常用的数据结构,也是我见过的最容易被“无意识误用”的工具。它们大多数时候表现很好,但性能承诺并不是免费的,背后依赖的是均匀的哈希分布、合理的负载因子和不可变的键对象。

当一段代码越来越慢时,与其怀疑 Python 本身,不如先问三个问题:我的键是稳定的吗?哈希值分布正常吗?操作是不是嵌套在循环里?把这三个问题排查完,你会发现自己对 set/dict 的理解,已经从“会用”上升到了“理解机制”的层面。这种理解,才是避免二次时间性能问题的根本解法。

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

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

立即咨询