当前位置: 首页 > news >正文

学习哈希表的基本结构

推荐使用电脑端浏览器浏览
前言:本文介绍我学习哈希时的理解和成果。

哈希表的基本结构

  • 1. 哈希概念
    • 1.1 举个简单例子
  • 2. 初学时,可能会存在的想法
  • 3. 哈希其他概念
    • 3.1 哈希冲突
    • 3.2 负载因子(或装载因子)
    • 3.3 将关键字转为整形(哈希要求对象要做到的接口)
    • 3.4 堆积现象
  • 4. 哈希函数
    • 4.1 除法散列法 / 除留余数法
    • 4.2 乘法散列法(介绍)
    • 4.3 全域散列法(介绍)
    • 4.4 其他方法(不做介绍,可以自己搜名字去了解)
  • 5. 处理哈希冲突
    • 5.1 开放定址法
      • 5.1.1 线性探测
      • 5.1.2 二次探测
      • 5.1.3 双重散列(了解)
      • 5.1.4 开放地址法代码实现
        • 5.1.4.1 开放定址法的哈希表结构
        • 5.1.4.2 扩容 和 取模 问题
        • 5.1.4.3 完整代码实现
    • 5.2 链地址法(拉链法)
      • 5.2.1 扩容问题
      • 5.2.2 链地址法代码实现
        • 5.2.2.1 基本框架
        • 5.2.2.2 构造和析构函数
        • 5.2.2.3 查找函数
        • 5.2.2.4 插入函数
        • 5.2.2.5 删除函数

1. 哈希概念

哈希(hash)又称散列,是一种组织数据的方式。从译名来看,有散乱排序的意思。本质就是通过哈希函数把关键字Key跟存储位置建立一个映射关系,查找时再次通过这个哈希函数计算出Key存储的位置,进行快速查找。
哈希概念

我个人感觉,哈希就是通过一个哈希函数给每一份数据快速分配了一个位置(当然这个位置是可控的,也是有限的),最后通过哈希函数可以快速地插入数据和查找数据是否存在。
哈希表是一种“知道键,就能瞬间得到值”的神奇工具。它的强大功能完全建立在“已知键”这个前提之上。如果你不知道你要找的数据的键是什么,那么哈希表就对你爱莫能助了。

1.1 举个简单例子

  我给一些数字,你需要怎么做才能在我需要的时候告诉我某个数字是否存在。此时,你需要快速的告诉我答案。也许你会想着,将这些数字存入数组后,通过排序将这组序列变成有序序列后,以后我每询问一次,就使用一次二分查找来确定某个数字是否存在等,不止一种方法。
但是哈希表可以这样做:
简单的例子
限定情况下:若这些数字较小且连续的情况下,采用哈希函数hash(key) = key,开辟一个够大的数组,将数组中对应下标做上标记后,根据标记来快速判断某个数字是否存在。因为同一个值经过哈希函数后得到的输出不变,保证了查询时的快速。

恭喜你,学会的第一个简单的哈希函数hash(key) = key,也许似曾相识,在某些场景中你已经使用过这样的方法,以前你可能会把这个叫做标记数组(books),用于提前知道某些数是否出现过。其实这是哈希表的一个简单体现。

或者是提前知道某些字符是否出现过,以ASCII码为例,给你一串由小写字母组成的字符串,我需要知道在这个字符串中,哪些字母没有出现过。这时,你不想开一个长度为127的数组,开了一个长度为26的数组,通过hash(key) = key - 'a'来标记这个字符。小写a的ASCII码为97,因为你的哈希函数减去了一个小写a的长度,让26个英文字母通过哈希函数输出的值的范围固定在了[0,26]之间,成功地缩小了数组的范围。

其实这个简单的案例中用到的哈希函数属于 直接定址法直接定址法 的本质就是使用关键字计算出一个绝对位置或者相对位置。它的哈希函数是一个线性函数,如hash(kay) = a * key + b。直接定址法的缺点比较明显,当关键字的范围比较分散时,就很浪费内存甚至内存不够用。如4个数字[1,5,100000,2000],这时候使用直接定址法就比较浪费空间了。


2. 初学时,可能会存在的想法

  • 之前的那两种哈希函数的方法,虽然我不知道这是哈希函数,但是我在实战中因为使用过,所以现在能够使用。但后面如果遇到更复杂的问题时,我自己能完成哈希函数的构建吗?
  • 直接定址法好像没有关键值通过哈希函数后得到相同的哈希值。如果出现了一些关键值通过哈希函数后得到相同的哈希值,那么又该怎么存放呢?

