【银四末尾,你上岸了吗?】哈希表,快速计算、均匀分布、扩容实现

537 阅读44分钟

🧑‍💻 写在开头

也不知道银四还存不存在,往年社区是比现在热闹的🤣

点赞 + 收藏 === 学会🤣🤣🤣

上一篇前端面试手写必备【实现常见八大数据结构一】我们介绍并实现了常见的数据结构,包括数组、栈、队列、单向链表、双向链表、集合、字典结构。本篇我们将介绍,哈希表产生的原因、如何处理哈希冲突、哈希表的效率分析、实现哈希表的结构,主要是哈希函数的实现需要一些深入的思考理解,这里的实现使用的是链式存储,至于是为什么,下文中有解答。

🥑 你能学到什么?

希望在你阅读本篇文章之后,不会觉得浪费了时间。如果你跟着读下来,你将会学到:

  • 理解哈希表产生的原因
  • 什么是哈希化,哈希化都有哪些方式
  • 如何处理哈希冲突
  • 哈希化的效率分析
  • 秦九韶算法在实现哈希表中的关键作用
  • 实现哈希表的结构-链式
  • 哈希表的扩容

🍐 一、为什么哈希表这种结构会产生?

哈希表(Hash Table),也被称为散列表,是一种结合了数组和链表(或其他高效的查找结构如红黑树)优点的数据结构。哈希表的产生与两个主要需求相关:一是高效的数据存取速度,二是对于数据的快速查找。

哈希表最关键的地方就是:均匀分布&快速访问,数据均匀分布主要是减少冲突,快速访问则是这个结构诞生的原因。

哈希表产生的原因:

  1. 快速访问数据:数组结构可以实现快速访问元素,因为数组允许通过索引来直接访问其元素,时间复杂度为O(1)。但前提是你需要知道要访问元素的索引,这在实际应用中不总是可行。
  2. 有效的搜索操作:链表允许你快速地添加和删除元素,但查找元素时却需要从头遍历,最差的时间复杂度是O(n)
  3. 键值对映射:在实际应用中,我们经常需要保存一些关联数据,例如一个人的名字和他的个人信息。这就要求一个能够根据“名字”这样的“键(Key)”快速访问“个人信息”这样的“值(Value)”的数据结构。

如何工作:

哈希表利用一个哈希函数来计算存储数据的地址。理想情况下,这个哈希函数应能为不同键生成独一无二的地址,实际上这很难做到,因此会产生哈希碰撞。为了解决哈希碰撞,可以使用各种策略(如链地址法、开放定址法等)。

实际例子:

比如一个图书馆中的书籍管理系统。每本书都有一个唯一的ISBN(国际标准书号),并且图书管理员需要根据ISBN快速查找对应的书籍。

如果使用数组,我们可能需要将整个数组遍历一遍才能根据ISBN找到书籍,这是不切实际的;如果用链表,虽然插入和删除简单,但同样查找效率低下;而如果我们用哈希表,就可以将ISBN通过哈希函数转换成一个数组的索引,这个索引对应数组中的一个位置,那么查找书籍的操作平均来说会非常快,时间复杂度为O(1)

例如,ISBN为"123-456-789-X"的书可以通过哈希函数计算出一个值,比如说是256。这个值代表数组中的位置,我们直接去数组的第256个位置就可以找到这本书的信息。

🍉 二、实现哈希表我们需要关注什么?

  • 字符转数字?
  • 单词或者字符串转数字?
  • 如何解决大数转小数?
  • 如何解决插入冲突?

为什么说是转数字呢,因为下标是数字呀。

1.案例

这个哈希函数如何计算,使用何种方式,这里我们看一个案例

案例: 图书信息的检索与管理

假设一个图书馆拥有数十万册图书。需要一个有效的数据结构来存储和检索每一本图书的信息,包括书名、作者、ISBN号、位置、可借状态等。

  • 方案一: 数组 - 如果用一个巨大的数组按顺序存储每本书的信息,当查找一本特定的书时,可能需要遍历整个数组,这对于效率是一个噩梦。
  • 方案二: 链表 - 与数组类似,链表便于添加和删除书籍,但查找仍然需要遍历,无法满足快速检索的需求。
  • 方案三: 用一种可以将书名转为下标值、或是其他什么信息可以转为下标值?

2.转换方法探索

  • 那如何将字符转为数字的?ASCII?或是其他的编码?都可以,你也可以自己设计编码。
  • 那如何将单词转为数字呢?字符转数字后相加?相乘?或者用幂的连乘还是其他方法?

相加

  • 一个转换单词的简单方案就是把单词每个字符的编码求和.

  • 假设我们用1-26来表示每个字母a-z,那么单词jack转成数字: 10 + 1 + 3 + 11 = 25, 那么25就作为jack单词的下标存在数组中.

  • 但是这个方案有致命的缺点,很可能有重复的下标,这样插入就出现了冲突。

相乘

另一种方式则是相乘,但是如果这个字符串很长,这里会乘出一个巨大的数,那有没有比较小一点的方法呢。

幂的连乘

  • 上面两种办法都不太可行
  • 我们需要一种既不让数组变得太大,也不能让冲突出现的概率太大的方法。
  • 那就是使用幂的连乘, 什么是幂的连乘呢?
  • 其实我们平时使用的大于10的数字, 可以用一种幂的连乘来表示它的唯一性:比如: 7654 = 7*10³+6*10²+5*10+4
  • 我们的单词也可以使用这种方案来表示: 比如jack = 10*27³+1*27²+3*27+11=197651
  • 这样得到的数字可以几乎保证它的唯一性, 不会和别的单词重复.
  • 但是如果一个单词是zzzzzzzzzzzz,这个数仍然超级大,所以这种方式虽然缓解了上面两种方式的问题,但是仍然存在一个超大数。没必要那么大的数组,jack在数组中附近存储表示的值也不一定是一个单词,没有意义。

image.png

  • 两种方案总结:

    • 第一种方案(把数字相加求和)产生的数组下标太少.
    • 第二种方案(把数字相乘)产生的数组下标超大,.
    • 第二种方案(与27的幂相乘求和)产生的数组下标又太多.

