C++:哈希表

1.前言

在前面我们学习序列式容器和关联式容器的时候,提到了unordered_map和unordered_set的概念,详情可以参考一下这篇文章:

https://blog.csdn.net/2502_91842264/article/details/157363958?fromshare=blogdetail&sharetype=blogdetail&sharerId=157363958&sharerefer=PC&sharesource=2502_91842264&sharefrom=from_link

在此我们就不过多赘述。

2.unordered_set的使用

2.1 unordered_set类的介绍

我们在官网中可以查看到关于unordered_set的介绍:

显而易见,既然是set那肯定是key的结构,它与set的区别在于这两个容器的仿函数不同:

unordered_set的第二和第三个仿函数不只是单纯的比较大小了,set的底层是二叉搜索树,而搜索树的底层逻辑是比较大小,所以使用的仿函数叫Compare。但unordered_set的底层是哈希,简单来说就是能实现将key的值转化成整型。另一个Pred是能实现key的比较是否相等。

这个时候就产生了一道题目:一个数据要去分别做set和unordered_set的key值,需要满足什么要求?如果是做set的key,就需要能满足比较大小,如果不能直接比较大小,需要自行控制仿函数来实现;如果是做unordered_set的key,就需要这个数据能转化成整型或原本就是整型,并且需要能比较是否相等。

unordered_set 底层是用哈希桶实现,增删查平均效率是O(1),迭代器遍历不再有序,为了跟 set 区分,所以取名 unordered_set。unordered就是无序的意思。

前面部分我们已经学习了 set 容器的使用,set 和 unordered_set 的功能高度相似,只是底层结构不同,有一些性能和使用的差异,这里我们只讲他们的差异部分。

2.2 unordered_set类的成员函数

我们可以看到和set相比,unordered_set没有rbegin、rend这样的函数,这就印证了unordered_set是一个单向迭代器。另外的insert、erase、swap这样的成员函数我们在set中都介绍过,在这里就不过多赘述。像Buckets、Hash policy这两个类型,后续我会讲解。

2.3 unordered_set和set的使用差异

查看文档我们会发现 unordered_set 的支持增删查且跟 set 的使用一模一样,关于使用我们这里就不再赘述和演示了。

unordered_set 和 set 的第一个差异是对 key 的要求不同,set 要求 Key 支持小于比较,而 unordered_set 要求 Key 支持转成整形且支持等于比较,要理解 unordered_set 的这个两点要求得后续我们结合哈希表底层实现才能真正理解,也就是说这本质是哈希表的要求。

unordered_set 和 set 的第二个差异是迭代器的差异,set 的 iterator 是双向迭代器,unordered_set 是单向迭代器,其次 set 底层是红黑树,红黑树是二叉搜索树,走中序遍历是有序的,所以 set 迭代器遍历是有序 + 去重。而 unordered_set 底层是哈希表,迭代器遍历是无序 + 去重。

unordered_set 和 set 的第三个差异是性能的差异,整体而言大多数场景下,unordered_set 的增删查改更快一些,因为红黑树增删查改效率是O(logN),而哈希表增删查平均效率是O(1)。

接下来看一下unordered_set函数的使用:

在这里实例化一个对象然后初始化值,接着插入一个新数据后,使用迭代器进行遍历。大家可以观察到打印出来的数据是无序的,并且如果有相同数据的话,打印的时候unordered_set会自动去重,大家可以自己尝试一下。

其实unordered_set和set的最大区别还是在于效率的问题上,我们用下面这段代码来进行解释:

