Re:从零开始的 C++ STL篇(十二)深度解析哈希函数设计、负载因子调节与两种冲突处理策略


◆ 博主名称: 晓此方-CSDN博客 大家好,欢迎来到晓此方的博客。
⭐️C++系列个人专栏: 主题曲:C++程序设计
⭐️ 踏破千山志未空,拨开云雾见晴虹。 人生何必叹萧瑟,心在凌霄第一峰


文章目录


概要&序論

这里是此方,好久不见。 随着 C++ 程序设计系列的主题曲接近尾声,我们终于迎来了C++STL篇最为关键的乐章------哈希表(Hash Table)。

本文将带你深度拆解哈希表的核心逻辑:从最基础的直接定址法,到应对冲突的开放定址法与链地址法。在哈希函数的设计上,我们将重点剖析最常用的除法散列法,并触类旁通探讨乘法散列法等进阶方案。

我会尝试从定义出发,抽丝剥茧,力求将这一面试高频考点讲得通透、彻底。 好的,让我们现在开始吧。

一,哈希的各种概念了解

1.1哈希表的定义与直接定址法

1.1.1哈希表究竟是什么嘛

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

哈希表、位图、布隆过滤器就是通过这种思想设计出来的。哈希不等于哈希表。

1.1.2最简单的哈希表:直接定址法哈希

关键字的范围比较集中 时,直接定址法就是非常简单高效的方法,比如一组关键字都在[0,99]之间,那么我们开一个100个数的数组,每个关键字的值直接就是存储位置的下标。

再比如一组关键字值都在[a,z]的小写字母 ,那么我们开一个26个数的数组 ,每个关键字的ASCII码就是存储位置的下标。

也就是说直接定址法本质就是用关键字计算出一个绝对位置或者相对位置。

一个非常经典的OJ题可以帮助我们来进一步理解这个问题: 字符串中的第一个唯一字符-力扣

cpp 复制代码
class Solution 
{
public:
    int firstUniqChar(string s) 
    {
        int arr[26] ={0};			//用数组模拟哈希
        for(int i=0;i<s.size();i++)
            arr[s[i]-'a']++;		//字母关键字映射
        for(int i=0;i<s.size();i++)
            if(arr[s[i]-'a']==1) return i;
        return -1;
    }
};

1.1.3直接定址法哈希最生动的案例:计数排序

计数排序的底层逻辑,正是哈希表直接定址法最纯粹的应用 ,它和我们曾经所讲的希尔排序和快速排序等等不同,属于非比较排序。

  1. 统计相同元素出现次数。
  2. 根据统计的结果将序列回收到原来的序列中
cpp 复制代码
vector<int>& CountSort(vector<int>& v ){
	int min = INT_MAX, max = 0, j = 0;
	for (int i = 0;i < v.size();i++){
		if (v[i] > max) max = v[i];
		else if (v[i] < min) min = v[i];
	}
	vector<int> CountHash(max - min + 1, 0);
	for (int i = 0;i < v.size();i++) 
		CountHash[v[i] - min]++;
	for (int i = 0;i < max - min + 1;i++)
		while (CountHash[i]-- != 0)
			v[j++] = i+min;
	return v;
}

计数排序的特性总结:

  1. 计数排序在数据范围集中时,效率很高,但是适用范围及场景有限。
  2. 时间复杂度:O(MAX(N,范围))
  3. 空间复杂度:O(范围)

总结: 计数排序的优势就是直接定址哈希的优势,计数排序的缺陷就是直接定址哈希的局限。(当关键字的范围比较分散时,就很浪费内存甚至内存不够用。)


1.2 负载因子

假设哈希表中已经映射存储了N个值,哈希表的大小为M,那么负载因子 = N/M。

负载因子有些地方也翻译为载荷因子/装载因子 等,他的英文为load factor。

  • 负载因子越大,哈希冲突的概率越高,空间利用率越高;
  • 负载因子越小,哈希冲突的概率越低,空间利用率越低;

怎么理解 :我已经放了 x% 的数据了,我接下来放入数据导致冲突的概率就是 1−x%

1.3 将关键字转为整数

我们将关键字映射到数组中位置,一般是整数好做映射计算,如果不是整数,我们要想办法转换成整数 ,这个细节我们后面代码实现中再进行细节展示。下面哈希函数部分我们讨论时,如果关键字不是整数,那么我们讨论的Key是关键字转换成的整数。

1.4 哈希冲突------哈希需要解决的主要问题

假设我们只有数据范围是[0, 9999]的N个值,我们要映射到一个M个空间的数组中 (一般情况下M ≥ N),那么就要借助哈希函数(hash function)hf ,关键字key被放到数组的h(key)位置,这里要注意的是h(key)计算出的值必须在[0, M)之间。

这里存在的一个问题就是,两个不同的key可能会映射到同一个位置去,这种问题我们叫做哈希冲突,或者哈希碰撞。理想情况是找出一个好的哈希函数避免冲突,但是实际场景中,冲突是不可避免的。

于是就出现了两路解决(减轻)哈希冲突的方法:

  1. 尽可能设计出优秀的哈希函数,减少冲突的次数。
  2. 设计出解决冲突的方案。

于是乎,人们开始兵分两路探索解决/减弱哈希冲突的方法。

二,对优秀哈希函数的探索

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

2.1最经典实用的方法------除法散列法(除留余数法)

