mirror of
https://github.com/krahets/hello-algo.git
synced 2026-04-04 19:20:52 +08:00
feat: Revised the book (#978)
* Sync recent changes to the revised Word. * Revised the preface chapter * Revised the introduction chapter * Revised the computation complexity chapter * Revised the chapter data structure * Revised the chapter array and linked list * Revised the chapter stack and queue * Revised the chapter hashing * Revised the chapter tree * Revised the chapter heap * Revised the chapter graph * Revised the chapter searching * Reivised the sorting chapter * Revised the divide and conquer chapter * Revised the chapter backtacking * Revised the DP chapter * Revised the greedy chapter * Revised the appendix chapter * Revised the preface chapter doubly * Revised the figures
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
# 哈希算法
|
||||
|
||||
在上两节中,我们了解了哈希表的工作原理和哈希冲突的处理方法。然而无论是开放寻址还是链地址法,**它们只能保证哈希表可以在发生冲突时正常工作,但无法减少哈希冲突的发生**。
|
||||
前两节介绍了哈希表的工作原理和哈希冲突的处理方法。然而无论是开放寻址还是链式地址,**它们只能保证哈希表可以在发生冲突时正常工作,而无法减少哈希冲突的发生**。
|
||||
|
||||
如果哈希冲突过于频繁,哈希表的性能则会急剧劣化。如下图所示,对于链地址哈希表,理想情况下键值对平均分布在各个桶中,达到最佳查询效率;最差情况下所有键值对都被存储到同一个桶中,时间复杂度退化至 $O(n)$ 。
|
||||
如果哈希冲突过于频繁,哈希表的性能则会急剧劣化。如下图所示,对于链式地址哈希表,理想情况下键值对均匀分布在各个桶中,达到最佳查询效率;最差情况下所有键值对都存储到同一个桶中,时间复杂度退化至 $O(n)$ 。
|
||||
|
||||

|
||||

|
||||
|
||||
**键值对的分布情况由哈希函数决定**。回忆哈希函数的计算步骤,先计算哈希值,再对数组长度取模:
|
||||
|
||||
@@ -14,25 +14,25 @@ index = hash(key) % capacity
|
||||
|
||||
观察以上公式,当哈希表容量 `capacity` 固定时,**哈希算法 `hash()` 决定了输出值**,进而决定了键值对在哈希表中的分布情况。
|
||||
|
||||
这意味着,为了减小哈希冲突的发生概率,我们应当将注意力集中在哈希算法 `hash()` 的设计上。
|
||||
这意味着,为了降低哈希冲突的发生概率,我们应当将注意力集中在哈希算法 `hash()` 的设计上。
|
||||
|
||||
## 哈希算法的目标
|
||||
|
||||
为了实现“既快又稳”的哈希表数据结构,哈希算法应包含以下特点。
|
||||
为了实现“既快又稳”的哈希表数据结构,哈希算法应具备以下特点。
|
||||
|
||||
- **确定性**:对于相同的输入,哈希算法应始终产生相同的输出。这样才能确保哈希表是可靠的。
|
||||
- **效率高**:计算哈希值的过程应该足够快。计算开销越小,哈希表的实用性越高。
|
||||
- **均匀分布**:哈希算法应使得键值对平均分布在哈希表中。分布越平均,哈希冲突的概率就越低。
|
||||
- **均匀分布**:哈希算法应使得键值对均匀分布在哈希表中。分布越均匀,哈希冲突的概率就越低。
|
||||
|
||||
实际上,哈希算法除了可以用于实现哈希表,还广泛应用于其他领域中。
|
||||
|
||||
- **密码存储**:为了保护用户密码的安全,系统通常不会直接存储用户的明文密码,而是存储密码的哈希值。当用户输入密码时,系统会对输入的密码计算哈希值,然后与存储的哈希值进行比较。如果两者匹配,那么密码就被视为正确。
|
||||
- **数据完整性检查**:数据发送方可以计算数据的哈希值并将其一同发送;接收方可以重新计算接收到的数据的哈希值,并与接收到的哈希值进行比较。如果两者匹配,那么数据就被视为完整的。
|
||||
- **数据完整性检查**:数据发送方可以计算数据的哈希值并将其一同发送;接收方可以重新计算接收到的数据的哈希值,并与接收到的哈希值进行比较。如果两者匹配,那么数据就被视为完整。
|
||||
|
||||
对于密码学的相关应用,为了防止从哈希值推导出原始密码等逆向工程,哈希算法需要具备更高等级的安全特性。
|
||||
|
||||
- **单向性**:无法通过哈希值反推出关于输入数据的任何信息。
|
||||
- **抗碰撞性**:应当极其困难找到两个不同的输入,使得它们的哈希值相同。
|
||||
- **抗碰撞性**:应当极难找到两个不同的输入,使得它们的哈希值相同。
|
||||
- **雪崩效应**:输入的微小变化应当导致输出的显著且不可预测的变化。
|
||||
|
||||
请注意,**“均匀分布”与“抗碰撞性”是两个独立的概念**,满足均匀分布不一定满足抗碰撞性。例如,在随机输入 `key` 下,哈希函数 `key % 100` 可以产生均匀分布的输出。然而该哈希算法过于简单,所有后两位相等的 `key` 的输出都相同,因此我们可以很容易地从哈希值反推出可用的 `key` ,从而破解密码。
|
||||
@@ -42,7 +42,7 @@ index = hash(key) % capacity
|
||||
哈希算法的设计是一个需要考虑许多因素的复杂问题。然而对于某些要求不高的场景,我们也能设计一些简单的哈希算法。
|
||||
|
||||
- **加法哈希**:对输入的每个字符的 ASCII 码进行相加,将得到的总和作为哈希值。
|
||||
- **乘法哈希**:利用了乘法的不相关性,每轮乘以一个常数,将各个字符的 ASCII 码累积到哈希值中。
|
||||
- **乘法哈希**:利用乘法的不相关性,每轮乘以一个常数,将各个字符的 ASCII 码累积到哈希值中。
|
||||
- **异或哈希**:将输入数据的每个元素通过异或操作累积到一个哈希值中。
|
||||
- **旋转哈希**:将每个字符的 ASCII 码累积到一个哈希值中,每次累积之前都会对哈希值进行旋转操作。
|
||||
|
||||
@@ -52,9 +52,9 @@ index = hash(key) % capacity
|
||||
|
||||
观察发现,每种哈希算法的最后一步都是对大质数 $1000000007$ 取模,以确保哈希值在合适的范围内。值得思考的是,为什么要强调对质数取模,或者说对合数取模的弊端是什么?这是一个有趣的问题。
|
||||
|
||||
先抛出结论:**当我们使用大质数作为模数时,可以最大化地保证哈希值的均匀分布**。因为质数不会与其他数字存在公约数,可以减少因取模操作而产生的周期性模式,从而避免哈希冲突。
|
||||
先抛出结论:**使用大质数作为模数,可以最大化地保证哈希值的均匀分布**。因为质数不与其他数字存在公约数,可以减少因取模操作而产生的周期性模式,从而避免哈希冲突。
|
||||
|
||||
举个例子,假设我们选择合数 $9$ 作为模数,它可以被 $3$ 整除。那么所有可以被 $3$ 整除的 `key` 都会被映射到 $0$、$3$、$6$ 这三个哈希值。
|
||||
举个例子,假设我们选择合数 $9$ 作为模数,它可以被 $3$ 整除,那么所有可以被 $3$ 整除的 `key` 都会被映射到 $0$、$3$、$6$ 这三个哈希值。
|
||||
|
||||
$$
|
||||
\begin{aligned}
|
||||
@@ -64,7 +64,7 @@ $$
|
||||
\end{aligned}
|
||||
$$
|
||||
|
||||
如果输入 `key` 恰好满足这种等差数列的数据分布,那么哈希值就会出现聚堆,从而加重哈希冲突。现在,假设将 `modulus` 替换为质数 $13$ ,由于 `key` 和 `modulus` 之间不存在公约数,输出的哈希值的均匀性会明显提升。
|
||||
如果输入 `key` 恰好满足这种等差数列的数据分布,那么哈希值就会出现聚堆,从而加重哈希冲突。现在,假设将 `modulus` 替换为质数 $13$ ,由于 `key` 和 `modulus` 之间不存在公约数,因此输出的哈希值的均匀性会明显提升。
|
||||
|
||||
$$
|
||||
\begin{aligned}
|
||||
@@ -74,7 +74,7 @@ $$
|
||||
\end{aligned}
|
||||
$$
|
||||
|
||||
值得说明的是,如果能够保证 `key` 是随机均匀分布的,那么选择质数或者合数作为模数都是可以的,它们都能输出均匀分布的哈希值。而当 `key` 的分布存在某种周期性时,对合数取模更容易出现聚集现象。
|
||||
值得说明的是,如果能够保证 `key` 是随机均匀分布的,那么选择质数或者合数作为模数都可以,它们都能输出均匀分布的哈希值。而当 `key` 的分布存在某种周期性时,对合数取模更容易出现聚集现象。
|
||||
|
||||
总而言之,我们通常选取质数作为模数,并且这个质数最好足够大,以尽可能消除周期性模式,提升哈希算法的稳健性。
|
||||
|
||||
@@ -82,12 +82,12 @@ $$
|
||||
|
||||
不难发现,以上介绍的简单哈希算法都比较“脆弱”,远远没有达到哈希算法的设计目标。例如,由于加法和异或满足交换律,因此加法哈希和异或哈希无法区分内容相同但顺序不同的字符串,这可能会加剧哈希冲突,并引起一些安全问题。
|
||||
|
||||
在实际中,我们通常会用一些标准哈希算法,例如 MD5、SHA-1、SHA-2、SHA3 等。它们可以将任意长度的输入数据映射到恒定长度的哈希值。
|
||||
在实际中,我们通常会用一些标准哈希算法,例如 MD5、SHA-1、SHA-2、SHA-3 等。它们可以将任意长度的输入数据映射到恒定长度的哈希值。
|
||||
|
||||
近一个世纪以来,哈希算法处在不断升级与优化的过程中。一部分研究人员努力提升哈希算法的性能,另一部分研究人员和黑客则致力于寻找哈希算法的安全性问题。下表展示了在实际应用中常见的哈希算法。
|
||||
|
||||
- MD5 和 SHA-1 已多次被成功攻击,因此它们被各类安全应用弃用。
|
||||
- SHA-2 系列中的 SHA-256 是最安全的哈希算法之一,仍未出现成功的攻击案例,因此常被用在各类安全应用与协议中。
|
||||
- SHA-2 系列中的 SHA-256 是最安全的哈希算法之一,仍未出现成功的攻击案例,因此常用在各类安全应用与协议中。
|
||||
- SHA-3 相较 SHA-2 的实现开销更低、计算效率更高,但目前使用覆盖度不如 SHA-2 系列。
|
||||
|
||||
<p align="center"> 表 <id> 常见的哈希算法 </p>
|
||||
@@ -95,7 +95,7 @@ $$
|
||||
| | MD5 | SHA-1 | SHA-2 | SHA-3 |
|
||||
| -------- | ------------------------------ | ---------------- | ---------------------------- | -------------------- |
|
||||
| 推出时间 | 1992 | 1995 | 2002 | 2008 |
|
||||
| 输出长度 | 128 bits | 160 bits | 256 / 512 bits | 224/256/384/512 bits |
|
||||
| 输出长度 | 128 bits | 160 bits | 256/512 bits | 224/256/384/512 bits |
|
||||
| 哈希冲突 | 较多 | 较多 | 很少 | 很少 |
|
||||
| 安全等级 | 低,已被成功攻击 | 低,已被成功攻击 | 高 | 高 |
|
||||
| 应用 | 已被弃用,仍用于数据完整性检查 | 已被弃用 | 加密货币交易验证、数字签名等 | 可用于替代 SHA-2 |
|
||||
@@ -105,7 +105,7 @@ $$
|
||||
我们知道,哈希表的 `key` 可以是整数、小数或字符串等数据类型。编程语言通常会为这些数据类型提供内置的哈希算法,用于计算哈希表中的桶索引。以 Python 为例,我们可以调用 `hash()` 函数来计算各种数据类型的哈希值。
|
||||
|
||||
- 整数和布尔量的哈希值就是其本身。
|
||||
- 浮点数和字符串的哈希值计算较为复杂,有兴趣的同学请自行学习。
|
||||
- 浮点数和字符串的哈希值计算较为复杂,有兴趣的读者请自行学习。
|
||||
- 元组的哈希值是对其中每一个元素进行哈希,然后将这些哈希值组合起来,得到单一的哈希值。
|
||||
- 对象的哈希值基于其内存地址生成。通过重写对象的哈希方法,可实现基于内容生成哈希值。
|
||||
|
||||
@@ -130,7 +130,7 @@ $$
|
||||
|
||||
str = "Hello 算法"
|
||||
hash_str = hash(str)
|
||||
# 字符串 Hello 算法 的哈希值为 4617003410720528961
|
||||
# 字符串“Hello 算法”的哈希值为 4617003410720528961
|
||||
|
||||
tup = (12836, "小哈")
|
||||
hash_tup = hash(tup)
|
||||
@@ -158,7 +158,7 @@ $$
|
||||
|
||||
string str = "Hello 算法";
|
||||
size_t hashStr = hash<string>()(str);
|
||||
// 字符串 Hello 算法 的哈希值为 15466937326284535026
|
||||
// 字符串“Hello 算法”的哈希值为 15466937326284535026
|
||||
|
||||
// 在 C++ 中,内置 std:hash() 仅提供基本数据类型的哈希值计算
|
||||
// 数组、对象的哈希值计算需要自行实现
|
||||
@@ -181,7 +181,7 @@ $$
|
||||
|
||||
String str = "Hello 算法";
|
||||
int hashStr = str.hashCode();
|
||||
// 字符串 Hello 算法 的哈希值为 -727081396
|
||||
// 字符串“Hello 算法”的哈希值为 -727081396
|
||||
|
||||
Object[] arr = { 12836, "小哈" };
|
||||
int hashTup = Arrays.hashCode(arr);
|
||||
@@ -209,7 +209,7 @@ $$
|
||||
|
||||
string str = "Hello 算法";
|
||||
int hashStr = str.GetHashCode();
|
||||
// 字符串 Hello 算法 的哈希值为 -586107568;
|
||||
// 字符串“Hello 算法”的哈希值为 -586107568;
|
||||
|
||||
object[] arr = [12836, "小哈"];
|
||||
int hashTup = arr.GetHashCode();
|
||||
@@ -243,7 +243,7 @@ $$
|
||||
|
||||
let str = "Hello 算法"
|
||||
let hashStr = str.hashValue
|
||||
// 字符串 Hello 算法 的哈希值为 -7850626797806988787
|
||||
// 字符串“Hello 算法”的哈希值为 -7850626797806988787
|
||||
|
||||
let arr = [AnyHashable(12836), AnyHashable("小哈")]
|
||||
let hashTup = arr.hashValue
|
||||
@@ -283,7 +283,7 @@ $$
|
||||
|
||||
String str = "Hello 算法";
|
||||
int hashStr = str.hashCode;
|
||||
// 字符串 Hello 算法 的哈希值为 468167534
|
||||
// 字符串“Hello 算法”的哈希值为 468167534
|
||||
|
||||
List arr = [12836, "小哈"];
|
||||
int hashArr = arr.hashCode;
|
||||
@@ -323,7 +323,7 @@ $$
|
||||
let mut str_hasher = DefaultHasher::new();
|
||||
str.hash(&mut str_hasher);
|
||||
let hash_str = str_hasher.finish();
|
||||
// 字符串 Hello 算法 的哈希值为 16092673739211250988
|
||||
// 字符串“Hello 算法”的哈希值为 16092673739211250988
|
||||
|
||||
let arr = (&12836, &"小哈");
|
||||
let mut tup_hasher = DefaultHasher::new();
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
@@ -1,10 +1,10 @@
|
||||
# 哈希冲突
|
||||
|
||||
上节提到,**通常情况下哈希函数的输入空间远大于输出空间**,因此理论上哈希冲突是不可避免的。比如,输入空间为全体整数,输出空间为数组容量大小,则必然有多个整数映射至同一桶索引。
|
||||
上一节提到,**通常情况下哈希函数的输入空间远大于输出空间**,因此理论上哈希冲突是不可避免的。比如,输入空间为全体整数,输出空间为数组容量大小,则必然有多个整数映射至同一桶索引。
|
||||
|
||||
哈希冲突会导致查询结果错误,严重影响哈希表的可用性。为解决该问题,我们可以每当遇到哈希冲突时就进行哈希表扩容,直至冲突消失为止。此方法简单粗暴且有效,但效率太低,因为哈希表扩容需要进行大量的数据搬运与哈希值计算。为了提升效率,我们可以采用以下策略。
|
||||
哈希冲突会导致查询结果错误,严重影响哈希表的可用性。为解决该问题,我们可以每当遇到哈希冲突就进行哈希表扩容,直至冲突消失为止。此方法简单粗暴且有效,但效率太低,因为哈希表扩容需要进行大量的数据搬运与哈希值计算。为了提升效率,我们可以采用以下策略。
|
||||
|
||||
1. 改良哈希表数据结构,**使得哈希表可以在存在哈希冲突时正常工作**。
|
||||
1. 改良哈希表数据结构,**使得哈希表可以在出现哈希冲突时正常工作**。
|
||||
2. 仅在必要时,即当哈希冲突比较严重时,才执行扩容操作。
|
||||
|
||||
哈希表的结构改良方法主要包括“链式地址”和“开放寻址”。
|
||||
@@ -18,8 +18,8 @@
|
||||
基于链式地址实现的哈希表的操作方法发生了以下变化。
|
||||
|
||||
- **查询元素**:输入 `key` ,经过哈希函数得到桶索引,即可访问链表头节点,然后遍历链表并对比 `key` 以查找目标键值对。
|
||||
- **添加元素**:先通过哈希函数访问链表头节点,然后将节点(即键值对)添加到链表中。
|
||||
- **删除元素**:根据哈希函数的结果访问链表头部,接着遍历链表以查找目标节点,并将其删除。
|
||||
- **添加元素**:首先通过哈希函数访问链表头节点,然后将节点(键值对)添加到链表中。
|
||||
- **删除元素**:根据哈希函数的结果访问链表头部,接着遍历链表以查找目标节点并将其删除。
|
||||
|
||||
链式地址存在以下局限性。
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
以下代码给出了链式地址哈希表的简单实现,需要注意两点。
|
||||
|
||||
- 使用列表(动态数组)代替链表,从而简化代码。在这种设定下,哈希表(数组)包含多个桶,每个桶都是一个列表。
|
||||
- 以下实现包含哈希表扩容方法。当负载因子超过 $\frac{2}{3}$ 时,我们将哈希表扩容至 $2$ 倍。
|
||||
- 以下实现包含哈希表扩容方法。当负载因子超过 $\frac{2}{3}$ 时,我们将哈希表扩容至原先的 $2$ 倍。
|
||||
|
||||
```src
|
||||
[file]{hash_map_chaining}-[class]{hash_map_chaining}-[func]{}
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
「开放寻址 open addressing」不引入额外的数据结构,而是通过“多次探测”来处理哈希冲突,探测方式主要包括线性探测、平方探测、多次哈希等。
|
||||
|
||||
下面将主要以线性探测为例,介绍开放寻址哈希表的工作机制与代码实现。
|
||||
下面以线性探测为例,介绍开放寻址哈希表的工作机制。
|
||||
|
||||
### 线性探测
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
|
||||
为此,考虑在线性探测中记录遇到的首个 `TOMBSTONE` 的索引,并将搜索到的目标元素与该 `TOMBSTONE` 交换位置。这样做的好处是当每次查询或添加元素时,元素会被移动至距离理想位置(探测起始点)更近的桶,从而优化查询效率。
|
||||
|
||||
以下代码实现了一个包含懒删除的开放寻址(线性探测)哈希表。为了更加充分地使用哈希表的空间,我们将哈希表看作是一个“环形数组”,当越过数组尾部时,回到头部继续遍历。
|
||||
以下代码实现了一个包含懒删除的开放寻址(线性探测)哈希表。为了更加充分地使用哈希表的空间,我们将哈希表看作一个“环形数组”,当越过数组尾部时,回到头部继续遍历。
|
||||
|
||||
```src
|
||||
[file]{hash_map_open_addressing}-[class]{hash_map_open_addressing}-[func]{}
|
||||
@@ -88,12 +88,12 @@
|
||||
|
||||
### 多次哈希
|
||||
|
||||
多次哈希使用多个哈希函数 $f_1(x)$、$f_2(x)$、$f_3(x)$、$\dots$ 进行探测。
|
||||
顾名思义,多次哈希方法使用多个哈希函数 $f_1(x)$、$f_2(x)$、$f_3(x)$、$\dots$ 进行探测。
|
||||
|
||||
- **插入元素**:若哈希函数 $f_1(x)$ 出现冲突,则尝试 $f_2(x)$ ,以此类推,直到找到空桶后插入元素。
|
||||
- **查找元素**:在相同的哈希函数顺序下进行查找,直到找到目标元素时返回;或当遇到空桶或已尝试所有哈希函数,说明哈希表中不存在该元素,则返回 $\text{None}$ 。
|
||||
- **查找元素**:在相同的哈希函数顺序下进行查找,直到找到目标元素时返回;若遇到空桶或已尝试所有哈希函数,说明哈希表中不存在该元素,则返回 $\text{None}$ 。
|
||||
|
||||
与线性探测相比,多次哈希方法不易产生聚集,但多个哈希函数会增加额外的计算量。
|
||||
与线性探测相比,多次哈希方法不易产生聚集,但多个哈希函数会带来额外的计算量。
|
||||
|
||||
!!! tip
|
||||
|
||||
@@ -103,6 +103,6 @@
|
||||
|
||||
各个编程语言采取了不同的哈希表实现策略,以下举几个例子。
|
||||
|
||||
- Java 采用链式地址。自 JDK 1.8 以来,当 HashMap 内数组长度达到 64 且链表长度达到 8 时,链表会被转换为红黑树以提升查找性能。
|
||||
- Python 采用开放寻址。字典 dict 使用伪随机数进行探测。
|
||||
- Golang 采用链式地址。Go 规定每个桶最多存储 8 个键值对,超出容量则连接一个溢出桶。当溢出桶过多时,会执行一次特殊的等量扩容操作,以确保性能。
|
||||
- Java 采用链式地址。自 JDK 1.8 以来,当 HashMap 内数组长度达到 64 且链表长度达到 8 时,链表会转换为红黑树以提升查找性能。
|
||||
- Go 采用链式地址。Go 规定每个桶最多存储 8 个键值对,超出容量则连接一个溢出桶。当溢出桶过多时,会执行一次特殊的等量扩容操作,以确保性能。
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
## 哈希表常用操作
|
||||
|
||||
哈希表的常见操作包括:初始化、查询操作、添加键值对和删除键值对等。
|
||||
哈希表的常见操作包括:初始化、查询操作、添加键值对和删除键值对等,示例代码如下:
|
||||
|
||||
=== "Python"
|
||||
|
||||
@@ -273,7 +273,7 @@
|
||||
|
||||
```
|
||||
|
||||
哈希表有三种常用遍历方式:遍历键值对、遍历键和遍历值。
|
||||
哈希表有三种常用的遍历方式:遍历键值对、遍历键和遍历值。示例代码如下:
|
||||
|
||||
=== "Python"
|
||||
|
||||
@@ -468,7 +468,7 @@
|
||||
|
||||
我们先考虑最简单的情况,**仅用一个数组来实现哈希表**。在哈希表中,我们将数组中的每个空位称为「桶 bucket」,每个桶可存储一个键值对。因此,查询操作就是找到 `key` 对应的桶,并在桶中获取 `value` 。
|
||||
|
||||
那么,如何基于 `key` 来定位对应的桶呢?这是通过「哈希函数 hash function」实现的。哈希函数的作用是将一个较大的输入空间映射到一个较小的输出空间。在哈希表中,输入空间是所有 `key` ,输出空间是所有桶(数组索引)。换句话说,输入一个 `key` ,**我们可以通过哈希函数得到该 `key` 对应的键值对在数组中的存储位置**。
|
||||
那么,如何基于 `key` 定位对应的桶呢?这是通过「哈希函数 hash function」实现的。哈希函数的作用是将一个较大的输入空间映射到一个较小的输出空间。在哈希表中,输入空间是所有 `key` ,输出空间是所有桶(数组索引)。换句话说,输入一个 `key` ,**我们可以通过哈希函数得到该 `key` 对应的键值对在数组中的存储位置**。
|
||||
|
||||
输入一个 `key` ,哈希函数的计算过程分为以下两步。
|
||||
|
||||
@@ -493,7 +493,7 @@ index = hash(key) % capacity
|
||||
|
||||
## 哈希冲突与扩容
|
||||
|
||||
本质上看,哈希函数的作用是将所有 `key` 构成的输入空间映射到数组所有索引构成的输出空间,而输入空间往往远大于输出空间。因此,**理论上一定存在“多个输入对应相同输出”的情况**。
|
||||
从本质上看,哈希函数的作用是将所有 `key` 构成的输入空间映射到数组所有索引构成的输出空间,而输入空间往往远大于输出空间。因此,**理论上一定存在“多个输入对应相同输出”的情况**。
|
||||
|
||||
对于上述示例中的哈希函数,当输入的 `key` 后两位相同时,哈希函数的输出结果也相同。例如,查询学号为 12836 和 20336 的两个学生时,我们得到:
|
||||
|
||||
@@ -512,6 +512,6 @@ index = hash(key) % capacity
|
||||
|
||||

|
||||
|
||||
类似于数组扩容,哈希表扩容需将所有键值对从原哈希表迁移至新哈希表,非常耗时。并且由于哈希表容量 `capacity` 改变,我们需要通过哈希函数来重新计算所有键值对的存储位置,这进一步提高了扩容过程的计算开销。为此,编程语言通常会预留足够大的哈希表容量,防止频繁扩容。
|
||||
类似于数组扩容,哈希表扩容需将所有键值对从原哈希表迁移至新哈希表,非常耗时;并且由于哈希表容量 `capacity` 改变,我们需要通过哈希函数来重新计算所有键值对的存储位置,这进一步提高了扩容过程的计算开销。为此,编程语言通常会预留足够大的哈希表容量,防止频繁扩容。
|
||||
|
||||
「负载因子 load factor」是哈希表的一个重要概念,其定义为哈希表的元素数量除以桶数量,用于衡量哈希冲突的严重程度,**也常被作为哈希表扩容的触发条件**。例如在 Java 中,当负载因子超过 $0.75$ 时,系统会将哈希表容量扩展为原先的 $2$ 倍。
|
||||
「负载因子 load factor」是哈希表的一个重要概念,其定义为哈希表的元素数量除以桶数量,用于衡量哈希冲突的严重程度,**也常作为哈希表扩容的触发条件**。例如在 Java 中,当负载因子超过 $0.75$ 时,系统会将哈希表扩容至原先的 $2$ 倍。
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
|
||||
!!! abstract
|
||||
|
||||
在计算机世界中,哈希表如同一位智能的图书管理员。
|
||||
在计算机世界中,哈希表如同一位聪慧的图书管理员。
|
||||
|
||||
他知道如何计算索书号,从而可以快速找到目标书籍。
|
||||
他知道如何计算索书号,从而可以快速找到目标图书。
|
||||
|
||||
@@ -8,35 +8,35 @@
|
||||
- 两个不同的 `key` 可能在经过哈希函数后得到相同的数组索引,导致查询结果出错,这种现象被称为哈希冲突。
|
||||
- 哈希表容量越大,哈希冲突的概率就越低。因此可以通过扩容哈希表来缓解哈希冲突。与数组扩容类似,哈希表扩容操作的开销很大。
|
||||
- 负载因子定义为哈希表中元素数量除以桶数量,反映了哈希冲突的严重程度,常用作触发哈希表扩容的条件。
|
||||
- 链式地址通过将单个元素转化为链表,将所有冲突元素存储在同一个链表中。然而,链表过长会降低查询效率,可以进一步将链表转换为红黑树来提高效率。
|
||||
- 链式地址通过将单个元素转化为链表,将所有冲突元素存储在同一个链表中。然而,链表过长会降低查询效率,可以通过进一步将链表转换为红黑树来提高效率。
|
||||
- 开放寻址通过多次探测来处理哈希冲突。线性探测使用固定步长,缺点是不能删除元素,且容易产生聚集。多次哈希使用多个哈希函数进行探测,相较线性探测更不易产生聚集,但多个哈希函数增加了计算量。
|
||||
- 不同编程语言采取了不同的哈希表实现。例如,Java 的 `HashMap` 使用链式地址,而 Python 的 `Dict` 采用开放寻址。
|
||||
- 在哈希表中,我们希望哈希算法具有确定性、高效率和均匀分布的特点。在密码学中,哈希算法还应该具备抗碰撞性和雪崩效应。
|
||||
- 哈希算法通常采用大质数作为模数,以最大化地保证哈希值的均匀分布,减少哈希冲突。
|
||||
- 常见的哈希算法包括 MD5、SHA-1、SHA-2 和 SHA3 等。MD5 常用于校验文件完整性,SHA-2 常用于安全应用与协议。
|
||||
- 哈希算法通常采用大质数作为模数,以最大化地保证哈希值均匀分布,减少哈希冲突。
|
||||
- 常见的哈希算法包括 MD5、SHA-1、SHA-2 和 SHA-3 等。MD5 常用于校验文件完整性,SHA-2 常用于安全应用与协议。
|
||||
- 编程语言通常会为数据类型提供内置哈希算法,用于计算哈希表中的桶索引。通常情况下,只有不可变对象是可哈希的。
|
||||
|
||||
### Q & A
|
||||
|
||||
!!! question "哈希表的时间复杂度为什么不是 $O(n)$ ?"
|
||||
|
||||
当哈希冲突比较严重时,哈希表的时间复杂度会退化至 $O(n)$ 。当哈希函数设计的比较好、容量设置比较合理、冲突比较平均时,时间复杂度是 $O(1)$ 。我们使用编程语言内置的哈希表时,通常认为时间复杂度是 $O(1)$ 。
|
||||
当哈希冲突比较严重时,哈希表的时间复杂度会退化至 $O(n)$ 。当哈希函数设计得比较好、容量设置比较合理、冲突比较平均时,时间复杂度是 $O(1)$ 。我们使用编程语言内置的哈希表时,通常认为时间复杂度是 $O(1)$ 。
|
||||
|
||||
!!! question "为什么不使用哈希函数 $f(x) = x$ 呢?这样就不会有冲突了"
|
||||
|
||||
在 $f(x) = x$ 哈希函数下,每个元素对应唯一的桶索引,这与数组等价。然而,输入空间通常远大于输出空间(数组长度),因此哈希函数的最后一步往往是对数组长度取模。换句话说,哈希表的目标是将一个较大的状态空间映射到一个较小的空间,并提供 $O(1)$ 的查询效率。
|
||||
|
||||
!!! question "哈希表底层实现是数组、链表、二叉树,但为什么效率可以比他们更高呢?"
|
||||
!!! question "哈希表底层实现是数组、链表、二叉树,但为什么效率可以比它们更高呢?"
|
||||
|
||||
首先,哈希表的时间效率变高,但空间效率变低了。哈希表有相当一部分的内存是未使用的,
|
||||
首先,哈希表的时间效率变高,但空间效率变低了。哈希表有相当一部分内存未使用。
|
||||
|
||||
其次,只是在特定使用场景下时间效率变高了。如果一个功能能够在相同的时间复杂度下使用数组或链表实现,那么通常比哈希表更快。这是因为哈希函数计算需要开销,时间复杂度的常数项更大。
|
||||
|
||||
最后,哈希表的时间复杂度可能发生劣化。例如在链式地址中,我们采取在链表或红黑树中执行查找操作,仍然有退化至 $O(n)$ 时间的风险。
|
||||
|
||||
!!! question "多次哈希有不能直接删除元素的缺陷吗?对于标记已删除的空间,这个空间还能再次使用吗?"
|
||||
!!! question "多次哈希有不能直接删除元素的缺陷吗?标记为已删除的空间还能再次使用吗?"
|
||||
|
||||
多次哈希是开放寻址的一种,开放寻址法都有不能直接删除元素的缺陷,需要通过标记删除。被标记为已删除的空间是可以再次被使用的。当将新元素插入哈希表,并且通过哈希函数找到了被标记为已删除的位置时,该位置可以被新的元素使用。这样做既能保持哈希表的探测序列不变,又能保证哈希表的空间使用率。
|
||||
多次哈希是开放寻址的一种,开放寻址法都有不能直接删除元素的缺陷,需要通过标记删除。标记为已删除的空间可以再次使用。当将新元素插入哈希表,并且通过哈希函数找到标记为已删除的位置时,该位置可以被新元素使用。这样做既能保持哈希表的探测序列不变,又能保证哈希表的空间使用率。
|
||||
|
||||
!!! question "为什么在线性探测中,查找元素的时候会出现哈希冲突呢?"
|
||||
|
||||
@@ -44,4 +44,4 @@
|
||||
|
||||
!!! question "为什么哈希表扩容能够缓解哈希冲突?"
|
||||
|
||||
哈希函数的最后一步往往是对数组长度 $n$ 取余,让输出值落入在数组索引范围;在扩容后,数组长度 $n$ 发生变化,而 `key` 对应的索引也可能发生变化。原先落在同一个桶的多个 `key` ,在扩容后可能会被分配到多个桶中,从而实现哈希冲突的缓解。
|
||||
哈希函数的最后一步往往是对数组长度 $n$ 取余,让输出值落在数组索引范围内;在扩容后,数组长度 $n$ 发生变化,而 `key` 对应的索引也可能发生变化。原先落在同一个桶的多个 `key` ,在扩容后可能会被分配到多个桶中,从而实现哈希冲突的缓解。
|
||||
|
||||
Reference in New Issue
Block a user