cpp 复制代码
int test3()
{
	const size_t N = 1000000;
	unordered_set<int> us;
	set<int> s;
	vector<int> v;
	v.reserve(N);
	srand(time(0));
	for (size_t i = 0; i < N; ++i)
	{
		//v.push_back(rand()); // N⽐较⼤时,重复值⽐较多
		v.push_back(rand() + i); // 重复值相对少
		//v.push_back(i); // 没有重复,有序
	}
	
	size_t begin1 = clock();
	for (auto e : v)
	{
		s.insert(e);
	}
	size_t end1 = clock();
	cout << "set insert:" << end1 - begin1 << endl;
    size_t begin2 = clock();
    us.reserve(N);
    for (auto e : v)
    {
        us.insert(e);
    }
    size_t end2 = clock();
    cout << "unordered_set insert:" << end2 - begin2 << endl;
    int m1 = 0;
    size_t begin3 = clock();
    for (auto e : v)
    {
        auto ret = s.find(e);
        if (ret != s.end())
        {
            ++m1;
        }
    }
    size_t end3 = clock();
    cout << "set find:" << end3 - begin3 << "->" << m1 << endl;
    int m2 = 0;
    size_t begin4 = clock();
    for (auto e : v)
    {
        auto ret = us.find(e);
        if (ret != us.end())
        {
            ++m2;
        }
    }
    size_t end4 = clock();
    cout << "unorered_set find:" << end4 - begin4 << "->" << m2 << endl;
    cout << "插入数据个数:" << s.size() << endl;
    cout << "插入数据个数:" << us.size() << endl << endl;

    size_t begin5 = clock();
    for (auto e : v)
    {
        s.erase(e);
    }
    size_t end5 = clock();
    cout << "set erase:" << end5 - begin5 << endl;
    size_t begin6 = clock();
    for (auto e : v)
    {
        us.erase(e);
    }
    size_t end6 = clock();
    cout << "unordered_set erase:" << end6 - begin6 << endl << endl;
    return 0;
}

在这段代码当中,我们在Debug版本下,向unordered_set和set分别插入了一百万个随机值,然后记录插入数据所需要的时间,以及查找数据所需要的时间:

不难看出,这里的数字单位是毫秒,不管是插入数据还是查找数据还是删除数据的速率,unordered_set的速度都比set快了接近两倍。如果是在Release版本下的话,这个差值会更加大。

3.unordered_map的使用

3.1 unordered_map的介绍

查看文档我们会发现 unordered_map 的支持增删查且跟 map 的使用一模一样,关于使用我们这里就不再赘述和演示了。

3.2 unordered_map和map的使用差异

首先最明显的还是仿函数的区别,这里unordered_map和unordered_set的仿函数类型是一样的。

unordered_map 和 map 的第一个差异是对 key 的要求不同,map 要求 Key 支持小于比较,而 unordered_map 要求 Key 支持转成整形且支持等于比较,要理解 unordered_map 的这个两点要求得后续我们结合哈希表底层实现才能真正理解,也就是说这本质是哈希表的要求。

unordered_map 和 map 的第二个差异是迭代器的差异,map 的 iterator 是双向迭代器,unordered_map 是单向迭代器,其次 map 底层是红黑树,红黑树是二叉搜索树,走中序遍历是有序的,所以 map 迭代器遍历是 Key 有序 + 去重。而 unordered_map 底层是哈希表,迭代器遍历是 Key 无序 + 去重。

unordered_map 和 map 的第三个差异是性能的差异,整体而言大多数场景下,unordered_map 的增删查改更快一些,因为红黑树增删查改效率是O(logN),而哈希表增删查平均效率是O(1)。

对于插入、查找、删除数据的速度,unordered_map和map的差别跟unordered_set和set的差别一样,在这里就不过多演示。

4. 哈希表的概念

哈希(hash)又称散列,是⼀种组织数据的方式。从译名来看,有散乱排列的意思。**本质就是通过哈希函数把关键字Key跟存储位置建立⼀个映射关系,查找时通过这个哈希函数计算出Key存储的位置,进行快速查找。**所以从表面看,哈希的排列像是散乱无章的,其实乱中有序。并且因为是通过哈希函数进行查找,那对于不同的映射关系就会有不同的哈希函数,后续我们会介绍。

5. 哈希函数

5.1 直接定址法