3.认识哈希化

我们一般不会开辟很大的存储空间,一般只会比需要的数量多一些。

  • 哈希化: 将大数字转化成数组范围内下标的过程, 我们就称之为哈希化.

🍒 三、哈希冲突是什么?如何解决?

就是在我们计算存储在哪个位置的,时候,不同的单词可能会计算出同样的位置,这就叫做冲突,我们需要一些策略去解决这个冲突,不是让冲突不发生,而是换一种方式去存储数据。冲突是不可避免的。

1.什么是冲突?

冲突是指在哈希表中两个或多个不同的键被哈希函数映射到了同一个位置的情况。这种情况会导致数据存储和检索的混乱,因为无法确定具体的键值对应的位置。冲突通常发生在哈希函数无法将键均匀地映射到哈希表的情况下,或者哈希表的容量不足以容纳所有的键,那如何解决冲突呢?

有几种常见的方法可以解决哈希表中的冲突:

  1. 链地址法(Separate Chaining) :将哈希表的每个槽(slot)作为一个链表的头节点,当发生冲突时,将新的键值对插入到对应槽的链表中。这样,每个槽都可以容纳多个键值对,不同键值对通过链表进行连接。
  2. 开放寻址法(Open Addressing) :当发生冲突时,通过一个探测序列(probing sequence)在哈希表中寻找下一个可用的槽位,直到找到一个空槽或者遍历完整个哈希表。常见的探测序列包括线性探测、二次探测和双重散列等。
  3. 再哈希(Rehashing) :当哈希表的负载因子(load factor)超过某个阈值时,可以对哈希表进行扩容,并重新计算已有键值对的哈希值和位置,将它们插入到新的哈希表中。这样可以减少冲突的概率。
  4. 建立公共溢出区(Overflow Area) :将所有发生冲突的键值对都放入一个公共的溢出区,这样哈希表中的每个槽都只包含一个键值对。虽然这种方法简单,但可能会导致溢出区的性能问题。

2.链地址法

链地址法(Separate Chaining)是一种解决哈希表冲突的方法,它通过将哈希表的每个槽(slot)作为一个链表的头节点,将哈希值相同的键值对存储在同一个链表中。

image.png

当发生冲突时,新的键值对被插入到对应槽的链表中,而不是直接插入到槽中。 链地址法的实现步骤如下:

  1. 初始化哈希表:创建一个数组,数组的每个元素称为槽,每个槽的初始值为一个空链表。
  2. 插入键值对:对于要插入的键值对,首先计算其哈希值,并根据哈希值找到对应的槽。然后,将键值对插入到该槽对应的链表中。
  3. 查找键值对:对于要查找的键,同样计算其哈希值,并根据哈希值找到对应的槽。然后,在该槽对应的链表中查找键值对。
  4. 删除键值对:对于要删除的键,同样计算其哈希值,并根据哈希值找到对应的槽。然后,在该槽对应的链表中删除键值对。

链地址法的优点是简单易实现,适用于存储冲突较少的情况。然而,当冲突较为频繁时,链表会变得很长,影响查找效率。因此,在设计哈希表时,需要根据实际情况选择合适的解决冲突方法。

3.开放地址法

开放地址法(Open Addressing)是一种解决哈希表冲突的方法,它不使用链表,而是尝试在哈希表中寻找其他空槽来存储发生冲突的键值对。具体的实现方式包括线性探测、二次探测和再哈希法等。 image.png

  1. 线性探测(Linear Probing) :当发生冲突时,线性探测会依次检查哈希表中的下一个位置,直到找到一个空槽或者遍历完整个哈希表。插入时通过 index = (index + 1) % tableSize 来寻找下一个位置。查找时也是类似的方式进行。
  2. 二次探测(Quadratic Probing) :二次探测在发生冲突时,通过二次函数来计算下一个探测位置,例如 index = (index + i^2) % tableSize。这种方法可以减少线性探测中的聚集现象,即连续探测到的位置都发生冲突的情况。
  3. 再哈希法(Double Hashing) :再哈希法使用两个哈希函数来计算下一个探测位置,例如 index = (index + hash2(key)) % tableSize。其中,hash2(key) 是第二个哈希函数的计算结果。这种方法可以更加均匀地分布键值对,减少冲突。

在使用开放地址法时,需要注意以下几点:

  • 哈希表的容量需要足够大,以便在发生冲突时能够找到合适的空槽。
  • 删除操作需要特殊处理,通常将删除的位置标记为已删除,并在查找时跳过这些位置。
  • 负载因子需要控制在一定范围内,以保证哈希表的性能。

开放地址法相对于链地址法的优点是节省了链表结构的空间开销,但在冲突较为频繁时,可能会导致性能下降。因此,需要根据实际情况选择合适的解决冲突方法。

线性探测

线性探测是一种开放地址法中解决哈希冲突的方法。当发生冲突时,线性探测会依次检查哈希表中的下一个位置,直到找到一个空槽或者遍历完整个哈希表。具体的实现方式是通过增加一个固定的步长来寻找下一个位置,通常步长为1。

下面是线性探测的详细步骤:

  1. 插入操作:当要插入一个键值对时,首先计算键的哈希值,然后计算出对应的初始位置。如果初始位置为空,则直接插入;如果不为空,则循环查找下一个位置,直到找到一个空槽为止。

    具体的查找方式是通过以下公式计算下一个位置:index = (index + 1) % tableSize。其中,index 是当前位置,tableSize 是哈希表的大小。

  2. 查找操作:当要查找一个键时,首先计算键的哈希值,然后计算出对应的初始位置。然后,循环查找下一个位置,直到找到匹配的键或者遍历完整个哈希表。

    具体的查找方式与插入操作类似,通过以下公式计算下一个位置:index = (index + 1) % tableSize

  3. 删除操作:删除操作比较复杂,因为直接删除会导致后续查找失败。通常的做法是将要删除的位置标记为已删除,而不是直接删除。在查找时,遇到已删除的位置会继续向后查找。

对于这个例子来说,如果在插入53,则会插入到33后面位置

image.png

再插入一个63的话,依次向下找,然后放置在45之后

