Python 字典为什么这么快?一次查找背后的哈希表、扰动函数和开放寻址

31 阅读4分钟

结论先行:Python 字典查找的平均耗时是 O(1),代价是哈希计算;本文实测 1 万个 key 的取值只要 4.6ms,而同样规模的列表成员判断要跑近 55 秒——差了约 12000 倍。但字典不是无条件快:一旦 key 的哈希值大面积撞桶,性能会断崖式下跌,本文构造全碰撞场景实测慢了 2111 倍。这篇用 4 组实验把「为什么快」和「什么时候不快」一次讲透。


1. 先看一组反常识的实验结果

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 ms0.46 μs
list 成员判断200 次1099.8 ms5.5 ms
list 成员判断(折算)10000 次约 55000 ms—

差了四个数量级。列表判断一次要扫半个表,100 万个元素的中位数就是 50 万次比较——55 秒这个数字听起来离谱,但它就是 O(n) 的本来面目。

字典的秘诀只有一句话:算一次哈希,直接跳到桶的位置。 中间的 49.9 万个元素,它一个都没看。

2. 桶里面是什么:开放寻址

CPython 的字典不是「桶里挂链表」(那是 Java HashMap 的做法),而是开放寻址:一张表,每个槽存一个 (hash, key, value) 三元组。

查找过程就三步:

1. h = hash(key)
2. 用 h 的低位定位起始槽位
3. 从该槽位开始线性探测,比对 hash 相等且 key 相等,命中

插入同理:找到第一个空槽放进去。所以哈希相等还不够,最终必须回到 == 比较——这解释了为什么不可哈希的对象(如 list)不能做 key:它连第一步都过不去。

3. 扰动函数:hash 值不能直接用

看一组真实 hash 值:

>>> 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 的定位差异

维度dictsetlist
查找复杂度平均 O(1)平均 O(1)O(n)
靠什么定位哈希 + 开放寻址同 dict(只存 key)顺序扫描 + ==
10000 次查找(实测)4.6 ms3.0 ms约 55000 ms
key 要求可哈希可哈希无要求
内存(10 万元素实测)8.2 MB约 8 MB0.8 MB

set 和 dict 底层是同一套哈希表代码(set 就是「只有 key 的 dict」),所以二者速度同级。list 省内存但慢,dict 快但费内存——这是一笔明码标价的空间换时间。

5. 什么时候会翻车:全碰撞实测慢 2111 倍

字典的 O(1) 有个前提:哈希分布均匀。如果所有 key 撞进同一个桶,开放寻址就变成了「沿着槽位一个一个问」。

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. 实用结论

  1. 成员判断、去重、计数,永远优先 dict/set,不要用 in list——本文 12000 倍的差距就是日常代码里最大的性能分水岭之一。
  2. key 优先用字符串或整数:自带稳定的哈希,省一次计算。
  3. 自定义对象做 key,__hash__ 必须分布均匀,全常量等于主动放弃 O(1)。
  4. dict 费内存是设计使然:每个槽要存 hash、key、value 三份信息,10 万元素约 8MB,敏感场景可考虑 __slots__ 类或元组列表替代。

(全文完)