除法散列法也叫做除留余数法 ,顾名思义,假设哈希表的大小为 M M M,那么通过 k e y key key 除以 M M M 的余数 作为映射位置的下标,也就是哈希函数为: h ( k e y ) = k e y ( m o d M ) h(key) = key \pmod M h(key)=key(modM)。

哈希函数非常简单,于是决定哈希冲突的因素就集中在M怎么取的问题上

2.1.1 常见的M取法

当使用除法散列法时,M M M 取不太接近 2 2 2 的整数次幂的一个素数是很多教科书上的范式。

当使用除法散列法时,要尽量避免 M M M 为某些值,如 2 2 2 的幂, 10 10 10 的幂等。 如果是 2 X 2^X 2X,那么 k e y ( m o d 2 X ) key \pmod{2^X} key(mod2X) 本质相当于保留 k e y key key 的后 X X X 位 ,那么后 X X X 位相同的值,计算出的哈希值都是一样的,就冲突了。

有可能你对本质相当于保留 k e y key key 的后 X X X 位有些困惑,那不妨思考一下,在十进制环境下12345%1000得到的是345.自然会明白那么在二进制环境下:31:00011111%00010000得到的是什么。

如: { 63 , 31 } \{63, 31\} {63,31} 看起来没有关联的值,如果 M M M 是 16 16 16,也就是 2 4 2^4 24,那么计算出的哈希值都是 15 15 15 ,因为 63 63 63 的二进制后 8 8 8 位是 00111111, 31 31 31 的二进制后 8 8 8 位是 00011111。

二进制的后五位为11111,这样的数在0~100万中有三万多个

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

于是我们认为:一个不太接近2的整数次幂的素数就成为了最好的选择。 但是实践中不太好弄。如果哈希表空间不够,扩容后就不太能够保证接下来还能符合要求。后面会介绍C++在此的一种解决方案:素数表。

2.1.2Java中一种优秀的M取法

需要说明的是,实践中也是八仙过海,各显神通,Java 的 HashMap 采用除法散列法时就是 2 2 2 的整数次幂做哈希表的大小 M M M,这种方法非常值得玩味。

不用取模,而可以直接位运算,由此优势有二:

  1. 不必为扩容后 M M M还能不能保持我们所希望的那样而焦虑。
  2. 位运算比起取模的效率在硬件层面要更高。

Java的这种方法不是单纯的去取模 ,比如 M M M 是 2 16 2^{16} 216 次方,本质是取后 16 16 16 位,那么用 k e y ′ = k e y ≫ 16 key' = key \gg 16 key′=key≫16,然后把 k e y key key 和 k e y ′ key' key′ 异或的结果作为哈希值。也就是说我们映射出的值还是在 [0, M)范围内,但是尽量让 key所有的位都参与计算,这样映射出的哈希值更均匀一些即可。

这种方法的计算过程也很简单,我们举例说明:

10110100 11000111 00101001 01011110

2.2《算法导论》补充方法

2.2.1乘法散列法

乘法散列法对哈希表大小 M M M 没有要求,他的思路可以分为两步:

  • 第一步:用关键字 K K K 乘上常数 A A A ( 0 < A < 1 0 < A < 1 0<A<1) ,并抽取出 k ∗ A k * A k∗A 的小数部分。
  • 第二步:后再用 M M M 乘以 k ∗ A k * A k∗A 的小数部分,再向下取整。

h ( k e y ) = f l o o r ( M × ( ( A × k e y ) ( m o d 1.0 ) ) ) h(key) = floor(M \times ((A \times key) \pmod{1.0})) h(key)=floor(M×((A×key)(mod1.0))),其中 f l o o r floor floor 表示对表达式进行下取整, A ∈ ( 0 , 1 ) A \in (0, 1) A∈(0,1),这里最重要的是 A A A 的值应该如何设定,K n u t h Knuth Knuth 认为 A = ( 5 − 1 ) / 2 = 0.6180339887.... A = (\sqrt{5} - 1) / 2 = 0.6180339887.... A=(5 −1)/2=0.6180339887....(黄金分割点)比较好。

乘法散列法对哈希表大小 M M M 是没有要求的,假设 M M M 为 1024 1024 1024, k e y key key 为 1234 1234 1234, A = 0.6180339887 A = 0.6180339887 A=0.6180339887, A ∗ k e y = 762.6539420558 A * key = 762.6539420558 A∗key=762.6539420558,取小数部分为 0.6539420558 0.6539420558 0.6539420558, M × ( ( A × k e y ) ( m o d 1.0 ) ) = 0.6539420558 ∗ 1024 = 669.6366651392 M \times ((A \times key) \pmod{1.0}) = 0.6539420558 * 1024 = 669.6366651392 M×((A×key)(mod1.0))=0.6539420558∗1024=669.6366651392,那么 h ( 1234 ) = 669 h(1234) = 669 h(1234)=669。

2.2.2全域散列法

如果存在一个恶意的对手,他针对我们提供的散列函数,特意构造出一个发生严重冲突的数据集 ,比如,让所有关键字全部落入同一个位置中。

这种情况是可以存在的,只要散列函数是公开且确定的,就可以实现此攻击。解决方法自然是见招拆招,给散列函数增加随机性,攻击者就无法找出确定可以导致最坏情况的数据。这种方法叫做全域散列。