线性探测的优点是实现简单,易于理解和实现。然而,它容易产生聚集现象(clustering),即连续位置上的元素密集分布,导致性能下降。为了减少聚集现象,可以采用二次探测或双重散列等方法。

二次探测

二次探测(Quadratic Probing)是一种开放地址法中解决哈希冲突的方法。与线性探测不同,二次探测在发生冲突时,通过一个二次函数来计算下一个探测位置,而不是简单地逐个检查下一个位置。

具体来说,二次探测的步骤如下:

  1. 插入操作:当要插入一个键值对时,首先计算键的哈希值,然后计算出对应的初始位置。如果初始位置为空,则直接插入;如果不为空,则使用二次探测找到下一个空槽。

    具体的查找方式是通过以下公式计算下一个位置:index = (index + i^2) % tableSize,其中,index 是当前位置,i 是探测的步长,tableSize 是哈希表的大小。初始时,i 取值为1,如果位置不为空,则递增 i 并重新计算下一个位置,直到找到空槽或者遍历完整个哈希表。

  2. 查找操作:当要查找一个键时,也是使用类似的方式进行二次探测,直到找到匹配的键或者遍历完整个哈希表。

  3. 删除操作:删除操作与线性探测类似,通常不直接删除,而是标记为已删除。

二次探测相比于线性探测,能够更加均匀地分布键值对,减少了聚集现象,提高了哈希表的性能。但是,需要注意的是,如果哈希表的大小不是素数,并且哈希函数的设计不当,可能会导致探测过程中出现循环,使得某些位置无法被访问到,这种情况称为探测到循环。因此,在实现二次探测时,需要注意选择合适的哈希表大小和哈希函数。

对于这个例子来说,如果在插入53,判断到3位置已经被占用了,然后使用(3 + 1 ^ 2) % / 10 = 4 来判断,然后将53放在了4位置。

image.png

再插入一个63的话,这个例子展示了二次探测中可能发生的探测循环

  • 依次向下找(3 + 1 ^ 2) % / 10 = 4,这个位置现在有53,
  • 继续往后找(3 + 2 ^ 2) % / 10 = 7,也被67占用
  • (3 + 3 ^ 2) % / 10 = 2,也被占用
  • (3 + 4 ^ 2) % / 10 = 7,又变成了7,
  • (3 + 5 ^ 2) % / 10 = 8
  • (3 + 6 ^ 2) % / 10 = 9
  • (3 + 7 ^ 2) % / 10 = 2
  • (3 + 8 ^ 2) % / 10 = 7
  • (3 + 9 ^ 2) % / 10 = 4
  • (3 + 10 ^ 2) % / 10 = 3
  • (3 + 11 ^ 2) % / 10 = 4

探测循环产生的原因和解决办法

插入63时发生的探测循环问题在于哈希表的大小tableSize = 10是一个合数,并且二次探测的序列并没有覆盖掉哈希表中的所有可能位置。二次探测序列结束时,某些位置从未被访问过,而有些位置则被访问多次,这就是探测循环的现象。

具体到这个例子,我们可以看到序列 (3,4,7,2,7,8,9,2,7,4,3,4...) 中的值开始重复。发生这种情况的原因是表大小10和用于二次探测(i^2)的平方项的最小公倍数以外的位置都没被探测到。这说明这个二次探测序列不能覆盖哈希表中的所有位置。

为什么选用一个质数作为哈希表的大小可以帮助避免探测循环?

使用质数作为哈希表的大小能减小遇到最大公约数问题的概率,是基于数学中关于素数的一些性质:

  1. 最大公约数(Greatest Common Divisor, GCD) :如果两个数的最大公约数为1,称这两个数是互质的。两个互质的整数的序列相对于每个数来说是一个完整的序列,即每个数都能通过两数的线性组合表示。
  2. 质数的互质特性:作为素数,它只能被1和自身整除。这意味着任何小于这个质数的正整数都与这个质数互质。
  3. 哈希表探测:在开放寻址哈希,特别是线性探测(探测步长为常数)和二次探测(探测步长按二次函数增长)时,想要遍历整个表,探测序列生成的索引应该覆盖到整个哈希表的空间。这意味着探测间隔(即探测步长)和哈希表大小的最大公约数应该为1,这样可以确保探测序列能够到达表中的每一个位置。
  4. 质数和探测步长的关系:当哈希表的大小为质数时,任何小于哈希表大小的探测步长(不等于1)都与哈希表大小互质。因为互质的关系,任何由这个步长产生的探测序列都将覆盖一次且仅一次哈希表中所有可能的位置,从而保证了当表中还有空位时,我们总能找到一个空的槽位而不会发生探测循环。

如果哈希表的大小是一个合数,则不难找到一个小于哈希表大小的数与它不互质(即它们有一个除了1之外的共同因子),这将导致一个不完整的探测序列,一些槽位会被反复探测,而其他槽位则可能永远不会被探测。这就是为什么选择质数作为哈希表的大小,尤其是在使用开放寻址法解决哈希冲突时,能够有效避免探测循环的原因。

在上述解释中,我们假设哈希函数是均匀的,即任何键都等可能地散列到任何一个槽位。实际中,哈希函数的选择也非常重要,因为即使哈希表的大小是一个质数,一个不好的哈希函数仍然可能导致一个不均匀分布的哈希,进而引起不必要的冲突。

我们来看例子

线性探测

线性探测的问题主要是聚集,聚集很多,会导致哈希表的效率变低,并且他的特性决定了他会产生探测循环,但是将表长度和探测长度设置为质数可以一定程度上避免循环

假设我们有一个使用线性探测策略的哈希表。这里的线性探测意味着每当初始化插入失败(即找到的槽位已经被占用)时,我们就会尝试下一个槽位,探测间隔fixed为1(即每次探测都是往后移一个位置)。

让我们通过比较两种情况来说明为什么使用素数作为哈希表的大小能减少循环探测的可能性:

示例1:哈希表大小是合数

步长为质数

哈希表的大小(N)是10(一个合数),我们将考虑的数字是0到9

假设我们的哈希函数h(k)就是简单地取模:index = i * i % 10

