← 返回文章列表

哈希算法

一、什么是哈希算法

哈希算法(Hash Function) 是一种把任意长度的输入(键)映射为固定长度输出(哈希值)的函数。在数据结构中,它主要用于哈希表(Hash Table):先计算键的哈希值,再通过取模、掩码等方式把哈希值映射到数组下标,从而让查找、插入和删除的平均时间复杂度接近 O(1)。

一个直观过程是:

key ──哈希函数──> hash(key) ──映射──> 数组下标 index

例如:

hash("apple") = 3287
index = 3287 % 16 = 7

于是 "apple" 对应的数据就放在哈希表下标为 7 的桶中。

🟡以下是关于上面提到的概念的一些解释。


1. key 是什么

key 是哈希表用来定位数据的“键”,也就是查找时使用的标识。

例如:

key = "apple"

在哈希表里,真正存储的通常不是单独的 "apple",而是一个“键值对”:

("apple", 价格=5)
("apple", 库存=100)

其中 "apple" 是 key,后面的内容是 value。

哈希表的目标是:给定一个 key,快速找到它对应的 value。


2. 哈希函数是什么

哈希函数是一个确定性的函数:

hash(key) -> 一个整数

例如假设:

hash("apple") = 3287

这里的 3287 叫哈希值或哈希码。

  • 同一个 key,每次算出来的哈希值必须一样;
  • 如果不同 key 算出来的哈希值一样,就叫做哈希冲突;
  • 哈希值通常是一个比较大的整数,不能直接拿来当数组下标。

例子里的 hash("apple") = 3287 只是示意,真实哈希函数算出来不一定是 3287。


3. hash(key) 和“数组下标”不是一回事

哈希表底层通常有一个数组,比如长度为 16:

下标:   0   1   2   3   4   5   6   7   8   9  10  11  12  13  14  15
桶:    []  []  []  []  []  []  []  []  []  []  []  []  []  []  []  []

数组下标只能是 0 到 15。

但哈希值 3287 远远超出这个范围,不能直接写:

array[3287] = ...

因为数组没有下标 3287。

所以需要把大整数哈希值“压缩”到数组下标范围内。


4. index = 3287 % 16 = 7 是什么意思

% 是取模运算,表示求余数。

3287 ÷ 16 = 205 余 7

所以:

3287 % 16 = 7

这样就把哈希值 3287 映射成了数组下标 7。

这里的 16 是哈希表当前数组容量,也就是桶数组长度。

取模的目的只有一个:

把任意大的哈希值压缩到 [0, capacity - 1] 范围内

容量是 16 时,下标范围就是:

0, 1, 2, ..., 15

所以:

index = hash(key) % capacity

例子中:

index = 3287 % 16 = 7

表示 "apple" 这个 key 应该放到下标为 7 的桶里。


5. “桶”是什么

“桶”可以理解为数组中的每一个槽位。

下标 0:桶 0
下标 1:桶 1
...
下标 7:桶 7

每个桶里放的不是单纯的 value,而通常放的是“键值对条目”,例如:

桶 7:
  ("apple", 价格=5)

那为什么桶里还要存 key?

因为不同 key 可能映射到同一个下标。查找时不能只看下标,还要比较 key 是否真的相同。

例如:

hash("apple")  % 16 = 7
hash("banana") % 16 = 7

两个 key 都落到下标 7,这叫哈希冲突。此时桶 7 可能长这样:

桶 7:
  ("apple",  价格=5)
  ("banana", 价格=3)

所以“桶”不一定是只能放一个元素,它可能是一个链表、红黑树,或者开放寻址法中的一个槽位。


6. “把数据放到桶中”完整是什么意思

完整过程不是:

"apple" -> 桶 7

而是:

1. 取 key = "apple"
2. 计算 hash("apple") = 3287
3. 计算 index = 3287 % 16 = 7
4. 把条目 ("apple", value) 放进桶数组的下标 7 中

如果桶 7 是空的,就直接放进去。

如果桶 7 已经有其他条目,就按冲突处理策略处理,例如挂到链表后面。


7. 查找时也不是只算下标就结束

查找 "apple" 时:

1. 计算 hash("apple") = 3287
2. 计算 index = 3287 % 16 = 7
3. 到桶 7 中查找
4. 比较桶里的 key 是否等于 "apple"
5. 找到后返回对应 value

如果桶 7 中有多个条目,就要逐个比较 key。

这也是为什么哈希表平均是 O(1),但冲突严重时可能退化。


那接下来自然会遇到几个问题:

  1. 不同 key 落到同一个桶怎么办?
  2. 桶快满了怎么办?
  3. 哈希函数应该怎么设计?
  4. 哈希表操作到底为什么平均 O(1),什么时候退化?
  5. 哈希表里的哈希和密码学哈希有什么区别?

对着这五个问题我们来展开接下来的内容


问题一:不同 key 落到同一个桶怎么办?

1.1 这件事叫什么

不同 key 经过哈希和取模后落到同一个桶,叫🟡哈希冲突。

例如:

hash("apple")  % 16 = 7
hash("banana") % 16 = 7

两个 key 都指向桶 7,但它们不是同一个 key,不能互相覆盖。

冲突不可避免:key 的可能取值远多于桶的数量,把大量 key 映射到少量桶,必然有多个 key 共享一个桶。这叫鸽巢原理。

所以问题不是“怎么消灭冲突”,而是“冲突后怎么存、怎么找”。

1.2 办法一:链地址法

思路:每个桶不直接放一个元素,而是挂一个容器,冲突的元素都放进这个容器。最常见的是链表。

桶 7: ("apple", 5) -> ("banana", 3) -> null

插入 "banana" 时:

1. 算下标 7
2. 桶 7 已有 "apple"
3. 把 ("banana", 3) 挂到链表后面

查找 "banana" 时:

1. 算下标 7
2. 遍历桶 7 的链表
3. 逐个比较 key
4. 找到 "banana",返回 value

关键点:下标只决定去哪个桶找,桶内还要比较 key 才能确定是不是目标元素。

链地址法的优缺点:

优点缺点
实现简单链表节点有额外内存开销
删除方便冲突多时链表变长,查找退化
桶数组可以装超过桶数的元素缓存局部性差

Java HashMap 用链地址法。当链表长度超过阈值且桶数组足够大时,链表转红黑树,最坏查找从 O(n) 降到 O(log n)。

1.3 办法二:开放寻址法

思路:不挂链表,所有元素都放在桶数组里。冲突时按某种规则找下一个空桶。

线性探测

规则:下标被占,就看下一个,再下一个。

插入 "apple":index=7,桶 7 空,放桶 7
插入 "banana":index=7,桶 7 被占,看 8;8 空,放桶 8
插入 "cherry":index=7,桶 7 被占,看 8;8 被占,看 9;9 空,放桶 9

查找时按同样规则探测:

查找 "banana":
index=7,桶 7 的 key 不是 banana;
看 8,是 banana,找到。

线性探测的问题叫聚集:连续被占的槽位越来越长,后续冲突更容易连成一片。

二次探测

不再每次加 1,而是按平方序列跳:

index, index+1², index+2², index+3², ...

即 7, 8, 11, 16, ...,对容量取模后继续找。缓解连续聚集,但可能探测不到所有槽位。

双重哈希

用第二个哈希函数决定步长:

step = hash2(key)
index = (hash1(key) + i * step) % capacity

不同 key 步长不同,分布更均匀,但计算量更大。

开放寻址法的删除问题

不能直接置空。因为查找依赖探测链:

桶 7: apple
桶 8: banana
桶 9: cherry

删除 "banana" 后如果把桶 8 置空,再查找 "cherry" 时:

index=7,不是 cherry;
看 8,空,查找误以为不存在而停止。

所以要用墓碑标记:删除位置标记为“已删除”,查找时继续探测,插入时可复用。

1.4 两种办法怎么选

维度链地址法开放寻址法
额外内存需要链表节点不需要额外节点
缓存局部性差好
删除直接删需要墓碑
装载率上限可以超过 1必须小于 1
冲突严重时链表变长探测链变长

问题二:桶快满了怎么办?

2.1 怎么衡量“快满了”