这些我都无法具体而深刻的向你描述问题是怎么解决的。我只能告诉你这些问题都有了成熟的解决方法。计算机界的大佬们已经为我们铺好前路了,等待着我们来汲取已经盛开的果实。


3. 哈希其他概念

3.1 哈希冲突

  之前提到存在多个关键值通过哈希函数后得到相同的哈希值。那么这么多的值要求放在同一个位置,这种问题我们就叫做哈希冲突,或者哈希碰撞。理想情况是找出一个好的哈希函数避免冲突,让每个值可以比较均匀地分布。但是在实际场景中,冲突是不可避免的,所以我们要尽可能设计出优秀的哈希函数,减少哈希的冲突,同时也要去设计解决冲突的方案。

3.2 负载因子(或装载因子)

  假设哈希表中已经映射存储了N个值,哈希表的大小为M,那么 负载因子 = NM\frac{N}{M}MN,负载因子有些地方也翻译为载荷因子装载因子,它的英文为load factor负载因子越大,哈希冲突的概率越高,空间利用率越高;负载因子越小,哈希冲突的概率越低,空间利用率越低。可以想象到,哈希表中已经映射存储的元素越多,那么映射存储新的一个值时,相同的哈希值出现的概率就会变大,哈希冲突的概率也随之升高。

3.3 将关键字转为整形(哈希要求对象要做到的接口)

  我们讲关键字映射到数组中某个位置,一般是整数好做映射处理(即数学运算),如果不是整数,我们要想办法转换成整数。如果关键字不是整数,Key就要想办法转换成整数。如字符串当关键字时,可以选择将整个字符串的ASCII码值累加后作为Key来通过哈希函数计算得到哈希值(这个办法不算是好办法,还能再改进)。当然还有其他的比较复杂的情况,如自定义类型日期类,就需要日期类自己提供哈希关键字的接口,选择拿什么值来传给哈希函数来计算。

3.4 堆积现象

  堆积现象是指在哈希表中,不同的键值在解决冲突时,探测序列出现重叠或交叉(解决哈希冲突时遇到的问题),导致数据在表中某些区域“堆积”起来,形成连续的占用块,从而显著增加后续操作的平均探测次数。

  • 举个例子,在你的前面有20个椅子,椅子上都没有人,等待入座中。演示需要,不要质疑安排方是不是蠢的。

    • 第一个人:小王哈希函数安排在 10 号椅子,10 号椅子上没人,小王落座了。
    • 第二个人:小张也被哈希函数安排在 10 号椅子,但是 10 号椅子不是空的。那么小张顺延到 11 号椅子落座。
    • 第三个人:小李哈希函数安排在 11 号椅子,如果小张没有被顺延到 11 号椅子,小李就是坐在 11 号椅子上的人。现在,小李也是顺延到 12 号椅子上落座。
  • 经过这样的安排,如果下一个人原本被安排在 12 号椅子上,又是只能顺延下去。导致插入的性能下降。
    同时查找的性能也会下降。

    • 找小王。小王一开始被安排在 10 号椅子上且坐在 10 号椅子上,那么一次就能找到。
    • 找小张。小张一开始也被安排在 10 号椅子上但不坐在 10 号椅子上,需要顺延往后找,在 11 号椅子上找到了,那么就多查找了一次。以此类推,找小李也是如此。增大了查找的负担。

4. 哈希函数

  一个哈希函数应该让N个关键字被等概率均匀的散列分布到哈希表的M个空间中,实际中却很难做到,但是我们要尽量往这个方向去考量设计。

4.1 除法散列法 / 除留余数法