现在假设有一系列的插入操作,且键都映射到了哈希表的同一位置5上(一个明显的哈希冲突)。按照线性探测,我们会依次检查位置重复这个模式,``形成了一个循环探测,正好遍历了所有位置。

步长为合数 哈希表的大小(N)是10(一个合数),我们将考虑的数字是0到9。步长为2 假设我们的哈希函数h(k)就是简单地取模:index = (index + 2) % 10

现在假设有一系列的插入操作,且键都映射到了哈希表的同一位置5上(一个明显的哈希冲突)。按照线性探测,我们会依次检查位置重复这个模式,7, 9, 1, 3, 5, 7, 9, 1, 3, 5, 7, 9 一直循环,有的位置可能永远也访问不到。

示例2:哈希表大小是质数

步长为质数 现在设想如果哈希表的大小是11(一个素数),我们将考虑的数字是0到10, 步长为3

假设我们的哈希函数h(k)就是简单地取模:index = (index + 3) % 11

我们再次有一系列的插入操作,且键同样都映射到了哈希表的某一位置。按照线性探测,同样是查看下一个位置:如果5被占用,查看8, 0, 3, 6, 9, 1, 4, 7, 10, 2, 5, 正好访问了所有位置。

步长为合数 现在设想如果哈希表的大小是11(一个素数),我们将考虑的数字是0到10, 步长为2

假设我们的哈希函数h(k)就是简单地取模:index = (index + 2) % 11

我们再次有一系列的插入操作,且键同样都映射到了哈希表的某一位置。按照线性探测,同样是查看下一个位置:如果5被占用,查看7, 9, 0, 2, 5, 7, 9, 0这里就已经开始循环了

二次探测

二次探测是另一种解决哈希冲突的开放寻址法,与线性探测不同的是,二次探测的探测序列不是简单的线性序列,而是二次方的形式。设初始哈希值为 h(k), 则探测序列将是 h(k)h(k) + 1^2h(k) + 2^2h(k) + 3^2, ..., h(k) + i^2, 其中 i 是探测次数。

这里以 Modulus(哈希表大小)为合数和素数的例子来比较:

示例1:哈希表大小是合数

哈希表的大小(N)是10(一个合数),我们将考虑的数字是0到9。

假设我们的哈希函数h(k)就是简单地取模:index = (index + i^2) % 10 i 从1开始。

现在假设有一系列的插入操作,且键都映射到了哈希表的同一位置5上(一个明显的哈希冲突)。按照线性探测,我们会依次检查位置重复这个模式,产生了探测循环

(5 + 1 * 1) % 10 = 6
(5 + 2 * 2) % 10 = 9
(5 + 3 * 3) % 10 = 4
(5 + 4 * 4) % 10 = 1
(5 + 5 * 5) % 10 = 0
(5 + 6 * 6) % 10 = 1
(5 + 7 * 7) % 10 = 4
(5 + 8 * 8) % 10 = 9
(5 + 9 * 9) % 10 = 6
(5 + 10 * 10) % 10 = 5
(5 + 11 * 11) % 10 = 6
(5 + 12 * 12) % 10 = 9
(5 + 13 * 13) % 10 = 4
示例2:哈希表大小是质数

哈希表的大小(N)是11(一个合数),我们将考虑的数字是0到9。

假设我们的哈希函数h(k)就是简单地取模:index = (index + i^2) % 11 i 从1开始。

现在假设有一系列的插入操作,且键都映射到了哈希表的同一位置5上(一个明显的哈希冲突)。按照线性探测,我们会依次检查位置重复这个模式,产生了探测循环

(5 + 1 * 1) % 11 = 6
(5 + 2 * 2) % 11 = 9
(5 + 3 * 3) % 11 = 3
(5 + 4 * 4) % 11 = 10
(5 + 5 * 5) % 11 = 8
(5 + 6 * 6) % 11 = 8
(5 + 7 * 7) % 11 = 10
(5 + 8 * 8) % 11 = 3
(5 + 9 * 9) % 11 = 9
(5 + 10 * 10) % 11 = 6
(5 + 11 * 11) % 11 = 5
(5 + 12 * 12) % 11 = 6
(5 + 13 * 13) % 11 = 9

总结

  • 线性探测可以将容器或步长设置为质数,可以探测完整个哈希表。但是当元素聚集之后,查找效率会下降。
  • 二次探测,无法探测到全部哈希表,即使设置了容量为质数,但是可以缓解聚集带来的效率问题,但也不能完全解决。

再哈希法

再哈希法(Rehashing)是处理哈希表冲突的另一种方法,它使用一系列哈希函数而不只是一个。如果第一个哈希函数导致冲突,再哈希法会尝试第二个哈希函数作为步长,如此类推,直至找到一个没有冲突的槽位。

基本步骤如下:

  1. 初始哈希:对于给定的键 k,使用初始哈希函数 hash1(k) 得到哈希表中的位置 i
  2. 检查冲突:如果位置 i 未被占用,则直接在 i 位置插入数据。如果位置 i 被占用,则发生了冲突。
  3. 再哈希:利用第二个哈希函数 hash2(k),生成一个新的位置。有时,第二个哈希函数会设计成与初始哈希函数不同的序列,以避免两个函数产生相同的模式。生成新位置的方法可能是 i = (hash1(k) + n*hash2(k)) mod table_size,其中 n 是已尝试的次数(通常从1开始)。
  4. 重复检查和再哈希:重复步骤2和步骤3,直到找到无冲突的位置或达到了尝试次数上限。

在再哈希中,第二个哈希函数与首个函数有几个重要差别:

  • 不同的计算方法:确保新的散列位置与原有散列位置有所不同,减少冲突概率。
  • 互质:为了最大化哈希表空间的使用,通常希望确保二次哈希的函数值与哈希表的大小是互质。这样可以在表不满的情况下最终探测到每一个槽位。
  • 非零值:应保证二次哈希的函数永远不返回0值,避免可能产生的无限循环。

再哈希是一种有效的解决哈希表冲突的方式,特别适合于不允许表项移动并且删除操作不频繁的应用场景,因为再哈希可能会使得找到一个元素变得计算量大且复杂。

