「哈希表 hash table」,又称「散列表」,其通过建立键
key
与值
value
之间的映射,实现高效的元素查询。具 体而言,我们向哈希表输入一个键 key
,则可以在
𝑂(1)
时间内获取对应的值
value
。
如图 所示,给定
𝑛
个学生,每个学生都有“姓名”和“学号”两项数据。假如我们希望实现“输入一个
学号,返回对应的姓名”的查询功能,则可以采用图 6‑1 所示的哈希表来实现。
![](https://img-blog.csdnimg.cn/direct/33559e0f6fc34fbdb673523211fce7cf.png)
除哈希表外,数组和链表也可以实现查询功能,它们的效率对比如图所示。
观察发现,
在哈希表中进行增删查改的时间复杂度都是
𝑂(1)
,非常高效。
1.HashMap
public static void main(String[] args) {
Map<String,String> map=new HashMap<>();
//添加数据
map.put("name","张三");
map.put("age","18");
map.put("price","100");
//取出数据
System.out.println(map.get("name"));
//删除
map.remove("name");
//三种遍历方式
/* 遍历键 */
for (String s : map.keySet()) {
System.out.println(map.get(s));
}
/* 遍历值 */
for (String value : map.values()) {
System.out.println(value);
}
/*遍历键值对 key->value*/
for (Map.Entry <String, String> kv: map.entrySet()) {
System.out.println(kv.getKey() + " -> " + kv.getValue());
}
}
2.哈希表的简单实现
我们先考虑最简单的情况,
仅用一个数组来实现哈希表
。在哈希表中,我们将数组中的每个空位称为「桶 bucket」,每个桶可存储一个键值对。因此,查询操作就是找到 key
对应的桶,并在桶中获取
value
。
那么,如何基于
key
来定位对应的桶呢?这是通过「哈希函数 hash function」实现的。哈希函数的作用是将 一个较大的输入空间映射到一个较小的输出空间。在哈希表中,输入空间是所有 key
,输出空间是所有桶(数 组索引)。换句话说,输入一个 key
,
我们可以通过哈希函数得到该
key
对应的键值对在数组中的存储位置
。
输入一个
key
,哈希函数的计算过程分为以下两步。
1. 通过某种哈希算法
hash()
计算得到哈希值。
2. 将哈希值对桶数量(数组长度)
capacity
取模,从而获取该
key
对应的数组索引
index
。
index = hash(key) % capacity
随后,我们就可以利用
index
在哈希表中访问对应的桶,从而获取
value
。
设数组长度
capacity = 100
、哈希算法
hash(key) = key
,易得哈希函数为
key % 100
。下图 以
key
学号 和 value
姓名为例,展示了哈希函数的工作原理
3.哈希冲突与扩容
本质上看,哈希函数的作用是将所有
key
构成的输入空间映射到数组所有索引构成的输出空间,而输入空间 往往远大于输出空间。因此,理论上一定存在“多个输入对应相同输出”的情况
。
对于上述示例中的哈希函数,当输入的
key
后两位相同时,哈希函数的输出结果也相同。例如,查询学号为 12836 和 20336 的两个学生时,我们得到
12836 % 100 = 3620336 % 100 = 36
如图 所示,两个学号指向了同一个姓名,这显然是不对的。我们将这种多个输入对应同一输出的情况称 为「哈希冲突 hash collision」。
容易想到,哈希表容量
𝑛
越大,多个
key
被分配到同一个桶中的概率就越低,冲突就越少。因此,
我们可以
通过扩容哈希表来减少哈希冲突
。
如图 所示,扩容前键值对
(136, A)
和
(236, D)
发生冲突,扩容后冲突消失。
类似于数组扩容,哈希表扩容需将所有键值对从原哈希表迁移至新哈希表,非常耗时。并且由于哈希表容量 capacity 改变,我们需要通过哈希函数来重新计算所有键值对的存储位置,这进一步提高了扩容过程的计算 开销。为此,编程语言通常会预留足够大的哈希表容量,防止频繁扩容。
「负载因子 load factor」是哈希表的一个重要概念,其定义为哈希表的元素数量除以桶数量,用于衡量哈希 冲突的严重程度,也常被作为哈希表扩容的触发条件
。例如在 Java 中,当负载因子超过
0.75
时,系统会将 哈希表容量扩展为原先的 2
倍。
3.1哈希冲突
上节提到,
通常情况下哈希函数的输入空间远大于输出空间
,因此理论上哈希冲突是不可避免的。比如,输 入空间为全体整数,输出空间为数组容量大小,则必然有多个整数映射至同一桶索引。
哈希冲突会导致查询结果错误,严重影响哈希表的可用性。为解决该问题,我们可以每当遇到哈希冲突时就 进行哈希表扩容,直至冲突消失为止。此方法简单粗暴且有效,但效率太低,因为哈希表扩容需要进行大量 的数据搬运与哈希值计算。为了提升效率,我们可以采用以下策略。
1. 改良哈希表数据结构,
使得哈希表可以在存在哈希冲突时正常工作
。
2. 仅在必要时,即当哈希冲突比较严重时,才执行扩容操作。
哈希表的结构改良方法主要包括“链式地址”和“开放寻址”。
3.1.1链式地址
在原始哈希表中,每个桶仅能存储一个键值对。「链式地址 separate chaining」将单个元素转换为链表,将 键值对作为链表节点,将所有发生冲突的键值对都存储在同一链表中。下图展示了一个链式地址哈希表的 例子。
![](https://img-blog.csdnimg.cn/direct/ee88ecbeee884cd39eada98aaee5f3ba.png)
基于链式地址实现的哈希表的操作方法发生了以下变化。
‧
查询元素
:输入
key
,经过哈希函数得到桶索引,即可访问链表头节点,然后遍历链表并对比
key
以查 找目标键值对。
‧
添加元素
:先通过哈希函数访问链表头节点,然后将节点(即键值对)添加到链表中。
‧
删除元素
:根据哈希函数的结果访问链表头部,接着遍历链表以查找目标节点,并将其删除。
链式地址存在以下局限性。
‧
占用空间增大
,链表包含节点指针,它相比数组更加耗费内存空间。
‧
查询效率降低
,因为需要线性遍历链表来查找对应元素。
值得注意的是,当链表很长时,查询效率
𝑂(𝑛)
很差。
此时可以将链表转换为“AVL 树”或“红黑树”
,从而 将查询操作的时间复杂度优化至 𝑂(
log
𝑛)
。
3.1.2开放寻址
「开放寻址 open addressing」不引入额外的数据结构,而是通过“多次探测”来处理哈希冲突,探测方式主 要包括线性探测、平方探测、多次哈希等。
下面将主要以线性探测为例,介绍开放寻址哈希表的工作机制
1.线性探测:
线性探测采用固定步长的线性搜索来进行探测,其操作方法与普通哈希表有所不同。
‧
插入元素
:通过哈希函数计算桶索引,若发现桶内已有元素,则从冲突位置向后线性遍历(步长通常为 1 ),直至找到空桶,将元素插入其中。
‧
查找元素
:若发现哈希冲突,则使用相同步长向后线性遍历,直到找到对应元素,返回
value
即可;如
果遇到空桶,说明目标元素不在哈希表中,返回 None 。
下图展示了开放寻址(线性探测)哈希表的键值对分布。根据此哈希函数,最后两位相同的 key
都会被映 射到相同的桶。而通过线性探测,它们被依次存储在该桶以及之下的桶中。
然而,
线性探测容易产生“聚集现象”
。具体来说,数组中连续被占用的位置越长,这些连续位置发生哈希冲 突的可能性越大,从而进一步促使该位置的聚堆生长,形成恶性循环,最终导致增删查改操作效率劣化。
值得注意的是,
我们不能在开放寻址哈希表中直接删除元素
。这是因为删除元素会在数组内产生一个空桶 None ,而当查询元素时,线性探测到该空桶就会返回,因此在该空桶之下的元素都无法再被访问到,程序 可能误判这些元素不存在。
为了解决该问题,我们可以采用「懒删除 lazy deletion」机制:它不直接从哈希表中移除元素,
而是利用一
个常量
TOMBSTONE
来标记这个桶
。在该机制下,None 和
TOMBSTONE
都代表空桶,都可以放置键值对。但不 同的是,线性探测到 TOMBSTONE
时应该继续遍历,因为其之下可能还存在键值对。
然而,
懒删除可能会加速哈希表的性能退化
。这是因为每次删除操作都会产生一个删除标记,随着
TOMBSTONE 的增加,搜索时间也会增加,因为线性探测可能需要跳过多个 TOMBSTONE
才能找到目标元素。
为此,考虑在线性探测中记录遇到的首个
TOMBSTONE
的索引,并将搜索到的目标元素与该
TOMBSTONE
交换位 置。这样做的好处是当每次查询或添加元素时,元素会被移动至距离理想位置(探测起始点)更近的桶,从而优化查询效率。
2. 平方探测
平方探测与线性探测类似,都是开放寻址的常见策略之一。当发生冲突时,平方探测不是简单地跳过一个固 定的步数,而是跳过“探测次数的平方”的步数,即 1, 4, 9, …
步。
平方探测主要具有以下优势。
‧ 平方探测通过跳过平方的距离,试图缓解线性探测的聚集效应。
‧ 平方探测会跳过更大的距离来寻找空位置,有助于数据分布得更加均匀。
然而,平方探测也并不是完美的。
‧ 仍然存在聚集现象,即某些位置比其他位置更容易被占用。
‧ 由于平方的增长,平方探测可能不会探测整个哈希表,这意味着即使哈希表中有空桶,平方探测也可能 无法访问到它。
3. 多次哈希
多次哈希使用多个哈希函数
𝑓
1
(𝑥)
、
𝑓
2
(𝑥)
、
𝑓
3
(𝑥)
、
…
进行探测。
‧
插入元素
:若哈希函数
𝑓
1
(𝑥)
出现冲突,则尝试
𝑓
2
(𝑥)
,以此类推,直到找到空桶后插入元素。
‧
查找元素
:在相同的哈希函数顺序下进行查找,直到找到目标元素时返回;或当遇到空桶或已尝试所有
哈希函数,说明哈希表中不存在该元素,则返回 None 。
与线性探测相比,多次哈希方法不易产生聚集,但多个哈希函数会增加额外的计算量
不同编程语言的选择:
各个编程语言采取了不同的哈希表实现策略,以下举几个例子。‧ Java 采用链式地址。自 JDK 1.8 以来,当 HashMap 内数组长度达到 64 且链表长度达到 8 时,链表会 被转换为红黑树以提升查找性能。‧ Python 采用开放寻址。字典 dict 使用伪随机数进行探测。‧ Golang 采用链式地址。Go 规定每个桶最多存储 8 个键值对,超出容量则连接一个溢出桶。当溢出桶 过多时,会执行一次特殊的等量扩容操作,以确保性能。
4.哈希算法
在上两节中,我们了解了哈希表的工作原理和哈希冲突的处理方法。然而无论是开放寻址还是链地址法,
它
们只能保证哈希表可以在发生冲突时正常工作,但无法减少哈希冲突的发生
。
如果哈希冲突过于频繁,哈希表的性能则会急剧劣化。如图所示,对于链地址哈希表,理想情况下键值 对平均分布在各个桶中,达到最佳查询效率;最差情况下所有键值对都被存储到同一个桶中,时间复杂度退 化至 𝑂(𝑛)
键值对的分布情况由哈希函数决定
。回忆哈希函数的计算步骤,先计算哈希值,再对数组长度取模:
index = hash(key) % capacity
观察以上公式,当哈希表容量
capacity
固定时,
哈希算法
hash()
决定了输出值
,进而决定了键值对在哈希 表中的分布情况。
这意味着,为了减小哈希冲突的发生概率,我们应当将注意力集中在哈希算法
hash()
的设计上。
4.1哈希算法的目标
为了实现“既快又稳”的哈希表数据结构,哈希算法应包含以下特点。
‧
确定性
:对于相同的输入,哈希算法应始终产生相同的输出。这样才能确保哈希表是可靠的。
‧
效率高
:计算哈希值的过程应该足够快。计算开销越小,哈希表的实用性越高。
‧
均匀分布
:哈希算法应使得键值对平均分布在哈希表中。分布越平均,哈希冲突的概率就越低。
实际上,哈希算法除了可以用于实现哈希表,还广泛应用于其他领域中。
‧
密码存储
:为了保护用户密码的安全,系统通常不会直接存储用户的明文密码,而是存储密码的哈希 值。当用户输入密码时,系统会对输入的密码计算哈希值,然后与存储的哈希值进行比较。如果两者匹 配,那么密码就被视为正确。
‧
数据完整性检查
:数据发送方可以计算数据的哈希值并将其一同发送;接收方可以重新计算接收到的 数据的哈希值,并与接收到的哈希值进行比较。如果两者匹配,那么数据就被视为完整的。
对于密码学的相关应用,为了防止从哈希值推导出原始密码等逆向工程,哈希算法需要具备更高等级的安全
特性。
‧
单向性
:无法通过哈希值反推出关于输入数据的任何信息。
‧
抗碰撞性
:应当极其困难找到两个不同的输入,使得它们的哈希值相同。
‧
雪崩效应
:输入的微小变化应当导致输出的显著且不可预测的变化。
请注意,
“均匀分布”与“抗碰撞性”是两个独立的概念
,满足均匀分布不一定满足抗碰撞性。例如,在随机 输入 key
下,哈希函数
key % 100
可以产生均匀分布的输出。然而该哈希算法过于简单,所有后两位相等的 key 的输出都相同,因此我们可以很容易地从哈希值反推出可用的
key
,从而破解密码。
4.2哈希算法的设计
哈希算法的设计是一个需要考虑许多因素的复杂问题。然而对于某些要求不高的场景,我们也能设计一些简
单的哈希算法。
‧
加法哈希
:对输入的每个字符的 ASCII 码进行相加,将得到的总和作为哈希值。
‧
乘法哈希
:利用了乘法的不相关性,每轮乘以一个常数,将各个字符的 ASCII 码累积到哈希值中。
‧
异或哈希
:将输入数据的每个元素通过异或操作累积到一个哈希值中。
‧
旋转哈希
:将每个字符的 ASCII 码累积到一个哈希值中,每次累积之前都会对哈希值进行旋转操作
/* 加法哈希 */
int addHash(String key) {
long hash = 0;
final int MODULUS = 1000000007;
for (char c : key.toCharArray()) {
hash = (hash + (int) c) % MODULUS;
}
return (int) hash;
}
/* 乘法哈希 */
int mulHash(String key) {
long hash = 0;
final int MODULUS = 1000000007;
for (char c : key.toCharArray()) {
hash = (31 * hash + (int) c) % MODULUS;
}
return (int) hash;
}
/* 异或哈希 */
int xorHash(String key) {
int hash = 0;
final int MODULUS = 1000000007;
for (char c : key.toCharArray()) {
hash ^= (int) c;
}
return hash & MODULUS;
}
/* 旋转哈希 */
int rotHash(String key) {
long hash = 0;
final int MODULUS = 1000000007;
for (char c : key.toCharArray()) {
hash = ((hash << 4) ^ (hash >> 28) ^ (int) c) % MODULUS;
}
return (int) hash;
}
观察发现,每种哈希算法的最后一步都是对大质数
1000000007
取模,以确保哈希值在合适的范围内。值得 思考的是,为什么要强调对质数取模,或者说对合数取模的弊端是什么?这是一个有趣的问题。
先抛出结论:
当我们使用大质数作为模数时,可以最大化地保证哈希值的均匀分布
。因为质数不会与其他数 字存在公约数,可以减少因取模操作而产生的周期性模式,从而避免哈希冲突。
举个例子,假设我们选择合数
9
作为模数,它可以被
3
整除。那么所有可以被
3
整除的
key
都会被映射到
0、
3、
6
这三个哈希值。
modulus = 9key = {0, 3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33, … }hash = {0, 3, 6, 0, 3, 6, 0, 3, 6, 0, 3, 6, … }
值得说明的是,如果能够保证
key
是随机均匀分布的,那么选择质数或者合数作为模数都是可以的,它们都 能输出均匀分布的哈希值。而当 key
的分布存在某种周期性时,对合数取模更容易出现聚集现象。 总而言之,我们通常选取质数作为模数,并且这个质数最好足够大,以尽可能消除周期性模式,提升哈希算 法的稳健性。
5.常见哈希算法
不难发现,以上介绍的简单哈希算法都比较“脆弱”,远远没有达到哈希算法的设计目标。例如,由于加法和异或满足交换律,因此加法哈希和异或哈希无法区分内容相同但顺序不同的字符串,这可能会加剧哈希冲突, 并引起一些安全问题。
在实际中,我们通常会用一些标准哈希算法,例如 MD5、SHA‑1、SHA‑2、SHA3 等。它们可以将任意长度 的输入数据映射到恒定长度的哈希值。
近一个世纪以来,哈希算法处在不断升级与优化的过程中。一部分研究人员努力提升哈希算法的性能,另一 部分研究人员和黑客则致力于寻找哈希算法的安全性问题。下图 展示了在实际应用中常见的哈希算法。
‧ MD5 和 SHA‑1 已多次被成功攻击,因此它们被各类安全应用弃用。
‧ SHA‑2 系列中的 SHA‑256 是最安全的哈希算法之一,仍未出现成功的攻击案例,因此常被用在各类安
全应用与协议中。 ‧ SHA‑3 相较 SHA‑2 的实现开销更低、计算效率更高,但目前使用覆盖度不如 SHA‑2 系列。
6.小结
1. 重点回顾
‧ 输入
key
,哈希表能够在
𝑂(1)
时间内查询到
value
,效率非常高。
‧ 常见的哈希表操作包括查询、添加键值对、删除键值对和遍历哈希表等。
‧ 哈希函数将
key
映射为数组索引,从而访问对应桶并获取
value
。
‧ 两个不同的
key
可能在经过哈希函数后得到相同的数组索引,导致查询结果出错,这种现象被称为哈 希冲突。
‧ 哈希表容量越大,哈希冲突的概率就越低。因此可以通过扩容哈希表来缓解哈希冲突。与数组扩容类 似,哈希表扩容操作的开销很大。
‧ 负载因子定义为哈希表中元素数量除以桶数量,反映了哈希冲突的严重程度,常用作触发哈希表扩容 的条件。
‧ 链式地址通过将单个元素转化为链表,将所有冲突元素存储在同一个链表中。然而,链表过长会降低查 询效率,可以进一步将链表转换为红黑树来提高效率。
‧ 开放寻址通过多次探测来处理哈希冲突。线性探测使用固定步长,缺点是不能删除元素,且容易产生聚 集。多次哈希使用多个哈希函数进行探测,相较线性探测更不易产生聚集,但多个哈希函数增加了计算 量。
‧ 不同编程语言采取了不同的哈希表实现。例如,Java 的
HashMap
使用链式地址,而 Python 的
Dict
采 用开放寻址。
‧ 在哈希表中,我们希望哈希算法具有确定性、高效率和均匀分布的特点。在密码学中,哈希算法还应该 具备抗碰撞性和雪崩效应。
‧ 哈希算法通常采用大质数作为模数,以最大化地保证哈希值的均匀分布,减少哈希冲突。
‧ 常见的哈希算法包括 MD5、SHA‑1、SHA‑2 和 SHA3 等。MD5 常用于校验文件完整性,SHA‑2 常用 于安全应用与协议。
‧ 编程语言通常会为数据类型提供内置哈希算法,用于计算哈希表中的桶索引。通常情况下,只有不可变 对象是可哈希的。