摘要
哈希表通过"键到值"的映射,平均情况下可以用接近 O(1) 的时间完成查找、插入和删除。Python 中的 dict 和 set 都建立在哈希结构思想之上,是业务开发和算法题中最常用的数据结构。
本文介绍哈希函数、冲突处理、负载因子、字典与集合的使用方式,并通过词频统计、两数之和、缓存和分组示例,理解哈希表如何把重复扫描优化为快速查找。
一、背景与问题
假设需要判断用户 ID 是否在权限列表中:
python
allowed_users = ["u001", "u002", "u003"]
user_id = "u003"
print(user_id in allowed_users)
列表查找需要从头到尾逐个比较,最坏情况下复杂度为 O(n)。当查询次数很多时,可以先把数据组织成集合:
python
allowed_users = {"u001", "u002", "u003"}
print("u003" in allowed_users)
集合平均情况下可以更快判断成员是否存在。
哈希表适合解决:
- 根据 ID 查找对象。
- 判断元素是否出现过。
- 统计频率。
- 缓存计算结果。
- 分组和建立索引。
- 去重。
二、核心概念
1. 键值映射
哈希表保存键和值:
text
key value
u001 → Alice
u002 → Bob
u003 → Carol
键必须能够计算哈希值,并且在作为键期间保持稳定。Python 中字符串、整数、元组等通常可以作为字典键,列表和字典本身不能直接作为键。
2. 哈希函数
哈希函数把键转换成一个整数,再根据表容量映射到存储位置:
text
hash(key)
→ 压缩到数组下标
→ 定位候选位置
→ 比较键是否相等
哈希值相同不代表两个键相等,因为不同键可能发生冲突。
3. 哈希冲突
两个键映射到相同位置时就发生冲突。常见处理方式:
- 链地址法:同一位置保存多个元素。
- 开放寻址法:寻找其他空闲位置。
具体实现由语言运行时负责,使用者主要需要理解冲突会影响实际性能。
4. 负载因子
负载因子表示表中元素数量与容量的比例。元素过多会增加冲突,哈希表通常在达到阈值时扩容并重新分布元素。
扩容需要重新计算位置,因此单次操作可能成本较高,但整体操作通常保持较好的均摊性能。
5. 字典与集合
| 结构 | 保存内容 | 常见用途 |
|---|---|---|
dict |
键和值 | 映射、索引、缓存 |
set |
只有键 | 去重、成员判断、集合运算 |
如果只关心是否存在,不需要额外的值,就使用集合。
三、工作原理
1. 平均复杂度
| 操作 | 平均复杂度 | 最坏情况 |
|---|---|---|
| 查找 | O(1) |
O(n) |
| 插入 | O(1) |
O(n) |
| 删除 | O(1) |
O(n) |
最坏情况通常与大量冲突、扩容或不理想的键分布有关。工程中应选择稳定的键,并避免把可变对象作为键。
2. 为什么哈希表能减少重复扫描?
如果有一组记录需要反复按 ID 查询,可以先建立索引:
text
原始列表
→ 遍历一次
→ 建立 id_to_record
→ 后续通过 ID 直接定位
建立索引需要额外内存,但可以把大量查询从重复的 O(n) 扫描变成平均 O(1) 查找。
3. 键的相等性与哈希值
哈希表要求相等的键具有相同的哈希值。对象作为键时,必须保证哈希结果和相等判断在生命周期内保持一致。
不要使用会改变参与哈希计算字段的可变对象作为键,否则对象可能再也无法被正确找到。
四、实战示例
1. 建立 ID 索引
python
users = [
{"id": "u001", "name": "Alice"},
{"id": "u002", "name": "Bob"},
{"id": "u003", "name": "Carol"},
]
user_by_id = {user["id"]: user for user in users}
print(user_by_id["u002"])
如果输入数据的 ID 不唯一,字典推导会覆盖前一个值。因此建立索引前应先检查唯一性。
2. 统计词频
python
from collections import Counter
words = ["python", "data", "python", "algorithm", "data", "python"]
counts = Counter(words)
print(counts)
print(counts["python"])
print(counts.most_common(2))
Counter 适合频率统计,比手写普通字典更直接。
3. 手写词频统计
python
def count_words(words: list[str]) -> dict[str, int]:
counts: dict[str, int] = {}
for word in words:
counts[word] = counts.get(word, 0) + 1
return counts
dict.get 可以在键不存在时提供默认值。
4. 两数之和
python
def two_sum(values: list[int], target: int) -> tuple[int, int] | None:
seen: dict[int, int] = {}
for index, value in enumerate(values):
complement = target - value
if complement in seen:
return seen[complement], index
seen[value] = index
return None
print(two_sum([2, 7, 11, 15], 9))
暴力方法需要两层循环,复杂度为 O(n²);使用字典记录已经见过的值后,可以把复杂度降为平均 O(n)。
5. 去重并保留顺序
python
def unique_in_order(values: list[str]) -> list[str]:
seen: set[str] = set()
result: list[str] = []
for value in values:
if value not in seen:
seen.add(value)
result.append(value)
return result
print(unique_in_order(["a", "b", "a", "c", "b"]))
集合负责快速判断是否出现过,列表负责保存第一次出现的顺序。
6. 分组
python
from collections import defaultdict
orders = [
{"region": "华东", "amount": 100},
{"region": "华南", "amount": 200},
{"region": "华东", "amount": 300},
]
by_region: defaultdict[str, list[dict]] = defaultdict(list)
for order in orders:
by_region[order["region"]].append(order)
print(dict(by_region))
分组就是把同一个键对应的记录聚合到一起,是哈希表的典型应用。
7. 简单缓存
python
def fibonacci(n: int, cache: dict[int, int] | None = None) -> int:
if cache is None:
cache = {}
if n in cache:
return cache[n]
if n < 2:
return n
cache[n] = fibonacci(n - 1, cache) + fibonacci(n - 2, cache)
return cache[n]
缓存把已经计算的结果保存起来,避免重复递归计算。缓存也会占用内存,需要设置容量或过期策略。
8. 集合运算
python
backend_users = {"u001", "u002", "u003"}
admin_users = {"u002", "u004"}
print(backend_users & admin_users)
print(backend_users | admin_users)
print(backend_users - admin_users)
集合交集、并集和差集可以直接表达权限集合、标签集合和数据对比。
五、常见问题与实践建议
1. 字典查找一定是 O(1) 吗?
不是。O(1) 是平均复杂度,实际性能还取决于哈希分布、扩容和键比较。工程中应使用稳定、可哈希且分布合理的键。
2. 为什么列表不能作为字典键?
列表是可变对象,内容变化后哈希值无法稳定维护,因此不能作为字典键。可以使用元组表示固定组合键:
python
coordinates = {(10, 20): "point"}
3. 字典是否保证插入顺序?
现代 Python 版本的字典保留插入顺序,但不能把顺序语义和排序语义混为一谈。需要按值排序时仍然要显式排序。
4. 使用集合去重会不会丢失顺序?
集合本身不应用来表达业务顺序。需要保留原顺序时,使用"集合判断 + 列表保存"的组合方式。
5. 哈希表能解决所有查找问题吗?
哈希表适合精确匹配,不适合范围查询、前缀查询和有序遍历。范围查询可以考虑排序数组、树结构或数据库索引。
六、进阶思考
1. 哈希表与数据库索引
数据库中的哈希索引和 B+ 树索引适用场景不同:
| 结构 | 擅长场景 |
|---|---|
| 哈希索引 | 等值查询 |
| B+ 树索引 | 等值、范围和排序 |
| 倒排索引 | 文本关键词查询 |
数据结构选择取决于查询模式,而不是单纯追求平均 O(1)。
2. 缓存淘汰
实际缓存不能无限增长,需要结合:
- 最大容量。
- 过期时间。
- LRU 或 LFU 淘汰。
- 命中率统计。
- 缓存穿透和击穿保护。
缓存的本质是用空间换时间,同时引入数据一致性问题。
3. 碰撞攻击与安全
对外部输入直接构造大量键时,需要关注哈希碰撞导致的 CPU 消耗。成熟语言运行时通常有一定防护,但 API 仍应限制请求体大小、键数量和嵌套深度。
4. 业务索引的一致性
建立 id_to_record 这类内存索引后,源数据变化时要同步更新索引。否则查找速度虽然很快,结果却可能过期或错误。
结论
哈希表通过键值映射把重复扫描转化为快速查找,是字典、集合、缓存、分组、去重和频率统计的基础。平均情况下,查找、插入和删除都接近 O(1)。
使用哈希表时,要注意键的稳定性、数据唯一性、内存占用、顺序语义和查询类型。下一步可以继续学习排序、二叉树和优先队列等有序数据结构。
参考资料
- Python 字典数据结构:https://docs.python.org/3/tutorial/datastructures.html#dictionaries
- Python 集合类型:https://docs.python.org/3/library/stdtypes.html#set-types-set-frozenset
- Python
collections文档:https://docs.python.org/3/library/collections.html