再哈希处理冲突的优点是归于它能够更均匀地分散冲突,减小产生聚集的可能性。但其缺点包括可能更加复杂的哈希计算和增加了处理冲突的时间成本。再哈希法的性能很大程度上取决于哈希函数的选择,特别是在冲突频繁的情况下。

我们并不需要自己去设计,因为业界已经有一种工作很好的哈希函数:

  • stepSize = constant - (key - constant)
  • 其中constant是质数, 且小于数组的容量.
  • 例如: stepSize = x - (key % x), 永远不可能等于0

🥝 四、为什么装填因子会影响哈希表的效率?

哈希表的效率在很大程度上依赖于哈希化过程的质量和哈希表结构的设计。这里简要分析几个关键因素:

装填因子(Load Factor)

  • 定义:加载因子 λ 定义为 λ = n/k,其中 n 是表中元素的数量,k 是槽位的数量。它衡量了表的"满"程度。
  • 性能影响:加载因子越高,从之前的分析中也能看出,产生冲突的概率越高,导致哈希表性能下降。通常,在加载因子达到某个阈值,具体看下文的分析(例如0.7或0.75)时进行扩张。

几种冲突解决方式的效率分析

哈希表解决冲突的几种常见方法包括开放寻址法、链地址法、再哈希等。这些方法各有优缺点,其效率受到很多因素的影响,包括填充因子(Load Factor)。填充因子是当前存储在哈希表中的元素数量与哈希表大小的比值,是衡量哈希表满载程度的一个指标。下面我们来分析这些解决冲突方法的效率以及它们与填充因子的关系。

1. 开放寻址法

当发生冲突时,开放寻址法会在哈希表中寻找空闲的位置来存储当前元素。常见的开放寻址技术有线性探测、二次探测和再哈希等。

  • 效率分析:开放寻址法的效率在很大程度上取决于哈希表的填充因子。当填充因子较低时,冲突的几率低,寻找空闲位置的成本也低,故查找、插入和删除操作的效率相对较高。随着填充因子的增加,冲突几率增加,性能逐渐下降,尤其是线性探测,在高填充因子时可能导致较长的探测链,严重影响性能。
  • 填充因子关系:一般情况下,为保持开放寻址法的高效性,填充因子不应超过0.7至0.8。

2. 链地址法

链地址法是将所有哈希到同一位置的元素存放在一个链表中。当发生冲突时,元素将被添加到该位置的链表中。

  • 效率分析:链表法的查找效率受到存储元素分布的影响。理想情况下,元素均匀分布,每个位置的链表长度大致相等,这时查找效率较高。但是,随着元素的增加,特定链表可能变长,导致某些查找、插入和删除操作耗时增加。相较于开放寻址法,链表法对高填充因子的容忍度更高,因为其性能主要取决于链表的平均长度,链地址法相对来说效率是好于开放地址法的,所以在真实开发中, 使用链地址法的情况较多, 因为它不会因为添加了某元素后性能急剧下降,大多数语言也是选择链地址法实现的,这也是我们这里实现选择这个的原因.

  • 填充因子关系:尽管链表法能够适应更高的填充因子,但过高的填充因子仍然会导致性能下降,因为长链表的查找时间会增加。

3. 再哈希

再哈希是开放寻址法的一种,它使用两个哈希函数来决定元素的存储位置和解决冲突。当第一个哈希函数引起冲突时,会计算第二个哈希函数来决定探测的下一个位置。

  • 效率分析:再哈希可以减少聚集效应,因为它用第二个哈希函数探测新的空位,相较于线性探测和二次探测,能更好地分散冲突。但是,与所有开放寻址法一样,其效率与填充因子密切相关,填充因子较高时查找空位的成本也随之增加。
  • 填充因子关系:双散列法的性能随着填充因子的增加而下降,虽然相较于线性和二次探测有所改善,但建议保持适中的填充因子以维持较高的效率。

结论

下面是一个展示线性探测、二次探测、再哈希和链式存储(分离链接法)在面对不同填充因子(Load Factor)时,对成功查找(查找成功的平均时间复杂度)和失败查找(查找失败或插入新元素的平均时间复杂度)影响的简化表格。需要指出的是,这里的时间复杂度是相对而言,并不是严格的数学公式结果,因为实际表现会受到多种因素的影响,如哈希函数的质量、数据分布等。

解决方案填充因子成功查找平均时间复杂度失败查找平均时间复杂度
线性探测低 (≤ 0.5)接近 O(1)接近 O(1)
线性探测高 (> 0.5)增长至 O(n)增长至 O(n)
二次探测低 (≤ 0.5)接近 O(1)接近 O(1)
二次探测高 (> 0.5)增长,但由于聚集问题较线性探测好增长,但由于聚集问题较线性探测好
再哈希低 (≤ 0.5)接近 O(1)接近 O(1)
再哈希高 (> 0.5)略增长,但通常比线性和二次探测要好略增长,但通常比线性和二次探测要好
链式存储低 (≤ 0.75)接近 O(1),假设分布均匀接近 O(1),假设分布均匀
链式存储高 (> 0.75)增长至 O(n),在极端情况下增长至 O(n),在极端情况下

几个结论:

  • 填充因子对开放寻址法(线性探测、二次探测、再哈希)的影响较大,因为高填充因子时发生冲突的概率增加,需要更多次探测才能找到空位或目标元素。
  • 链式存储对填充因子的容忍度相对较高,经过上面的比较我们可以发现, 链地址法相对来说效率是好于开放地址法的,所以在真实开发中, 使用链地址法的情况较多, 因为它不会因为添加了某元素后性能急剧下降,但是因为它通过链表解决冲突,但如果某个槽位的链表变得很长,性能同样会降低。
  • 成功和失败的查找或插入操作的平均时间复杂度都会随着填充因子的增加而增加,但不同的解决冲突策略对填充因子的敏感程度不同。
  • 以上表格中的描述是基于理想化的假设,在实际应用中,具体的性能表现还需要考虑哈希函数的质量、数据的特性等多种因素。

🥑 五、如何设计哈希函数?