当哈希的范围比较集中时,直接定址就是非常简单高效的方法。比如一组关键字都在 0~99 之间,那么我们开一个 100 个数的数组,每个关键字的值就是存储位置的下标。再比如一组关键字的值在 A~z 的小写字母,那么我们开 26 个数的数组,每个关键字 acsi 码 - a 的 ascii 码就是存储位置的下标。也就是说直接定址法本质就是用关键字计算一个对应位置或者相对位置。这个方法我们在数据排序部分已经用过了,它在 string 节下的 OJ 也用过了。

387. 字符串中的第一个唯一字符 - 力扣(LeetCode)

直接定址法的缺点也非常明显,一个是只适用于部分的类型,比如上述的题目当中,如果我想查找浮点数,就需要重新再写函数。另一个,当关键字的范围比较分散时,就很浪费内存,存在内存无法使用。假设我现在只需要100个值,但是这100个值是分散在一百万个数据中的随机的100个值,那么开空间就需要开一百万个空间,不仅速度降低,内存占用也更大。所以总结是:直接定址法只适合范围集中的整型。

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

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

除法散列法也叫做除留余数法,顾名思义,假设哈希表的大小为 M,那么通过 key 除以 M 之后得到的余数作为映射位置的下标,也就是哈希函数为:h (key)=key% M。

当使用除法散列法时,要尽量避免 M 为某些值,如 2 的幂,10 的幂等。如果是 2 的 X 次方,那么 key%2^X 本质相当于保留 key 的后 X 位,那么后 x 位相同的值,计算出的哈希值都是一样的,就冲突了。如:{63 , 31} 看起来没有关联的值,如果 M 是 16,也就是 2 的 4 次方,那么计算出的哈希值都是 15,因为 63 的二进制后 8 位是 00111111,31 的二进制后 8 位是 00011111。