h a b ( k e y ) = ( ( a × k e y + b ) ( m o d P ) ) ( m o d M ) h_{ab}(key) = ((a \times key + b) \pmod P) \pmod M hab(key)=((a×key+b)(modP))(modM), P P P 需要选一个足够大的质数, a a a 可以随机选 [ 1 , P − 1 ] [1, P-1] [1,P−1] 之间的任意整数, b b b 可以随机选 [ 0 , P − 1 ] [0, P-1] [0,P−1] 之间的任意整数,这些函数构成了一个 P ∗ ( P − 1 ) P * (P-1) P∗(P−1) 组全域散列函数组。假设 P = 17 P = 17 P=17, M = 6 M = 6 M=6, a = 3 a = 3 a=3, b = 4 b = 4 b=4,则 h 34 ( 8 ) = ( ( 3 × 8 + 4 ) ( m o d 17 ) ) ( m o d 6 ) = 5 h_{34}(8) = ((3 \times 8 + 4) \pmod{17}) \pmod 6 = 5 h34(8)=((3×8+4)(mod17))(mod6)=5。

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

2.3 其他方法(了解)

《殷人昆 数据结构:用面向对象方法与C++语言描述(第二版)》和《[数据结构(C语言版)].严蔚敏_吴伟民》等教材型书籍上面还给出了平方取中法、折叠法、随机数法、数学分析法等,这些方法相对更适用于一些局限的特定场景,有兴趣可以去看看这些书籍。

三,彻底解决哈希冲突的办法

3.1开放定址法

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

3.1.1线性探测

从发生冲突的位置开始,依次线性向后探测,直到寻找到下一个没有存储数据的位置为止 (我的位置被别人占了,我就要占别人的位置),如果走到哈希表尾,则回绕到哈希表头的位置。

h ( k e y ) = h a s h 0 = k e y ( m o d M ) h(key) = hash0 = key \pmod M h(key)=hash0=key(modM),hash0 位置冲突了,则线性探测公式为: h c ( k e y , i ) = h a s h i = ( h a s h 0 + i ) ( m o d M ) , i = { 1 , 2 , 3 , ... , M − 1 } hc(key, i) = hashi = (hash0 + i) \pmod M, \quad i = \{1, 2, 3, \dots, M-1\} hc(key,i)=hashi=(hash0+i)(modM),i={1,2,3,...,M−1},

这个公式怎么理解?如何用这个公式实现循环 ,比如:hashi0 = 8.

8 + i(i = 2)这个时候就是找到了末尾。找到了末尾 i 再加 1,取模就变成了 1,也就是开头。

因为负载因子小于 1 1 1,则最多探测 M − 1 M-1 M−1 次(我要找的新位置刚好在我的原位置之前),一定能找到一个存储 k e y key key 的位置。

线性探测的比较简单且容易实现,线性探测的问题假设,hash0 位置连续冲突 ,hash0, hash1, hash2 位置已经存储数据了,后续映射到 hash0, hash1, hash2, hash3 的值都会争夺 hash3 位置,这种现象叫做群集/堆积。 (持续堆积恶性循环,哈希表的效率越来越低)

下面演示 { 19 , 30 , 5 , 36 , 13 , 20 , 21 , 12 } \{19, 30, 5, 36, 13, 20, 21, 12\} {19,30,5,36,13,20,21,12} 等这一组值通过线性探测映射到 M = 11 M=11 M=11 的表中。


3.1.2二次探测

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

h ( k e y ) = h a s h 0 = k e y ( m o d M ) h(key) = hash0 = key \pmod M h(key)=hash0=key(modM),hash0 位置冲突了,则二次探测公式为: h c ( k e y , i ) = h a s h i = ( h a s h 0 ± i 2 ) ( m o d M ) , i = { 1 , 2 , 3 , ... , M 2 } hc(key, i) = hashi = (hash0 \pm i^2) \pmod M, \quad i = \{1, 2, 3, \dots, \frac{M}{2} \} hc(key,i)=hashi=(hash0±i2)(modM),i={1,2,3,...,2M}(空间比较小的时候直接+i^2即可,不需要flag来控制正负 ),最多探测 M/2次

二次探测当 h a s h i = ( h a s h 0 − i 2 ) ( m o d M ) hashi = (hash0 - i^2) \pmod M hashi=(hash0−i2)(modM) 时,当 h a s h i < 0 hashi < 0 hashi<0 时,需要 h a s h i + = M hashi += M hashi+=M。

下面演示 { 19 , 30 , 52 , 63 , 11 , 22 } \{19, 30, 52, 63, 11, 22\} {19,30,52,63,11,22} 等这一组值映射通过二次映射到 M = 11 M=11 M=11 的表中

如果发现位置被占领了,就左右跳着去占位置,目的是让他更加分散。不至于像一次探测那样成堆的冲突。

3.1.3双重探测(了解)

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

h 1 ( k e y ) = h a s h 0 = k e y ( m o d M ) h_1(key) = hash0 = key \pmod M h1(key)=hash0=key(modM),hash0 位置冲突了,则双重探测公式为: h c ( k e y , i ) = h a s h i = ( h a s h 0 + i × h 2 ( k e y ) ) ( m o d M ) , i = { 1 , 2 , 3 , ... , M } hc(key, i) = hashi = (hash0 + i \times h_2(key)) \pmod M, \quad i = \{1, 2, 3, \dots, M\} hc(key,i)=hashi=(hash0+i×h2(key))(modM),i={1,2,3,...,M}