1.快速计算

分析下暴力计算多项式的复杂度

我们先来分析一下之前的乘幂的方式计算哈希code的复杂度是多少? 我们将之前的多项式推广到n词多项式

10*27³+1*27²+3*27+11=197651

image.png

我们来看一下这个表达式需要多少次乘法和加法

10*27³ = 10 * (27 * 27 * 27) = 1 + 2 三次
1*27²  = 1 * (27 * 27) = 1 + 1 二次
3 * 27 一次
乘法总共:3 + 2 + 1 = 6

那推广到n次多项式呢

anxn = 1 + (n - 1)

an-1xn-1 = 1 + (n - 2)

an-2xn-2 = 1 + (n - 3)

以此类推,累加起来就是 1 + (n - 1) + 1 + (n - 2) + 1 + (n - 3) .... + 1, 就是一个等差数列,1到n,

image.png

  • 所以这个多项式的乘法计算就是O(n2)
  • 加法的计算就是你n-1,即O(n)的复杂度

乘法是比较耗时间的,我们需要快速计算的话,最好减少乘法和除法的计算,那有没有什么方法呢,那就是秦九韶算法

多项式-秦九韶算法

秦九韶算法是一种将一元n次多项式转换为n个一次式的简化算法。 我们还是以10*27³+1*27²+3*27+11=197651为例,简单推一下秦九韶算法的计算过程

f(x)=10x3+1x2+3x+11x0f(x) = 10 * x^3 + 1 * x^2 + 3 * x + 11 * x^0
=x(10x2+1x+3)+11= x * (10 * x^2 + 1 * x + 3) + 11
=x(x(10x+1)+3)+11= x * (x * (10 * x + 1) + 3) + 11

则,计算f(27)从内到外逐层计算

1027+1=27110 * 27 + 1 = 271

则原式等于

f(27)=x((x271)+3)+11;f(27) = x * ((x * 271) + 3) + 11;

再计算x*271

x271+3=7320x * 271 + 3 = 7320

再计算x*7320,最后结果:

x7320+11=197640+11=197651x * 7320 + 11 = 197640+11 = 197651

分析下秦九韶算法的时间复杂度

从上面的计算中可以看出,拆解成了三次乘法、三次加法,总共需要至多n次乘法,n次加法。

image.png

秦九韶算法作为计算一元n次多项式的最优算法,怎么用代码实现呢?

首先给定次数和系数就可以唯一确定一个多项式,系数用一个数组存储,数组中的元素下标从 0 - n ,刚好对应存入 a0 - an 。

秦九韶算法的主要思想是通过多次乘法和加法的组合,将多项式的计算复杂度从 O(n2) 降低到 O(n),其推导过程如下:

image.png

我们要计算 f(n),其中 n 是一个给定的值。我们可以将计算过程分解为以下步骤:

  1. 计算 ak*nk
  2. 计算 ak - 1*nk - 1,并将其与第一步结果相加
  3. 重复以上步骤,直到计算完所有aini的结果
  4. 将所有结果相加得到最终结果

image.png 也就是说:使用秦九韶算法计算一元n次多项式时,乘法运算的时间复杂度为O(n) ,加法运算的时间复杂度为O(n) 。对比暴力算法,大大简化了乘法运算的次数。

2.均匀分布:质数的使用

哈希表实现均匀分布主要利用的是,数学上质数的特性,我们在解释哈希冲突的时候简单分析了探测循环产生的原因,这里我们再梳理一遍,质数的特性如下:

  1. 最大公约数(Greatest Common Divisor, GCD)  :如果两个数的最大公约数为1,称这两个数是互质的。两个互质的整数的序列相对于每个数来说是一个完整的序列,即每个数都能通过两数的线性组合表示。
  2. 质数的互质特性:作为质数,它只能被1和自身整除。这意味着任何小于这个质数的正整数都与这个素数互质。
  3. 哈希表探测:在开放寻址哈希,特别是线性探测(探测步长为常数)和二次探测(探测步长按二次函数增长)时,想要遍历整个表,探测序列生成的索引应该覆盖到整个哈希表的空间。这意味着探测间隔(即探测步长)和哈希表大小的最大公约数应该为1,这样可以确保探测序列能够到达表中的每一个位置。
  4. 质数和探测步长的关系:当哈希表的大小为质数时,任何小于哈希表大小的探测步长(不等于1)都与哈希表大小互质。因为互质的关系,任何由这个步长产生的探测序列都将覆盖一次且仅一次哈希表中所有可能的位置,从而保证了当表中还有空位时,我们总能找到一个空的槽位而不会发生探测循环。

哈希表的长度为质数

具体看之前分析探测循环产生的原因和解决办法那一节

N次幂的底数, 底数的选择

选择哈希表的底数(或者称为哈希函数中的乘数)时,特别倾向于使用像37、41、31这样的质数,主要是基于以下几个原因:也是同样的利用质数的原理

  1. 计算效率:尽管素数的选择可以优化哈希表的性能,但这并不意味着需要非常大的质数。相对较小的质数(如37、41、31)就能够实现良好的均匀分布,并且它们在计算时还能保持较好的效率。即使是较小的质数也能有效避免模式的出现,模式的出现会导致某些键值过于集中于哈希表的特定区域。
  2. 经验和传统:选择这些特定的质数(如37、41、31)也有一定的经验和传统原因。多年来的实践表明,这些质数在多种情况下都能够提供较好的性能表现。因此,它们被广泛采用并出现在很多教科书和实际应用程序中。
  3. 避免与哈希表大小的关联:如果哈希表的大小也是一个质数,那么使用另一个无关的质数作为底数有助于进一步分散哈希值。这是为了防止某些输入序列与哈希表大小产生意料之外的关联,进而导致分布不均。

🍑 六、实现哈希表

1.实现哈希函数

我们这里选择使用的是链式存储,仅需要将大数转为小数,并且使用秦九韶算法优化多项式计算,从上面的分析中我们发现,秦九韶算法的从内到外每一层都是aix + ai - 1的格式,因此我们可以通过for循环从内到外实现,用取余实现大数转小数

image.png

