为什么会有哈希冲突?
哈希函数
哈希函数是一种算法,用于将任意长度的输入数据映射为固定长度的输出值,输出值通常被称为哈希值。
常见应用场景:
- 加密:例如 MD5 算法,将输入数据映射为 128 位固定长度的二进制,用于数据完整性校验。
- Java 中的哈希值:例如 Object.hashCode() 方法,生成一个 32 位的整数,默认为对象的内存地址,用于支持哈希表数据结构,如 HashMap。
哈希表
哈希表的本质是一维数组,使用哈希函数计算输入值的哈希值,再通过对数组长度取模,将哈希值映射为数组的索引,确定数据存储的位置。
哈希冲突
由于哈希值的映射范围有限,但输入值理论上可以是无限的,必然会出现不同输入值映射到相同哈希值的情况,也就是哈希冲突。
常见冲突原因
- 哈希函数设计不合理:若生成的哈希值分布不均匀,则规律性的输入值可能会映射到相近的哈希值。例如,简单的取模运算。
- 哈希表容量不足:当数据量远大于哈希表的容量时,冲突的概率会显著增加。例如,将 10000 个元素映射到长度为 10 的表中。
- 负载因子过高:负载因子=已存储元素数 ÷ 哈希表长度,用来衡量哈希表的空间使用率,当负载因子过高时,哈希表中的空槽位减少,扩容不及时会增加冲突概率。例如,Java 中 HashMap 默认表长为 16,负载因子为 0.75,也就是使用了 75% 容量时会触发扩容。
三种常见解决策略
链地址法
将冲突的元素存储在同一个哈希桶中,哈希桶通常使用链表、红黑树等数据结构。
操作过程
- 查询:计算哈希值,找到对应哈希桶,遍历桶内元素。如果找到相等的 key,返回值;否则返回 null。。
- 插入:若桶内存在相同 key,则更新值;否则将新元素插入桶中。
- 删除:若桶内存在相同 key,删除该节点,并调整链表或树结构。
优缺点
- 可以使用多种数据结构,来优化查询效率。如 JDK 1.8 之后,HashMap 在哈希桶链表长度超过 8 且哈希表长度超过 64 时,链表会转换为红黑树。
- 插入、删除操作简单,不需要移动其他元素。
- 如果某个桶内链表过长,查询效率降低。
- 每个节点需要额外存储指针,增加内存开销。
开放地址法
将所有元素直接存储在哈希表的数组中。如果发生冲突,系统会根据探测策略查找下一个空槽位。
常见探测策略
- 线性探测:每次冲突后按照固定步长(通常为 1)向后探测,可能导致冲突元素集中在相邻槽位中,称为一次聚集。
index = (Hash(key) + i) % table_size,其中 i 为探测次数,i = 0, 1, 2, 3...
- 平方探测:每次冲突后按照平方递增的步长向后探测,减少一次聚集现象,但冲突元素会总会探测到相同槽位,称为二次聚集。
index = (Hash(key) + i²) % table_size,其中 i 为探测次数, i² = 0,1,4,9 ...
- 双重哈希:使用两个不同的哈希函数处理冲突,第一个哈希函数确定初始槽位,第二个哈希函数确定步长,减少聚集现象,但对哈希函数要求较高。
index = (Hash1(key) + i * Hash2(key)) % table_size,其中 i 为探测次数。
如何判断哈希表探测完成?
- 空槽位:探测到空槽位,说明该位置未被占用,可以停止探测。
- 已删除槽位:探测到已删除槽位,说明该位置曾经被使用过,后续可能还存在冲突元素,应继续探测。
- 回到起始槽位:表明所有位置已探测完毕。
操作过程
- 查询:计算哈希值,确定初始槽位,按探测策略依次检查槽位,若找到目标 key,则返回值;否则返回 null。
- 插入:空槽位,插入,停止探测;非空槽位,若 key 相等更新值,否则继续探测。
- 删除:找到 key 相等的目标后,把槽位标记为已删除,并停止探测。
示例
假设哈希表长度 table_size = 10 ,初始状态为空。
哈希函数为 Hash(key) = key % table_size。
待插入元素为 (1,A)、(11,B)、(21,C)、(31,D)。
线性探测
index = ( (key % 10) + i) % 10
- 插入(1,A): 计算初始槽位 i = 0, index_0 = 1,槽位 1 为空, A 插入槽位 1 。
- 插入(11,B): 计算初始槽位 i = 0, index_0 = 1,槽位 1 非空且 key 不相等,继续探测;
i = 1,index_1 = 2,槽位 2 为空,B 插入槽位 2 。
- 插入(21,C):计算初始槽位 i = 0, index_0 = 1,槽位 1 非空且 key 不相等,继续探测;
i = 1,index_1 = 2,槽位 2 非空且 key 不相等,继续探测;
i = 2,index_2 = 3,槽位 3 为空,C 插入槽位 3。
- 插入(31,D):计算初始槽位 i = 0,index_0 = 1,槽位 1 非空且 key 不相等,继续探测;
i = 1,index_1 = 2,槽位 2 非空且 key 不相等,继续探测;
i = 2,index_2 = 3,槽位 3 非空且 key 不相等,继续探测;
i = 3,index_3 = 4,槽位 4 为空,D 插入槽位 4。
最终结果
| 哈希表 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|---|---|---|---|---|---|---|---|---|---|---|
| 值 | null | A | B | C | D | null | null | null | null | null |
从结果可以看出,线性探测中,冲突元素会占据相邻槽位,也就是一次聚集。
平方探测
原理同线性探测,只是探测步长不一样,感兴趣可以自己计算一下。
最终结果:
| 哈希表 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|---|---|---|---|---|---|---|---|---|---|---|
| 值 | null | A | B | null | null | C | null | null | null | D |
从结果可以看出,平方探测分布更均匀,减少了一次聚集,但冲突元素会探测到相同槽位,出现二次聚集。
优缺点
- 实现简单,不需要额外的数据结构,所有数据都存储在哈希表中。
- 查询效率较高,直接访问数组。
- 冲突频繁时,探测效率会急剧下降。
- 扩容成本高,需要重新分配和移动所有元素。
再哈希法
使用多个独立的哈希函数处理冲突。冲突时,依次用不同的哈希函数来探测槽位。
操作过程
查询:计算哈希值 hash = H1(key),定位槽位,若冲突,则尝试 H2(key),H3(key)... 依次类推。
插入:若槽位为空则插入;若冲突,则尝试下一个哈希函数。
删除:若槽位中的 key 匹配,则标记为已删除;否则尝试下一个哈希函数。
优缺点
- 哈希值分布更均匀,冲突概率低。
- 实现复杂,需要设计多个独立的哈希函数。
- 性能依赖哈希函数的计算效率。
总结
- 链地址法:可以动态扩展数据结构,灵活性高,适合数据量大、冲突多的场景。
- 开放地址法:直接使用数组,不需要额外数据结构,适合数据量较小、内存敏感的场景,
- 再哈希法:冲突概率低,但实现复杂,适合高性能需求。