很多人背过一句话:"Python 的字典查询是 O(1) 的。"但真到了面试或者调性能的时候,这句话经不起两问:凭什么是常数?常数就不是要算哈希吗?哈希不要时间?今天我们就把 CPython 3.13 的 dict 拆开,用实测把"O(1)"钉死。
一、先上实测:规模涨 1000 倍,查询时间纹丝不动
我在 CPython 3.13.14 上跑了一组对比:dict.get 和 list.index 找中间那个元素,规模从 100 一路拉到 10 万。
ini
n= 100 dict.get=0.0001ms list.index=0.0007ms speedup=6x
n= 1000 dict.get=0.0001ms list.index=0.0061ms speedup=47x
n= 10000 dict.get=0.0001ms list.index=0.0607ms speedup=474x
n= 100000 dict.get=0.0001ms list.index=0.6090ms speedup=4743x
重点看 dict.get 那一列:从 100 到 10 万,它一直停在 0.0001ms(约 0.1 微秒) ,根本不随数据量变化。而 list.index 是线性上涨的------这就是 O(1) 和 O(n) 最直白的脸对脸。当数据到 10 万条,列表查找比字典慢了 4700 多倍。
二、那哈希计算不耗时吗?
耗,但它是"一次"的,不随表里有多少元素变。CPython 的 dict 底层是一个开放寻址(open addressing)的哈希表,找值分两步:
- 对 key 调用
hash(),得到一个整数; - 用这个整数算出在表里的下标,直接去那个槽位取。
关键在第 2 步。CPython 不是直接拿哈希值当下标,而是经过一次 扰动(perturbation) 处理,再和当前表大小做位与。所以无论表里是 100 个还是 10 万个元素,你都只做一次哈希 + 一次定位,和总数无关。这也是为什么 dict.get 的时间不随 n 变------它压根没"遍历"这一说。
三、哈希冲突怎么办?------扰动函数在这里救场
光算下标还不够。两个不同的 key 完全可能算出同一个槽位,这就是哈希冲突。CPython 用的就是开头说的"扰动函数":把哈希值的高位也卷进来参与寻址,冲突时按一个固定序列往后探测下一个空槽(开放寻址)。
实测上,整数 key 和字符串 key 的查询代价几乎一样:
ini
int key get = 0.1us
str key get = 0.1us
说明在常规数据量下,冲突带来的额外探测成本被压得极低,体感上就是"常数时间"。
四、创建字典不是 O(1),别混了
O(1) 说的是查询。创建是另一回事,它得一个个算哈希、插槽位,所以是 O(n):
ini
n= 100 create=0.006ms
n= 1000 create=0.062ms
n= 10000 create=0.725ms
所以"字典查询 O(1)"和"字典构建 O(n)"要分开记。一个常见误用是:为了查一次,现造一个大字典------那构建的 O(n) 早把查询的 O(1) 优势吃光了。真要省,用 set 做成员判断,或预先建好复用。
五、什么时候 O(1) 会"翻车"
两种典型情况会退化:
- key 的
__hash__写得差:比如所有对象返回同一个常数,哈希表退化成链表,查询变 O(n)。 - 装载率过高触发扩容:CPython 在装载率到约 2/3 时会整体 rehash 扩容(新表大小取大于当前 2 倍的 2 的幂)。单次插入偶尔会"卡"一下,但均摊下来插入仍是 O(1)。
小结
dict.get 在 CPython 3.13 下,10 万规模仍稳在 0.1 微秒,和 100 条时一模一样------这就是 O(1) 的实证。它靠的是"一次哈希 + 一次定位"的开放寻址,扰动函数把冲突成本压平;查询快,但创建是 O(n),别把两者混为一谈。下次有人问"为什么用字典不用列表存查找",把这组 4700 倍的数据甩过去就够了。