image.png

    /**
   * 设计hash函数
   * 1.将字符串转为大数字,hashCode
   * 2.将大数字hashCode压缩大数组范围(大小)之内:哈希化
   */
  myHashFunc(str, arrSize) {
    // 1.定义hashCode变量
    let hashCode = 0;
    // 2.秦九昭算法计算hashCode
    // cats -> unicode编码
    for (let i = 0; i < str.length; i++) {
      hashCode = 37 * hashCode + str.charCodeAt(i);
      //   console.log("✅ ~ hashCode:", hashCode);
    }
    // 3.取余
    let index = hashCode % arrSize;
    return index;
  }

2.实现哈希表结构

我们这里使用的是链式存储,每个index也是一个数组,当然也可以用链表,在删除的时候有一些优势。

class MyHashMap {
  //存数据
  storage = [];
  //计数器,计算转载因子
  count = 0;
  //哈希表的总长度
  limit = 7;
  /**
   * 设计hash函数
   * 1.将字符串转为大数字,hashCode
   * 2.将大数字hashCode压缩大数组范围(大小)之内:哈希化
   */
  myHashFunc(str, arrSize) {
    // 1.定义hashCode变量
    let hashCode = 0;
    // 2.秦九昭算法计算hashCode
    // unicode编码
    for (let i = 0; i < str.length; i++) {
      hashCode = 37 * hashCode + str.charCodeAt(i);
      //   console.log("✅ ~ hashCode:", hashCode);
    }
    // 3.取余
    let index = hashCode % arrSize;
    return index;
  }
}

3.增加&修改数据

  • 主要就是判断这个索引下是否有数据
    • 有数据:判断是增加还是更新
    • 无数据:新建一个数组,插入元素
  /**
   * 插入数据
   * @param {*} key
   * @param {*} value
   */
  put(key, value) {
    const index = this.myHashFunc(key, this.limit);
    let bucket = this.storage[index];
    if (!bucket) {
      bucket = [];
      this.storage[index] = bucket;
    }
    let override = false;
    // 修改
    for (let i = 0; i < bucket.length; i++) {
      const tuple = bucket[i];
      if (tuple[0] === key) {
        tuple[1] = value;
        override = true;
      }
    }
    // 新增
    if (!override) {
      bucket.push([key, value]);
      this.count++;
    }
  }

4.获取数据

  • 获取元素索引
    • 有数据,遍历查找
    • 无数据,返回
/**
   * 获取元素
   * @param {*} key
   */
  get(key) {
    const index = this.myHashFunc(key, this.limit);

    const bucket = this.storage[index];

    // 没有元素
    if (!bucket) {
      return null;
    }

    // 有元素,查找元素
    for (let i = 0; i < bucket.length; i++) {
      const tuple = bucket[i];
      if (tuple[0] === key) {
        return tuple[1];
      }
    }

    return null;
  }

5.删除数据

  • 获取元素索引
    • 有数据,遍历查找,找到删除
    • 无数据,返回
/**
   * 删除元素
   * @param {*} key
   */
  remove(key) {
    const index = this.myHashFunc(key, this.limit);

    const bucket = this.storage[index];

    if (!bucket) return null;

    for (let i = 0; i < bucket.length; i++) {
      const tuple = bucket[i];
      if (tuple[0] === key) {
        bucket.splice(i, 1);
        this.count--;
        return tuple[1];
      }
    }

    return null;
  }

6.其他方法

isEmpty() {
    return this.count === 0;
}

size() {
    return this.size;
}

☀️ 七、扩容

1.为什么需要扩容?

在效率分析那节我们分析了填充因子对效率的影响,对应的每个index下的链越长,哈希表的效率就越低,所以在合适的情况下,我们需要给哈希表扩容,保持一个较好的效率。

image.png

2.什么时候扩容?

  • 增加时,判断填充因子,是否大于0.75,如果大于,获取一个新的容量,进行扩容
  • 删除是,判断填充因子,是否小于0.25,如果小于,将容量减半,主要是减少空间浪费。

3.怎么扩容?

哈希表在元素数量达到一定阈值时需要进行扩容,以保持较低的负载因子,提高性能。哈希表的扩容一般包括以下步骤:

  1. 创建新的哈希表:创建一个新的、更大的哈希表,通常是当前哈希表大小的两倍。
  2. 重新哈希:将当前哈希表中的所有元素重新插入到新的哈希表中。这涉及到计算每个元素的新哈希值,并将其插入到新的位置上。由于哈希表的大小发生了变化,因此元素的哈希值也会发生变化。
  3. 新表替换旧表
  4. 释放旧哈希表:释放旧的哈希表所占用的内存空间。

4.扩容的实现

获取质数,原理之前已经说过了,容量质数有利于均匀分布

// 判断是否是质数
  isPrime = function (num) {
    var temp = parseInt(Math.sqrt(num));
    // 2.循环判断
    for (var i = 2; i <= temp; i++) {
      if (num % i == 0) {
        return false;
      }
    }
    return true;
  };

  // 获取质数
  getPrime = function (num) {
    while (!isPrime(num)) {
      num++;
    }
    return num;
  };

扩容函数实现

/**
   * 扩容哈希表
   * @param {*} newLimit
   */
  resize(newLimit) {
    const oldStorage = this.storage;

    this.limit = newLimit;
    this.count = 0;
    this.storage = [];

    oldStorage.forEach((bucket) => {
      if (!bucket) return null;

      for (let i = 0; i < bucket.length; i++) {
        const tuple = bucket[i];
        this.put(tuple[0], tuple[1]);
      }
    });
  }

插入的处理

 /**
   * 插入数据
   * @param {*} key
   * @param {*} value
   */
  put(key, value) {
    const index = this.myHashFunc(key, this.limit);
    let bucket = this.storage[index];
    if (!bucket) {
      bucket = [];
      this.storage[index] = bucket;
    }
    let override = false;
    // 修改
    for (let i = 0; i < bucket.length; i++) {
      const tuple = bucket[i];
      if (tuple[0] === key) {
        tuple[1] = value;
        override = true;
      }
    }
    // 新增
    if (!override) {
      bucket.push([key, value]);
      this.count++;
      // 数组扩容
      if (this.count > this.limit * 0.75) {
        const limit = this.getPrime(this.limit * 2);
        this.resize(limit);
      }
    }
  }