hash(key) = key % M (常用)
  • 除法散列法也叫除留余数法,顾名思义,假设哈希表大小为 M,那么通过 key 除以 M 的余数作为映射位置的下标,也就是哈希函数为:hash(key) = key % M
    • 举个例子,哈希表存储的是整形数据。假设哈希表的大小(M)为 100,现在要插入数据:
      • 插入整形数据 50hash(50) = 50 % 100 = 50,那么 50 这个整形数据就被映射存储在哈希表下标为 50 的位置。
      • 插入整形数据 127hash(127) = 127 % 100 = 27,那么 127 这个整形数据就被映射存储在哈希表下标为 27 的位置。
  • 当使用除法散列法时,建议 M 取不太接近 2 的整数次幂的一个质数(素数)。这样做的目的是为了最大限度地利用键(Key)的所有信息位,从而减少哈希冲突。参考来自 Donald Knuth 的《计算机程序设计艺术》(The Art of Computer Programming)第 3 卷:排序与查找。这本书里面对此进行了详细的分析和推导。
  • 当使用除法散列法时,尽量避免 M 为某些值,如 2 的幂,10 的幂等。如果是 2X2^X2X,那么 hash(key) = key % 2X2^X2X 本质相当于保留 key 的后 x 位(转换成二进制的后x位),那么后 x 位相同的值,计算出来的哈希值都是一样的,也就造成了冲突。
  • 需要说明的是,实践中也是八仙过海,各显神通。Java的HashMap采用除法散列法时就是采用 2 的整数次幂做哈希表的大小 M,这样做的目的是不需要取模,直接进行位运算,相对而言位运算比模运算更高效一些。关键在于,它不是单纯的只做一次位运算,比如 M 是 2162^{16}216,本质就是取二进制的后16位作为 key,此时再采用 key′=key>>16key'=key >> 16key=key>>16,然后把 keykeykeykey′key'key 异或的结果作为哈希值。也就是说映射出来的值还是在[0,M)范围内,但是尽量让 key 所有的位都参与计算,这样映射出来的哈希值更均匀一些。

4.2 乘法散列法(介绍)

hash(key)=floor(M×((A×key)%1.0))hash(key)=floor(M \times ((A \times key) \% 1.0))hash(key)=floor(M×((A×key)%1.0))

  • 乘法散列法对哈希表大小 M 没有要求,它的大思路中的第一步:用 关键字 K 乘上常数 A(0 < A < 1),并抽取出 K×AK \times AK×A 的小数部分。第二步:再用 M 乘以 K×AK \times AK×A 的小数部分,再向下取整。M 乘以一个小于 1 的小数,得到的结果属于 [0,M) 的一个范围内。
  • floorfloorfloor 表示对表达式进行向下取整。A∈(0,1)A \in (0,1)A(0,1),哈希函数中最重要的是 A 的值应该如何设定。不用担心,算法大师已经给我们提供了一个参考值。Knuth 认为 A=5−12=0.61803...A = \frac{\sqrt{5} - 1}{2} = 0.61803...A=251=0.61803... (黄金分割点)比较好。

4.3 全域散列法(介绍)

  • 全域散列法是一种通过随机化来保证最坏情况下性能的哈希技术,它不再依赖一个固定的哈希函数,而是准备一个哈希函数的家族,并在每次使用时随机从中选取一个
  • 全域散列法可以有效地抵御攻击。如果存在一个恶意的攻击者,针对我们单一普通的散列函数特意构造出一个可以发生严重冲突的数据集。比如让所有关键字全部落入同一个位置中等情况,这类的攻击会严重损害我们哈希的性能,导致性能下降。通过给散列函数增加随机值,攻击者就无法找出确定的可以导致最坏情况的数据出来。
  • hab(key)=((a×key+b)%P)%Mh_{ab}(key) = ((a \times key + b) \% P) \% Mhab(key)=((a×key+b)%P)%M,上面的公式为经典的散列变种。P 选择为一个足够大的质数,a 可以随机选自 [1, P-1] 中的任意整数(a∈[1,P−1]a \in [1, P-1]a[1,P1]),b 可以随机选自 [0, P-1] 中的任意整数(b∈[0,P−1]b \in [0, P-1]b[0,P1]),这些函数构成了一个 P×(P−1)P \times (P-1)P×(P1) 组全域散列函数组。
  • 需要注意的是每次初始化哈希表时,随机选取全域散列函数组中的一个散列函数使用,后续增删查改都固定使用这个散列函数。如果每次哈希都是随机选一个散列函数,插入是另一个散列函数,查找又是另一个,就会导致找不到插入的 Key。

不知道读者有没有和我一样在顾虑一些问题(大家可以丢给大模型问问):

每次启动,全域散列法就选择一个哈希函数。可是如果是服务器,服务器不能关机,那么这个哈希函数就会是唯一的,直到服务器重新启动时再次更新哈希函数。
这里我有两个问题:
\1. 当服务器不关机时,唯一的哈希函数是否有可能会被攻击,出现堆积现象,导致哈希表性能下降?(堆积现象同样会出现在拉链法中,因为可以一直放入哈希后哈希值一样的数据,使某个桶巨大,导致查找的性能下降)
\2. 服务器关机后,再次启动时刷新哈希函数。那么之前服务器存的数据是否会因为新的哈希函数而丢失(或者说不能正确被找到)?

4.4 其他方法(不做介绍,可以自己搜名字去了解)

  1. 平方取中法
  2. 折叠法
  3. 随机数法
  4. 数学分析法

这些方法相对更适用与一些局限的特定场景。