例子 1:X=4(即 2⁴=16),key=63

  • 63 的二进制:111111(最后 4 位是1111
  • 计算取余:63 % 16 = 15
  • 验证:二进制最后 4 位1111对应的十进制数就是 15 → 符合 "保留后 X 位" 的规律。

例子 2:X=4(即 2⁴=16),key=31

  • 31 的二进制:11111(最后 4 位是1111
  • 计算取余:31 % 16 = 15
  • 验证:二进制最后 4 位1111对应十进制 15 → 符合 "保留后 X 位" 的规律。

这会导致 "最后 X 位相同的不同 key" 哈希值一致,引发哈希冲突;这也是除法散列法不建议选 2 的幂作为 M 的核心原因 ------ 哈希值分布会极度不均匀。

如果是 10 的 X 次方,就更明显了,保留的都是十进制的后 x 位,如:{112,12312},如果 M 是 100,也就是 10 的 2 次方,那么计算出的哈希值都是 12。

当使用除法散列法时,建议 M 取不太接近 2 的整数次幂的一个质数 (素数)。

需要说明的是,实践中也是八仙过海,各显神通,Java 的 HashMap 采用除法散列法时就是 2 的整数次幂做哈希表的大小 M,这样玩的话,就不用取模,而可以直接位运算,相对而言位运算比模运算更高效一些。但是他不是单纯的去取模,比如 M 是 2^16 次方,本质是取后 16 位,那么用 key'=key>>16,然后把 key 和 key' 异或的结果作为哈希值。也就是说我们映射出的值还是在 [0,M) 范围内,但是尽量让 key 所有的位都参与计算,这样映射出的哈希值更均匀一些即可。所以我们上面建议 M 取不太接近 2 的整数次幂的一个质数的理论是大多数数据结构书籍中写的理论,但是实践中,灵活运用,抓住本质,而不能死读书。

5.3 哈希冲突

刚刚的除留余数法存在的⼀个问题就是,两个不同的key可能会映射到同⼀个位置去,这种问题我们叫做哈希冲突, 或者哈希碰撞。理想情况是找出⼀个好的哈希函数避免冲突,但是实际场景中,冲突是不可避免的, 所以我们尽可能设计出优秀的哈希函数,减少冲突的次数,同时也要去设计出解决冲突的方案。换句话说:哈希表的核心问题,就是如何去更好的解决哈希冲突的问题。

5.4 乘法散列法(了解即可)

乘法散列法对哈希表大小 M 没有要求,它的大思路第一步:用关键字 K 乘上常数 A (0<A<1),并抽取出 kA 的小数部分。第二步:再用 M 乘以 kA 的小数部分,再向下取整。

h (key) = floor (M ×((A ×key)%1.0)),其中 floor 表示对表达式进行下取整,A∈(0,1),这里最重要的是 A 的值应该如何设定,Knuth 认为 A =(√5−1)/2 = 0.6180339887....(黄金分割点) 比较好。

乘法散列法对哈希表大小 M 是没有要求的,假设 M 为 1024,key 为 1234,A=0.6180339887,Akey = 762.6539420558,取小数部分为 0.6539420558, M×((A×key)%1.0)=0.65394205581024=669.6366651392,那么 h (1234)=669。

5.5 全域散列法(了解即可)

如果存在一个恶意的对手,他针对我们提供的散列函数,特意构造出一个发生严重冲突的数据集,比如,让所有关键字全部落入同一个位置中。这种情况是可以存在的,只要散列函数是公开且确定的,就可以实现此攻击。解决方法自然是见招拆招,给散列函数增加随机性,攻击者就无法找出确定可以导致最坏情况的数据。这种方法叫做全域散列。

h_ab (key) = ((a × key + b) % P) % MP 需要选一个足够大的质数,a 可以随机选 [1,P-1] 之间的任意整数,b 可以随机选 [0,P-1] 之间的任意整数,这些函数构成了一个 P*(P-1) 组全域散列函数组。假设 P=17,M=6,a=3,b=4,则h_ab (8) = ((3 × 8 + 4) % 17) % 6 = 5。

需要注意的是每次初始化哈希表时,随机选取全域散列函数组中的一个散列函数使用,后续增删查改都固定使用这个散列函数,否则每次哈希都是随机选一个散列函数,那么插入是一个散列函数,查找又是另一个散列函数,就会导致找不到插入的 key 了。

6. 负载因子

假设哈希表中已经映射存储了 N 个值,哈希表的大小为 M,那么负载因子 = N / M,负载因子有些地方也翻译为载荷因子 / 装载因子等,它的英文为 load factor。负载因子越大,哈希冲突的概率越高,空间利用率越高;负载因子越小,哈希冲突的概率越低,空间利用率越低。

7. 处理哈希冲突

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

7.1 开放定址法

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

7.1.1 线性探测

从发生冲突的位置开始,每次线性向后探测,直到找到下一个有空余数据位的位置为止,如果到达哈希表末尾,则回绕到哈希表的开头。

h(key)=hash0​=key%M,若 hash0​ 位置发生了冲突,则线性探测公式为:

hc​(key,i)=hash i​ =( hash 0 ​+ i )%M, i = {1,2,...,M−1} ,因为探测次数小于 M,则最多探测 M−1 次,一定能找到一个存放 key 的位置,因为开放定址法中负载因子是小于 1 的,就意味着一定有空位。上述线性探测公式中之所以要 %M ,就是要实现回绕到哈希表开头这个操作。

线性探测比较简单且容易实现,但线性探测的问题很低效,hash0​ 位置线性冲突,hash0​, hash1​,hash2​ 位置已经存有数了,后续数据都会扎堆到 hash3​ 位置,这种现象称为 群集/ 堆积。下面的二次探测可以一定程度缓解这个问题。

下面表示 {19,30,5,36,13,20,2,12} 这一组值映射到 M = 11 的表中。

这里的逻辑就是:h(19) = 8 , h(30) = 8 ,30映射的位置和19发生哈希冲突,那19就进行线性探测,找到了第 9 个位置。 h(5) = 5 , h(36) = 3 , h(13) = 2 , h(20) = 9 此时20映射的位置和30发生哈希冲突,20进行线性探测之后找到第 10 个位置。 h(21) = 10 ,21映射的位置和20发生哈希冲突,21进行线性探测,找到第 0 个位置。 h(12) = 1。

因为线性探测的判断逻辑是:查找时遇到空位置,就直接停止不再查找。所以在进行查找数据的时候是这样一个逻辑:比如我现在要查找20,因为h(20) = 9 ,所以要先从 9 这个位置查找,然后再用线性探测的规则,找到 20 这个所要的数据,直到查找到空的时候停止。但这就存在一个弊端,如果已经把 30 这个数据删除了,那第 9 个位置就是空,那打算通过线性探测找到 20 的话,需要经过第 9 个位置,但因为第 9 个位置是空,所以探测就停止,认为 20 这个数据就不存在了。因此我们要做出调整。

我们现在来简单实现一下这个哈希表的线性探测:

创建一个头文件Hash.h,首先我们进行一个枚举。然后哈希表中的每个位置都当成一个结构体,里面除了存储的数据内容,还有用枚举来表示哈希表中的每个节点的存储状态。有:存在、空、删除。那么我就可以进行逻辑判断:当遇到存在或者删除的数据,就依照线性探测向后查找,如果遇到空就停止。

然后就是来实现线性探测的逻辑:

因为我们需要hash0和hashi的位置,所以我一一进行了表示,并且按照公式进行了线性探测的逻辑,但是还要考虑一个问题,如果当前的哈希表满了之后,就遇到了需要扩容的问题。

因为我们的哈希表用的是vector,那么能直接进行二倍扩容吗?比如原来哈希表的容量是11,二倍扩容之后,哈希表容量就变成22。这样乍一看好像并没有什么问题,但实际上映射关系已经全乱套了。以前面的哈希表的位置举例:

原来 21 这个数据的位置是 0 ,容量从 11 扩容到 22 之后,再去拿21%22,所得余数是 21 ,那么就应该变成第 21 个位置了,所以说映射关系乱套了。

所以这里的扩容逻辑是,扩容之后,还需要再重新映射,既然要重新映射,那么原来产生的哈希冲突可能就没有了,但原来没有哈希冲突的位置,重新映射之后可能又产生冲突了,但冲突的概率会降低,因为扩容的本质是降低负载因子,前面提到过:负载因子越大,哈希冲突的概率越高,空间利用率越高;负载因子越小,哈希冲突的概率越低,空间利用率越低。

在这里扩容我给出了两个方法,第一个方法是:创建一个新的哈希表newtables,然后利用范围for遍历整个旧的哈希表,如果遍历到某个位置时,该位置存在数据,那就调用线性探测的逻辑。但是这种方法有点太冗余,因为下面我们已经写了一份线性探测的代码了。于是就有了第二个方法:直接实例化一个新的对象newHT,然后将这个新对象newHT中的_tables扩容到旧哈希表的容量的两倍,再遍历,当遇到旧表中又存在的数据,就调用Insert函数进行数据插入,最后再将新旧两表的指针通过swap进行交换即可,这样一来,因为newHT是栈上的对象,出了作用域之后就销毁了,所以我们也不需要再去写析构函数。

接下来我们用刚刚的例子:{ 19,30,5,36,13,20,21,12 },进行代码调试:

大家可以观察到,与我们给出的样例一模一样,所以代码是没有问题的。

但是这样的话就衍生出一个小问题,我们刚刚在哈希表中取的M = 11,扩容之后就变成 22 ,我们之前说,为了尽可能地避免哈希冲突,M 的值要尽可能的取 不接近2的次方数的一个素数。但是 22 这个数字就不是素数了,所以我们可以尝试优化一下。

7.1.2 扩容问题

这是STL库里面给出的一个素数表,并且这些素数都是有序排列的,比如53,97,193....近似于二倍扩容。这个函数最后的返回值是:这个素数表中大于 n 的那个位置的素数。所以我们在扩容的时候,就可以根据这个表中的内容来选择 M 的值:

在初始化时,调用这个函数传值1,这样的话就是找到表中大于等于 1 的素数, 那就是53。扩容时,传的值是比当前存储的有效数据个数大 1 的数字,那这样的话相当于取当前 M 值对应素数表中位置的后一个素数。

不过这样的话,我们的 M 就是固定的了,如果被泄露的话,那我们的哈希表可能就会遭到别人的恶意攻击,这时候前面提到的全域散列法就可以解决我们的问题,所以很多解决方法都是衍生出来的,不过在这我们不多讲解,大家有兴趣可以自行了解。

7.1.3 Find和Erase函数的实现

Find查找函数在这里可以直接沿用线性探测的逻辑,Erase函数就可以直接复用Find函数,找到要删除的key值的位置之后,我们直接将当前位置的状态置为空,然后将哈希表有效数据个数减一就像,因为状态设置为空的时候,下次遇到该位置就可以直接赋值覆盖了。

实现了Find函数之后,我们还可以控制哈希表中不会有冲突的值:

7.1.4 key取模问题

对于我们当前编写的这个简单的哈希表来说,只能支持整型数据的处理,不能处理像string这种类型的数据,因为我们在线性探测的步骤当中,获取hash0的位置就有这样一句代码:size_t hash0 = kv.first % _tables.size(); 如果kv.first的数据是string类型,就没有办法进行直接取模。

那么我们需要给 HashTable 增加一个仿函数,这个仿函数支持把 key 转换成一个可以取模的整形,如果 key 可以转换为整形并且不容易冲突,那么这个仿函数就用默认参数即可,如果这个 Key 不能转换为整形,我们就需要自己实现一个仿函数传给这个参数,实现这个仿函数的要求就是尽量让 key 的每一位值都参与到计算中,让不同的 key 转换出的整形值不同。string 做哈希表的 key 非常常见,所以我们可以考虑把 string 特化一下。

首先我们先解决负数的key转化的问题:

在这个仿函数当中,我们直接重构一下operator(),将key值都强转成无符号整型,这样的话如果是负数,被强转成无符号类型之后,在哈希表中也可以找到映射位置了。并且有了缺省值,如果不传仿函数,就按照缺省值执行,那么在HashTable当中,有以下内容需要做出调整:

对于string类型的数据,我们自己写的仿函数是这样的:

这里的代码逻辑是:因为string类的数据都是由字母组成的,而字母有对应的ASCLL码值,我们把一个单词或者一个汉字的字母组成中的字母的ASCLL码值都加起来,值之和就是一个数字,这个数字就可以映射哈希表中的位置,然后再在这个位置当中存储内容就行了。

就像这样,实例化一个对象dict,它存储的类型就是<string,string>,成功实现了映射。

同时查找函数也是可以实现的,所以根据这些内容,我们就可以尝试写一个字典的程序。

不过这里还存在着一个比较致命的问题,我们刚刚说了,单词或者汉字是由字母组成的,字母对应的ASCLL码值加起来之后是一个数字,但是这样的话,相同的一个数字有可能有不同的排列组合,那这样哈希冲突就会非常多,所以我们还需要优化一下我们的StringHashFunc:

在这里我在累加的基础上,加了一个累乘的步骤,其实也可以不用累乘,累除或者再加一个任意数,或者其他任意的一个累计的计算步骤,就可以把这个问题给规避,因为尽管数字排列组合不同能组成相同的ASCLL码值,但我在累计的过程当中,加一些相同的步骤,到最后得到的答案就不一样了。

到这里还需要大家回想一下,我们在讲unordered_map的时候,也使用过unordered_map<string,string> dict这样的代码,但是大家会发现,使用标准库中的unordered_map的时候,并没有传第三个模板参数,而我们自己实现的哈希表,却需要传参,其实是因为标准命名空间中的unordered_map使用了模板特化,模板特化的概念我在这篇文章中提到过:

https://blog.csdn.net/2502_91842264/article/details/157101345?fromshare=blogdetail&sharetype=blogdetail&sharerId=157101345&sharerefer=PC&sharesource=2502_91842264&sharefrom=from_link

所以在这里我们可以这样优化:

这就意味着,当key值为string类型的时候,就自动调用string类型的HashFunc仿函数,就不需要我们自行传参了。

7.1.5 二次探测

从发生冲突的位置开始,依次左右按二次方跳跃式探测,直到寻找到下一个没有存储数据的位置为止,如果往右走到哈希表尾,则回绕到哈希表头的位置;如果往左走到哈希表头,则回绕到哈希表尾的位置;

h(key)=hash0=key%M,hash0 位置冲突了,则二次探测公式为:hc(key,i)=hashi=(hash0±i^2)%M, i={1,2,3,...,2M​}

二次探测当 hashi=(hash0−i^2)%M 时,当 hashi<0 时,需要 hashi += M

这实际上和线性探测的唯一不同的点就是,线性探测每次是+ i ,而二次探测是 + i^2。并且公式当中的 hash0 ± i^2 ,相当于是左右跳跃式探测,当向右二测探测之后还是冲突,那下一次就变成向左的二测探测,如果向左也冲突就再向右,这样依次循环。并且向左二次探测的时候,如果说hashi的位置变成负数了,就需要进行 hashi += M 的操作,以达到回绕到哈希表以便继续二次探测的操作。

7.1.6 双重散列(了解即可)

第一个哈希函数计算出的值发生冲突,使用第二个哈希函数计算出一个跟 key 相关的偏移量值,不断往后探测,直到寻找到下一个没有存储数据的位置为止。

h1​(key)=hash0=key%M,hash0 位置冲突了,则双重探测公式为:hc(key,i)=hashi=(hash0+i∗h2​(key))%M, i={1,2,3,...,M}

要求 h2​(key)<M 且 h2​(key) 和 M 互为质数,有两种简单的取值方法:1、当 M 为 2 整数幂时,h2​(key) 从 [0,M-1] 任选一个奇数;2、当 M 为质数时,h2​(key)=key%(M−1)+1

互为质数的意思就是:两个正整数,除了 1 以外,没有其他共同的约数,就叫它们互质。

保证 h2​(key) 与 M 互质是因为根据固定的偏移量所寻址的所有位置将形成一个群,若最大公约数 p=gcd(M,h1​(key))>1,这里的 gcd( ) 就是求最大公约数的符号。那么所能寻址的位置的个数为 M/P<M,使得对于一个关键字来说无法充分利用整个散列表。举例来说,若初始探查位置为 1,偏移量为 3,整个散列表大小为 12,那么所能寻址的位置为 {1,4,7,10},寻址个数为 12/gcd(12,3)=4

由于双重散列所需要的数学原理比较多,理解起来可能会稍微有点困难,所以了解即可。

7.2 链地址法

7.2.1 解决哈希冲突的思路

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

链地址法的效果要比开放定址法好很多,因为开放地址法归根到底还是:原本的位置被占了,引发哈希冲突,去线性探测找下一个位置。但找下一个位置也有可能会导致下一个哈希冲突,有点像鸠占鹊巢的循环往复。并且因为哈希冲突的原因,在查找的过程当中,也会降低效率。而链地址法就不存在这样的问题。

下面演示 {19,30,5,36,13,20,21,12,24,96} 等这一组值映射到 M=11 的表中。

h(19) = 8 , h(30) = 8 , h(5) = 5 , h(36) = 3 , h(13) = 2 , h(20) = 9 , h(21) = 10 , h(12) = 1 , h(24) = 2 , h(96) = 8

7.2.2 扩容

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

7.2.3 极端场景

如果极端场景下,某个桶特别长怎么办?其实我们可以考虑使用全域散列法,这样就不容易被针对了。但是假设不是被针对了,用了全域散列法,但是偶然情况下,某个桶很长,查找效率很低怎么办?这里在 Java8 的 HashMap 中当桶的长度超过一定阈值 (8) 时就把链表转换成红黑树。一般情况下,不断扩容,单个桶很长的场景还是比较少的,下面我们实现就不搞这么复杂了,这个解决极端场景的思路,大家了解一下。

7.3 哈希桶的代码实现

接下来我们来实现一下哈希桶的逻辑:

首先为了和开放定址法区分开来,哈希桶的实现我们重新开一块命名空间hash_bucket。

这就是哈希桶的一个基础的框架,表体还是vector顺序表,表中每个位置都存储一个节点,每个节点实际上都是一个链表,这里其实也可以直接写成:vector<list<pair<K,V>>> _tables; 调用标准命名空间中的 list 就可以了,但这里写成这样是为了后续我们调用哈希表封装unordered_map和unordered_set时更方便。

7.3.1 Insert函数

第一步还是实现Inset函数,我们首先要搞清楚一个点,当遇到像开放定址法中的哈希冲突的情况的时候,即hashi的位置一样,在链地址法中是将该节点的指针与hashi位置中的已存在的节点进行链接,问题就在于是头插还是尾插。如果是使用了list<pair<K,V>> 的情况下就无所谓了,因为标准命名空间中有找到头尾的函数,但如果是我们自己实现的节点HashNode,还是推荐头插,因为如果尾插的话还需要去找到单链表的尾部,还要再遍历一遍链表,就会降低效率。

我们以要插入一个新的数据16为例,此时来到哈希表中第五个位置,此时第五个位置中存储的是节点 5 的地址,我们的目的是要让节点 16 的 _next 指向节点 5 ,再让哈希表中第五个位置存储节点 16 的地址,所以代码这样编写:

7.3.2 扩容问题

对于链地址法来说,当负载因子达到1的时候才需要扩容,而扩容的方法我们在讲解开放定址法的时候提到过,创建一个新的对象newHT,然后遍历旧表,将旧表中的值依次插入到newHT._tables当中,最后再让_tables 和 newHT._tables进行交换。 那么这个方法能不能复用到链地址法当中呢?答案是可以的,但是,有一个非常明显的弊端:

如果是这样写的话,既然是调用了Insert函数,那必然会调用到new Node(kv)这个语句,也就意味着,这样写实际上是将原表中的所有节点再重新开辟一份出来,然后依次插入到新表当中,这样可以是可以,但是效率太低下了,因为不仅重新new出来需要时间,原节点还需要释放,这也需要时间。

所以我们的处理方式是这样的:

我们重新创建一个底层的容器vector<Node*> newtables;然后去遍历旧表,将旧表中的存储的节点取出,如果节点不为空,那就进入循环。要注意的是,因为表中存储的节点都是链表的头节点,所以我们还需要创建一个变量来存储cur的下一个节点,然后就是找到hashi的位置,去进行插入操作了。但是一次循环只能将该链表的一个节点插入到新表当中,因此才会有while循环。最后用swap进行交换即可。

我们来测试一下:

首先先写一个基本的构造函数,然后进行以下插入测试:

大家可以看到,和下图的逻辑是一样的:

这就说明我们的代码逻辑没有问题。

7.3.3 Find和Erase函数

在这里需要提一嘴的是,这里的Erase不能直接调用Find函数,因为我们还需要cur节点的上一个节点,用于链接去掉cur后的前后节点。

对于链地址法还剩下一个key取模问题,这个问题的处理方法和开放定址法当中的key取模方法是一样的,只需要加上仿函数即可,就不过多赘述了。

本文到此结束,感谢各位读者的阅读,如果有讲解的不到位或者错误的地方,欢迎各位读者批评或指正。

相关推荐
稻草猫.2 小时前
SpringBoot日志全解析:从调试到持久化
java·开发语言·spring boot·java-ee·idea
WBluuue2 小时前
数据结构与算法:01分数规划
c++·算法
小鸡吃米…2 小时前
Python线程同步
开发语言·数据结构·python
白帽子黑客-宝哥2 小时前
渗透测试“保姆级”实战成长手册
开发语言·网络安全·渗透测试·php
行稳方能走远2 小时前
结构体传参,到底该传值还是传指针?
c++·单片机
sycmancia2 小时前
C++——函数模板的概念和意义
c++
闻缺陷则喜何志丹2 小时前
【巴什博弈 线性筛】P8901 [USACO22DEC] Circular Barn S|普及+
c++·数学·洛谷·巴什博弈·线型筛
样例过了就是过了2 小时前
LeetCode热题100 电话号码的字母组合
数据结构·c++·算法·leetcode·dfs
Yusei_05232 小时前
C++17入门
c++