删除的处理

/**
   * 删除元素
   * @param {*} key
   */
  remove(key) {
    const index = this.myHashFunc(key, this.limit);

    const bucket = this.storage[index];

    if (!bucket) return null;

    for (let i = 0; i < bucket.length; i++) {
      const tuple = bucket[i];
      if (tuple[0] === key) {
        bucket.splice(i, 1);
        this.count--;

        // 缩小数组的容量,为什么是8,设置容量的最小值
        if (this.limit > 8 && this.count < this.limit * 0.25) {
          const limit = this.getPrime(Math.floor(this.limit / 2));
          this.resize(limit);
        }
        return tuple[1];
      }
    }

    return null;
  }

完整代码

class MyHashMap {
  //存数据
  storage = [];
  //计数器,计算转载因子
  count = 0;
  //哈希表的总长度
  limit = 7;
  /**
   * 设计hash函数
   * 1.将字符串转为大数字,hashCode
   * 2.将大数字hashCode压缩大数组范围(大小)之内:哈希化
   */
  myHashFunc(str, arrSize) {
    // 1.定义hashCode变量
    let hashCode = 0;
    // 2.霍纳算法(秦九昭算法)计算hashCode
    // cats -> unicode编码
    for (let i = 0; i < str.length; i++) {
      hashCode = 37 * hashCode + str.charCodeAt(i);
      //   console.log("✅ ~ hashCode:", hashCode);
    }
    // 3.取余
    let index = hashCode % arrSize;
    return index;
  }

  /**
   * 插入数据
   * @param {*} key
   * @param {*} value
   */
  put(key, value) {
    const index = this.myHashFunc(key, this.limit);
    let bucket = this.storage[index];
    if (!bucket) {
      bucket = [];
      this.storage[index] = bucket;
    }
    let override = false;
    // 修改
    for (let i = 0; i < bucket.length; i++) {
      const tuple = bucket[i];
      if (tuple[0] === key) {
        tuple[1] = value;
        override = true;
      }
    }
    // 新增
    if (!override) {
      bucket.push([key, value]);
      this.count++;
      // 数组扩容
      if (this.count > this.limit * 0.75) {
        const limit = this.getPrime(this.limit * 2);
        this.resize(limit);
      }
    }
  }

  /**
   * 获取元素
   * @param {*} key
   */
  get(key) {
    const index = this.myHashFunc(key, this.limit);

    const bucket = this.storage[index];

    // 没有元素
    if (!bucket) {
      return null;
    }

    // 有元素,查找元素
    for (let i = 0; i < bucket.length; i++) {
      const tuple = bucket[i];
      if (tuple[0] === key) {
        return tuple[1];
      }
    }

    return null;
  }

  /**
   * 删除元素
   * @param {*} key
   */
  remove(key) {
    const index = this.myHashFunc(key, this.limit);

    const bucket = this.storage[index];

    if (!bucket) return null;

    for (let i = 0; i < bucket.length; i++) {
      const tuple = bucket[i];
      if (tuple[0] === key) {
        bucket.splice(i, 1);
        this.count--;

        // 缩小数组的容量,为什么是8,设置容量的最小值
        if (this.limit > 8 && this.count < this.limit * 0.25) {
          const limit = this.getPrime(Math.floor(this.limit / 2));
          this.resize(limit);
        }
        return tuple[1];
      }
    }

    return null;
  }

  /**
   * 扩容哈希表
   * @param {*} newLimit
   */
  resize(newLimit) {
    const oldStorage = this.storage;

    this.limit = newLimit;
    this.count = 0;
    this.storage = [];

    oldStorage.forEach((bucket) => {
      if (!bucket) return null;

      for (let i = 0; i < bucket.length; i++) {
        const tuple = bucket[i];
        this.put(tuple[0], tuple[1]);
      }
    });
  }

  isEmpty() {
    return this.count === 0;
  }

  size() {
    return this.size;
  }

  // 判断是否是质数
  isPrime = function (num) {
    var temp = parseInt(Math.sqrt(num));
    // 2.循环判断
    for (var i = 2; i <= temp; i++) {
      if (num % i == 0) {
        return false;
      }
    }
    return true;
  };

  // 获取质数
  getPrime = function (num) {
    while (!isPrime(num)) {
      num++;
    }
    return num;
  };
}

const myHashMap = new MyHashMap();
// 测试哈希表

// 2.插入数据
myHashMap.put("abc", "123");
myHashMap.put("cba", "321");
myHashMap.put("nba", "521");
myHashMap.put("mba", "520");

// 3.获取数据
console.log(myHashMap.get("abc"));
myHashMap.put("abc", "111");
console.log(myHashMap.get("abc"));

// 4.删除数据
console.log(myHashMap.remove("abc"));
console.log(myHashMap.get("abc"));

参考文档

推荐阅读

工程化系列

本系列是一个从0到1的实现过程,如果您有耐心跟着实现,您可以实现一个完整的react18 + ts5 + webpack5 + 代码质量&代码风格检测&自动修复 + storybook8 + rollup + git action实现的一个完整的组件库模板项目。如果您不打算自己配置,也可以直接clone组件库仓库切换到rollup_comp分支即是完整的项目,当前实现已经足够个人使用,后续我们会新增webpack5优化、按需加载组件、实现一些常见的组件封装:包括但不限于拖拽排序、瀑布流、穿梭框、弹窗等

面试手写系列

react实现原理系列

其他

🍋 写在最后

如果您看到这里了,并且觉得这篇文章对您有所帮助,希望您能够点赞👍和收藏⭐支持一下作者🙇🙇🙇,感谢🍺🍺!如果文中有任何不准确之处,也欢迎您指正,共同进步。感谢您的阅读,期待您的点赞👍和收藏⭐!

感兴趣的同学可以关注下我的公众号ObjectX前端实验室

🌟 少走弯路 | ObjectX前端实验室 🛠️「精选资源|实战经验|技术洞见」