5. 处理哈希冲突

  实践中哈希表一般还是选择除法散列法作为哈希函数,当然哈希表无论选择什么哈希函数也避免不了冲突,那么插入数据时,怎么解决冲突呢?主要有两种方法,开放定址法和链地址法

5.1 开放定址法

  在开放定址法中,所有的元素都放在哈希表里,当一个关键字 Key 用哈希函数计算出来的位置冲突了,则按照某种规则找到一个没有存储数据的位置进行存储,开放定址法中负载因子一定是小于 1 的。这里的规则有三种:线性探测、二次探测、双重探测

5.1.1 线性探测

从发生冲突的位置开始,依次线性向后探测 ,直到寻找到下一个没有存储数据的位置为止,如果走到哈希表尾,则回绕到哈希表头的位置
  • h(key)=hash0=key%Mh(key) = hash0 = key \% Mh(key)=hash0=key%M,假设第一次探测值(hash0)位置冲突了。则线性探测公式为:hc(key,i)=hashi=(hash0+i)modM,i={1,2,3,...,M−1}hc(key,i) = hashi = (hash0 + i) \mod M, i=\{1,2,3,...,M-1\}hc(key,i)=hashi=(hash0+i)modM,i={1,2,3,...,M1}
    公式的意思是在第一次探测的哈希值基础上,不断往后探测是否存在未存储数据的位置(到表尾要回到表头继续探测)。因为负载因子小于 1,则最多探测 M-1 次,一定能找到一个存储 Key 的位置(再大就扩容了)。

  • 线性探测比较简单且容易实现,线性探测也存在一定的问题。假设 hash0 位置连续冲突,hahs0、hash1、hash2位置已解决存储数据了,后续映射到hash0、hash1、hash2、hash3的值都会争夺 hash3 这个位置,这种现象叫做群集、堆积。后面介绍的二次探测可以一定程度改善这个问题。

  • 下面演示 19, 30, 5, 36, 13, 20, 21, 12这一组数据映射到 M=11 的哈希表中。
    线性探测法的演示

5.1.2 二次探测

  • h(key)=hash0=key%Mh(key) = hash0 = key \% Mh(key)=hash0=key%M,hash0 位置冲突了,则二次探测公式为:hc(key,i)=hashi=(hash0±i2)%M,i={1,2,3,...,,M2}hc(key,i) = hashi = (hash0 \pm i^2) \% M, i = \{1,2,3,...,,\frac{M}{2}\}hc(key,i)=hashi=(hash0±i2)%M,i={1,2,3,...,,2M}第一次探测的结果为 hash0,若 hash0 位置冲突了,则从发生冲突的位置开始,依次左右二次方跳跃式探测,直到寻找到下一个没有存储数据的位置为止,如果往右走到哈希表尾,则回绕到哈希表头的位置;如果往左走到哈希表头,则回绕到哈希表尾的位置。
  • 注意二次探测当 hashi=(hash0−i2)%Mhashi = (hash0 - i^2) \% Mhashi=(hash0i2)%M后,若 hashi < 0,需要 hashi += M
  • 下面演示19, 30, 52, 63, 11, 22这一组数据映射到 M=11 的哈希表中(图中的演示先往左探测,再往右探测):二次探测演示

5.1.3 双重散列(了解)

  • 拥有两个哈希函数。第一个哈希函数计算出来的值发生冲突,使用第二个哈希函数计算出一个跟 Key 相关的偏移量,不断往后探测,直到寻找到下一个没有存储数据的位置为止。
  • h1(key)=hash0=key%Mh_{1}(key) = hash0 = key \% Mh1(key)=hash0=key%M,hash0 位置冲突了,则双重探测公式为:hc(key,i)=hashi=(hash0+i×h2(key))%M,i={1,2,3,...,M}hc(key, i) = hashi = (hash0 + i \times h_{2}(key)) \% M, i = \{1,2,3,...,M\}hc(key,i)=hashi=(hash0+i×h2(key))%M,i={1,2,3,...,M}公式中 hash0(首次探查位置) 和 h2(key)h_{2}(key)h2(key) (偏移量)是不变的,变的是 iii
  • 双重探测公式要求 h2(key)<Mh_{2}(key) < Mh2(key)<Mh2(key)h_2(key)h2(key)MMM 互为质数。有两种简单的取值方法:1、当 M 为 2 的整数幂时,h2(key)h_2(key)h2(key)[0,M−1][0, M-1][0,M1]中任选一个奇数;2、当 M 为质数时,h2(key)=key%(M−1)+1h_2(key) = key \% (M-1) + 1h2(key)=key%(M1)+1
  • 保证 h2(key)h_2(key)h2(key)MMM 互质是因为根据固定的偏移量所能寻址的所有为止将形成一个群,若最大公约数 p=gcd(M,h2(key))>1p = gcd(M, h_2(key)) > 1p=gcd(M,h2(key))>1,那么所能寻址的位置个数为 M/P<MM/P < MM/P<M,使得对于一个关键字来说无法充分利用整个哈希表。
    • 举例来说,若初始探查位置为 1(hash0),偏移量为 3(h2(key)),整个散列表大小为 12,那么所能寻址的位置为 {1,4,7,10}\{1,4,7,10\}{1,4,7,10},寻址个数为 12/gcd(12,3)=412 / gcd(12, 3) = 412/gcd(12,3)=4
  • 下面演示19, 30, 52, 74这一组数据映射到 M=11 的哈希表中,设 h2(key)=key%10+1h_2(key) = key \% 10 + 1h2(key)=key%10+1双重散列演示

