你天天用的 Python dict,90% 的人没搞懂这三个坑
上周同事的代码在线上崩了,原因是
d['user_id']炸了一个KeyError。我问他为什么不写d.get('user_id'),他说:"我以为这个 key 肯定在。" 😶dict 人人会用,但真正理解它底层怎么工作的人没那么多。这篇文章不谈花活,就把最关键的几个底层原理讲清楚。
一、为什么 dict 查找这么快?
1.1 不用 dict 的话,怎么查?
假设要根据名字查成绩,最朴素的做法是用两个列表,靠下标一一对应:
python
names = ['Michael', 'Bob', 'Tracy']
scores = [95, 75, 85]
# 查 Bob 的成绩:先遍历 names 找到索引 1,再 scores[1] 取 75
每次查找都要从头到尾遍历列表。计算机科学里管这叫 O(n)------3 个人最坏找 3 次,10 万个人最坏找 10 万次,数据越多越慢。
1.2 Dict 怎么就不一样?
python
d = {'Michael': 95, 'Bob': 75, 'Tracy': 85}
d['Bob'] # 75,一步到位,不管 d 里有多少条数据
Dict 不靠"遍历",靠**"算"**。就像查字典------你不是从第一页翻到最后一页找"哈希"这个词,而是翻到目录,按拼音 H 一查,直接跳到对应页码。dict 的名字(dictionary)就是这么来的。不管字典有 100 页还是 10000 页,查目录都是一次操作------这就是 O(1),和数据量无关。
1.3 底层原理------哈希表
Dict 的底层是哈希表(Hash Table),核心逻辑是"不靠找,靠算"。
存入数据时:
vbnet
key(如 'Bob')→ 哈希函数 → 哈希值(如 284719)→ 内存存储位置 → 放入 value
取数据时,对同样的 key 再算一次哈希,得到同样的位置,直接取走 value。
打个比方:快递柜。哈希函数就像快递柜系统------用你的手机号算出一个柜子编号(比如 38 号)。你取件时不需要把小区所有柜子都打开找一遍,你知道就是 38 号,走过去开门就行。不管是 50 个柜子还是 5000 个柜子,你取一次都是"走到 38 号"这一个动作。
1.4 空间换时间
Dict 有代价------吃内存。哈希表需要预留很多空位来减少"冲突"(两个 key 算到同一个位置,这个话题下次展开)。
| Dict(哈希表) | List(顺序存储) | |
|---|---|---|
| 查找 | O(1),直接算位置 | O(n),逐个找 |
| 内存 | 大,预留空位 | 小,紧凑排列 |
| 顺序 | 无(按哈希值分布) | 有(按插入顺序) |
一句话:用更多内存换来了瞬间查找。需要高频查数据用 dict,只做遍历用 list。
二、为什么 list 不能当 key?
你一定见过这个报错:
python
d = {}
d[[1, 2, 3]] = 'a list'
# TypeError: unhashable type: 'list'
2.1 问题出在"哈希函数要求输入不变"
哈希函数有个铁律:同一个输入,必须永远产出同一个输出。 否则存的时候算出一个位置,取的时候算出另一个位置,数据就丢了。
那什么输入能保证永远不变?
'Bob'------字符串对象创建后就固定了,你没法改它42------整数永远是 42[1, 2, 3]------但你可以 append、pop、改某个下标的值!
想象你允许列表当 key:
lua
1. d[[1,2,3]] = 'hello' → 对 [1,2,3] 算哈希,存入位置 A
2. key.append(4) → key 变成了 [1,2,3,4]
3. d[[1,2,3,4]] → 对 [1,2,3,4] 算哈希,得到位置 B
4. 数据在 A,你在 B 找 → KeyError,而且整个哈希表结构被打乱
这就是为什么 Python 规定:key 必须是可哈希的,也就是不可变类型。
2.2 两派阵营
| 可当 key(不可变) | 不能当 key(可变) |
|---|---|
str、int、float、bool |
list(可增删改) |
tuple(元素也都不可变才行) |
dict、set |
tuple 有个坑:(1, 2, 3) 可哈希,(1, [2, 3]) 不行------因为里面藏了一个 list。
三、Set 就是没有 value 的 dict
Set 和 dict 底层是同一套哈希表实现,唯一区别:dict 存 key + value,set 只存 key。
因为哈希表的 key 不能重复,所以 set 天生去重:
python
s = set([1, 2, 3, 2, 5])
print(s) # {1, 2, 3, 5} ------ 重复的 2 自动消失
基本操作用 add() 和 remove(),不展开了。
3.1 集合运算------set 真正好用的地方
需求:两个列表找共有元素,再去重合并。
手写循环版:
python
a = [1, 2, 3, 2]
b = [2, 3, 4]
# 找共有元素
common = []
for x in a:
if x in b and x not in common:
common.append(x)
print(common) # [2, 3]
# 去重合并
merged = []
for x in a + b:
if x not in merged:
merged.append(x)
print(merged) # [1, 2, 3, 4]
十几行,两层判断,费眼 😮💨。
Set 版:
python
a = [1, 2, 3, 2]
b = [2, 3, 4]
sa = set(a) # {1, 2, 3}
sb = set(b) # {2, 3, 4}
sa & sb # 交集 → {2, 3}
sa | sb # 并集 → {1, 2, 3, 4}
& 就是"两个集合都有的",| 就是"合起来去重"------中学数学的集合运算,Python 里两个符号搞定。
四、可变 vs 不可变------sort() 和 replace() 的故事
4.1 猜猜输出
python
# 代码 A
a = ['c', 'b', 'a']
print(a.sort()) # ?
print(a) # ?
# 代码 B
s = 'abc'
print(s.replace('a', 'A')) # ?
print(s) # ?
4.2 答案
python
# A
print(a.sort()) # None ← 为什么不是排序后的列表?
print(a) # ['a', 'b', 'c'] ← 原列表确实变了
# B
print(s.replace('a', 'A')) # 'Abc'
print(s) # 'abc' ← 没变!
4.3 关键区分:变量名 vs 对象
这里有一个很容易混淆的点:变量名只是贴在对象上的标签,不是对象本身。
a.sort() 是可变对象的行为 ------直接改了列表对象自身的内容(把元素重排了),返回 None 是在说"别找我要新对象,我原地改完了"。打个比方:你在白板上擦掉 c b a 重写成 a b c------白板还是那块白板。
str.replace() 是不可变对象的行为 ------字符串对象一旦创建就不能改,所以 replace 只能创建了一个新字符串 'Abc' 返回给你。原来的 'abc' 纹丝不动。就像你有一张写着 abc 的纸,replace 不是拿涂改液去改------它拿了一张新纸,上面写着 Abc,递给你。
4.4 这张表收好
| 不可变 (Immutable) | 可变 (Mutable) |
|---|---|
str、int、float、bool |
list、dict、set |
tuple(元素也都不可变时) |
这张表和前面"什么能当 dict key"是完全对应的------不可变的才能哈希,才能当 key。一环扣一环。
总结
四个核心原理:
- Dict 快是因为哈希表------不遍历,直接"算位置"。代价是吃内存(空间换时间)
- Key 必须是不可变的------哈希函数要求输入永远不变,list 可变所以不配
- Set 就是去掉了 value 的 dict ------同一套哈希表,交集
&、并集|是核心用法 - 可变对象原地改,不可变对象返回新的 ------
list.sort()返回 None 不是 bug,是设计
最后留一个问题:dict 有 1000 万个 key,查询还是 O(1) 吗? 理论上是------但内存不够开始 swap 到硬盘时,情况就变了。哈希冲突与动态扩容,下次聊 😏。