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

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

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

(全文完)

相关推荐
ATMQuant31 分钟前
量化指标解码21:逻辑回归支撑阻力 - 让逻辑回归给关键价位打分
python·机器学习·逻辑回归·量化交易·技术指标
q0371801 小时前
深色 IDE 截图 OCR 老是漏字,我加了一层预处理,顺手把识别结果做成了可搜索历史
python
蜗牛8751 小时前
个人机票监控开源项目:6 个平台、10 条通道的自托管方案
python
秋名山小白2 小时前
Playwright UI 自动化测试实战教程
python·ui
AI源码3 小时前
基于 Python 的汽车市场数据分析与可视化平台
python·数据分析·汽车
会飞锦鲤3 小时前
基于 SVM + HOG 的铸造缺陷检测系统
python·opencv·机器学习·计算机视觉·缺陷检测·图像分类·svm
starrysky8104 小时前
zipfile 中文乱码修复:把已知 GBK 旧 ZIP 解压到正确中文目录
python
用户91294046848424 小时前
Codex 从零到能交付(09):让 Codex 写有用的测试,而不是只测成功路径
python
2601_967987374 小时前
Debian如何配置Python调试工具
python·debian·pdb·调试工具·ipdb
MoRay4 小时前
放弃云端 Computer Use,我用 Python 手搓了一个带“物理防呆”的本地 GUI Agent 底座
python·github