5.1.4 开放地址法代码实现

  开放地址法在实践中不如下面讲的链地址法,因为开放定址法解决哈希冲突不管使用哪种方法,占用的都是哈希表中的空间,始终存在互相影响的问题。

下面的开放定址法,解决冲突选择线性探测哈希函数选择除留余数法

5.1.4.1 开放定址法的哈希表结构

  首先说明一下,我们要给哈希表中的空间设置一个状态值EXIST,EMPTY,DELETE。这是因为冲突时,为了解决冲突,会把数据存储在 hashi 的位置(也就是 hash0 的位置)。这样会导致部分情况下,因为冲突存储在特定位置的数据不能被正确找到。假设,A 和 B 先后选择存储在 10 号位置,因为 A 先存储到 10 号位置,造成冲突,B 存储到 11 号位置。如果 A 被删除了,那么要寻找 B 的时候,一开始我们会先查看 10 号位置是不是 B。我们肯定知道 10 号位置没有存储 B,但我们也不能确定 B 数据到底存储了没有。其实 B 数据存储了,但查找的人不能确定要不要继续查找下去,也不知道查找到什么时候才能结束(最坏的打算是全部找一遍)。所以我们使用EXIST表示该位置存储了数据;

  EMPTY表示该位置未存储数据;DELETE表示该位置的数据被删除了,但是查找的时候要继续往后查找知道某个位置的状态为EMPTY,因为DELETE表示之前存储过数据,也许现在要查询的数据因为冲突被存放在特定的位置。

  后续使用插入功能时,位置信息为EMPTY 或者 DELETE则可以插入;使用删除功能时,位置信息只修改为DELETE(为保证冲突的数据可以被找到),修改为EMPTY

enum State {EXIST,EMPTY,DELETE
};template <class K, class V>
struct HashData {std::pair<K, V> _kv;State _state = EMPTY;
};template <class K, class V>
class HashTable {
private:std::vector<HashData<K, V> > _tables;size_t _n = 0;	// 表中存储数据的个数
};
5.1.4.2 扩容 和 取模 问题
  • 扩容问题是因为开放定址法的哈希表的初始空间有限,随着存储量的增加,需要给哈希表进行扩容操作。
    • 这里我们哈希表的负载因子控制在 0.7,当负载因子大于等于 0.7以后,我们就进行扩容。
    • 因为我们的哈希函数采用除留余数法,所以希望我们的哈希表的大小是一个质数。有两种方案,一种方案是采用Java HashMap使用 2 的整数幂作为哈希表的大小,但是有所改进的方法;第二种方案是 sgi 版本的哈希表使用的方法,给了一个近似 2 倍的质数表,每次去质数表获取扩容后的大小。质数表代码如下,使用方法是__stl_next_prime(val),会返回一个大于等于val的一个质数:
inline unsigned long __stl_next_prime(unsigned long n)
{// Note: assumes long is at least 32 bits.static const int __stl_num_primes = 28;static const unsigned long __stl_prime_list[__stl_num_primes] ={53			, 97		, 193		, 389		, 769		,1543		, 3079		, 6151		, 12289		, 24593		,49157		, 98317		, 196613	, 393241	, 786433	,1572869		, 3145739	, 6291469	, 12582917	, 25165843	,50331653	, 100663319	, 201326611	, 402653189	, 805306457	,1610612741	, 3221225473, 4294967291};const unsigned long* first = __stl_prime_list;const unsigned long* last = __stl_prime_list + __stl_num_primes;const unsigned long* pos = std::lower_bound(first, last, n);return pos == last ? *(last - 1) : *pos;
}
  • Key不能取模问题,当 Key 是 string 或自定义类型等类型时,Key 不能取模,那么我们需要给 HashTable 增加一个仿函数,这个仿函数支持把 Key 转换成一个可以取模的整形。
    • 如果 Key 可以转换成整形并且不容易冲突,那么这个仿函数就用默认参数即可。
    • 如果 Key 不能转换成整形,需要自己实现一个仿函数传给这个参数。实现的这个仿函数要求尽量让 Key 的每一个信息位都参与到计算中,让不同的 Key 转换出的整形值不同。string 做哈希表的 Key 非常常见,所以我们可以考虑把 string 特化一下。