要求 h 2 ( k e y ) < M h_2(key) < M h2(key)<M 且 h 2 ( k e y ) h_2(key) h2(key) 和 M M M 互为质数,有两种简单的取值方法:当 M M M 为 2 2 2 的整数幂时, h 2 ( k e y ) h_2(key) h2(key) 从 [ 0 , M − 1 ] [0, M-1] [0,M−1] 任选一个奇数;当 M M M 为质数时, h 2 ( k e y ) = k e y ( m o d ( M − 1 ) ) + 1 h_2(key) = key \pmod{(M - 1)} + 1 h2(key)=key(mod(M−1))+1。

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

下面演示 { 19 , 30 , 52 , 74 } \{19, 30, 52, 74\} {19,30,52,74} 等这一组值映射到 M = 11 M=11 M=11 的表中,设 h 2 ( k e y ) = k e y ( m o d 10 ) + 1 h_2(key) = key \pmod{10} + 1 h2(key)=key(mod10)+1。

3.2开放定址法的实现代码

开放定址法在实践中,不如下面讲的链地址法,因为开放定址法解决冲突不管使用哪种方法,占用的都是哈希表中的空间,始终存在互相影响的问题。所以开放定址法,我们简单选择线性探测实现即可。 (当然我二次探测也写一份。作为大家参考参考。)

3.2.1开放定址法结构

cpp 复制代码
enum State
{
    EXIST,
    EMPTY,
    DELETE
};
template<class K, class V>
struct HashData
{
    pair<K, V> _kv; //一个哈希值要建立映射
    State _state = EMPTY;//哈希值的状态
};
template<class K, class V>
class HashTable
{
private:
    vector<HashData<K, V>> _tables;//以vector作为表体
    size_t _n = 0; //表种插入的数据个数
};

要注意的是这里需要给每个存储值的位置加一个状态标识,否则删除一些值以后,会影响后面冲突的值的查找。如下图,我们删除30,会导致查找20失败。

有人说,为啥呀? ,这就是我接下来写代码要讲的,删除逻辑,如果我们要删除一个值,先去基于哈希函数去找到这个值应该在哪里,如果这个位置找到了,但是它不是我们要找的哪个值,于是我们认为是哈希冲突导致的,就会去向后查找,如果直到我们找打空都没有找到,就证明这个20,它不在。但是与事实相违背,删除失败。

当我们给每个位置加一个状态标识 {EXIST, EMPTY, DELETE},删除30就可以不用删除值,而是把状态改为 DELETE,那么查找20时是遇到 EMPTY 才能,就可以找到20。

3.2.2扩容问题的讨论

3.2.2.1扩容的阈值与负载因子

扩容阈值,说到这个概念(我自己取的),我就要开始谈谈前面埋下伏笔的负载因子了。这里我们哈希表负载因子控制在0.7,当负载因子到0.7以后我们就需要扩容

负载因子控制在0.7的另外一个原因:

保留"刹车信号": 确保表内始终有足够的 EMPTY 状态作为探测终止符,防止在查找不存在的 key 时因找不到空位而陷入无限回绕的死循环。

3.2.2.2二倍扩容法

为什么扩容要重新映射。因为模的对象变大了。查找的时候要按照新的对象去模去得出答案

旧方法:手动映射(操作 vector

核心逻辑 :手动创建一个更大的新 vector,遍历旧表,遇到有效数据时重新计算哈希值并手写线性探测逻辑 存入新 vector

  • 优点:逻辑直观,展示了哈希映射的底层过程。
  • 缺点:代码冗余,需要重复编写查找空位、处理冲突的逻辑,极其容易出错且难以维护。
cpp 复制代码
//旧的扩容方案,不推荐。
bool Insert(const pair<K, V>& kv)
{
    // 负载因子 >= 0.7扩容
    if (_n * 10 / _tables.size() >= 7)
    {
        vector<HashData<K, V>> newtables(_tables.size() * 2);
        for (auto& data : _tables)
        {
            // 旧表的数据映射到新表
            if (data._state == EXIST)
            {
                size_t hash0 = data._kv.first % newtables.size();
                // ...
            }
        }
        tables.swap(newtables);
    }
}

新方法:对象复用(操作 HashTable 对象)

核心逻辑 :创建一个临时的 HashTable 实例化对象,将其底层 vector 扩容后,直接通过循环调用该对象的 Insert 接口。

  • 优点极致复用 。直接利用已有的 Insert 逻辑处理冲突和状态更新,代码简洁,逻辑高度统一。
  • 缺点 :对于初学者来说,理解对象内部嵌套调用另一个对象的 Insert 需要转个弯。
cpp 复制代码
HashTable<K, V> newht;
newht._tables.resize(_tables.size() * 2);
for (auto& data : _tables)
{
    // 旧表的数据映射到新表
    if (data._state == EXIST)
    {
        newht.Insert(data);
    }
}
_tables.swap(newht._tables);
特性 旧方法(手动映射) 新方法(对象复用)
操作目标 面对底层数据结构 (vector) 面对哈希表类对象 (HashTable)
冲突处理 必须手写 探测逻辑 自动复用 已有的 Insert 逻辑
代码量 臃肿,容易产生 Bug 精简,安全性与一致性极高
工程建议 不推荐 强烈推荐
3.2.2.3SGI素数表表扩容

以上我们还是按照2倍扩容,但是同时我们要保持哈希表大小是一个质数,第一个是质数,2倍后就不是质数了。 那么如何解决?

解释一下,为什么是素数: 一个数模一个素数比起模一个合数相比对更多位有关系,这是数论基础的内容。

  • 方案一:Java HashMap的使用2的整数幂,但是计算时不能直接取模的改进方法。
  • 方案二:SGI素数表,SGI版本的哈希给了一个近似2倍的质数表,每次去质数表获取扩容后的大小。

我们从源代码stl_hashtable.h种直接抄一份装在我们的哈希表里面。

cpp 复制代码
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 = lower_bound(first, last, n);
	return pos == last ? *(last - 1) : *pos;
}