用负载因子:

负载因子 = 元素个数 / 桶数组长度

例如 16 个桶放了 12 个元素:

负载因子 = 12 / 16 = 0.75

负载因子越高,冲突概率越大,查找越慢。开放寻址法尤其敏感,因为所有元素都在数组里,满了就探测不动了。

2.2 超过阈值就扩容

哈希表通常设一个阈值,超过就扩容。

以 Java HashMap 为例:

默认负载因子 = 0.75
容量 16 时阈值 = 16 * 0.75 = 12

放入第 13 个元素时触发扩容,桶数组从 16 扩到 32。

2.3 扩容不是简单复制

下标依赖容量:

旧:index = hash % 16
新:index = hash % 32

同一个哈希值在容量变化后,下标可能变。所以扩容时要对每个元素重新计算下标,这个过程叫 rehash。

扩容前:
桶 7: ("apple", 5)

扩容到 32 后:
hash("apple") = 3287
3287 % 32 = 23
桶 23: ("apple", 5)

2.4 容量为什么常取 2 的幂

取模 % 相对慢。容量是 2 的幂时,可以用位运算替代:

hash % 16 等价于 hash & 15

因为 16 是 10000,15 是 01111,与运算保留低 4 位,结果正好是 0 到 15。

Java HashMap 就要求容量是 2 的幂:

index = (n - 1) & hash

但位运算只保留低位,如果哈希值低位分布不好,冲突会多。所以 Java 加了扰动函数,把高位混到低位:

static int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

2.5 扩容的代价

扩容要遍历所有元素重新计算下标,代价 O(n)。

但扩容不是每次都发生。容量翻倍后,能再装很多元素才触发下一次扩容。把扩容代价摊还到每次插入上,平均仍是 O(1)。这叫摊还分析。

2.6 负载因子怎么选

负载因子效果
太低冲突少,但内存浪费,扩容频繁
太高内存省,但冲突多,查找慢
0.75 左右时间和空间的常见折中

Java HashMap 默认 0.75,就是这个折中。


问题三:哈希函数应该怎么设计?

3.1 三个基本要求

确定性

同一个 key 每次必须得到相同哈希值。

hash("apple") 今天 = 3287
hash("apple") 明天也必须 = 3287

否则今天存进去,明天就找不到了。

均匀性

不同 key 应尽量均匀落到各个桶,避免大量 key 挤在一起。

如果 1000 个 key 有 900 个落到桶 7,哈希表就退化成链表了。

高效性

哈希计算本身要快。哈希表靠 O(1) 取胜,如果哈希函数比查找还慢,就失去意义。

3.2 常见哈希表哈希函数

函数特点
MurmurHash分布好、速度快,通用哈希表常用
xxHash非常快,适合性能敏感场景
CityHash / FarmHashGoogle 出品,面向字符串
Java String.hashCodes[0]*31^(n-1) + s[1]*31^(n-2) + ...

Java 用 31 的原因:

  • 31 是奇素数,分布较好;
  • JVM 可以把乘 31 优化成移位减法:31 * i = (i << 5) - i。

3.3 设计时要注意的坑

坑一:低位分布差

如果容量是 2 的幂,下标只看哈希值的低位。若哈希函数低位分布差,冲突就多。

解决:扰动函数,把高位混到低位。

h ^ (h >>> 16)

坑二:相似 key 产生相似哈希

比如:

"aab" 和 "aba"

如果哈希函数只是简单相加,可能得到相同哈希值。好的哈希函数要让每个字符的位置都影响结果。

坑三:哈希函数太慢

SHA-256 分布很好,但对哈希表来说太慢。哈希表不需要抗攻击,只需要分布均匀和快。

3.4 总结

所以哈希表哈希函数的设计目标是:

确定性 + 均匀 + 快

不要求不可逆,不要求抗碰撞攻击。冲突有冲突处理机制兜底。


问题四:哈希表操作到底为什么平均 O(1),什么时候退化?

4.1 平均 O(1) 的推导

设:

n = 元素个数
m = 桶数
负载因子 α = n / m

如果哈希函数均匀,每个桶平均有 α 个元素。