template <class K>
struct HashFunc {size_t operator() (const K& key) {return (size_t)key;}
};// 特化
template <>
struct HashFunc<std::string> {// 字符串转换成整形,可以把字符ASCII码相加即可// 但是直接相加的话,类似 "abcd" 和 "dcba" 这样的字符串计算出的Key是相同的// 这里我们使用 BKDR哈希 的思路,用上次的计算结果去乘以一个质数,// 这个质数一般取 31,131等效果会比较好size_t operator() (const std::string& key) {size_t hash = 0;for (char ch : key) {hash *= 131;hash += ch;}return hash;}
};
5.1.4.3 完整代码实现

哈希表的实现思路

  • 默认构造函数
    • 根据前面写的仿函数,给 HashTable 类三个模版参数,第一个参数 K 就是关键字;第二个参数 V 就是被存储的数据的类型;第三个参数 Hash 是一个哈希函数对象,负责将任意类型的 键K 转换成 size_t 类型的整数值,这个整数值将用于计算哈希表中的存储位置。
    • 成员变量有一个 vector 容器用于保存数据,一个 size_t 变量记录当前存入数据的个数。
template <class K, class V, class Hash = HashFunc<K> >
class HashTable {
public:HashTable() {// 从质数表中取出第一个数作为哈希表的大小_tables.resize(__stl_next_prime(0));}
private:std::vector<HashData<K, V> > _tables;size_t _n = 0;	// 表中存储数据的个数
}
  • 插入函数
    • 这里实现的哈希表不允许插入相同的数据,所以当插入的数据已存在,则插入失败。
    • 负载因子 大于等于 0.7 则进行扩容,扩容采用 Insert 复用设计,创建一个同类型的哈希表(newHT),把旧值通过 Insert 函数插入到 newHT 中,数据迁移成功后,交换两个对象的 vector 容器则完成扩容操作。
    • 插入数据的逻辑为:第一次计算哈希值->默认发生冲突,使用 while 循环默认解决冲突->退出while循环后,冲突解决,插入新元素->修改哈希表的成员变量
bool Insert(const std::pair<K, V>& kv) {// 不允许插入相同的值if (Find(kv.first))return false;// 判断是否需要扩容if (_n * 1.0 / _tables.size() >= 0.7) {// 负载因子 大于等于 0.7 后开始扩容// 这里利用类似深拷贝现代写法的思想,插入后交换解决HashTable<K, V, Hash> newHT;newHT._tables.resize(__stl_next_prime(_tables.size() + 1));for (size_t i = 0; i < _tables.size(); ++i) {if (_tables[i]._state == EXIST) {// 复用 Insert ,不需要重写一遍 Insert 的插入逻辑newHT.Insert(_tables[i]._kv);}}_tables.swap(newHT._tables);}Hash hash;size_t hash0 = hash(kv.first) % _tables.size();size_t hashi = hash0;	// 直接将 hash0 的值给 hashi,进行冲突判定size_t i = 1;while (_tables[hashi]._state == EXIST) {// 发生冲突,采用 线性探测 来解决冲突hashi = (hash0 + i) % _tables.size();// 如果采用 二次探测 ,这里就变成 +- i^2,额外控制 +- 的次序++i;}// 退出循环后,hashi 的位置一定是可以插入的位置	_tables[hashi]._kv = kv;_tables[hashi]._state = EXIST;++_n;return true;
}
  • 查找函数
    • 返回值类型为HashData<K, V>*,是为了方便删除函数调用查找函数后能快速确定位置,然后通过指针解引用来把该数据的状态值设置为DELETE
    • 查找逻辑和插入逻辑类似,都是通过哈希函数来得到数据存储的位置,唯一的不同就是while循环的条件改变了。
HashData<K, V>* Find(const K& key) {Hash hash;	// 为哈希函数计算提供 整形size_t hash0 = hash(key) % _tables.size();size_t hashi = hash0;size_t i = 1;while (_tables[hashi]._state != EMPTY) {if (_tables[hashi]._state == EXIST&& _tables[hashi]._kv.first == key) {return &_tables[hashi];}// 线性探测hashi = (hash0 + i) % _tables.size();++i;}return nullptr;
}
  • 删除函数
    • 使用查找函数可以简化删除函数的代码,根据查找函数的返回值可以对数据进行删除。若查找函数的返回值不为空,说明找到了要删除的数据,此时通过指针解引用修改数据的状态值即可。
bool Erase(const K& key) {// 首先得知道 数据 是否存在HashData<K, V>* ret = Find(key);if (ret != nullptr) {// 数据 存在ret->_state = DELETE;	// 状态设置为删除,即可--_n;return true;}else {return false;}
}
  • 需要重写析构函数,我们类中的成员变量会自己调用自己的析构函数,所以使用编译器自动生成的析构函数即可。

5.2 链地址法(拉链法)

