flowchart LR K["请求关键字"] --> H["计算散列值"] H --> R["路由到分片"] R --> S1["分片 A"] R --> S2["分片 B"] R --> S3["分片 C"]
Hashing
假设一个系统保存了 100 万个用户名,现在要判断 alice 是否存在。
最直接的方法是从头比较;如果已经排序,可以二分查找。但这两种思路本质上都还在“搜索”。
如果我们能根据 alice 本身计算出一个位置,例如:
alice → hash(alice) → 桶 38127
那么查找就可能从“不断比较”变成“先计算地址,再检查少量位置”。
能不能让关键字(key)自己告诉我们它大概应该存在哪里?
类似的问题反复出现在:
这些系统共享同一个诱人的目标:接近 \(O(1)\) 的按关键字访问。
但“计算地址”马上带来另一个问题:不同关键字可能算到同一个位置。于是本章真正的主线不是“学一个散列函数(hash function)”,而是理解 映射(mapping)、碰撞(collision)、装载因子(load factor)和碰撞解决(collision resolution)如何共同决定散列表的真实性能。
完成本章后,应能回答四个问题:
同样的数据,目标是查找 24:
\[ [10,\ 11,\ 12,\ 17,\ 24,\ 13] \]
先插入 10,再插入 17。二者都有 \(H(key)=3\),因此第二次插入必须沿着探测序列继续找空桶。查找 17 时,也必须走同一条路径。
设:
\[ H(x) = x \bmod 7 \]
这里使用20个桶,500个球
先播放前10次插入观察碰撞,再直接看 500 次插入后的涨落
桶数有限时,多个关键字落入同一桶是概率事件。
多次投球后,不会完美均匀,在均值附近涨落。
装载因子:
\[ \alpha = \frac{n}{b} \]
其中 \(n\) 是元素个数,\(b\) 是桶数。
低装载因子:
高装载因子:
\[ H(key)=key \bmod M \]
常见做法是让 \(M\) 取不超过表长的较大质数。
质数不能消灭碰撞,但能减少某些周期性输入造成的集中分布。
平方取中法先计算 \(key^2\),再取中间若干位作为地址。
\[ \begin{array}{r} \phantom{00000}101101 \\ \times\phantom{000}101101 \\ \hline \phantom{00000}101101 \\ \phantom{000}10110100 \\ \phantom{00}101101000 \\ 10110100000 \\ \hline 11111101001 \end{array} \qquad 11111101001 \Rightarrow 111\color{#1266d6}{1110}1001 \]
取中间 4 位得到 1110。竖式的意义是:平方后的中间位同时受原关键字的高位和低位影响,因此比“只取低位”更不容易继承输入模式。
它适合低位或高位分布不均的场景。核心目的不是平方本身,而是让关键字的多位信息共同影响地址。
\[ H(key)=\lfloor M \cdot ((A \cdot key) \bmod 1) \rfloor \]
其中 \(0<A<1\)。
常见选择之一是 \(A \approx 0.618\)。
它利用小数部分打散输入中的简单模式。
private static void requireTableSize(int tableSize) {
if (tableSize <= 0) {
throw new IllegalArgumentException("table size must be positive");
}
}
static int hashSum(String key, int tableSize) {
requireTableSize(tableSize);
int hash = 0;
for (int i = 0; i < key.length(); i++) {
hash += key.charAt(i);
}
return Math.floorMod(hash, tableSize);
}requireTableSize(…) 只验证表长能否作为合法散列模数;失败时立即阻止后续槽位计算。
hashSum(…) 把整数关键字映射到表槽;最终下标必须始终落在合法表长范围内。
floorMod 只负责把累计值压进合法桶范围;它不能补回前面已经丢失的信息,所以散列质量取决于累计方式本身。
hashString(…) 把字符串逐字符累积成散列值,使顺序信息参与最终槽位计算。
floorMod 只把已经充分混合的整数映射到桶下标;与简单求和相比,关键改进发生在混合阶段,而不是取模阶段。
| 维度 | 问题 | 典型风险 |
|---|---|---|
| 均匀性 | 是否把关键字分散到不同桶 | 聚集、长探测链 |
| 速度 | 每次计算是否足够快 | 常数因子过大 |
| 稳定性 | 相同关键字是否稳定映射 | 难以复现或调试 |
| 抗模式 | 能否处理有规律的输入 | 周期性冲突 |
| 安全性 | 是否容易被构造碰撞 | 拒绝服务攻击 |
开放寻址:
拉链法:
本章重点比较开放寻址和拉链法。
若 \(H(key)=d\),且位置 \(d\) 已被占用,则依次检查:
\[ d+1, d+2,\ldots,m-1,0,1,\ldots,d-1 \]
插入序列:
\[ 10,\ 11,\ 12,\ 17 \]
成功查找比较次数:
\[ 1,\ 1,\ 1,\ 4 \]
这里开始用 平均查找长度(Average Search Length,ASL) 衡量查找代价。对成功查找来说,\(ASL_{succ}\) 就是“把表中每个已存在关键字成功找到时所需的比较/探测次数”取平均。
\[ ASL_{succ}=\frac{7}{4}=1.75 \]
线性探测容易形成连续占用区。不同关键字的探测路径会合并,使之后的插入和查找越来越慢。
使用 \(H(k)=k\bmod 11\),依次插入 80, 40, 65, 24, 58, 35。
平均查找长度用来衡量平均查找代价。这里计算的是成功查找的平均查找长度:对表中每个已经存在的关键字,统计从它的初始桶开始,沿线性探测序列直到找到它一共比较/探测了多少次,再对这些次数取平均。
先列出每个关键字的初始桶:
\[ H(80)=3,\ H(40)=7,\ H(65)=10,\ H(24)=2,\ H(58)=3,\ H(35)=2 \]
线性探测后的成功查找比较次数是:
\[ 1,\ 1,\ 1,\ 1,\ 2,\ 4 \]
因此:
\[ ASL_{succ}=\frac{1+1+1+1+2+4}{6}=\frac{10}{6} \]
这个例子要观察的是:同义词并不只是“落在同一个初始桶的关键字”。线性探测会把相邻冲突链合并,形成更大的聚集区(cluster)。
考虑一个只看首字母的散列函数:
\[ hash(key)=ord(key[0])-ord('A') \]
Burke、Broad、Blum 都落在 B;Attlee、Alton 都落在 A;Ekers、Ederly 都落在 E。
这说明一个重要工程原则:
如果删除探测链中间的元素时直接设为空,后面的元素可能再也查不到。
这里 \(H(11)=11\bmod 7=4\)。查找 11 必须沿着从 4 开始的探测链继续走,不能在 24 原来的位置提前停止。
EMPTY / OCCUPIED / DELETED 不是实现细节:EMPTY 能证明探测链到此结束,DELETED 只能表示“可复用但不能停止”,两者语义必须分开。
Slot 把散列表槽中的关键字、值与占用状态绑定在一起,避免删除后语义混淆。
EMPTY,只有 OCCUPIED 状态的关键字和值才算当前有效记录,墓碑位置不会被当作真实元素。
private static <Key> List<Slot<Key>> newTable(int capacity) {
ArrayList<Slot<Key>> result = new ArrayList<>(capacity);
for (int i = 0; i < capacity; i++) {
result.add(new Slot<>());
}
return result;
}
private static int nextPrime(int value) {
int candidate = Math.max(3, value);
if (candidate % 2 == 0) {
candidate++;
}
while (!isPrime(candidate)) {
candidate += 2;
}
return candidate;
}
private static boolean isPrime(int value) {
if (value < 2) {
return false;
}
if (value % 2 == 0) {
return value == 2;
}
for (int divisor = 3; (long) divisor * divisor <= value; divisor += 2) {
if (value % divisor == 0) {
return false;
}
}
return true;
}
OpenAddressingHashTable(int capacity) {
this(capacity, Objects::hashCode);
}
OpenAddressingHashTable(int capacity, ToIntFunction<? super Key> hasher) {
if (capacity < 3) {
throw new IllegalArgumentException("capacity must be at least 3");
}
this.hasher = Objects.requireNonNull(hasher, "hasher");
table = newTable(nextPrime(capacity));
}newTable(…) 建立一个满足初始表示不变量的空结构,供后续更新操作使用。
Slot 对象;初始化整张表后,所有位置都以 EMPTY 开始,探测逻辑才能统一按三态处理。
nextPrime(…) 找到不小于给定值的下一个素数,作为开放定址表扩容后的新容量。
nextPrime 从请求容量向上找质数,并跳过无意义的偶数候选;这里的目标是给探测序列更少的周期性结构,而不是“质数保证无碰撞”。
isPrime(…) 判断候选容量是否为素数;只需检查到平方根即可排除全部非平凡因子。
isPrime 只检查到平方根,并在排除偶数后只试奇数因子;它服务于容量选择,不进入每次散列表查找的热路径。
OpenAddressingHashTable(…) 构造满足该数据结构初始不变量的对象;后续操作都以这个初始状态为前提。
OpenAddressingHashTable(…) 构造满足该数据结构初始不变量的对象;后续操作都以这个初始状态为前提。
hash(…) 把任意关键字的散列值规范到主表合法下标;插入、查找和删除都必须从同一个初始桶出发。
private int findSlot(Key key) {
int firstDeleted = -1;
int start = hash(key);
for (int probe = 0; probe < table.size(); probe++) {
int position = (start + probe) % table.size();
Slot<Key> slot = table.get(position);
if (slot.state == SlotState.EMPTY) {
return firstDeleted >= 0 ? firstDeleted : position;
}
if (slot.state == SlotState.DELETED) {
if (firstDeleted < 0) {
firstDeleted = position;
}
} else if (Objects.equals(slot.key, key)) {
return position;
}
}
return firstDeleted;
}findSlot(…) 按探测序列寻找关键字或第一个可用槽;返回结果决定查找与插入的落点。
EMPTY 表示这条探测链从未越过这里,因此关键字不可能藏在后面;若之前见过墓碑就优先复用墓碑,否则当前 EMPTY 就是插入位置。
DELETED 只能作为候选空位,不能终止查找;继续向后若遇到同一关键字的 OCCUPIED 槽,必须返回旧位置更新值,避免产生重复关键字。
boolean insert(Key key, int value) {
int position = findSlot(key);
if (position >= 0 && table.get(position).state == SlotState.OCCUPIED) {
Slot<Key> slot = table.get(position);
slot.key = key;
slot.value = value;
return true;
}
if ((size + 1.0) / table.size() > MAX_LOAD_FACTOR) {
rehash();
}
return insertWithoutResize(key, value);
}
private boolean insertWithoutResize(Key key, int value) {
int position = findSlot(key);
if (position < 0) {
return false;
}
Slot<Key> slot = table.get(position);
if (slot.state != SlotState.OCCUPIED) {
size++;
}
slot.key = key;
slot.value = value;
slot.state = SlotState.OCCUPIED;
return true;
}
private void rehash() {
List<Slot<Key>> oldTable = table;
table = newTable(nextPrime(oldTable.size() * 2));
size = 0;
for (Slot<Key> slot : oldTable) {
if (slot.state == SlotState.OCCUPIED) {
if (!insertWithoutResize(slot.key, slot.value)) {
throw new IllegalStateException("rehash failed");
}
}
}
}insert(…) 把新数据纳入当前结构;操作结束时必须恢复该结构原有的不变量。
findSlot 区分“已有关键字”与“真正新增”:命中已有记录只更新值,不能增加 size,也不需要为一次覆盖操作扩容。
insertWithoutResize(…) 把新数据纳入当前结构;操作结束时必须恢复该结构原有的不变量。
insertWithoutResize 只在当前表写入:复用 EMPTY/DELETED 时增加 size,写入后槽位统一变为 OCCUPIED。
rehash(…) 申请更大表并重新插入所有有效关键字;旧槽下标不能直接复制到新表。
size 清零;旧槽位下标已经失效,不能按原位置复制记录。
OCCUPIED 记录重新插入;每个关键字都按新容量重算初始桶,墓碑自然消失,最终 size 也由有效记录重新累计。
OptionalInt find(Key key) {
int start = hash(key);
for (int probe = 0; probe < table.size(); probe++) {
int position = (start + probe) % table.size();
Slot<Key> slot = table.get(position);
if (slot.state == SlotState.EMPTY) {
return OptionalInt.empty();
}
if (slot.state == SlotState.OCCUPIED && Objects.equals(slot.key, key)) {
return OptionalInt.of(slot.value);
}
}
return OptionalInt.empty();
}find(…) 根据当前结构的不变量定位目标;未命中时返回失败结果而不破坏结构。
EMPTY 才证明插入过程从未越过这里,因此可以立即判定不存在;DELETED 不能提供这个结论,必须继续向后探测。
OCCUPIED 槽就立即返回值;这一刻已经得到唯一目标,不需要继续检查冲突链后面的槽位。
boolean erase(Key key) {
int start = hash(key);
for (int probe = 0; probe < table.size(); probe++) {
int position = (start + probe) % table.size();
Slot<Key> slot = table.get(position);
if (slot.state == SlotState.EMPTY) {
return false;
}
if (slot.state == SlotState.OCCUPIED && Objects.equals(slot.key, key)) {
slot.key = null;
slot.state = SlotState.DELETED;
size--;
return true;
}
}
return false;
}erase(…) 从当前结构移除一个目标;操作结束时其余元素仍必须满足原来的表示不变量。
EMPTY 就能确定目标不存在;DELETED 或别的 OCCUPIED 关键字都不能提前停止,因为目标可能仍在冲突链后面。
DELETED 并减少 size;墓碑保留探测链连续性,所以后面的冲突元素仍然可达。
false 才是“目标不存在”的最终结论。
二次探测让探测距离按平方变化:
\[ H_i(k)=(H(k)+i^2)\bmod m \]
| 维度 | 线性探测 | 二次探测 |
|---|---|---|
| 探测路径 | 连续相邻 | 跳跃式 |
| 主聚集 | 明显 | 缓解 |
| 缓存局部性 | 更好 | 稍弱 |
| 约束 | 简单 | 依赖表长和装载因子 |
二次探测不是“线性探测的无条件升级”,它换来了不同的约束。
双散列使用第二个散列函数决定步长:
\[ H_i(k)=(H_1(k)+i\cdot H_2(k))\bmod m \]
常见选择:
\[ H_2(k)=R-(k\bmod R) \]
\[ H_1(k)=k\bmod 7,\quad H_2(k)=5-(k\bmod 5),\quad H_i(k)=(H_1(k)+i\cdot H_2(k))\bmod 7 \]
步长必须能够覆盖表中所有位置。
若 \(\gcd(H_2(k),m)\ne 1\),探测序列可能只访问表的一部分。
因此表长常取质数,第二个散列函数的步长也要和表长互质。下面的 7 桶例子中,\(R=5\),每个非零步长都能覆盖全表。
当装载因子过高时,散列表需要扩容,并对每个有效关键字重新计算新表地址。
rehash(…) 申请更大表并重新插入所有有效关键字;旧槽下标不能直接复制到新表。
size 清零;容量变化后原槽位下标已经没有意义,不能原地搬运。
拉链法让每个桶保存一个链表或其他容器。
static final class SeparateChainingHashTable<Key> {
private final List<LinkedList<Entry<Key>>> buckets;
private final ToIntFunction<? super Key> hasher;
private int size;
SeparateChainingHashTable(int bucketCount) {
this(bucketCount, Objects::hashCode);
}
SeparateChainingHashTable(int bucketCount, ToIntFunction<? super Key> hasher) {
requireTableSize(bucketCount);
this.hasher = Objects.requireNonNull(hasher, "hasher");
buckets = new ArrayList<>(bucketCount);
for (int i = 0; i < bucketCount; i++) {
buckets.add(new LinkedList<>());
}
}
void put(Key key, int value) {
LinkedList<Entry<Key>> bucket = bucket(key);
ListIterator<Entry<Key>> iterator = bucket.listIterator();
while (iterator.hasNext()) {
Entry<Key> entry = iterator.next();
if (Objects.equals(entry.key(), key)) {
iterator.set(new Entry<>(key, value));
return;
}
}
bucket.addLast(new Entry<>(key, value));
size++;
}
OptionalInt find(Key key) {
for (Entry<Key> entry : bucket(key)) {
if (Objects.equals(entry.key(), key)) {
return OptionalInt.of(entry.value());
}
}
return OptionalInt.empty();
}
boolean erase(Key key) {
ListIterator<Entry<Key>> iterator = bucket(key).listIterator();
while (iterator.hasNext()) {
if (Objects.equals(iterator.next().key(), key)) {
iterator.remove();
size--;
return true;
}
}
return false;
}
int size() {
return size;
}
private LinkedList<Entry<Key>> bucket(Key key) {
int index = Math.floorMod(hasher.applyAsInt(key), buckets.size());
return buckets.get(index);
}
}SeparateChainingHashTable 让同一散列槽的关键字共享一个桶,冲突只在桶内继续处理。
buckets 保存各桶的链,hasher 决定桶位置,size 只统计逻辑记录数。
Objects::hashCode 交给完整构造器;两条初始化路径最终建立同一表示。
SeparateChainingHashTable(…) 构造满足该数据结构初始不变量的对象;后续操作都以这个初始状态为前提。
put(…) 把新数据纳入当前结构;操作结束时必须恢复该结构原有的不变量。
put 只扫描目标链:同一关键字原地更新,不改变元素数;扫描结束仍未命中才追加新记录,并把 size 加一。
find(…) 根据当前结构的不变量定位目标;未命中时返回失败结果而不破坏结构。
find 的候选集合就是一条桶链;命中直接返回,否则走完这条链即可证明目标不存在,不需要再访问其他桶。
erase(…) 从当前结构移除一个目标;操作结束时其余元素仍必须满足原来的表示不变量。
erase 可以直接从链表移除节点并减少 size;删除不会截断其他关键字的查找路径,所以完全不需要墓碑。
size(…) 只报告当前逻辑规模,不应触发结构遍历或修改。
size() 返回逻辑记录数,而不是桶数或链表节点容量;只有真正新增和成功删除才改变它。
bucket(…) 只读取当前结构中由参数指定的结果,不改变结构状态。
bucket(key) 集中完成“关键字 → 桶”的映射;put、find、erase 共用同一个入口,保证碰撞记录始终落在同一条链上。
| 维度 | 开放寻址 | 拉链法 |
|---|---|---|
| 存储结构 | 单个数组 | 桶数组 + 链表或容器 |
| 缓存局部性 | 通常较好 | 节点分散时较差 |
| 删除 | 需要墓碑或重排 | 链表删除直接 |
| 高装载因子 | 性能下降明显 | 可承受 \(\alpha>1\) |
| 迭代稳定性 | 扩容会移动元素 | 扩容也会重分桶 |
| 工程风险 | 探测链变长 | 指针和内存分配开销 |
散列不仅用于单机查找,也用于分布式系统。
Redis 关键字查找、字典扩容、渐进式再散列
PostgreSQL 散列连接(hash join)、散列聚合(hash aggregate)、索引访问
Kafka 按关键字分区,把消息路由到对应分区
Python
dict / set 是语言级核心结构
flowchart LR K["请求关键字"] --> H["计算散列值"] H --> R["路由到分片"] R --> S1["分片 A"] R --> S2["分片 B"] R --> S3["分片 C"]
一致性散列(consistent hashing)、虚拟节点(virtual node)和分片迁移,都是把“关键字到位置”的思想扩展到多台机器上。
散列表的平均 \(O(1)\) 依赖输入不是恶意构造。
在公开服务中,如果攻击者能制造大量碰撞,散列表可能退化为 \(O(n)\)。
现代运行时和框架通常会考虑:
散列表快,是因为它把“查找”转化为“地址计算”。
它可靠,是因为工程实现同时处理了:
main 函数main(…) 只负责串联示例输入与数据结构操作,不属于本页要分析的核心算法。
HashTableDemo 只是把本章散列操作串成可运行示例,不承担新的散列算法。
HashTableDemo 持有一张长期存在的开放寻址表;连续操作共享同一批槽位和同一套墓碑/负载因子状态,而不是每次调用重新建表。
run(…) 只组织演示流程,把若干数据结构操作按示例顺序执行。
run 依次制造“两个有效记录 → 删除其中一个”的状态变化,用来验证删除后结构仍保持可继续插入和查找的散列表不变量。
现在回到算法层面:一张散列表不能只问“平均是不是 \(O(1)\)”,还要同时问六个问题。
| 维度 | 关键问题 | 对应内容 |
|---|---|---|
| 正确性 | 插入、查找、删除是否沿同一条可达路径 | findSlot、find、墓碑 |
| 平均性能 | 随机输入下探测长度多长 | 装载因子、平均查找长度 |
| 最坏性能 | 能否退化到 \(O(n)\) | 聚集、恶意碰撞 |
| 空间成本 | 桶、墓碑、链表节点是否浪费 | 开放寻址与拉链法 |
| 局部性 | 处理器缓存是否友好 | 数组连续性、链表指针跳转 |
| 可维护性 | 扩容、泛型、异常路径是否清楚 | rehash、模板参数 |
线性探测
二次探测
双散列
成功查找的代价来自“这个关键字插入时走了多远”。
失败查找的代价来自“从初始桶出发,要走到哪里才能确认不存在”。
开放寻址中:
因此删除逻辑不是实现细节,而是查找正确性的一部分。
一种常见经验是:当表项数超过表长的约 70% 时触发再散列。工程实现中阈值不是唯一答案:
0.5 到 0.85,因为探测长度会随 \(\alpha\) 急剧上升。rehash)是 \(O(n)\),但如果容量按比例增长,多次插入的均摊成本仍可接近 \(O(1)\)。要注意:再散列必须对每个关键字重新计算地址,不能把旧桶原样搬过去。
好的散列函数至少要考虑:
入门例子常直接计算 key mod m,但真实工程中会先把关键字混合成高质量散列值,再映射到桶下标。
拉链法把“继续找下一个桶”改成“在同一个桶内部查找”。
优点:
代价:
| 场景 | 更常见选择 | 原因 |
|---|---|---|
| 小对象、读写频繁、追求缓存局部性 | 开放寻址 | 数据连续,常数小 |
| 删除频繁、元素较大、装载因子波动大 | 拉链法 | 删除简单,扩容压力较低 |
| 面向公开输入的服务 | 带随机化或防御策略的实现 | 避免碰撞攻击 |
| 需要稳定迭代顺序 | 普通散列表不一定合适 | 扩容会改变桶位置 |
| 分布式分片 | 一致性散列或最高随机权重散列(rendezvous hashing) | 节点变化时减少迁移 |