Update the book based on the revised second edition (#1014)

* Revised the book

* Update the book with the second revised edition

* Revise base on the manuscript of the first edition
This commit is contained in:
Yudong Jin
2023-12-28 18:06:09 +08:00
committed by GitHub
parent 19dde675df
commit f68bbb0d59
261 changed files with 643 additions and 647 deletions

View File

@ -82,7 +82,7 @@ $$
不难发现,以上介绍的简单哈希算法都比较“脆弱”,远远没有达到哈希算法的设计目标。例如,由于加法和异或满足交换律,因此加法哈希和异或哈希无法区分内容相同但顺序不同的字符串,这可能会加剧哈希冲突,并引起一些安全问题。
在实际中,我们通常会用一些标准哈希算法,例如 MD5、SHA-1、SHA-2SHA-3 等。它们可以将任意长度的输入数据映射到恒定长度的哈希值。
在实际中,我们通常会用一些标准哈希算法,例如 MD5、SHA-1、SHA-2SHA-3 等。它们可以将任意长度的输入数据映射到恒定长度的哈希值。
近一个世纪以来,哈希算法处在不断升级与优化的过程中。一部分研究人员努力提升哈希算法的性能,另一部分研究人员和黑客则致力于寻找哈希算法的安全性问题。下表展示了在实际应用中常见的哈希算法。
@ -92,13 +92,13 @@ $$
<p align="center"><id> &nbsp; 常见的哈希算法 </p>
| | MD5 | SHA-1 | SHA-2 | SHA-3 |
| -------- | ------------------------------ | ---------------- | ---------------------------- | -------------------- |
| 推出时间 | 1992 | 1995 | 2002 | 2008 |
| 输出长度 | 128 bits | 160 bits | 256/512 bits | 224/256/384/512 bits |
| 哈希冲突 | 较多 | 较多 | 很少 | 很少 |
| 安全等级 | 低,已被成功攻击 | 低,已被成功攻击 | 高 | 高 |
| 应用 | 已被弃用,仍用于数据完整性检查 | 已被弃用 | 加密货币交易验证、数字签名等 | 可用于替代 SHA-2 |
| | MD5 | SHA-1 | SHA-2 | SHA-3 |
| -------- | ------------------------------ | ---------------- | ---------------------------- | ------------------- |
| 推出时间 | 1992 | 1995 | 2002 | 2008 |
| 输出长度 | 128 bit | 160 bit | 256/512 bit | 224/256/384/512 bit |
| 哈希冲突 | 较多 | 较多 | 很少 | 很少 |
| 安全等级 | 低,已被成功攻击 | 低,已被成功攻击 | 高 | 高 |
| 应用 | 已被弃用,仍用于数据完整性检查 | 已被弃用 | 加密货币交易验证、数字签名等 | 可用于替代 SHA-2 |
## 数据结构的哈希值
@ -354,4 +354,4 @@ $$
虽然自定义对象(比如链表节点)的成员变量是可变的,但它是可哈希的。**这是因为对象的哈希值通常是基于内存地址生成的**,即使对象的内容发生了变化,但它的内存地址不变,哈希值仍然是不变的。
细心的你可能发现在不同控制台中运行程序时,输出的哈希值是不同的。**这是因为 Python 解释器在每次启动时,都会为字符串哈希函数加入一个随机的盐(Salt值**。这种做法可以有效防止 HashDoS 攻击,提升哈希算法的安全性。
细心的你可能发现在不同控制台中运行程序时,输出的哈希值是不同的。**这是因为 Python 解释器在每次启动时,都会为字符串哈希函数加入一个随机的盐(salt值**。这种做法可以有效防止 HashDoS 攻击,提升哈希算法的安全性。

View File

@ -2,7 +2,7 @@
上一节提到,**通常情况下哈希函数的输入空间远大于输出空间**,因此理论上哈希冲突是不可避免的。比如,输入空间为全体整数,输出空间为数组容量大小,则必然有多个整数映射至同一桶索引。
哈希冲突会导致查询结果错误,严重影响哈希表的可用性。为解决该问题,我们可以每当遇到哈希冲突就进行哈希表扩容,直至冲突消失为止。此方法简单粗暴且有效,但效率太低,因为哈希表扩容需要进行大量的数据搬运与哈希值计算。为了提升效率,我们可以采用以下策略。
哈希冲突会导致查询结果错误,严重影响哈希表的可用性。为解决该问题,每当遇到哈希冲突时,我们就进行哈希表扩容,直至冲突消失为止。此方法简单粗暴且有效,但效率太低,因为哈希表扩容需要进行大量的数据搬运与哈希值计算。为了提升效率,我们可以采用以下策略。
1. 改良哈希表数据结构,**使得哈希表可以在出现哈希冲突时正常工作**。
2. 仅在必要时,即当哈希冲突比较严重时,才执行扩容操作。
@ -23,8 +23,8 @@
链式地址存在以下局限性。
- **占用空间增大**链表包含节点指针,它相比数组更加耗费内存空间。
- **查询效率降低**因为需要线性遍历链表来查找对应元素。
- **占用空间增大**链表包含节点指针,它相比数组更加耗费内存空间。
- **查询效率降低**因为需要线性遍历链表来查找对应元素。
以下代码给出了链式地址哈希表的简单实现,需要注意两点。
@ -39,7 +39,7 @@
## 开放寻址
「开放寻址 open addressing」不引入额外的数据结构而是通过“多次探测”来处理哈希冲突探测方式主要包括线性探测、平方探测多次哈希等。
「开放寻址 open addressing」不引入额外的数据结构而是通过“多次探测”来处理哈希冲突探测方式主要包括线性探测、平方探测多次哈希等。
下面以线性探测为例,介绍开放寻址哈希表的工作机制。
@ -48,19 +48,19 @@
线性探测采用固定步长的线性搜索来进行探测,其操作方法与普通哈希表有所不同。
- **插入元素**:通过哈希函数计算桶索引,若发现桶内已有元素,则从冲突位置向后线性遍历(步长通常为 $1$ ),直至找到空桶,将元素插入其中。
- **查找元素**:若发现哈希冲突,则使用相同步长向后线性遍历,直到找到对应元素,返回 `value` 即可;如果遇到空桶,说明目标元素不在哈希表中,返回 $\text{None}$
- **查找元素**:若发现哈希冲突,则使用相同步长向后进行线性遍历,直到找到对应元素,返回 `value` 即可;如果遇到空桶,说明目标元素不在哈希表中,返回 `None`
下图展示了开放寻址(线性探测)哈希表的键值对分布。根据此哈希函数,最后两位相同的 `key` 都会被映射到相同的桶。而通过线性探测,它们被依次存储在该桶以及之下的桶中。
![开放寻址线性探测](hash_collision.assets/hash_table_linear_probing.png)
![开放寻址线性探测)哈希表的键值对分布](hash_collision.assets/hash_table_linear_probing.png)
然而,**线性探测容易产生“聚集现象”**。具体来说,数组中连续被占用的位置越长,这些连续位置发生哈希冲突的可能性越大,从而进一步促使该位置的聚堆生长,形成恶性循环,最终导致增删查改操作效率劣化。
值得注意的是,**我们不能在开放寻址哈希表中直接删除元素**。这是因为删除元素会在数组内产生一个空桶 $\text{None}$ ,而当查询元素时,线性探测到该空桶就会返回,因此在该空桶之下的元素都无法再被访问到,程序可能误判这些元素不存在。
值得注意的是,**我们不能在开放寻址哈希表中直接删除元素**。这是因为删除元素会在数组内产生一个空桶 `None` ,而当查询元素时,线性探测到该空桶就会返回,因此在该空桶之下的元素都无法再被访问到,程序可能误判这些元素不存在。
![在开放寻址中删除元素导致的查询问题](hash_collision.assets/hash_table_open_addressing_deletion.png)
为了解决该问题,我们可以采用「懒删除 lazy deletion」机制它不直接从哈希表中移除元素**而是利用一个常量 `TOMBSTONE` 来标记这个桶**。在该机制下,$\text{None}$`TOMBSTONE` 都代表空桶,都可以放置键值对。但不同的是,线性探测到 `TOMBSTONE` 时应该继续遍历,因为其之下可能还存在键值对。
为了解决该问题,我们可以采用「懒删除 lazy deletion」机制它不直接从哈希表中移除元素**而是利用一个常量 `TOMBSTONE` 来标记这个桶**。在该机制下,`None``TOMBSTONE` 都代表空桶,都可以放置键值对。但不同的是,线性探测到 `TOMBSTONE` 时应该继续遍历,因为其之下可能还存在键值对。
然而,**懒删除可能会加速哈希表的性能退化**。这是因为每次删除操作都会产生一个删除标记,随着 `TOMBSTONE` 的增加,搜索时间也会增加,因为线性探测可能需要跳过多个 `TOMBSTONE` 才能找到目标元素。
@ -90,8 +90,8 @@
顾名思义,多次哈希方法使用多个哈希函数 $f_1(x)$、$f_2(x)$、$f_3(x)$、$\dots$ 进行探测。
- **插入元素**:若哈希函数 $f_1(x)$ 出现冲突,则尝试 $f_2(x)$ ,以此类推,直到找到空后插入元素。
- **查找元素**:在相同的哈希函数顺序下进行查找,直到找到目标元素时返回;若遇到空或已尝试所有哈希函数,说明哈希表中不存在该元素,则返回 $\text{None}$
- **插入元素**:若哈希函数 $f_1(x)$ 出现冲突,则尝试 $f_2(x)$ ,以此类推,直到找到空后插入元素。
- **查找元素**:在相同的哈希函数顺序下进行查找,直到找到目标元素时返回;若遇到空或已尝试所有哈希函数,说明哈希表中不存在该元素,则返回 `None`
与线性探测相比,多次哈希方法不易产生聚集,但多个哈希函数会带来额外的计算量。
@ -103,6 +103,6 @@
各种编程语言采取了不同的哈希表实现策略,下面举几个例子。
- Python 采用开放寻址。字典 dict 使用伪随机数进行探测。
- Java 采用链式地址。自 JDK 1.8 以来,当 HashMap 内数组长度达到 64 且链表长度达到 8 时,链表会转换为红黑树以提升查找性能。
- Go 采用链式地址。Go 规定每个桶最多存储 8 个键值对,超出容量则连接一个溢出桶当溢出桶过多时,会执行一次特殊的等量扩容操作,以确保性能。
- Python 采用开放寻址。字典 `dict` 使用伪随机数进行探测。
- Java 采用链式地址。自 JDK 1.8 以来,当 `HashMap` 内数组长度达到 64 且链表长度达到 8 时,链表会转换为红黑树以提升查找性能。
- Go 采用链式地址。Go 规定每个桶最多存储 8 个键值对,超出容量则连接一个溢出桶当溢出桶过多时,会执行一次特殊的等量扩容操作,以确保性能。

View File

@ -1,6 +1,6 @@
# 哈希表
「哈希表 hash table」又称「散列表」通过建立键 `key` 与值 `value` 之间的映射,实现高效的元素查询。具体而言,我们向哈希表输入一个键 `key` ,则可以在 $O(1)$ 时间内获取对应的值 `value`
「哈希表 hash table」又称「散列表」通过建立键 `key` 与值 `value` 之间的映射,实现高效的元素查询。具体而言,我们向哈希表输入一个键 `key` ,则可以在 $O(1)$ 时间内获取对应的值 `value`
如下图所示,给定 $n$ 个学生,每个学生都有“姓名”和“学号”两项数据。假如我们希望实现“输入一个学号,返回对应的姓名”的查询功能,则可以采用下图所示的哈希表来实现。
@ -41,7 +41,7 @@
hmap[10583] = "小鸭"
# 查询操作
# 向哈希表输入键 key ,得到值 value
# 向哈希表输入键 key ,得到值 value
name: str = hmap[15937]
# 删除操作
@ -64,7 +64,7 @@
map[10583] = "小鸭";
/* 查询操作 */
// 向哈希表输入键 key ,得到值 value
// 向哈希表输入键 key ,得到值 value
string name = map[15937];
/* 删除操作 */
@ -87,7 +87,7 @@
map.put(10583, "小鸭");
/* 查询操作 */
// 向哈希表输入键 key ,得到值 value
// 向哈希表输入键 key ,得到值 value
String name = map.get(15937);
/* 删除操作 */
@ -110,7 +110,7 @@
};
/* 查询操作 */
// 向哈希表输入键 key ,得到值 value
// 向哈希表输入键 key ,得到值 value
string name = map[15937];
/* 删除操作 */
@ -133,7 +133,7 @@
hmap[10583] = "小鸭"
/* 查询操作 */
// 向哈希表输入键 key ,得到值 value
// 向哈希表输入键 key ,得到值 value
name := hmap[15937]
/* 删除操作 */
@ -156,7 +156,7 @@
map[10583] = "小鸭"
/* 查询操作 */
// 向哈希表输入键 key ,得到值 value
// 向哈希表输入键 key ,得到值 value
let name = map[15937]!
/* 删除操作 */
@ -178,7 +178,7 @@
map.set(10583, '小鸭');
/* 查询操作 */
// 向哈希表输入键 key ,得到值 value
// 向哈希表输入键 key ,得到值 value
let name = map.get(15937);
/* 删除操作 */
@ -202,7 +202,7 @@
console.info(map);
/* 查询操作 */
// 向哈希表输入键 key ,得到值 value
// 向哈希表输入键 key ,得到值 value
let name = map.get(15937);
console.info('\n输入学号 15937 ,查询到姓名 ' + name);
@ -228,7 +228,7 @@
map[10583] = "小鸭";
/* 查询操作 */
// 向哈希表输入键 key ,得到值 value
// 向哈希表输入键 key ,得到值 value
String name = map[15937];
/* 删除操作 */
@ -512,6 +512,6 @@ index = hash(key) % capacity
![哈希表扩容](hash_map.assets/hash_table_reshash.png)
类似于数组扩容,哈希表扩容需将所有键值对从原哈希表迁移至新哈希表,非常耗时;并且由于哈希表容量 `capacity` 改变,我们需要通过哈希函数来重新计算所有键值对的存储位置,这进一步提高了扩容过程的计算开销。为此,编程语言通常会预留足够大的哈希表容量,防止频繁扩容。
类似于数组扩容,哈希表扩容需将所有键值对从原哈希表迁移至新哈希表,非常耗时;并且由于哈希表容量 `capacity` 改变,我们需要通过哈希函数来重新计算所有键值对的存储位置,这进一步增加了扩容过程的计算开销。为此,编程语言通常会预留足够大的哈希表容量,防止频繁扩容。
「负载因子 load factor」是哈希表的一个重要概念其定义为哈希表的元素数量除以桶数量用于衡量哈希冲突的严重程度**也常作为哈希表扩容的触发条件**。例如在 Java 中,当负载因子超过 $0.75$ 时,系统会将哈希表扩容至原先的 $2$ 倍。

View File

@ -44,4 +44,4 @@
!!! question "为什么哈希表扩容能够缓解哈希冲突?"
哈希函数的最后一步往往是对数组长度 $n$ 取,让输出值落在数组索引范围内;在扩容后,数组长度 $n$ 发生变化,而 `key` 对应的索引也可能发生变化。原先落在同一个桶的多个 `key` ,在扩容后可能会被分配到多个桶中,从而实现哈希冲突的缓解。
哈希函数的最后一步往往是对数组长度 $n$ 取模(取余),让输出值落在数组索引范围内;在扩容后,数组长度 $n$ 发生变化,而 `key` 对应的索引也可能发生变化。原先落在同一个桶的多个 `key` ,在扩容后可能会被分配到多个桶中,从而实现哈希冲突的缓解。