
大家好,很高兴又和大家见面啦!!! 在前面的内容中我们学习了两类查找算法:
这些算法各有其特点和适用场景,但也存在一些固有的局限性。
更重要的是,无论是 线性查找 还是 树形查找,在查找过程中都不可避免地要进行一系列关键字的比较操作,比较次数决定了查找效率的上限。 那么,是否存在一种查找方式,能够尽可能地减少比较次数,甚至通过关键字直接定位到存储地址呢? 答案是肯定的,这就是我们接下来要重点探讨的散列查找(Hash Search)。 散列查找 通过 哈希函数 建立关键字与存储地址的直接映射,理想情况下可以实现 O(1) 的时间复杂度,为我们突破传统比较查找的效率瓶颈提供了全新的思路。 下面,就让我们一起深入探究散列查找的奥秘。
散列表(又称 哈希表(Hash Table)):是一种根据关键字而直接进行访问的数据结构。特点是:数据元素的关键字与其存储地址直接相关。 散列函数(也称哈希函数):一个把查找表中的关键字映射成该关键字对应的地址的函数; 同义词:不同的关键字通过散列函数映射到了同一个值 冲突:通过散列函数确定的位置已经存放了其他元素
散列表 也就是我们所说的 哈希表 ,是一个能够将查找效率降至到 O(1) 的 高效查找表。 我们可以通过 散列函数:H(key)=Addr 来建立关键字与存储地址的联系,从而达到通过关键字直接进行访问的目的;
flowchart LR
subgraph Key[关键字]
end
Hash[散列函数]
subgraph Addr[地址]
end
Key --->Hash--->Addr可能有朋友不太理解 散列函数 中将 关键字映射到存储地址 这一概念,这里就需要我们从数学函数的角度来进行说明: 在函数中,通常包含三要素:
在 散列表 中,同样存在三要素:
从这里我们就不难看出 散列表 的三要素与 函数 的三要素之间的关系:
PS: 在实际的应用中,我们常使用 哈希表 ,哈希函数 的说法,因此这里我们直接统一其称呼,在后续的内容中,我们都会使用 哈希;
理解了 哈希表 的三要素后,下面我们就来说明一下什么是 映射关系; 简单的理解就是 关键字 与 存储地址 之间存在的某种联系,这里我们以我们熟悉的 数组 为例进行说明; 在 数组 中,我们可以通过 数组下标 直接访问对应的 数组元素;
flowchart LR
a[数组下标] ---> b[数组元素]但是实际的访问过程应该是:
flowchart LR
a[数组下标] ---> add[存储地址] ---> b[数组元素]在前面的学习中我们有介绍过,数组 中,每个下标都会对应一个存储地址,而下标则是当前地址相对于数组起始地址的偏移量:
$$ P_i = Add_i - Add_ 0$$
那也就是说,下标 与 存储地址 之间的关系为:P_i + Add_0 = Add_i。这个关系就是 下标 与 存储地址 之间的 映射关系;
在 哈希表 中,哈希函数 的作用就是在 关键字 与 存储地址 之间构建这么一个 映射关系 ; 这里我们举一个例子,假设我们所使用的 哈希函数 为:
这里的 L 指的是 哈希表 的总长度; 当 key 的取值均不大于 L 时,我们通过 哈希函数 得到的结果都是唯一的,即 关键字与存储地址一一对应。 这就好比当我们取 L = 7 时,从 1 \leq key \leq 7 这个 关键字 的范围中获取的 关键字 的值通过 哈希函数 得到的 存储地址 的值分别为:
$$ \begin{align*} Hash(1) &= 1 \bmod 7 = 1 \ Hash(2) &= 2 \bmod 7 = 2 \ Hash(3) &= 3 \bmod 7 = 3 \ Hash(4) &= 4 \bmod 7 = 4 \ Hash(5) &= 5 \bmod 7 = 5 \ Hash(6) &= 6 \bmod 7 = 6 \ Hash(7) &= 7 \bmod 7 = 0 \ \end{align*} $$
若我们将这些 关键字 通过 哈希函数 获得的值作为 数组下标 存储到一个长度为 7 的 数组 中时,我们就得到了一个 关键字与存储地址一一对应 的 哈希表:
1 | 2 | 3 | 4 | 5 | 6 | |
|---|---|---|---|---|---|---|
7 | 1 | 2 | 3 | 4 | 5 | 6 |
在这个哈希表中,我们进行查找时的效率可以达到 O(1) ; 当我们需要在该表中存储 key = 8 这个 关键字 时,通过 哈希函数 我们所得到的 数组下标 为:Hash(8) = 8 \bmod 7 = 1 ; 可以看到 key = 8 与 key = 1 通过 哈希函数 得到的值相同,这时我们就可以称其为 同义词; 当我们将该 关键字 存储到该 哈希表 中时,我们会发现下标为 1 的位置已经存储了一个 关键字 ,我们将这种情况称为 冲突;
装填因子\alpha :是评估 哈希表 性能的一个关键参数 其值我们可以通过下面的公式进行获取:
一个 哈希表 性能的好坏我们可以通过 装填因子 进行评估:
哈希表 是一种非常高效的数据结构,但它并非完美,确实存在一些固有的局限性;
理想情况下,哈希表 的查找时间复杂度为 O(1) ,即 关键字与存储地址之间一一对应; 但是在实际的使用中,冲突无法避免,当频繁的发生 冲突 时,这就会导致 哈希表 的性能下降,最坏的情况就是其查找的时间复杂度退化为 O(N);
1 | 2 | 3 | 4 | 5 | 6 | |
|---|---|---|---|---|---|---|
43 | 1 | 8 | 15 | 22 | 29 | 36 |
就比如上面这个 哈希表 ,其中每个 关键字 均为 同义词,当我们将其依次存储到 哈希表 中后,我们在后续的查找过程中就已经无法直接通过 哈希函数 直接查找到 目标关键字。 这时的查找过程就从 哈希查找 退化为了 顺序查找 ,其查找的时间复杂度也从 O(1) 退化到了 O(N);
除了 哈希表 中存在 哈希冲突 外, 哈希函数 也是直接影响 哈希表 性能的关键; 一个好的 哈希函数 应该尽可能的 减少冲突,这里我们还是以长度为 L = 7 的哈希表为例;
1 | 2 | 3 | 4 | 5 | 6 | |
|---|---|---|---|---|---|---|
7 | 1 | 2 | 3 | 4 | 5 | 6 |
1 | 2 | 3 | 4 | 5 | 6 | |
|---|---|---|---|---|---|---|
3 | 1 | 2 | 4 | 5 | 6 | 7 |
可以看到,在不同的 哈希函数 下,同样的 关键字集合 所得到的 哈希表 也是不同的:
因此我们在设计一个 哈希函数 时,应该尽量减少冲突,同时还要设计好 处理冲突的方法;
哈希查找 实际上是一个典型的 空间换时间 的查找算法。为了尽可能的减少冲突,我们希望 每一个关键字有且仅有唯一与之关联的存储地址。 就比如我要存储 0 \leq n \leq 100 这个范围内的任意个 关键字,最简便的方法就是创建一个长度 L \geq 100 的 哈希表 ,这样我们就可以通过 哈希函数 :Hash(key) = key 使得 关键字 与 存储地址 达到不存在冲突的 映射关系; 那也就是说,当 哈希表 的长度足够大时,我们可以通过 完美哈希函数:Hash(key) = key 来实现高效查找; 但此时就会导致一个问题:当预分配的 哈希表 长度为 100 ,但是我们只存储了 10 个关键字时,这就出现了大量的 空间浪费;
相比于 顺序表 、链表 这种有序结构,哈希表 中数据的存储结构属于 离散存储 ,因此 哈希表不支持高效的范围查询和顺序遍历; 这时有朋友可能就会问了,这个 离散存储 是什么意思? 简单的说就是 元素 与 元素 之间不存在 线性存储 中的 逻辑与物理位置均相邻 的关系;也不存在 链式存储 中的 逻辑相邻,物理位置不一定相邻 的关系;更不存在 索引存储 中的 索引表与元素相对应 的关系; 那也就是说,我们无法通过一个 元素 或者 索引值 找到另一个 元素 ,唯一能够确定元素位置的方法只有 哈希函数; 因此在 哈希表 中,若要进行顺序遍历,那么只能够得到关键字的 随机顺序;若要进行范围查找,那么只能够逐个判断关键字是否存在于 范围内;
哈希表 和 顺序表 一样,都是通过预先分配一个固定长度的空间,随后进行数据的存储; 当数据量增长需要扩容时,通常需要重建整个表(Rehashing),这是一次耗时操作; 这就好比 动态顺序表 的扩容操作,在 动态顺序表 中,若当前表长与最大表长相等时,就表明此时的表中已经存满了数据元素,无法继续插入新元素; 若此时我们需要继续向表中插入新元素,我们就需要对 顺序表 进行 扩容:
完成 扩容 后,我们就能继续向 顺序表 中插入新的元素; 哈希表 的扩容同样如此,为了确保 哈希表 的性能,我们会根据 装填因子\alpha 来判断是否需要进行 扩容 操作:
可以看到,哈希表 的 扩容 相比于 动态顺序表 会更加的复杂,整个扩容过程涉及两步关键过程:
由于 哈希表 是一个 离散存储 结构,因此移植的过程只能够将元素依次移植,这一过程非常的耗时;
通过本文的学习,我们系统地掌握了 散列查找 的核心原理与特性。 散列查找通过建立关键字与存储地址之间的直接映射,在理想情况下能够实现 O(1) 时间复杂度的查找效率,有效突破了传统基于比较的查找算法在效率上的瓶颈。 我们认识到,散列表的性能主要依赖于三个关键要素:
其中,冲突是散列查找中不可避免的现象,而优秀的 散列函数 能够有效减少冲突的发生。 同时,装填因子 \bm{\alpha} 作为 衡量散列表空间利用率和冲突概率的重要指标,通常需要维持在一个合理的范围内以平衡空间与时间效率。 然而,散列查找也存在一些固有的局限性:
尽管如此,通过精心设计的 散列函数 和合适的 冲突处理方法,散列查找在大多数需要快速点查询的场景中,依然展现出卓越的性能优势。 至此,我们已经理解了 散列查找的基本原理。而构建高效散列表的核心在于其 散列函数 的设计。 在下一篇内容中,我们将深入探讨 哈希函数的构造方法,详细分析直接定址法、除留余数法、平方取中法等多种经典 散列函数的设计思路、适用场景及优缺点,帮助大家掌握构建高效散列表的关键技能。 互动与分享
感谢您的耐心阅读! 关注博主,不错过更多技术干货。我们下一篇再见!