结论先行:Python 字典查找的平均耗时是 O(1),代价是哈希计算;本文实测 1 万个 key 的取值只要 4.6ms,而同样规模的列表成员判断要跑近 55 秒------差了约 12000 倍。但字典不是无条件快:一旦 key 的哈希值大面积撞桶,性能会断崖式下跌,本文构造全碰撞场景实测慢了 2111 倍。这篇用 4 组实验把「为什么快」和「什么时候不快」一次讲透。
1. 先看一组反常识的实验结果
python
import timeit, random
N = 1_000_000
d = {i: i for i in range(N)}
lst = list(range(N))
keys = [random.randrange(N) for _ in range(10000)]
# dict 取值
t_dict = timeit.timeit(lambda: [d[k] for k in keys], number=1)
# list 成员判断(只跑 200 个,否则跑不完)
t_list = timeit.timeit(lambda: [k in lst for k in keys[:200]], number=1)
实测结果:
| 操作 | 次数 | 耗时 | 折算每次 |
|---|---|---|---|
| dict 取值 | 10000 次 | 4.6 ms | 0.46 μs |
| list 成员判断 | 200 次 | 1099.8 ms | 5.5 ms |
| list 成员判断(折算) | 10000 次 | 约 55000 ms | --- |
差了四个数量级。列表判断一次要扫半个表,100 万个元素的中位数就是 50 万次比较------55 秒这个数字听起来离谱,但它就是 O(n) 的本来面目。
字典的秘诀只有一句话:算一次哈希,直接跳到桶的位置。 中间的 49.9 万个元素,它一个都没看。
2. 桶里面是什么:开放寻址
CPython 的字典不是「桶里挂链表」(那是 Java HashMap 的做法),而是开放寻址 :一张表,每个槽存一个 (hash, key, value) 三元组。
查找过程就三步:
text
1. h = hash(key)
2. 用 h 的低位定位起始槽位
3. 从该槽位开始线性探测,比对 hash 相等且 key 相等,命中
插入同理:找到第一个空槽放进去。所以哈希相等还不够,最终必须回到 == 比较 ------这解释了为什么不可哈希的对象(如 list)不能做 key:它连第一步都过不去。
3. 扰动函数:hash 值不能直接用
看一组真实 hash 值:
python
>>> hash(1)
1
>>> hash('a')
2161380871107616833
hash(1) = 1、hash(2) = 2、hash(3) = 3......如果直接拿小整数当槽位索引,连续整数键会把前 N 个槽位占满,后面的键全部撞在同一区域,开放寻址当场退化成线性扫描。
CPython 的解法是扰动函数:把 hash 值的高位混进低位,让分布更均匀。这也是为什么整数键和字符串键的查找速度有差距(本文实测:2000 个整数键 0.13ms,2000 个字符串键 0.20ms)------字符串要先算哈希,整数直接就是哈希。
4. 一张表看懂:dict / set / list 的定位差异
| 维度 | dict | set | list |
|---|---|---|---|
| 查找复杂度 | 平均 O(1) | 平均 O(1) | O(n) |
| 靠什么定位 | 哈希 + 开放寻址 | 同 dict(只存 key) | 顺序扫描 + == |
| 10000 次查找(实测) | 4.6 ms | 3.0 ms | 约 55000 ms |
| key 要求 | 可哈希 | 可哈希 | 无要求 |
| 内存(10 万元素实测) | 8.2 MB | 约 8 MB | 0.8 MB |
set 和 dict 底层是同一套哈希表代码(set 就是「只有 key 的 dict」),所以二者速度同级。list 省内存但慢,dict 快但费内存------这是一笔明码标价的空间换时间。
5. 什么时候会翻车:全碰撞实测慢 2111 倍
字典的 O(1) 有个前提:哈希分布均匀。如果所有 key 撞进同一个桶,开放寻址就变成了「沿着槽位一个一个问」。
python
class Collide:
def __init__(self, v): self.v = v
def __hash__(self): return 42 # 全部撞同一个桶
def __eq__(self, o): return isinstance(o, Collide) and self.v == o.v
cd = {Collide(i): i for i in range(2000)}
实测 2000 个 key:
| 场景 | 2000 次取值耗时 |
|---|---|
| 正常整数键 | 0.13 ms |
| 哈希全为 42 的自定义对象 | 271.31 ms |
慢 2111 倍,O(1) 直接变成 O(n)。 这不是 bug,是哈希表的本性------它赌的就是「哈希值足够散」。Web 框架被 hashDoS 攻击打穿过一轮之后,Python 给字符串哈希加了随机盐(每次进程启动 hash('a') 都可能不同),就是为了让攻击者算不出必撞的输入。
所以自定义 __hash__ 时,让它全返回常量是最省事也最危险的写法。
6. 实用结论
- 成员判断、去重、计数,永远优先 dict/set ,不要用
in list------本文 12000 倍的差距就是日常代码里最大的性能分水岭之一。 - key 优先用字符串或整数:自带稳定的哈希,省一次计算。
- 自定义对象做 key,
__hash__必须分布均匀,全常量等于主动放弃 O(1)。 - dict 费内存是设计使然 :每个槽要存 hash、key、value 三份信息,10 万元素约 8MB,敏感场景可考虑
__slots__类或元组列表替代。
(全文完)