讲一讲这里面的逻辑是怎么实现的:

3.2.2.4扩容小细节

扩容的一个非常精小但是致命的细节,下面的这两扩容判断条件哪个是对的?

  • if (_n / _tables.size() >= 0.7)
  • if (_n * 10 / _tables.size() >= 7)

猜对了吗?答案是第二个才是对的。 有人这个时候就懵了,这两个不是一样的吗?实际上,这里隐藏着C++在整数除法上的问题。

  • 整数除法会"截断": 在 C++ 中,_n / _tables.size() 的结果只有 0 或 1。只要哈希表没满,结果永远是 0,导致扩容判断彻底失效。
  • 先乘后除避开小数: 第二个条件通过先乘以 10,把 0.7 的判断转换成了整数比对,确保了计算的准确性。

3.2.3 Key不能取模问题

当key是string/Date等类型时,key不能取模,那么我们需要给HashTable增加一个仿函数,这个仿函数支持把key转换成一个可以取模的整形

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

cpp 复制代码
template<class K>
struct HashFunc
{
    size_t operator()(const K& key)
    {
        return (size_t)key;
    }
};
// 特化
template<>
struct HashFunc<string>
{
    // 字符串转换成整形,可以把字符ascii码相加即可
    // 但是直接相加的话,类似"abcd"和"bcad"这样的字符计算出是相同的
    // 这里我们使用BKDR哈希的思路,用上次的计算结果去乘以一个质数,这个质数一般去31,131等效果会比较好
    size_t operator()(const string& key)
    {
        size_t hash = 0;
        for (auto e : key)
        {
            hash *= 131;
            hash += e;
        }
        return hash;
        //解决第一个问题: 插入的时候我用 string 插入,
        //string 把 ascii 码加起来是 xxx。取模找到位置,插入。
		//查找的时候还是用这个 string,把 ascii 码加起来是 xxx。
		//取模找到位置,查找得结果。
    }
};
template<class K, class V, class Hash = HashFunc<K>>
				//k是string的时候走特化。
class HashTable
{
public:
private:
    vector<HashData<K, V>> _tables;
    size_t _n = 0; 
};

科普内容:BKDR哈希

BKDR Hash中的 "BKDR"是 Brian Kernighan 和 Dennis Ritchie 的姓名首字母缩写 ,这两位都是计算机科学领域知名人物,他们撰写的《The C Programming Language》一书开创性地影响了程序设计语言的发展。BKDR Hash是一种简单高效的字符串哈希函数,以其创作者的名字命名,这个散列算法的主要思路是将输入的字符串视为一个多基数的大整数,然后对这个整数进行线性变换,最终获得哈希值。

cpp 复制代码
HashTable<Date, int> ht;
ht.Insert({ { 2024, 10, 12 }, 1});
ht.Insert({ { 2024, 12, 10 }, 1 });

除了以上两种解决取模问题,如果还有其他自定义类型,就必须自己写一个仿函数了,受到BKDR哈希的启发,我们可以这样写:

cpp 复制代码
struct DateHashFunc{
    size_t operator()(const Date& d){
        size_t hash = 0;
        hash += d._year;
        hash *= 131;
        hash += d._month;
        hash *= 131;
        hash += d._day;
        hash *= 131;
        return hash;
    }
};

3.2.4BKDR哈希不仅是类型转换,更是为了"去冲突"

cpp 复制代码
template<class K>
struct HashFunc {
    size_t operator()(const K& key) {
        return (size_t)key;
    }
};
template<>
struct HashFunc<string> {
    size_t operator()(const string& key) {
        size_t hash = 0;
        for (auto e : key) {
            hash = hash * 131 + e; 
        }
        return hash;
    }
};
int main() {
    cout << "abcd: " << HashFunc<string>()("abcd") << endl;
    cout << "bcad: " << HashFunc<string>()("bcad") << endl; 
    cout << "aadd: " << HashFunc<string>()("aadd") << endl;
    return 0;
}
//输出结果:3018713518 3315427208 3016482588

除了将 string 等无法直接取模的类型转为整数外,BKDR 哈希的核心价值在于大幅降低哈希冲突概率

原本: 如果只是简单累加字符 ASCII 值(如 hash += e):

cpp 复制代码
struct StringHashFunc{
    size_t operator()(const string& s){
        size_t hash = 0;
        for (auto ch : s){
            hash += ch;
        }
        return hash;
    }
};

字符串 "abcd""bcad""aadd" 的结果都是 394 。这会导致它们挤在同一个桶(Bucket)中,使哈希表退化。

但是: 通过BKDR 采用多项式加权计算:hash = hash * 131 + e;结果 :即使字符组成完全相同,只要顺序 有一丝不同,最终生成的 hash 值就会产生巨大差异。

3.2.5 重谈 Java 哈希中的位运算哈希函数

Java 中的哈希函数使用的不是取模,而是位运算。这里有一个小细节需要注意

3.2.5.1溢出风险与映射约束