  开放地址法中所有元素都直接存储在哈希表中;链地址法中所有的数据不再直接存储在哈希表中,哈希表中对应位置存储的是一个指针,没有数据映射这个位置时,这个指针为空;有多个数据映射到这个位置时,我们把这些冲突的数据链接成一个链表,挂在哈希表的这个位置下面,所以链地址法也叫做拉链法或者哈希桶。

  • 下面先演示 19, 30, 5, 36 这一组数据映射到 M=11 的表中:
    链地址法的演示

5.2.1 扩容问题

  开放地址法负载因子必须小于 1(空间大小限制),而链地址法的负载因子就没有限制,可以大于 1(链表可以继续往下不断延伸)。负载因子越大,哈希冲突的概率越高,空间利用率越高。STL中 unordered_xxx 的最大负载因子基本控制在 1,大于 1 就扩容,我们下面实现也使用这个方式。

  偶然情况下,某个桶很长,查找效率很低怎么办?在 Java8 的 HashMap 中当桶的长度超过一定阈值(8)时就把链表转换成红黑树。一般情况下,不断扩容,单个桶很长的场景还是比较少的。

5.2.2 链地址法代码实现

5.2.2.1 基本框架
  • 结点结构体声明定义 struct HashNode{}
  • 扩容函数 __stl_next_prime()
  • 哈希函数仿函数实现及其特化 class HashFunc{}
  • 哈希表类声明定义 class HashTable{}

基本框架和开放定址法类似。

namespace hash_bucket {template <class K, class V>struct HashNode {std::pair<K, V> _kv;HashNode<K, V>* _next;HashNode(const std::pair<K, V>& kv):_kv(kv),_next(nullptr){ }};inline unsigned long __stl_next_prime(unsigned long n){// Note: assumes long is at least 32 bits.static const int __stl_num_primes = 28;static const unsigned long __stl_prime_list[__stl_num_primes] ={53			, 97		, 193		, 389		, 769		,1543		, 3079		, 6151		, 12289		, 24593		,49157		, 98317		, 196613	, 393241	, 786433	,1572869		, 3145739	, 6291469	, 12582917	, 25165843	,50331653	, 100663319	, 201326611	, 402653189	, 805306457	,1610612741	, 3221225473, 4294967291};const unsigned long* first = __stl_prime_list;const unsigned long* last = __stl_prime_list + __stl_num_primes;const unsigned long* pos = std::lower_bound(first, last, n);return pos == last ? *(last - 1) : *pos;}template <class K>struct HashFunc {size_t operator() (const K& key) {return (size_t)key;}};// 特化template <>struct HashFunc<std::string> {// 字符串转换成整形,可以把字符ASCII码相加即可// 但是直接相加的话,类似 "abcd" 和 "dcba" 这样的字符串计算出的Key是相同的// 这里我们使用 BKDR哈希 的思路,用上次的计算结果去乘以一个质数,// 这个质数一般取 31,131等效果会比较好size_t operator() (const std::string& key) {size_t hash = 0;for (char ch : key) {hash *= 133;hash += ch;}return hash;}};template <class K, class V, class Hash = HashFunc<K> >class HashTable {typedef HashNode<K, V> Node;public:// ...private:std::vector<Node*> _tables;	// 指针数组size_t _n = 0;				// 表中存储数据个数};
}
5.2.2.2 构造和析构函数
  • 构造函数
HashTable() {// 先开一个 质数大小 的指针数组_tables.resize(__stl_next_prime(0)nullptr);
}
  • 析构函数
    因为开辟了空间,所以要自己写一个析构函数去释放空间。
~HashTable() {// 因为我们开辟了空间,所以需要手动去回收空间// 依次把每个桶释放for (size_t i = 0; i < _tables.size(); i++) {Node* cur = _tables[i];while (cur) {Node* next = cur->_next;delete cur;cur = next;}// 对哈希表指定位置置空_tables[i] = nullptr;}_n = 0;
}
5.2.2.3 查找函数