查找时:

1. 算哈希和下标:O(1)
2. 在桶内找目标:平均 O(α)

如果负载因子控制在常数,比如 0.75,那么:

平均查找 = O(1 + α) = O(1)

插入和删除同理,主要开销也在桶内操作。

这就是平均 O(1) 的来源:不是每一步都 O(1),而是桶内平均元素数是常数。

4.2 三个操作的复杂度

操作平均最坏
查找O(1)O(n)
插入O(1) 摊还O(n)
删除O(1)O(n)

插入的摊还 O(1) 是因为扩容偶尔发生,代价 O(n),但摊到每次插入上平均是 O(1)。

4.3 什么时候退化

情况一:所有 key 冲突到同一个桶

hash(key1) % 16 = 7
hash(key2) % 16 = 7
...
hash(keyn) % 16 = 7

链地址法下桶 7 变成一条长链表,查找 O(n)。

开放寻址法下探测链极长,也退化。

情况二:哈希函数分布差

如果哈希函数把大量 key 映射到少数桶,即使 key 本身不同,也会造成聚集。

情况三:负载因子过高

桶快满了还不扩容,冲突概率大增,桶内元素变多,查找变慢。

情况四:恶意构造 key

攻击者知道哈希函数,故意构造大量冲突的 key,让哈希表退化成链表。这叫哈希碰撞攻击。

Java 8 之后用红黑树缓解:链表长度超过 8 且容量 ≥ 64 时转红黑树,最坏从 O(n) 降到 O(log n)。

4.4 怎么避免退化

手段作用
好的哈希函数均匀分布,减少冲突
扰动函数改善低位分布
控制负载因子避免桶过满
及时扩容降低冲突概率
链表转红黑树冲突严重时兜底
随机化哈希种子防哈希碰撞攻击

4.5 准确说法

哈希表平均 O(1),最坏 O(n)。
平均 O(1) 依赖:哈希函数均匀 + 负载因子受控 + 冲突处理有效。

不能说“哈希表就是 O(1)”,因为它有退化条件。


问题五:哈希表里的哈希和密码学哈希有什么区别?

5.1 名字一样,目标不同

两者都叫哈希函数,都是把任意输入映射为固定长度输出,但设计目标完全不同。

维度哈希表哈希密码学哈希
代表MurmurHash、xxHash、Java hashCodeSHA-256、SHA-3、BLAKE3
主要目标分布均匀、计算快安全、抗攻击
输出长度常为 32/64 位常为 256 位等
是否要求单向不要求要求不可逆
是否要求抗碰撞不要求,冲突可处理要求抗碰撞
是否要求雪崩效应不强制要求
典型用途HashMap、布隆过滤器数字签名、证书、密码存储

5.2 哈希表哈希:只要均匀和快

哈希表哈希不要求不可逆。就算你从哈希值反推出 key,对哈希表也没影响,因为哈希表本来就要存 key 来比较。

也不要求抗碰撞。冲突了有链地址法、开放寻址法兜底。

它最关心的是:

同一个 key 每次哈希值相同;
不同 key 尽量分散;
计算尽量快。

5.3 密码学哈希:安全性质是硬要求

密码学哈希要求:

单向性

给定哈希值,无法在可行时间内反推出原文。

抗碰撞性

很难找到两个不同输入得到相同哈希值。

雪崩效应

输入改一个比特,输出应面目全非。

抗长度扩展攻击等

这些性质用于数字签名、证书、密码存储等安全场景。

5.4 为什么哈希表不用 SHA-256

三个原因:

太慢

SHA-256 计算量远大于 MurmurHash。哈希表靠 O(1) 取胜,哈希函数太慢会拖垮性能。

没必要

哈希表能处理冲突,不需要抗碰撞。攻击者构造冲突的问题可以用随机化种子或红黑树缓解,不需要动用密码学哈希。

输出太长

SHA-256 输出 256 位,哈希表只需要几十位就能映射到桶。多出来的位数对哈希表没用。

5.5 一句话区分

哈希表哈希:为“分布”服务,冲突可处理。
密码学哈希:为“安全”服务,冲突不可接受。
目录