在使用位运算替代取模时,异或后的位数必须严格小于或等于哈希表大小的幂次

  • 映射越界的本质 :如果哈希表的大小为 2 4 2^4 24(即 16 个桶),索引的有效范围仅为 00001111(二进制)。如果你尝试将高位(如前 12 位)直接异或到这 4 位上,一旦计算结果在第 5 位及以上出现了 1,生成的索引就会大于 15。
  • 溢出后果 :这种"位数不匹配"的异或操作会导致计算出的 index 远超哈希表开辟的物理空间,从而引发数组越界。

听不懂?我们画个图

3.2.5.2空间成本的权衡

虽然位运算极大地提升了效率,但 Java 这种依赖 2 n 2^n 2n 大小的设计存在明显的空间浪费

  • 起步开销大 :为了让位运算生效,如果我们需要较好的散列效果,可能一上来就要开辟 2 16 2^{16} 216(65536 个桶)大小的空间。
3.2.5.3优化方案:切成多块异或(多段扰动)

为了在不浪费巨量空间的前提下尽可能保留高位特征,可以采用多次分块异或的方法:

  • 逻辑 :不只是简单地将 32 位对半折(16位异或),而是根据当前哈希表实际大小对应的位数(例如 4 位),将整个 32 位的 hash 值切成 8 个 4 位的"小块"。
  • 执行:让这 8 个小块依次进行异或累加。
  • 优点 :这样即使哈希表很小(只有 2 4 2^4 24),最终的 4 位索引也融合了原 32 位数据的所有分段特征,极大降低了冲突概率,且不会发生映射越界。

总的来说,Java 这种强行要求 2 n 2^n 2n 大小并配合简单位运算的方式并不完美

3.2.6自定义类型必须要支持operator==

哈希函数仅负责将 Key 映射到数组索引,但由于不同 Key 可能产生相同哈希值 ,必须依靠 operator== 在冲突位置进行精确的二进制或成员对比。

只有通过 operator== 确认 Key 完一致,哈希表才能在查找时定位特定对象,并在插入时判定该键是否已存在。

3.2.7总结字符串映射到哈希的两次映射

这里有两层映射:

  • 第一层映射:字符串转换成整型。
  • 第二层映射:整型转换成哈希表中的位置。
  • 尽可能让第一层转换出来的整型是不一样的第二层才可能不一样。

第一层映射的冲突可能性很大:

我们做一个假设:字符串可以出现的组合,我们以长度为10的字符串为例。只有小写字母就是 26 10 26^{10} 2610,如果是所有字符就是 256 10 256^{10} 25610。这个数据远远大于42亿9千万的整型可能。

根据鸽巢原理,把一个大范围映射到一个小范围很有可能会出现重复。

3.2.6完整代码与详细注释参考

cpp 复制代码
#pragma once
#include<iostream>
#include<vector>
#include<string>
using namespace std;

// 哈希函数对象模板(用于非字符串类型)
template<class K>
struct HashFunc
{
    // 将键转换为size_t类型的哈希值
    size_t operator()(const K& key) { return (size_t)key; }
};

// 特化:字符串类型的哈希函数
template<>
struct HashFunc<string>
{
    size_t operator()(const string& key)
    {
        size_t sum = 0;
        string::const_iterator it = key.begin();
        // 使用BKDR哈希算法:每个字符乘以131并累加
        while (it != key.end())
        {
            sum += (*it);   // 加上当前字符的ASCII码
            sum *= 131;     // 乘以131
            ++it;
        }
        return sum;
    }
};

// 开放地址法命名空间
namespace OpenAddress
{
    // 哈希表每个槽位的状态
    enum State { EXIST,  // 已存在有效数据
                 EMPTY,  // 空槽位
                 DELETE  // 被删除(逻辑删除)
               };

    // 哈希表节点结构
    template <class K, class V>
    struct HashDate
    {
        pair<K, V> _kv;      // 存储键值对
        State _state = EMPTY; // 默认状态为空
    };

    // 哈希表类模板(开放地址法实现)
    template <class K, class V, class Hash = HashFunc<K>>
    class HashTable
    {
    public:
        // 构造函数:初始使用不小于0的下一个质数作为表大小(也就是53)
        HashTable()
            :_table(__stl_next_prime(0))
            , _n(0)
        {}

        // 返回不小于n的下一个质数(用于扩容)
        inline unsigned long __stl_next_prime(unsigned long n)
        {
            // 预定义的质数列表 SGI STL
            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;
            // 二分查找第一个不小于n的质数
            const unsigned long* pos = lower_bound(first, last, n);
            return pos == last ? *(last - 1) : *pos; // 超出范围则返回最大质数
        }