  创建模版类时,有一个模版参数 K,这个 K 就明确了我们比较的键是什么类型的。

Node* Find(const K& key) {Hash hs;size_t hash0 = hs(key) % _tables.size();Node* cur = _tables[hash0];while (cur) {if (cur->_kv.first == key) {return cur;}// 继续往下搜索cur = cur->_next;}return nullptr;
}
5.2.2.4 插入函数

  插入逻辑和开放定址法的逻辑大致。有两点不同于开放地址法:1.链表的插入,采用的是头插法。2.这里不采用建立新的对象,然后复用Insert函数的方法来扩容,因为复用Insert函数,会重新建立新的结点,需要拷贝数据。所以这里采用直接移动结点,将结点移动到新表来减小对于拷贝的开销。

bool Inser(const std::pair<K, V>& kv) {// 不允许相同的值插入,所以先检查是否有相同的值if (Find(kv.first))return false;Hash hs;// 然后检查是否需要扩容,负载因子达到 1 后就要开始扩容if (_n / _tables.size() == 1) {// 开始扩容操作std::vector<Node*> newtables(__stl_next_prime(_tables.size() + 1), nullptr);for (size_t i = 0; i < _tables.size(); ++i) {Node* cur = _tables[i];while (cur) {Node* next = cur;// 旧表中的节点 重新 映射到新表中size_t hash0 = hs(cur->_kv.first) % newtables.size();// 头插到新表cur->_next = newtables[hash0];newtables[hash0] = cur;cur = next;}_tables[i] = nullptr;}_tables.swap(newtables);}size_t hash0 = hs(kv.first) % _tables.size();// 头插法Node* newnode = new Node(kv);newnode->_next = _tables[hash0];_tables[hash0] = newnode;++_n;return true;
}
5.2.2.5 删除函数
bool Erase(const K& key) {Hash hs;size_t hash0 = hs(key) % _tables.size();Node* prev = nullptr;Node* cur = _tables[hash0];while (cur) {if (cur->_kv.first == key) {if (prev == nullptr) // 说明要删除的是第一个,需要特殊处理一下_tables[hash0] = cur->_next;else prev->_next = cur->_next;delete cur;--_n;return true;}// 寻找下一个prev = cur;cur = cur->_next;}// 未找到该元素return false;
}

希望我的学习总结对大家有帮助 😃

http://www.dtcms.com/a/508063.html

相关文章:

  • 学习Python 04
  • AJAX的学习
  • Python爬虫实战:淘宝模拟人工搜索关键词采集商品列表
  • VB与PyCharm——工具的选择与编程的初心
  • 网站制作公司价格网站策划与运营
  • 旅游网站建设的费用明细路桥区商用营销型网站建设
  • 五大工作流自动化平台实测对比:从执行到定义的差距
  • 实战Kaggle比赛:图像分类 (CIFAR-10) - 用PyTorch挑战经典计算机视觉任务
  • 做网站需要会语言吗wordpress 淘宝
  • 电子商务与网站建设实践论文更改wordpress管理地址
  • 正点原子RK3568学习日志12-注册字符设备
  • zookeeper简介
  • 注册中心对比 -- eureka、nacos、consul、zookeeper、redis过期key
  • php 茶叶网站网页qq登录保护怎么关闭
  • 做南美生意做什么网站好网站维护需要多久时间
  • MFC 在list右键弹出菜单栏功能 ,在list控件自定义绘制按钮控件
  • 网站设计中的事件是什么宝钢工程建设有限公司网站
  • vue3 之 基础+核心概念+上手技巧
  • 兰州网站建设推荐q479185700顶上北京邢台企业商会网站
  • TypeScript基础入门与数据类型
  • PHP面试题——情景应用
  • 看门狗设置
  • 部门网站建设总结网上商城网站建设
  • 做网站服务器哪种好外贸企业网站推广方案
  • 合肥企业网站推广英文网站建设情况
  • MVVM 架构 android
  • 数据结构8:栈
  • 激活函数只是“非线性开关“?ReLU、Sigmoid、Leaky ReLU的区别与选择
  • C# 基础——多态的实现方式
  • 【Nginx反向代理技术详解】原理、配置与实践