Hash 表:拉链法与开放地址法详解
拉链法
你说的“变种链”,如果是在 Hash 表(哈希表)场景里,通常指的是 拉链法(Separate Chaining)的一些改进形式,用于解决哈希冲突。
先从普通拉链法开始理解。
1. 什么是拉链法
假设 Hash 函数:
hash(key) % 8
有数据:
1
9
17
25
计算后:
1 % 8 = 1
9 % 8 = 1
17 % 8 = 1
25 % 8 = 1
全部落到桶 1。
于是:
bucket[1]
|
v
1 -> 9 -> 17 -> 25
这就是最经典的:
数组 + 链表
结构。
2. 普通链表的问题
如果冲突很多:
bucket[1]
1 -> 9 -> 17 -> 25 -> 33 -> 41 -> ...
查找:
O(n)
性能下降严重。
3. 什么是变种链(Variant Chain)
为了优化链表性能,人们对链表做各种改造。
例如:
链表
↓
双向链表
↓
有序链表
↓
跳表
↓
红黑树
这些都可以理解为:
拉链法的变种
4. Java HashMap 的典型例子
JDK 8 以后:
冲突少
↓
链表
冲突多
↓
红黑树
结构:
bucket
|
v
Node
|
v
Node
|
v
Node
当长度超过阈值:
8
会变成:
17
/ \
9 25
/ \
1 33
即:
链表 → 红黑树
查找复杂度:
O(n)
↓
O(log n)
这就是一种典型的“变种链”。
5. Go Map 用的不是变种链
很多人以为 Go map 也用链表。
实际上不是。
Go map 采用的是:
Hash Table
+
Bucket
+
Overflow Bucket
结构类似:
bucket0
+---+---+---+---+---+---+---+---+
|kv |kv |kv |kv |kv |kv |kv |kv |
+---+---+---+---+---+---+---+---+
一个桶能放:
8 个元素
满了以后:
bucket0
|
v
overflow bucket
形成:
bucket
|
v
overflow1
|
v
overflow2
虽然看起来像链表:
bucket -> overflow -> overflow
但本质是:
桶链(Bucket Chain)
而不是:
Node -> Node -> Node
6. 数据库里的“变种链”
如果你是在看数据库源码(MySQL、Redis、LevelDB 等)时看到“变种链”,通常指:
普通链
A -> B -> C
头插链
C -> B -> A
新增更快。
双向链
A <-> B <-> C
删除更快。
有序链
1 -> 5 -> 8 -> 20
查找更高效。
树化链
链表
↓
红黑树
Java HashMap 就属于这种。
里的标准回答
如果官问:
Hash 表中的变种链是什么?
可以回答:
Hash 冲突常见解决方案是拉链法,即每个桶挂一个链表。
为了避免链表过长导致查找退化为 O(n),实际工程中会对链结构进行优化,
例如使用双向链表、有序链表、跳表、红黑树等结构代替普通链表,
这些改进形式通常称为拉链法的变种链(Variant Chaining)。
典型例子是 Java 8 HashMap:
当桶中元素较少时使用链表,
当链表长度超过 8 时转换为红黑树,
将查找复杂度从 O(n) 降低到 O(log n)。
如果你是在看 Go map 源码 时看到“变种链”这个词,我还可以结合 Go 的 bucket + overflow bucket 结构解释为什么有些资料把它称为“变种拉链法”。
开放地址法
开放地址法(Open Addressing)其实并不少见,但它在不同语言和场景中的使用情况差异很大。
很多人会有一种错觉:
Java HashMap -> 拉链法
Go Map -> Bucket + Overflow Bucket
Redis Dict -> 拉链法
似乎大家都不用开放地址法
实际上:
Python dict ✓ 开放地址法
Python set ✓ 开放地址法
Rust HashMap ✓ 开放地址法(SwissTable)
Abseil flat_hash_map ✓ 开放地址法
Folly F14 ✓ 开放地址法
Go map ✗ 桶链式方案
Java HashMap ✗ 拉链法+红黑树
现代高性能 Hash 表反而越来越倾向开放地址法。
1. 什么是开放地址法
假设:
容量 = 8
hash(key)=3
位置 3 已经被占了:
0 1 2 3 4 5 6 7
A
插入 B:
0 1 2 3 4 5 6 7
A B
继续冲突:
0 1 2 3 4 5 6 7
A B C D
元素直接存放在数组里。
没有链表。
没有额外节点。
2. 为什么曾经很多系统不用它
因为传统开放地址法有几个明显缺陷。
缺陷1:删除麻烦
拉链法删除:
A -> B -> C
删 B:
A -> C
结束。
开放地址法:
槽位:
3:A
4:B
5:C
如果直接删 B:
3:A
4:空
5:C
查找 C 时:
hash(C)=3
3 -> A
4 -> 空
会误认为:
C不存在
因此需要:
墓碑(Tombstone)
标记删除。
结果:
查找越来越慢
缺陷2:聚集问题(Clustering)
例如线性探测:
3:A
4:B
5:C
6:D
7:E
形成:
AAAAA
大块连续区域。
以后元素越来越容易撞进去。
性能下降。
称为:
Primary Clustering
缺陷3:装载因子不能太高
拉链法:
load factor = 2
还能工作。
只是链长一点。
开放地址法:
70%
80%
90%
性能急剧下降。
例如:
容量=100
已使用=95
插入可能探测几十次。
3. 为什么现代系统又开始大量采用
因为 CPU 变了。
过去:
CPU慢
内存慢
差距不大
今天:
CPU: 几 GHz
内存: 几十~上百 ns
一次缓存未命中(Cache Miss)代价非常高。
拉链法的问题
链表:
bucket
|
v
node1
|
v
node2
|
v
node3
节点可能散落在内存:
0x1000
0x9000
0x50000
每次都要:
跳内存
跳内存
跳内存
CPU Cache 很难命中。
开放地址法的优势
元素连续存储:
[A][B][C][D][E]
CPU 一次 Cache Line:
64 Bytes
可能直接加载:
[A][B][C][D]
连续访问非常快。
4. 为什么 Python dict 特别快
原因之一就是:
开放地址法
+
连续内存
+
优秀探测算法
查找时:
数组扫描
而不是:
指针跳来跳去
5. Go 为什么不用开放地址法
Go 设计者做了折中。
Go Map 的 Bucket:
bucket
8个key
8个value
结构:
bucket
k1 k2 k3 k4 k5 k6 k7 k8
v1 v2 v3 v4 v5 v6 v7 v8
查找时:
一次读取整个 bucket
CPU Cache 友好。
但又不像开放地址法那样:
删除困难
探测链过长
所以 Go 采用:
Bucket +
Overflow Bucket
作为折中方案。
6. 现代高性能 Hash 表趋势
近几年趋势其实是:
传统拉链法
↓
桶化(Bucketization)
↓
开放地址法
↓
SIMD开放地址法
代表:
-
Python dict
-
Rust HashMap(hashbrown)
-
SwissTable
-
Abseil flat_hash_map
-
Folly F14
这些都是现代工业级实现。
一句话总结
开放地址法并不是很少使用,相反在现代高性能 Hash 表中非常流行。
它过去不够流行的原因是:
1. 删除复杂(需要墓碑)
2. 容易产生聚集
3. 负载率高时性能下降明显
但随着 CPU Cache 成为性能瓶颈,开放地址法连续内存布局的优势越来越明显,因此 Python、Rust、SwissTable 等现代实现都选择了开放地址法,而 Java 和 Go 则采用了更偏向拉链法的桶式设计来做折中。