        // 插入键值对
        bool Insert(const pair<K, V> kv)
        {
            // 若键已存在,则不插入,防止冗余
            if (Find(kv.first) != INT_MAX)
                return false;

            Hash hash; // 哈希函数对象

            // 扩容条件:负载因子 >= 0.7 (元素数/表大小 >= 0.7)
            if (_n * 10 / _table.size() >= 7)
            {
                // 创建新哈希表,大小扩容到下一个质数
                HashTable<K, V> newtable;
                newtable._table.resize(__stl_next_prime(_table.size() + 1));
                //小细节,我这里传递的是size()+1而不是size(),如果我直接传递size(),比如我传递53过去得到的还是53不会是53的下一个数。
                // 将原表中所有有效数据重新插入新表
                for (auto& e : _table)
                {
                    if (e._state == EXIST)
                        newtable.Insert(e._kv);
                }
                // 交换新旧表
                _table.swap(newtable._table);
            }

            // 计算初始哈希位置
            int hash0 = hash(kv.first) % _table.size();
            //这里要模size而不是capacity,因为vector只有在size范围内的数据才能被访问。
            //所以我们要保证在初始化和扩容种size==capacity
            int hashi = hash0;
            int i = 1;      // 平方探测的步长基数
            int flag = 1;   // 方向标志:1表示正向,-1表示反向

            // 二次探测解决哈希冲突
            // 探测序列:hash0 + 1^2, hash0 - 1^2, hash0 + 2^2, hash0 - 2^2, ...
            while (_table[hashi]._state == EXIST)
            {
            // hashi = (hash0 + i) % _tables.size();线性探测
                hashi = (hash0 + (i * i * flag)) % _table.size();
                // 确保索引非负
                if (hashi < 0)
                    hashi += _table.size();
                // 切换方向:正向->反向->正向(步长+1)
                if (flag == 1)
                    flag = -1;
                else
                {
                    ++i;
                    flag = 1;
                }
            }

            // 找到空槽(EMPTY或DELETE),插入数据
            _table[hashi]._kv = kv;
            _table[hashi]._state = EXIST;
            _n++; // 元素计数增加
            return true;
        }

        // 查找键key,返回其下标,若不存在返回INT_MAX
        size_t Find(const K& key)
        {
            Hash hash;
            size_t hash0 = hash(key) % _table.size();
            size_t hashi = hash0;
            int i = 1;
            int k = 0;      // 未使用变量(可忽略)
            int flag = 1;

            // 遍历直到遇到空槽(EMPTY)
            while (_table[hashi]._state != EMPTY)
            {
                // 若当前槽位存在且键匹配,则查找成功
                if (_table[hashi]._state == EXIST &&
                    _table[hashi]._kv.first == key)
                    //即使哈希地址匹配正确,但是哈希值不一样也不行
                {
                    cout << "Find" << endl;
                    return hashi;
                }
                else
                {
                    // 继续二次探测
                    hashi = (hash0 + (i * i * flag)) % _table.size();
                    if (hashi < 0)
                        hashi += _table.size();
                    if (flag == 1)
                        flag = -1;
                    else
                    {
                        ++i;
                        flag = 1;
                    }
                }
                i++;
            }
            // 遇到EMPTY说明key不存在
            cout << "NoFind" << endl;
            return INT_MAX;
        }
        // 删除键key(逻辑删除)
        bool Erase(const K& key)
        {
            size_t hashi = Find(key);
            if (hashi == INT_MAX)
                return false;  // 键不存在,删除失败
            else
            {
                _table[hashi]._state = DELETE; // 标记为删除
                return true;
            }
        }
    private:
        vector<HashDate<K, V>> _table; // 哈希表底层数组
        size_t _n = 0;// 当前有效元素个数(EXIST状态)            
    };
}

3.3链地址法

3.3.1链地址法解决冲突的思路

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

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

3.3.2扩容方案

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

扩容小细节: 模的数变大了,导致我们原来的映射关系不再成立,所以需要你去把结点一个一个重新拿下来映射。

cpp 复制代码
if (_n == _tables.size())
{
    HashTable<K, V> newht;
    newht._tables.resize(__stl_next_prime(_tables.size() + 1));
    for (size_t i = 0; i < _tables.size(); i++)
    {
        Node* cur = _tables[i];
        while (cur)
        {
            newht.Insert(cur->_kv);
            cur = cur->_next;
        }
    }

    _tables.swap(newht._tables);
}

3.3.3极端场景分析

如果极端场景下,某个桶特别长怎么办?其实我们可以考虑使用全域散列法 ,这样就不容易被针对于了。但是假是偶然情况下,某个桶很长,查找效率很低怎么办?

在Java8种有一种很好的解决方案: HashMap 中当桶的长度超过一定阈值 (8) 时就把链表转换成红黑树。 一般情况下,不断扩容,单个桶很长的场景还是比较少的,下面我们实现就不搞这么复杂了,这个解决极端场景的思路,大家了解一下。

3.3.4图片解释插入数据

3.4链地址法的实现代码

cpp 复制代码
#pragma once
#include<iostream>
#include<vector>
using namespace std;

namespace Separate_Chaining
{
	// 哈希桶节点类模板
	// K: 键类型, V: 值类型
	template<class K, class V>
	class HashBucketNode
	{
		using Node = HashBucketNode<K, V>;  // 类型别名,简化节点指针声明
	public:
		pair<K, V> _date;  // 存储键值对数据
		Node* _next;       // 指向下一个节点的指针(链地址法)
	 //size_t len =0; 我们不打算实现红黑树切换的那个,那太麻烦了。    

		// 构造函数:初始化节点,将_next置空,并存储数据
		// 参数 date: 要存储的键值对(使用const引用避免拷贝开销)
		HashBucketNode(const pair<K, V> date)
			: _next(nullptr)   // 初始化指针为空
			, _date(date)      // 拷贝构造键值对
		{}
	};

	// 哈希函数对象模板(默认实现)
	// 适用于整数类型的键(通过强制类型转换)
	template <class K>
	class HashFunc
	{
	public:
		// 函数调用运算符重载,返回键的哈希值
		// 参数 key: 需要计算哈希值的键
		// 返回值: 键对应的哈希值(size_t类型)
		size_t operator()(const K& key)
		{
			return (size_t)key;  // 直接将整数转换为size_t作为哈希值
		}
	};

