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__ 类或元组列表替代。

(全文完)

相关推荐
外收内放1 小时前
Python基础语法练习题(72)
开发语言·python
171320330计算机毕设编程1 小时前
计算机毕业设计 基于Hadoop的新能源汽车发展前景分析 LW PPT
java·大数据·hadoop·python·算法·随机森林·线性回归
haluhalu.2 小时前
深入理解 AI Agent(1.2):Harness 工程——模型之外的竞争力
前端·数据结构·人工智能·python·langchain·个人开发
东莞市云毅网络有限公司2 小时前
把网页表格与列表还原成结构化条目:抽取与对账实现
python·文本处理·数据抽取
夜瞬2 小时前
从零微调 Qwen3-8B:基于 LoRA 与 LLaMA-Factory 打造专属 AI 编程助教
人工智能·python·llama
障碍的枫子3 小时前
我的第一个Agent
开发语言·python·langchain
打工仔折腾 AI3 小时前
260+异构同步链路实战:如何用语义化自动翻译破解CDC DDL同步痛点
人工智能·后端·python·自然语言处理·性能优化·langchain·机器翻译
用户7624117567053 小时前
第十三章 网络编程
python
曲径海盗3 小时前
【Python 学习第 33 天】条件判断 if/else
python·学习笔记·编程入门