C++--哈希

哈希

  • 哈希
    • [1. 哈希的概念及性质](#1. 哈希的概念及性质)
      • [1.1 哈希概念](#1.1 哈希概念)
      • [1.2 负载因子](#1.2 负载因子)
      • [1.3 将关键字转为整数](#1.3 将关键字转为整数)
      • [1.4 哈希函数](#1.4 哈希函数)
        • [1.4.1 直接定制法(常用)](#1.4.1 直接定制法(常用))
        • [1.4.2 除留余数法 (常用)](#1.4.2 除留余数法 (常用))
        • [1.4.3 乘法散列法(了解)](#1.4.3 乘法散列法(了解))
        • [1.4.4 全域散列法(了解)](#1.4.4 全域散列法(了解))
      • [1.5 哈希冲突](#1.5 哈希冲突)
    • [2. 闭散列(开放地址法)](#2. 闭散列(开放地址法))
      • [2.1 冲突解决介绍](#2.1 冲突解决介绍)
        • [2.1.1 线性探测法](#2.1.1 线性探测法)
        • [2.1.2 二次探测](#2.1.2 二次探测)
        • [2.1.3 双重散列(了解)](#2.1.3 双重散列(了解))
      • [2.2 闭散列代码实现](#2.2 闭散列代码实现)
        • [2.2.1 闭散列的基本框架](#2.2.1 闭散列的基本框架)
        • [2.2.2 闭散列的插入删除与查找](#2.2.2 闭散列的插入删除与查找)
        • [2.2.3 闭散列的扩容](#2.2.3 闭散列的扩容)
        • [2.2.4 key不能取模的问题](#2.2.4 key不能取模的问题)
        • [2.2.5 整体代码实现](#2.2.5 整体代码实现)
    • [3. 开散列(链地址法)](#3. 开散列(链地址法))
      • [3.1 开散列的概念](#3.1 开散列的概念)
      • [3.2 开散列的代码实现](#3.2 开散列的代码实现)
        • [3.2.1 开散列的结点结构](#3.2.1 开散列的结点结构)
        • [3.2.2 开散列的插入删除和查找](#3.2.2 开散列的插入删除和查找)
          • [3.2.2.1 开散列的插入](#3.2.2.1 开散列的插入)
          • [3.2.2.2 开散列的查找](#3.2.2.2 开散列的查找)
          • [3.2.2.3 开散列的删](#3.2.2.3 开散列的删)
        • [3.2.3 开散列的扩容](#3.2.3 开散列的扩容)
      • [3.3 整体代码实现](#3.3 整体代码实现)

哈希

1. 哈希的概念及性质

1.1 哈希概念

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

这中组织数据的方式可以通过某种函数 (hashFunc) 使元素的存储位置与它的关键码之间能够建立一对一的映射关系,那么在查找时通过该函数就可以很快找到该元素 ,而不是像之前在顺序结构 以及平衡树中,由于元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。

比如顺序表中需要从表头开始依次往后比对寻找,查找时间复杂度为 O ( N ) O(N) O(N),平衡树中需要从第一层开始逐层往下比对寻找,查找时间复杂度为 O ( l o g N ) O(logN) O(logN);即搜索的效率取决于搜索过程中元素的比较次数。尽管平衡树的查找方式已经很快了,但程序员仍然认为该方法不够极致,所以哈希被设计出来了。

1.2 负载因子

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

1.3 将关键字转为整数

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

1.4 哈希函数

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

哈希函数有如下设计原则

  1. 哈希函数的定义域必须包括需要存储的全部关键码,而如果哈希表允许有m个地址时,其值域必须在0到 m-1 之间;
  2. 哈希函数计算出来的地址要尽量能均匀分布在整个空间中;
  3. 哈希函数应该比较简单。

有如下常见的哈希函数

1.4.1 直接定制法(常用)

直接定址法是最简单的哈希函数,顾名思义,直接定址就是根据 key 值直接得到存储位置,最多再进行一个简单的常数之间的转换,其哈希函数如下:

cpp 复制代码
Hash(Key)= A*Key + B (A B 均为常数)

直接定址法的优点是简单,且不会引起哈希冲突(哈希冲突是指多个不同的 key 值映射到同一个存储位置),由于直接定址法的 key 值经过哈希函数转换后得到的值一定是唯一的,所以不存在哈希冲突。

直接定址法适用于数据范围集中的情况,这样 key 值映射到哈希表后,哈希表的空间利用率高,浪费的空间较少;如下:

但是直接定址法不适用于数据范围分散的情况,因为这样会导致哈希表的空间利用率很低,会浪费很多空间,比如:

cpp 复制代码
int arr[] = { 123, 126, 125, 138, 122331, 1};

下面这道题是哈希直接定址法的典型例子:387. 字符串中的第一个唯一字符 - 力扣(LeetCode)

cpp 复制代码
class Solution 
{
public:
 	int firstUniqChar(string s) 
 	{
 		// 每个字⺟的ascii码-'a'的ascii码作为下标映射到count数组,数组中存储出现的次数 
 		int count[26] = {0};
 
 		// 统计次数 
 		for(auto ch : s)
 		{
 			count[ch-'a']++;
 		}
 		for(size_t i = 0; i < s.size(); ++i)
 		{
 			if(count[s[i]-'a'] == 1)
 			return i;
 		}
 		return -1;
 	}
};
1.4.2 除留余数法 (常用)

除法散列法也叫做除留余数法,顾名思义,假设哈希表的大小为 M ,那么通过 key 除以 M 的余数作为哈希函数为: h ( k e y ) = k e y h(key) = key % M h(key)=key,将关键码转换成哈希地址。

**注意:**当使用除法散列法时,要尽量避免 M 为某些值,如2的幂,10的幂等。

如果是 2 X 2^X 2X,那么key % 2 X 2^X 2X 本质是相当于保留 Key 的二进制形式的后 X 位,那么后 X 位相同的得出的哈希值就是相同的,就冲突了。如:{63, 31}看起来没有关联的值,如果 M 是16,也就是 2 4 2^4 24 ,那么计算出的哈希值都是15,因为63的二进制后8位是 00111111,31的二进制后8位是 00011111。

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

除留余数的优点是可以处理数据范围分散的数据,缺点是会引发哈希冲突,当使用除法散列法时,建议M取不太接近2的整数次幂的一个质数(素数)。

补充:

需要说明的是,实践中也是八仙过海,各显神通,Java 的 HashMap 采用除法散列法时就是2的整数次幂做哈希表的大小M,这样玩的话,就不用取模,而可以直接位运算,相对而言位运算比模更高效一些。但是他不是单纯的去取模 ,比如 M 是 2 16 2^{16} 216次方,本质是取后16位,那么用key' = key>>16,将 key 的高十六位数据给 key' ,然后把 key 和 key' 异或的结果作为哈希值。

也就是说映射出的值还是在[0,M]范围内,但是尽量让 key 所有的位都参与计算,这样映射出的哈希值更均匀一些。所上面建议M取不太接近2的整数次幂的一个质数的理论是大多数数据结构书籍中写的理论吗,但是实践中,灵活运用,排除本案。(了解)

但是在C++的STL的源码中的取值都是一些接近2的整数次幂的一些素数。

1.4.3 乘法散列法(了解)

乘法散列法对哈希表大小M有要求,他的思想步骤:用关键字 K 乘上常数 A (0<A<1) ,并抽取出 k ∗ A k * A k∗A 的小数部分。第二步:后再用 M 乘以 k ∗ A k * A k∗A 的小数部分,再向下取整。

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

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

1.4.4 全域散列法(了解)

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

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

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

1.5 哈希冲突

**引入:**前文提到直接定位法的缺点非常明显,当关键字的范围比较分散时,就很浪费内存甚至存储不够用。

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

这里存在的一个问题就是,两个不同的 key 可能会映射到同一个位置去,这种问题叫做哈希冲突 ,或者哈希碰撞。理想情况下是找出一个好的哈希函数避免冲突,但是实际场景中,冲突是不可避免的,所以尽可能设计出优秀的哈希函数,减少冲突的次数,同时也要去设计出解决冲突的方案。

哈希冲突有两种常见的解决方法:

  1. 闭散列(开放定址法):当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位,那么可以把key存放到冲突位置的"下一个"空位置中去;
  2. 开散列(链地址法):首先对关键字集合应用哈希函数计算出地址,具有相同地址的关键字(哈希冲突)归于同一子集合,每个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各关键字的头结点存储在哈希表中;也就是说,当发生哈希冲突时,把key直接链接在该位置的下一个面。

2. 闭散列(开放地址法)

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

2.1 冲突解决介绍

2.1.1 线性探测法

**操作:**从发生冲突的位置开始,依次线性向后探测,直到找到下一个没有存储数据的位置为止,如果走到哈希表尾,则回绕到哈希表头的位置。

公式:

h ( k e y ) = h a s h 0 = k e y % M h(key) = hash0 = key \ \% \ M h(key)=hash0=key % M, hash0位置冲突了,则线性探测公式为:

h c ( k e y , i ) = h a s h ( i ) = ( h a s h 0 + i ) % M , i = { 1 , 2 , 3 , . . . , M − 1 } h_c(key \ , i) = hash(i) = (hash0 + i) \ \% \ M, i = \{1, 2, 3, ..., M-1\} hc(key ,i)=hash(i)=(hash0+i) % M,i={1,2,3,...,M−1},因为负载因子小于1,最大探测M-1次,一定能找到一个存储key的位置。

缺点:线性探测的比较简单且容易实现,但是仍存在一些问题,hash0 位置冲突,hash0,hash1,hash2 位置已经存储数据了,后续映射到 hash0,hash1,hash2,hash3 的位置值都争夺 hash3 位置,这样现象叫做群集/堆积。下面的二次探测可以一定程度改进这个问题。

具体示例:

下面演示 {19, 30, 5, 36, 13, 20, 21, 12} 等这一组值映射到 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

2.1.2 二次探测

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

公式:

h ( k e y ) = h a s h 0 = k e y % M h(key) = hash0 = key \ \% \ M h(key)=hash0=key % M,hash0 位置冲突了,则二次探测公式为:

h c ( k e y , i ) = h a s h ( i ) = ( h a s h 0 ± i 2 ) % M , i = { 1 , 2 , 3 , . . . , M / 2 } h_c(key,i) = hash(i) = (hash0 ± i²) \ \% \ M, i = \{1, 2, 3, ..., M/2\} hc(key,i)=hash(i)=(hash0±i2) % M,i={1,2,3,...,M/2}

**补充:**二次探测当 h a s h 0 = ( h a s h 0 − i 2 ) % M hash0 = (hash0 - i²) \ \% \ M hash0=(hash0−i2) % M 时,当 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} 等这一组值映射到 M=11 的表中。

h(19) = 8, h(30) = 8, h(52) = 8, h(63) = 8, h(11) = 0, h(22) = 0

2.1.3 双重散列(了解)

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

公式:

h 1 ( k e y ) = h a s h 0 = k e y % M h_1(key) = hash0 = key \ \% \ M h1(key)=hash0=key % M, hash0 位置冲突了,则双重探测公式为:

h c ( k e y , i ) = h a s h ( i ) = ( h a s h 0 + i ∗ h 2 ( k e y ) ) % M , i = { 1 , 2 , 3 , . . . , M } h_c(key \ , i) = hash(i) = (hash0 + i * h2(key)) \ \% \ M, i = \{1, 2, 3, ..., M\} hc(key ,i)=hash(i)=(hash0+i∗h2(key)) % M,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 互质,有两种简单的取值方法:

  1. 当 M M M 为2整数时, h 2 ( k e y ) = k e y % ( M − 1 ) + 1 h_2(key) = key \% (M-1) + 1 h2(key)=key%(M−1)+1;
  2. 当 M M M 为质数时, h 2 ( k e y ) = k e y % ( M − 1 ) + 1 h_2(key) = key \% (M-1) + 1 h2(key)=key%(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,偏移量为3,整个散列表大小为12,那么能寻址的位置 {1, 4, 7, 10},寻址个数为 12 / gcd ⁡ ( 12 , 3 ) = 4 12 / \gcd(12, 3) = 4 12/gcd(12,3)=4。

具体示例:

下面演示 {19,30,52,74} 等这一组值映射8位置冲突,映射到 M = 11 M=11 M=11 的表中,设 h 2 ( k e y ) = k e y % 10 + 1 h_2(key) = key \% 10 + 1 h2(key)=key%10+1。

2.2 闭散列代码实现

开放定址法在实践中,不如下面讲的链地址法,因为开放定址法解决冲突不管使用哪种方法,占用的都是哈希表中的空间,始终存在互相影响的问题。所以开放定址法,简单选择线性探测实现即可。

2.2.1 闭散列的基本框架

哈希表节点结构如下:

cpp 复制代码
//标识每个存储位置的状态:空、存在与删除
enum State 
{
	EMPTY,
	EXIST,
	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;
	size_t _n;  //记录表中有效数据的个数
};

如上,为了方便,在哈希表中我们使用了 vector 来存储数据,并增加了一个变量 _n 来记录表中有效数据的个数。同时,哈希表的每个下标位置存储的数据都是一个 KV 模型的键值对。

为什么在哈希表的每个位置的数据中还增加了一个 _state 变量来记录该位置的状态?

假设现在要将 arr 映射到哈希表中,设哈希表的大小为 10,则哈希函数为:

cpp 复制代码
int arr[] = { 18, 8, 7, 27, 57, 3, 38};
hash(key) = key % HashTable.size()  //10

映射完毕后的哈希表如下:

注意: 当 key 映射的下标位置被占用时,key 会向后寻找下一个空位置进行插入,但如果 key 走到数组尾都还没找到空位置,那么 key 就会从数组起始位置重新往后寻找。比如插入27时由于7、8、9位置都被占用,所以它只能从数组起始位置重新寻找空位置插入,57也是如此。

那么如果现在要求分析27删除掉之后再插入17的情况,会遇到以下几个问题。

如何表示被删除的27?如何让待插入的17找到插入位置

一般遇到这种需要标记一个位置被删除可以将这个位置使用一个特殊的数据进行标记(比如 0、-1),但是这里如果将27删除后填入特殊数据怎么保证这个位置原来就数据就不是这里的特殊数据呢,这样就存在歧义。(比如删除后将对应位置的数据置为0/-1,但是key也有可能等于0/-1,此时是删了还是没删呢?)

同样,因为这个歧义问题,当17找到原来27的位置的时候也无法知道此处的位置到底是被特殊标记还是原本就是这个数据。也就无法实现插入。

如已经删除27,如何找到后面的57?

因为一开始映射数据的时候使用的解决哈希冲突的方法是线性探测,如果现在删除了27并用一个特殊标记标记了该位置,此位置以空,此时再想找到57就那找不到了,因为查找57时查到到空即0号下标的位置就会停止查找并返回false,直接结束查找。

此时可能有人会想到,删除27之后将后面的元素向前挪一位就可以了,这里先不谈效率的问题,而是一旦挪动了数据,则原来已经插入的 key 与数组下标的映射关系就改变了,比如挪动数据还能找到3吗?

所以,在哈希表的每个位置的数据中还增加了一个 state 变量来记录该位置的状态 (存在、删除、空) 是非常有必要的

2.2.2 闭散列的插入删除与查找

有了 state 变量,我们就可以很方便的进行插入、删除和查找操作:

  1. 插入:通过哈希函数得到余数即可插入,如果该标记的状态为删除则为空插入,如果该位置存在则调用再哈希删除后的位置进行插入;

  2. 查找:通过哈希函数得到余数即可查找,先与标记位置的键值进行比较,如果查找到该位置为为空则返回 nullptr;注意:这是三个细节:

    1. 当返回该标记的标记位置返回 nullptr,而不能得到该标记删除后的位置返回 nullptr,因为必须找到该标记所在的真实位置;

    2. 将查询数据返回值为 Data*,而不是 bool,这样可以方便进行修改操作(修改 key 对应的 value)---查询返回后直接指向特定位置进行修改 value 与 state;

    3. 哈希表经过插入删除,最终查找是通过一种端到端搜索 --- 哈希表中的元素完全 EXIST 和 DELETE,此时如果表元素还未被删除,会死循环,因此要求对这些情况要独立处理;

  3. 删除:恢复查找查询,查找结果返回的查找的返回值的哈希位置状态为删除,查找则返回 false。

代码实现:

cpp 复制代码
#pragma once
#include <vector>
#include <utility>
using std::pair;
using std::vector;

// 标识每个存储位置的状态:空、存在与删除
enum State 
{
	EMPTY,
	EXIST,
	DELETE
};

// 哈希表中的最小单元
template<class K, class V>
struct HashData 
{
	pair<K, V> _kv;
	State _state = EMPTY;  // 默认为空
};

// 哈希表
template<class K, class V>
class HashTable 
{
public:
	HashTable()
		: _n(0)
	{
		// 将哈希表的大小默认给为10
		_tables.resize(10);
	}

	bool Insert(const pair<K, V>& kv) 
    {
        // 不允许数据冗余,先查找
		if (Find(kv.first))
			return false;

		// 除留余数法 && 线性探测法
		// 将数据映射到数据的key值除以哈希表的大小得到的余数的位置,如果该位置被占用往后放
		size_t hashi = kv.first % _tables.size();
		// 不能放在EXIST的位置,DELETE和EMPTY都能放
		while (_tables[hashi]._state == EXIST) 
        {
            // 冲突探测
			++hashi;
			if (hashi == _tables.size()) 
                hashi = 0;  //如果探测到末尾则从头开始重新探测
		}

        // 找到数据插入位置
		_tables[hashi]._kv = kv;
		_tables[hashi]._state = EXIST;
		++_n;

		return true;
	}

	// 将Find的返回值定义为Data的地址,可以方便我们进行删除以及修改V
	HashData<K, V>* Find(const K& key) 
   	{
		Hash hash;  // 仿函数对象
		size_t hashi = hash(key) % _tables.size();
		// 记录hashi的起始位置,避免哈希表中元素全为EXIST和DELETE时导致死循环
		size_t starti = hashi;
		// 最多找到空
		while (_tables[hashi]._state != EMPTY) 
        {
			// key相等并且state为EXIST才表示找到
			if (_tables[hashi]._kv.first == key && _tables[hashi]._state == EXIST)
            {
                return &_tables[hashi];
            }
            
            // 线性探测器
			++hashi;
            
			// 如果找到尾还没找到,就从0重新找
			if (hashi == _tables.size()) 
                hashi = 0;
            
			//如果找一圈还没找到,就跳出循环
			if (hashi == starti) 
                break;
		}

		return nullptr;
	}

	boolErase(const K& key) 
    {
		//找不到就不删,找到就把状态置为DELETE即可
		Data* ret = find(key);
		if (ret) 
        {
			ret->_state = DELETE;
			return true;
		}

		return false;
	}

private:
	vector<HashData<K, V>> _tables;
	size_t _n;  //记录表中有效数据的个数
}
2.2.3 闭散列的扩容

空间扩容

上面在实现插入代码的时候后,固定了哈希表的大小为10,然而实际情况肯定不是这样,所以需要实现哈希标的扩容。

这里需要将哈希表负载因子控制在 0.7,当负载因子到 0.7 以后需要扩容,但是同时要保持哈希表大小是一个质数,第一个是质数,2后就是不是质数了。那么如何解决呢,一种方案就是上面介绍除法散列中讲的 Java HashMap 的使用 2 的整数幂,但是计算时不能直接取获取模式的改进方法。另一种方案是 sgi版本的哈希表使用的方法,给了一个近似 2 倍的质数表,每次去扩展表获取后的大小

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;
}

数据转移:

哈希表的扩容并不是简单的扩大空间,而是需要将已经插入哈希表的元素取出全部重新插入一遍,因为扩容后哈希表的长度改变,那么 key 通过哈希函数映射到的位置也会改变;比如17扩容前插入的位置为7,扩容后插入位置就变为17,所以需要将其取出重新插入。

所以需要设计一种类似于深拷贝的方式重新将哈希表的数据进行分布。

cpp 复制代码
bool Insert(const pair<K, V>& kv)
{
 	if (Find(kv.first))
        return false;
    
 	// 负载因⼦⼤于0.7就扩容 
 	if (_n * 10 / _tables.size() >= 7)
 	{
    	// 获取素数表里面当前表最大的下一个素数
    	size_t newSize = _stl_next_prime(_tables.size() + 1);
    	vector<HashData<K, V>> newTables(newSize);
    	// 遍历旧表,将数据都映射到新表
   	 	for (size_t i = 0; i < _tables.size(); i++)
    	{
        	if (_tables[i]._state == EXIST)
        	{
            	// 再写一遍下面寻找插入位置并插入数据的代码...
        	}
   	 	}
    	_tables.swap(newTables)
 	}
	
    // 寻找插入位置并插入数据...
}

**补充:**上面代码需要在重新映射中重复下面寻找插入位置并插入数据的代码,过于麻烦且冗余,可以更改代码使得弥补这一缺陷。

上面的的代码是以 HashTable 类为模版创建的 vector 作为新表,其本质还是 vector 其映射的逻辑还是需要由程序员手动编写;但是当直接创建一个 HashTable 类的局部对象就可以直接调用类中的 insert 接口实现哈希表的重新映射,这样不仅避免了代码的冗余还增强了代码的可维护性。

cpp 复制代码
bool Insert(const pair<K, V>& kv)
{
 	if (Find(kv.first))
        return false;
    
 	// 负载因⼦⼤于0.7就扩容 
 	if (_n * 10 / _tables.size() >= 7)
 	{
    	// 获取素数表里面当前表最大的下一个素数
		size_t newSize = __stl_next_prime(_tables.size() + 1);
		HashTable<K, V> newHT;
		newHT._tables.resize(newSize);

		// 遍历旧表,将数据都映射到新表
		for (size_t i = 0; i < _tables.size(); i++)
		{
    		if (_tables[i]._state == EXIST)
    		{
       		 	newHT.Insert(_tables[i]._kv);
    		}
		}
		_tables.swap(newHT._tables)
 	}
    
    // 寻找插入位置并插入数据...
}

代码实现:

因为有了stl中的源码所以直接在 HashTable 类的构造函数中和后期扩容时,调用质数表中的数据即可。

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;
}

HashTable()
    : _n(0)
{
 	_tables.resize(__stl_next_prime(0));
}

bool Insert(const pair<K, V>& kv)
{
 	if (Find(kv.first))
        return false;
    
 	// 负载因⼦⼤于0.7就扩容 
 	if (_n * 10 / _tables.size() >= 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)
 			{
 				newHT.Insert(_tables[i]._kv);
 			}
 		}
 		_tables.swap(newHT._tables);
 	}
	
    // 寻找插入位置并插入数据...
}
2.2.4 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;
		}
};

template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
	// 功能实现...

private:
	vector<HashData<K, V>> _tables;
	size_t _n = 0; // 表中存储数据个数 
};
2.2.5 整体代码实现
cpp 复制代码
#pragma once
#include <vector>
#include <utility>
#include <string.>
using std::pair;
using std::vector;
using std::string;

// 标识每个存储位置的状态:空、存在与删除
enum State 
{
	EMPTY,
	EXIST,
	DELETE
};

// 哈希表中的最小单位
template<class K, class V>
struct HashData 
{
	pair<K, V> _kv;
	State _state = EMPTY;  // 默认为空
}

// 哈希表的仿函数
template<class K>
struct HashFunc
{
	size_t operator()(const K& key)
	{
		return (size_t)key;
	}
};

// string类模板特化
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;
		}
};

// 哈希表
template<class K, class V>
class HashTable 
{
public:
	HashTable()
        : _n(0)
	{
 		_tables.resize(__stl_next_prime(1));
	}
    
    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;
	}

	bool Insert(const pair<K, V>& kv) 
    {
        // 不允许数据冗余,先查找
		if (Find(kv.first))
			return false;
    
 		// 负载因⼦⼤于0.7就扩容 
 		if (_n * 10 / _tables.size() >= 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)
 				{
 					newHT.Insert(_tables[i]._kv);
 				}
 			}
 			_tables.swap(newHT._tables);
 		}
        
		// 除留余数法 && 线性探测法
		// 将数据映射到数据的key值除以哈希表的大小得到的余数的位置,如果该位置被占用往后放
		size_t hashi = kv.first % _tables.size();
		// 不能放在EXIST的位置,DELETE和EMPTY都能放
		while (_tables[hashi]._state == EXIST) 
        {
            // 冲突探测
			++hashi;
			if (hashi == _tables.size()) 
                hashi = 0;  //如果探测到末尾则从头开始重新探测
		}

        // 找到数据插入位置
		_tables[hashi]._kv = kv;
		_tables[hashi]._state = EXIST;
		++_n;

		return true;
	}

	// 将Find的返回值定义为Data的地址,可以方便我们进行删除以及修改V
	HashData<K, V>* Find(const K& key) 
   	{
		Hash hash;  // 仿函数对象
		size_t hashi = hash(key) % _tables.size();
		// 记录hashi的起始位置,避免哈希表中元素全为EXIST和DELETE时导致死循环
		size_t starti = hashi;
		// 最多找到空
		while (_tables[hashi]._state != EMPTY) 
        {
			// key相等并且state为EXIST才表示找到
			if (_tables[hashi]._kv.first == key && _tables[hashi]._state == EXIST)
            {
                return &_tables[hashi];
            }
            
            // 线性探测器
			++hashi;
            
			// 如果找到尾还没找到,就从0重新找
			if (hashi == _tables.size()) 
                hashi = 0;
            
			//如果找一圈还没找到,就跳出循环
			if (hashi == starti) 
                break;
		}

		return nullptr;
	}

	boolErase(const K& key) 
    {
		//找不到就不删,找到就把状态置为DELETE即可
		HashData<K, V>* ret = find(key);
		if (ret) 
        {
            --n
			ret->_state = DELETE;
			return true;
		}

		return false;
	}

private:
	vector<HashData<K, V>> _tables;
	size_t _n;  //记录表中有效数据的个数
};

3. 开散列(链地址法)

3.1 开散列的概念

开散列法又叫链地址法 (开链法),首先对关键码集合用散列函数计算散列地址,即 key 映射的下标位置,具有相同地址的关键码 (哈希冲突) 归于同一子集合 ,每一个子集合称为一个桶 (哈希桶),各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中;也就是说,当发生哈希冲突时,把 key 作为一个节点直接链接到下标位置的哈希桶中。

从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素 ;由于开散列的不同冲突之间不会互相影响,同一冲突都链接在自己下标位置的哈希桶中,并不会去占用别人的下标位置;所以不管是在插入还是查找方面,开散列都比闭散列要高效,所以 C++ STL 中的unordered_map 和 unordered_set 容器以及 Java 中的 HashMap 和 HashSet 容器其底层哈希表都是使用开散列来实现的,只是某些细节方面有些不同;所以开散列也是学习哈希表的重点。

3.2 开散列的代码实现

3.2.1 开散列的结点结构

由于开散列的不同冲突之间不会互相影响,所以开散列不再需要 state 变量来记录每个下表位置的状态。同时,因为开散列每个下标位置链接的都是一个哈希桶,所以 vector 中的每个元素都是一个节点的指针,指向单链表的第一个元素,所以 _tables 是一个指针数组。最后,为了是不同类型的 key 都能够计算出映射的下标位置,所以这里也需要传递仿函数,并将常用的 string 模版进行特化。如下:

cpp 复制代码
// 开散列
namespace BucketHash 
{
	// 哈希表的节点结构:单链表
	template<class K, class V>
	struct HashNode 
    {
		pair<K, V> _kv;
		HashNode<K, V>* next;

		HashNode(const pair<K, V>& kv)
			: _kv(kv)
			, next(nullptr)
		{}
	};

	// 哈希表的仿函数
	template<class K>
	struct HashFunc
	{
		size_t operator()(const K& key)
		{
			return (size_t)key;
		}
	};

	// string类模板特化
	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;
		}
	};


	// 哈希表
	template<class K, class V, class Hash = HashFunc<K>>
	class HashTable 
    {
		typedef HashNode<K, V> Node;
    public:
        // ...
    private:
		vector<Node*> _tables;  // 指针数组
		size_t _n;  			// 表中有效数据的个数
	};
}
3.2.2 开散列的插入删除和查找
3.2.2.1 开散列的插入

开辟列插入的前分配和判断数组时,根据信息与哈希表大小得到映射的下标位置,与哈希表不同的是,由于哈希表中每个下标位置都是一个哈希桶,即一个单链表,那么对于发现哈希冲突的元素我们只需要将其链接到哈希桶中即可,这里一共有两种链接方式:

  1. 将发生冲突的元素链接到单链表的末尾,即尾插。
  2. 将发生冲突的元素链接到单链表的开头,即头插。

这里显然是选择将冲突元素进行头插,因为尾插需要查找,会导致效率降低,插入部分代码如下:

cpp 复制代码
// 插入
bool Insert(const pair<K, V>& kv) 
{
    if (Find(kv.first))
        return false;

    // 扩容...
    
    // 调用仿函数的匿名对象来将key转换为整数
    size_t hashi = Hash()(kv.first) % _tables.size();
    
    // 哈希桶头插
    Node* newNode = new Node(kv);
    newNode->next = _tables[hashi];
    _tables[hashi] = newNode;
    ++_n;

    return true;
}
3.2.2.2 开散列的查找

开散列的查找也很简单,根据余数找到下标,由于下标位置存储的是链表首元素地址,所以只需要取出首元素地址,然后顺序遍历单链表即可:

cpp 复制代码
// 查找
Node* Find(const K& key) 
{
	size_t hashi = Hash()(key) % _tables.size();
	Node* cur = _tables[hashi];
	while (cur) 
    {
		if (cur->_kv.first == key)
			return cur;
		cur = cur->next;
	}

	return nullptr;
}
3.2.2.3 开散列的删

和闭散列不同的是,开散列的删除不能直接通过查找函数的返回值来进行删除,因为单链表在删除节点时还需要改变父节点的指向,让其指向目标节点的下一个节点,所以需要通过遍历单链表来进行删除:

cpp 复制代码
// 删除
bool Erase(const K& key) 
{
    // 由于单链表中删除节点需要改变上一个节点的指向,所以这里不能find后直接erase
    size_t hashi = Hash()(key) % _tables.size();
    Node* prev = nullptr;
    Node* cur = _tables[hashi];
    while (cur) 
    {
        // 删除还要分是否为头结点
        if (cur->_kv.first == key) 
        {
            // 头删
            if (cur == _tables[hashi])
                _tables[hashi] = cur->next;
            else
                prev->next = cur->next;

            delete cur;
            --_n;
            return true;
        }

        // 迭代
        prev = cur;
        cur = cur->next;
    }

    return false;
}
3.2.3 开散列的扩容

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

代码实现:

cpp 复制代码
//析构:手动释放哈希表中的每个元素,以及每个元素指向的哈希桶
~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;
    }
}

//插入
bool insert(const pair<K, V>& kv) 
{
    if (find(kv.first))
        return false;

    //扩容--当载荷因子达到1时我们进行扩容
    if (_n == _tables.size()) 
    {
        //法一:采用闭散列的扩容方法--复用insert接口
        //优点:实现简单;
        //缺点:先开辟节点再释放节点代价大
        //HashTable<K, V, Hash> newHT;
        //newHT._tables.resize(_tables.size() * 2, nullptr);
        //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);

        //法二:取原表中的节点直接移动链接到当前表中
        //缺点:实现比较复杂
        //优点:不用再去开辟新节点,也不用释放旧节点,消耗小
        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->next;
                
                //重新计算映射关系,调用仿函数的匿名对象来将key转换为整数
                size_t hashi = Hash()(cur->_kv.first) % newTables.size();
                // 头插到新表
                cur->next = newTables[hashi];
                newTables[hashi] = cur;

                cur = next;
            }
            
            _tables[i] = nullptr;
        }

        _tables.swap(newTables);
    }

    //调用仿函数的匿名对象来将key转换为整数
    size_t hashi = Hash()(kv.first) % _tables.size();
    //哈希桶头插
    Node* newNode = new Node(kv);
    newNode->next = _tables[hashi];
    _tables[hashi] = newNode;
    ++_n;

    return true;
}

补充:

以上给出两种扩容方法:

  1. 通过复用 insert 函数接口来进行扩容,但是这种扩容方法的效率很低,因为将旧表节点插入到新表时需要重新开辟节点,在插入并交换完毕后,又需要释放掉旧表中的节点,而 new 和 delete 的代价是很大的,特别是当 KV 是自定义类型时。
  2. 取出旧表中的每个节点链接到新表中,然后再交换旧表与新表。这样做就减少了 new 和 delete 的过程,大大提高了扩容的效率。(注:这里不能将原表中的整个哈希桶链接到新表中,因为新表的大小改变后原表中的元素可能会映射到新表的其他位置)

同时,开散列的析构函数是需要自行实现的,因为默认生成的析构函数只能释放掉 vector 结构的哈希表,对于链接在每个哈希表单元的单链并不能释放,故需要自行手动释放。

3.3 整体代码实现

cpp 复制代码
// 开散列
namespace BucketHash
{
	// 哈希表的节点结构:单链表
	template<class K, class V>
	struct HashNode
	{
		pair<K, V> _kv;
		HashNode<K, V>* next;

		HashNode(const pair<K, V>& kv)
			: _kv(kv)
			, next(nullptr)
		{}
	};

	// 哈希表的仿函数
	template<class K>
	struct HashFunc
	{
		size_t operator()(const K& key)
		{
			return (size_t)key;
		}
	};

	// string类模板特化
	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;
		}
	};


	// 哈希表
	template<class K, class V, class Hash = HashFunc<K>>
	class HashTable
	{
		typedef HashNode<K, V> Node;

		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;
		}

	public:
		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;
			}
		}

		//插入
		bool insert(const pair<K, V>& kv)
		{
			if (find(kv.first))
				return false;

			//扩容--当载荷因子达到1时我们进行扩容
			if (_n == _tables.size())
			{
				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->next;

						//重新计算映射关系,调用仿函数的匿名对象来将key转换为整数
						size_t hashi = Hash()(cur->_kv.first) % newTables.size();
						// 头插到新表
						cur->next = newTables[hashi];
						newTables[hashi] = cur;

						cur = next;
					}

					_tables[i] = nullptr;
				}

				_tables.swap(newTables);
			}

			//调用仿函数的匿名对象来将key转换为整数
			size_t hashi = Hash()(kv.first) % _tables.size();
			//哈希桶头插
			Node* newNode = new Node(kv);
			newNode->next = _tables[hashi];
			_tables[hashi] = newNode;
			++_n;

			return true;
		}
		
		// 查找
		Node* Find(const K& key)
		{
			size_t hashi = Hash()(key) % _tables.size();
			Node* cur = _tables[hashi];
			while (cur)
			{
				if (cur->_kv.first == key)
					return cur;
				cur = cur->next;
			}

			return nullptr;
		}

		// 删除
		bool Erase(const K& key)
		{
			// 由于单链表中删除节点需要改变上一个节点的指向,所以这里不能find后直接erase
			size_t hashi = Hash()(key) % _tables.size();
			Node* prev = nullptr;
			Node* cur = _tables[hashi];
			while (cur)
			{
				// 删除还要分是否为头结点
				if (cur->_kv.first == key)
				{
					// 头删
					if (cur == _tables[hashi])
						_tables[hashi] = cur->next;
					else
						prev->next = cur->next;

					delete cur;
					--_n;
					return true;
				}

				// 迭代
				prev = cur;
				cur = cur->next;
			}

			return false;
		}

	private:
		vector<Node*> _tables;  // 指针数组
		size_t _n;  			// 表中有效数据的个数
	};
}