	// 哈希函数特化:针对string类型
	// 使用BKDR哈希算法,减少字符串哈希冲突
	template<>
	class HashFunc<string>
	{
	public:
		// 计算字符串的哈希值
		// 参数 key: 需要计算哈希值的字符串
		// 返回值: 字符串的哈希值(size_t类型)
		size_t operator()(const string& key)
		{
			size_t sum = 0;  // 累积哈希值
			string::const_iterator it = key.begin();  // 字符串起始迭代器
			while (it != key.end())  // 遍历字符串每个字符
			{
				sum += (*it);    // 加上当前字符的ASCII码
				sum *= 131;      // 乘以131(BKDR哈希算法的典型质数)
				++it;            // 移动到下一个字符
			}
			return sum;  // 返回最终哈希值
		}
	};

	// 哈希桶类模板
	// K: 键类型, V: 值类型, Hash: 哈希函数类型(默认为HashFunc<K>)
	template<class K, class V, class Hash = HashFunc<K>>
	class HashBucket
	{
		using Node = HashBucketNode<K, V>;  // 节点类型别名
	public:
		// 构造函数:初始化哈希表
		// 初始容量为4,元素个数为0
		HashBucket()
			: _Table(4)   // 初始创建4个桶(每个桶是一个节点指针)
			, _n(0)       // 初始没有元素
		{}

		// 获取大于等于n的下一个质数(哈希表扩容时使用)
		// 使用质数作为哈希表大小可以减少哈希冲突
		// 参数 n: 当前需要的容量
		// 返回值: 大于等于n的最小质数(从预定义质数列表中查找)
		inline unsigned long __stl_next_prime(unsigned long n)
		{
			// SGI质数列表
			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 = lower_bound(first, last, n);  // 二分查找第一个>=n的质数
			return pos == last ? *(last - 1) : *pos;  // 如果没找到返回最大质数,否则返回找到的质数
		}

		// 插入键值对
		// 参数 kv: 要插入的键值对
		// 返回值: 插入成功返回true(当前实现总是返回true,未处理重复键的情况)
		bool Insert(const pair<K, V> kv)
		{
			// 检查是否需要扩容:元素个数等于哈希表大小时进行扩容
			if (_n == _Table.size())
			{
				// 创建新的哈希表,容量为下一个质数
				vector<Node*> tmp;
				tmp.resize(__stl_next_prime(_Table.size() + 1));

				// 遍历旧哈希表的所有桶
				for (int i = 0; i < _Table.size(); i++)
				{
					Node* cur = _Table[i];  // 当前桶的链表头节点
					while (cur)  // 遍历该桶的所有节点
					{
						Node* next = cur->_next;  // //记得保存下一个结点的指针,
						//因为直接拿走之后会发生断链
						// 重新计算当前节点在新表中的哈希桶位置
						size_t hashi = (cur->_date.first) % tmp.size();
						// 头插法:将节点插入到新桶的链表头部
						cur->_next = tmp[hashi];
						tmp[hashi] = cur;
						cur = next;  // 移动到下一个节点
					}
					_Table[i] = nullptr;  // 将旧桶置空
				}
				_Table.swap(tmp);  // 交换新旧哈希表
			}

			// 计算插入位置:取模运算得到桶的索引
			size_t hashi = kv.first % _Table.size();
			// 创建新节点
			Node* newnode = new Node(kv);
			// 头插法:新节点指向当前桶的头节点
			newnode->_next = _Table[hashi];
			// 更新桶的头节点为新节点
			_Table[hashi] = newnode;
			_n++;  // 元素个数加1
			return true;
		}

	private:
		vector<Node*> _Table;  // 哈希表:每个元素是一个桶(链表头指针)
		//这里实际上有两种方法,指针数组还是对象数组,
		//还是指针数组好,不然迭代器麻烦
		size_t _n;             // 当前哈希表中存储的元素总个数
	};
}

好的,本文到此结束,如果对你有帮助还请不要忘记点赞三联一波哦。我是此方,我们下期再见。

相关推荐
Karle_1 小时前
为AI编辑器准备c++编译环境,onnxruntime、cmake、cl,网上坑太多备份记录后续方便使用。
开发语言·c++·编辑器
lcj25111 小时前
【数据结构精讲】堆与二叉树从底层原理到代码落地:堆的构建 / 调整 / 排序 + 二叉树遍历 / 操作(附完整 C++ 源码 + LeetCode 题解)
数据结构·c++·leetcode
努力努力再努力wz1 小时前
【MySQL 进阶系列】C/C++ 如何通过客户端库访问 MySQL?从连接原理到 API 调用流程详解(附完整demo代码)
服务器·c语言·数据结构·数据库·c++·b树·mysql
xuhaoyu_cpp_java1 小时前
单调栈(算法)
java·数据结构·经验分享·笔记·学习·算法
CSCN新手听安2 小时前
【Qt】Qt窗口(七)QColorDialog颜色对话框,QFileDialog文件对话框的使用
开发语言·c++·qt
A charmer2 小时前
从 C++ 到 Objective-C:零基础平滑转学专栏【总目录】
开发语言·c++·objective-c
cookies_s_s2 小时前
C++ 内存模型与无锁编程:从底层原理到实战
linux·服务器·开发语言·c++
诙_2 小时前
C++数据结构--排序算法
数据结构·算法·排序算法
jieyucx2 小时前
Go 切片核心:子切片详解(下篇)
开发语言·算法·golang·切片