【C++修炼】哈希表的实现

哈希表的实现

1. 哈希的概念

哈希 (hash) ⼜称散列,前者是译名,后者则更具结构代表意味,⼀种散乱排列的数据组织⽅式。

  • 哈希 不等价于哈希表 。哈希是一种设计思想 ,哈希表是基于哈希思想设计的数据结构,如位图、布隆过滤器等也是基于哈希的数据结构。
  • 哈希本质是一种映射,通过哈希函数把关键字 (Key) 跟存储位置建⽴⼀个映射关系。

对比二叉搜索树:

  • 二叉搜索树是通过结构特性实现快速查找。比根的值大往右走,比根的值小往左走,实现一个类似二分的算法。完全二叉树/满二叉树基本就接近二分了,效率为O(logN)。但左右高度不好控制,因此引入了平衡的概念,出现了AVL树和红黑树,使用了两种不同的控制平衡的思路。

而哈希思想中,每个数据都有一个对应的存储位置,每个关键字通过映射关系就可以快速计算出存储位置。

2. 直接定址法

当关键字的范围⽐较集中时,直接定址法就是⾮常简单⾼效的⽅法。

  • 例如计数排序就运用了直接定址法。找出最大值和最小值,从而确定范围长度并开辟临时数组,用于统计计数。值决定元素在数组的下标位置,二者是相对映射关系。在计数数组的统计过程中,自然完成排序。

代码示例 2.1

cpp 复制代码
void CountSort(int* a, int n)
{
    // 确定最大值、最小值
    int max = a[0];
    int min = a[0];
    for (int i = 1; i < n; i++)
    {
        if (a[i] > max) max = a[i];
        if (a[i] < min) min = a[i];
    }
    // 确定数据范围长度
    int range = max - min + 1;
    // 根据范围[min, min+range)长度创建临时数组,用于计数
    int* count = (int*)malloc(range * sizeof(int));
    if (!count)
    {
        // 创建失败,报错并返回
        printf("malloc fail!");
        return;
    }
    // count数组中元素全部置为0
    memset(count, 0, range * sizeof(int));
    // 遍历数据,统计到count中
    // 元素的值决定下标位置,相对映射关系,存储过程即排序过程
    for (int i = 0; i < n; i++)
    {
        count[a[i] - min]++;
    }
    // 将排序结果放回原数组
    int j = 0;
    for (int i = 0; i < range; i++)
    {
        while (count[i]--)
        {
            a[j++] = i + min;
        }
    }
    free(count);
}

回到哈希:

  • 值即关键字,下标位置即存储位置。⼀组数据范围在50, 99,范围长度49,我们只需要开辟50个数的数组即可。如果值为60则下标为60-50=10,如果值为79则下标为79-50=29,这就是一种映射,或者说哈希函数。如果想查找计数数组中的某个值,通过该映射关系,我们可以快速计算出值对应的下标位置。

再⽐如⼀组关键字a, z,26个小写字母,我们开辟26个数的数组即可。小写字母的ASCII码'a'的ASCII码就是下标。

  • 直接定址法本质是⽤关键字计算出⼀个绝对位置或相对位置。

缺陷在于:

首先,关键字必须本身就是整型,或者支持转换成整型。像浮点型、string等就不太好转。其次,直接定址法要求数据集中。如果数据分散,可能只有100个数据,但值的范围长度有1万,不可能为此开辟1万个数的数组。

因此,直接定址法对数据范围比较严格,只适用一些特定场景,否则会浪费大量内存。

3. 哈希表

哈希冲突

每一个数据都能在数组中找到对应唯一的位置,这是直接地址法的本质,既是优点,更是缺陷所在。因为关键字和存储位置之间是一一对应关系,要求数据比较集中。于是设计出了更通用的哈希,允许数据较为分散,例如哈希表。

假设数据范围0,9999内有N个值,现在我们不管数据集中或分散,统一映射到M个数的数组空间 (该空间即称哈希表,M >= N)。每一个关键字 key,通过哈希函数 h (Hash Function),都可计算出下标位置 h(key)。注意,下标 h(key) 在[0, M)之间。

  • 不在乎数据集中或分散,值和下标位置就无法一一对应,数据必须通过合适的映射方式找到对应的位置,不可避免不同关键字被映射到同⼀位置 h(key)。这种问题称为哈希冲突或哈希碰撞。
  • 理想情况下只要哈希函数足够优秀,就能让关键字与存储位置一一对应。但实际是,冲突无法避免,我们只能设计出尽可能优秀的哈希函数,使映射方式尽量合理,减少冲突次数,同时也要设计出解决冲突的⽅案。

负载因子

补充概念:

  • 设哈希表中已映射存储的值为N个,哈希表⼤⼩为M,那么 负载因子 = 。负载因⼦ (Load Factor) 有些地⽅也翻译为载荷因⼦/装载因⼦等。
  • 负载因⼦越⼤,空间利⽤率越⾼,但哈希冲突的概率越⾼;负载因⼦越⼩,空间利⽤率越低,但哈希冲突的概率越低。这是一个权衡的问题。

注:整型更容易做映射计算,如果关键字不是整型,就要想办法转换成整型。后⾯代码实现时再展示转换细节。下⾯讨论中,我们暂且默认关键字都是整型,或已完成整型转换。

4. 哈希函数

哈希函数即关键字与存储位置的映射关系。目的是将分散的数据统一映射到数组空间中。

  • 优秀的哈希函数,应该让N个关键字被均匀的、等概率的散列分布到M个元素空间中,虽然实际很难做到,但我们必须往该⽅向进行考量与设计。

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

除法散列法或称除留余数法。

  • 顾名思义,假设哈希表⼤⼩为M,用key除以M的余数作为映射的下标位置,对应哈希函数为:h(key) = key % M。

为了尽可能减少冲突,使⽤除法散列法应避免M为某些值,例如2的幂、10的幂等。

  • 如果M为,那么 key % M 本质等于key 的二进制数的后X位 (key右移X位),后X位相同则计算得到的哈希值都相同,就冲突了。
  • 如果M为,那么 key % M 本质等于key 的十进制数的后X位 (key右移X位),后X位相同则计算得到的哈希值都相同,依此类推。

例如待插入数据{63, 31},用8位二进制数表示,63是00111111,31是00011111,如果M为16,即,后X位都是1111,则下标位置都是15。如果M为100,即,就更明显了,如{112, 12312},直接看十进制数的后X位,得到的哈希值都是12。

通常建议:

  • 使⽤除法散列法,M取不太接近2的整数次幂的质数 (也称素数:大于1的自然数,只能被1和自身整除)。
cpp 复制代码
例如,取2^4=16和2^5=32中间的一个质数,共有:17、19、23、29、31
通常取23,离2^4=16和2^5=32更远,更居中

但实践操作中不好控制,因为哈希表需要扩容,我们一般都是二倍扩容,
但这里需要找下一个符合条件的质数作为扩容后的空间大小,略微麻烦。

需要说明:

M的选取,实践中是⼋仙过海,各显神通。例如Java的HashMap,采⽤除法散列法时就是用2的整数幂作为M,好处之一是扩容方便,但会增加后X位相同导致的冲突问题。实际上,该问题本质是只取了key的后X位作为下标位置,其他位没有参与。因此我们希望 key 所有比特位都参与计算,这样映射得到的哈希值更均匀。该方法需要用到位运算,好处是位运算取模相较而言比模运算 (相当于除运算) 更⾼效。

假设M为 (17个比特位,1后面跟16个0,x86下用32位存储),本质是取二进制数的后16位:

  • 我们希望前16位也参与计算,用 key' = key >> 16 取得前16位,key' 前16位都为0,key' 的后16位即 key 的前16位。用 key 和 key' 的异或结果作为新的key进行取模运算。key 和 key' 的前16位异或结果是0,本质还是 key 和 key' 的后16位异或,即 key 的后16位和前16位异或。最终映射得到的值必然在[0,M)之内,因为 key 的后16位和前16位异或结果再取模,最大也只是16位全1即

注意,哈希表本身带来的冲突是无法避免的,Java这里解决的冲突只是取模带来的冲突,降低了哈希函数导致的冲突概率。

上⾯建议M取质数,是⼤多数据结构书籍中写的理论,本质是另一种解决取模问题的方法。实践中应该抓住本质,灵活运用,像Java一样抓住模运算和位运算的底层关系同样可以找出解决方案。

4.2 乘法散列法 (了解)

  • 乘法散列法对哈希表的⼤⼩M没有要求。
  • 第一步是:关键字 K 乘以常数 A (0 < A < 1),提取 K * A 的⼩数部分。
  • 第⼆步是:M 乘以⼩数部分,向下取整。
  • ,floor 表⽰对表达式进⾏向下取整,常数A∈(0,1)。

重要的是A的值,Knuth建议取黄金分割点比较好,

假设key为1234,M为1024。A * key = 762.6539420558,取⼩数部分0.6539420558。M * ((A * key) % 1.0) = 1024 * 0.6539420558 = 669.6366651392。向下取整得h(1234) = 669。符合[0, M)。

4.3 全域散列法 (了解)

哈希思想运用是很广泛的,例如分布式与集群。现实不乏数据量十分庞大的情况,比如某个社交平台上的所有聊天记录,有很多很多的数据,必须分布在多台机器上进行存储或运算,这些机器就称为集群。集群就相当于一张哈希表,假设要存储一张照片的数据,必然也是要用哈希函数,将对应关键字映射到某个机器的某个存储位置。

  • 如果存在恶意对⼿,针对我们提供的散列函数,故意设计⼀个严重冲突的数据集,⽐如所有关键字映射位置全部相同。这是可能的,只要散列函数公开且确定就可以实现此攻击。解决⽅法⾃然是⻅招拆招,给哈希函数增加随机性,攻击者就很难确定可以导致最坏情况的数据。这种⽅法叫做全域散列。
  • ,P 需要选⼀个⾜够⼤的质数,a 可以随机选1, P-1之间的任意整数,b 可以随机选0, P-1之间的任意整数,这些函数构成了⼀个 (P - 1) * P 组全域散列函数组。假设M=6、P=17、a=3、b=4则
  • 需要注意,每次初始化哈希表都是随机选取函数组的其中⼀个使⽤,固定好P以后系统运行时随机选取a、b,后续增删查改都固定使⽤该散列函数,否则每次哈希都随机,插⼊是⼀个散列函数,查找⼜是另⼀个散列函数,就对应不上了。

注:上⾯⼏种⽅法是《算法导论》中讲解的⽅法。《殷⼈昆 数据结构:⽤⾯向对象⽅法与C++语⾔描述 (第⼆版)》和《数据结构(C语⾔版).严蔚敏_吴伟⺠》等教材型书籍上⾯还给出了平⽅取中法、折叠法、随机数法、数学分析法等,这些⽅法适用场景相对更局限一些。

5. 处理哈希冲突

实践中⼀般选择除法散列法作为哈希函数。但不管选择什么也无法避免冲突,解决冲突主要有两种方法:开放定址法链地址法

5.1 开放定址法

在开放定址法中:

  • 所有元素都存储在哈希表里。
  • 不同关键字通过哈希函数计算得到的下标位置冲突时,则按规则找未存储数据的位置进行存储。
  • 负载因⼦⼀定⼩于1 (N < M)。

寻找规则主要有以下三种:线性探测、⼆次探测、双重探测。

线性探测
  • 从发⽣冲突的位置开始,依次向后探测,如果⾛到哈希表末尾就绕回表头,直到寻找到未存储数据的位置为⽌。
  • 发生冲突的关键字为 key,冲突位置记为hash0,h(key) = hash0 = key % M。则线性探测公式为:hc(key,i) = hashi = (hash0 + i) % M ,i = {1, 2, 3, ..., M −1}。
  • 因为负载因⼦⼩于1,则最多探测M-1次一定能找到存储位置。

下⾯演⽰{19, 30, 5, 36, 13, 20, 21, 12}这⼀组值映射到M=11的表中。

线性探测比较简单且容易实现,问题在于:

  • hash0位置连续冲突,数据依次抢占hash1、hash2,导致后续映射在hash0/hash1/hash2的数据,都会和映射在hash3的数据争抢位置,这种现象叫做群集/堆积。
  • 哈希冲突越多,效率越低。每次得到映射位置后,因为被抢占,必须向后查找。尤其是连续一片占用,离映射位置越远,查找距离越远。

并且,上图看上去只有19和30位置冲突,实际上,由于位置被抢占,其他本可以正常映射的数据也发生了冲突。30抢占了20的位置,20又去抢占21的位置,21又抢占了下标0的位置。你抢我的,我抢它的,一大片的抢占导致了连续冲突。

  • 另一个问题是查找与删除。例如查找30,计算得下标8,因为8位置已经有元素,因此我们判断是发生了线性探测,从8位置开始向后找30。但如果把19删了再查找,8位置上元素被删除了,这时我们怎么判断30是不存在,还是发生了线性探测不在该位置?又如何表示一个位置为空呢?

HashTable.h

cpp 复制代码
#pragma once
#include <utility>
#include <vector>

namespace david
{
    // 用枚举类型标识当前位置的状态
	enum BucketState
	{
		EMPTY,    // 空
		OCCUPIED, // 已占用
		TOMBSTONE // 已删除
	};

    // 数据类型
	template<class K, class V>
	struct HashData
	{
		std::pair<K, V> _kv;        // 元素为键值对
		BucketState _state = EMPTY; // 位置默认为空,未存储数据,_kv为无效值
	};

    // 哈希表
	template<class K, class V>
	class HashTable
	{
	private:
		std::vector<HashData<K, V>> _table; // 组合/复用vector作为哈希表的底层
        // 哈希表中数据散列分布,_table.size()并非有效数据个数,应单独记录
        size_t _n = 0;                      // 记录有效数据个数
	};
}

封装数据类型,因此可以标识数组各位置上元素的状态。默认为空,则位置上的数据无效。标记为已删除,则数据同样无效。只有标记为已存在,位置上的元素有效。

在查找和删除时就可以根据状态进行判断,已存在或已删除就说明发生了线性探测,应该不断往后查找偏移的关键字。

二次探测

二次探测可以一定程度改善堆积问题。它和线性探测类似,但线性探测是挨着找,它是跳着找,每次探测距离是,抢占位置更分散了。

  • 从发⽣冲突的位置开始,依次向左右按⼆次⽅跳跃式探测,直到寻找到下⼀个未存储数据的位置为⽌。如果往右⾛到哈希表末尾则绕回表头,如果往左⾛到表头则绕回表尾。
  • 发生冲突的关键字为 key,冲突位置记为hash0,h(key) = hash0 = key % M。则⼆次探测公式为:hc(key, i) = hashi = (hash0 ± i^2) % M,i = {1, 2, 3, ..., M/2}。从i=1开始,先以i=1向右探测hash0+1,再以i=1向左探测hash0-1,然后再以i=2先向右探测hash0+4,再以i=2向左探测hash0-4,i=3则hash0±9,以此类推。左右最多分别探测M/2次,即可找到存储位置。
  • 向左探测需要注意负数问题,hashi = (hash0 − i^2) % M,hash0 - i^2 可能小于0,负数取模还是负数本身,需要特殊处理,相当于从表头绕回表尾 hashi += M。

下⾯演⽰{19, 30, 52, 63, 11, 22}这⼀组值映射到M=11的表中。

  • 当然,在实践实现中也可以进行简化,单纯的向右探测也可以 (向左有负数问题),也能一定程度避免堆积问题。
双重探测 (了解)

双重探测本质也是跳跃探测,减少冲突堆积。但不像二次探测一样固定跳跃,而是多增加了一个哈希函数对关键字进行映射得到特定偏移量,每次探测距离需要乘以偏移量。好处是可以探测到某些二次探测找不到的位置,充分利用整个散列表。

  • 哈希函数 h(key) 计算得到的下标位置冲突,则使⽤增加的哈希函数 h2(key) 计算特定偏移量,不断向后探测,直到寻找到下⼀个没有存储数据的位置为⽌。
  • 发生冲突的关键字为 key,冲突位置记为hash0,h(key) = hash0 = key % M。则双重探测公式为:hc(key, i) = hashi = (hash0 + i * h2(key)) % M,i = {1, 2, 3, ..., M}。最多向后探测M次即可找到存储位置。

h2(key) 同样小于M,且 h2(key) 和M互为质数 (共同的因数只有1,不能被其他数整除),有两种简单的取质数的⽅法:

  1. 当M为2的整数幂时,h2(key) 从0,M-1任选⼀个奇数;
  2. 当M为质数时,h2(key) = key % (M - 1) + 1 (取模结果加1的范围是1到M-1,M为质数,小于M的数一定与M互质)。

保证 h2(key) 与M互质是因为根据固定偏移量寻找的所有位置将形成⼀个群。

  • 若M和 h(key) 的最⼤公约数 p = gcd(M, h(key)) > 1,那么能寻找到的位置个数 M/p < M,⽆法充分利⽤整个散列表。
  • 举例来说,如果不互质就会像下面一样:冲突位置即初始探查位置hash0为1,整个散列表⼤⼩M为12,偏移量 h2(key) 为3,那么寻址个数为12/gcd(12, 3) = 4,能寻址的位置为{1, 4, 7, 10},每个寻址位置之间相当于跳跃了3个距离,0/2/3/5/6/8/9这些位置就都被跳过去了。
  • 如果互质的话,例如整个散列表⼤⼩M为16,从0, 15任选一个奇数作为偏移量 h2(key),那么gcd(16, h2(key))一定是1,因为16和偏移量互质,所以寻址个数16/gcd一定是16,各个位置都可以探查。

下⾯是{19, 30, 52, 74}这⼀组值映射到M=11的表中,设h2(key) = key % 10 + 1。

5.2 开放定址法代码实现

开放定址法本质是抢占其他数据在哈希表中的位置解决冲突,不管是线性探测、二次探测还是双重探测,始终存在互相影响的问题。实践中用的更多的是链地址法。因此,开放定址法的实现,这里简单实现一个线性探测即可,另两个是类似的,区别在于探测距离。

插入与扩容

扩容我们一般还是选择二倍扩容。但关于哈希表的大小M,必须考虑到两种方案的选择问题:

  1. 取2的整数幂。参考上⾯除法散列法中Java的HashMap的改进方法:哈希函数计算时先进行位运算得到异或结果再取模。
  2. 取不太接近2的整数幂的质数。但按二倍扩容就不是质数了。这里提供SGI STL版本的哈希表使⽤的⽅法:预先给出⼀个质数表,相邻质数之间近似二倍关系,每次扩容用当前哈希表大小加1,再去质数表中获取下一位质数作为扩容后的大小。

这里先演示一下第二种方案。

代码示例 5.2.1

cpp 复制代码
#pragma once
#include <utility>
#include <vector>

namespace david
{
    // 标识存储位置状态
	enum BucketState
	{
		EMPTY,
		OCCUPIED,
		TOMBSTONE
	};
    // 数据类型
	template<class K, class V>
	struct HashData
	{
		std::pair<K, V> _kv;
		BucketState _state = EMPTY;
	};
    // 哈希表
	template<class K, class V>
	class HashTable
	{
	public:
        // 返回最近的大于等于n的质数,扩容时传入当前哈希表的大小加1,则返回下一位质数
		inline unsigned long __stl_next_prime(unsigned long n)
		{
			// Note: assumes long is at least 32 bits.
            // 预置的质数表中共28个质数
			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;
            // 类似在[first, last)区间,即质数表中二分查找
            // 返回大于等于n的质数
			const unsigned long* pos = lower_bound(first, last, n);
            // pos == last,即质数表中找不到大于等于n的质数,直接返回最后一个质数
			return pos == last ? *(last - 1) : *pos;
		}

        // 哈希表的构造函数
        // __stl_next_prime(0)会查找质数表中大于等于0的质数返回,即哈希表初始大小为53
		HashTable()
			: _table(__stl_next_prime(0))
			, _n(0)
		{ }

		bool Insert(const std::pair<K, V>& kv)
		{
            // ...
		}
		HashData<K, V>* Find(const K& key)
		{
            // ...
		}
		bool Erase(const K& key)
		{
            // ...
		}

	private:
		std::vector<HashData<K, V>> _table;
		// 哈希表中数据散列分布,_table.size()并非有效数据个数,应单独记录
		size_t _n = 0; // 记录有效数据个数
	};
}

要点一:

  • 注意区分 _table 的 capacity() 和 size()。前者是 vector 容器的总容量,vector 容器的扩容逻辑归属它的内部实现;后者返回容器中的元素个数,这才是哈希表当前的空间大小,哈希表只使用了这么多位置。哈希表的扩容更多是增加 vector 容器中被哈希表使用的位置。

要点二:

  • 每次扩容,我们只需要通过 __stl_next_prime(_table.size() + 1) 即可获取扩容后的下一位质数,除了最后一个质数都近似二倍增长。返回逻辑是大于等于,不加1会返回 _table.size() 本身。
  • 质数表中最大为4294967291,即小于的最大质数。M是哈希表的大小,相当于4294967291个数据,就算一个 HashData 大小为1个字节,哈希表也需要占4294967291个字节,约4G。正常是不可能到这么大的,完全不用担心质数表中的质数不够用。即使扩到最后哈希表大小为4294967291,也不会再扩了,因为找不到更大的质数了,永远只返回最后一个质数4294967291。

实现插入与扩容:

  • 哈希表必须通过负载因子权衡空间利用率与冲突概率。
  • 这⾥我们将负载因⼦控制在0.7,负载因⼦大于等于0.7就扩容。

代码示例 5.2.2

cpp 复制代码
template<class K, class V>
class HashTable
{
public:
    // 返回质数表中大于等于n的质数
	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()
		: _table(__stl_next_prime(0))
		, _n(0)
	{ }

    // 插入
	bool Insert(const std::pair<K, V>& kv)
	{
		// 负载因子 >= 0.7,扩容
		if (_n * 10 / _table.size() >= 7)
		{
			// M发生变化,映射关系同样变化,必须重新映射
			// 直接定义新的HashTable
			HashTable<K, V> newht;
			// 将新表大小置为近似二倍大的质数
			newht._table.resize(__stl_next_prime(_table.size() + 1));
			// 逐个取出旧表的数据,重新映射
			for (auto& data : _table)
				if (OCCUPIED == data._state) newht.Insert(data._kv);
			// 将newht中的新表和其他数据全部交换过来
            // newht出了函数自动调用析构,旧表自动释放
			_table.swap(newht._table);
		}

		// 根据关键字计算下标位置
		size_t hash0 = kv.first % _table.size();
		size_t hashi = hash0;
		size_t i = 1;
		// 如果哈希表存满,会因为所有位置都是OCCUPIED陷入死循环
		// 但平衡因子控制了哈希表的空间利用率,不可能所有位置都被使用
		// 因此必然存在空位置或者已删除的位置可供插入
		while (OCCUPIED == _table[hashi]._state)
		{
			// 线性探测 hc(key,i) = (hash0 + i) % M
			// hashi位置为空或已删除则找到存储位置
			hashi = (hash0 + i) % _table.size();
			i++;
		}
		// 在hashi位置插入
		_table[hashi]._kv = kv;
		_table[hashi]._state = OCCUPIED;
		_n++;
		return true;
	}

private:
	std::vector<HashData<K, V>> _table;
	// 哈希表中数据散列分布,_table.size()并非有效数据个数,应单独记录
	size_t _n = 0; // 记录有效数据个数
};

要点三:

  • 哈希函数:h(key) = key % M。扩容时,M发生变化,映射关系同样变化,因此必须全部重新映射。原本冲突的值可能不冲突,也可能继续冲突;原本不冲突的值可能冲突,也可能仍然不冲突。

要点四:

  • 关于如何重新映射?常规思路是定义一个新的 vector 容器,即创建一个新的 _table 表,遍历原 _table 表中的数据,重新走一遍下面的插入逻辑。但我们可以直接定义新的 HashTable 对象 newht,直接复用当前 Insert 的插入逻辑,避免代码冗余。这里并不算递归,因为本质是两个不同的 HashTable 对象 newht 和 *this 在调用,互不影响。而且 newht.Insert() 并不会进入扩容逻辑,因为 newht 的新表大小已经是二倍,平衡因子一定小于0.7。

要点五:

  • 上述实现是线性探测,但想实现其他探测也是很简单的。例如二次探测,将变为即可。正负可以用 flag = 1 控制,一开始向右探测,flag 为正1,加 i 的二次方。然后 flag 变负1,向左探测,减 i 的二次方。左右探测完再 i++。
查找与删除

代码示例 5.2.3

cpp 复制代码
#pragma once
#include <utility>
#include <vector>

namespace david
{
	enum BucketState
	{
		EMPTY,
		OCCUPIED,
		TOMBSTONE
	};

	template<class K, class V>
	struct HashData
	{
		std::pair<K, V> _kv;
		BucketState _state = EMPTY;
	};

	template<class K, class V>
	class HashTable
	{
	public:
		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()
			: _table(__stl_next_prime(0))
			, _n(0)
		{ }

		bool Insert(const std::pair<K, V>& kv)
		{
			// 不允许冗余,关键字重复则插入失败,直接返回false
			if (Find(kv.first)) return false;

			// 负载因子 >= 0.7,扩容
			if (_n*10 / _table.size() >= 7)
			{
				// M发生变化,映射关系同样变化,必须重新映射
				HashTable<K, V> newht;
				newht._table.resize(__stl_next_prime(_table.size() + 1));
				// 逐个取出旧表的数据,重新映射
				for (auto& data : _table)
					if (OCCUPIED == data._state) newht.Insert(data._kv);
				_table.swap(newht._table);
			}

			// 先根据关键字计算下标位置
			size_t hash0 = kv.first % _table.size();
			size_t hashi = hash0;
			size_t i = 1;
			while (OCCUPIED == _table[hashi]._state)
			{
				// 当前位置已占用,线性探测
				hashi = (hash0 + i) % _table.size();
				i++;
			}
			_table[hashi]._kv = kv;
			_table[hashi]._state = OCCUPIED;
			_n++;
			return true;
		}

        // 根据key查找数据并返回
		HashData<K, V>* Find(const K& key)
		{
            // 先根据key计算映射位置
			size_t hash0 = key % _table.size();
			size_t hashi = hash0;
			size_t i = 1;
            // 映射位置必须为已占用或已删除
			while (EMPTY != _table[hashi]._state)
			{
                // 查找到就返回
				if (OCCUPIED == _table[hashi]._state 
					&& _table[hashi]._kv.first == key)
					return &_table[hashi];
				// 没查找到,则线性探测向后找
				hashi = (hash0 + i) % _table.size();
				i++;
			}
            // 数据不存在则返回空
			return nullptr;
		}
        
        // 根据key查找数据并删除,返回删除结果
		bool Erase(const K& key)
		{
            // 复用Find
			HashData<K, V>* ret = Find(key);
            // ret不为空说明找到了,删除数据并返回true,否则删除失败返回false
			if (nullptr == ret) return false;
			else ret->_state = TOMBSTONE;
			return true;
		}

	private:
		std::vector<HashData<K, V>> _table;
		// 哈希表中数据散列分布,_table.size()并非有效数据个数,应单独记录
		size_t _n = 0; // 记录有效数据个数
	};
}

要点六:

  • 根据关键字 key 进行查找或删除。通过哈希函数直接计算映射位置即可,如果当前位置关键字不对,注意判断位置状态,已占用或已删除说明发生了线性探测,需要继续向后找 key。删除和查找逻辑差不多,直接复用,然后删除找到的数据即可。

要点七:

  • 当前实现的主要是不支持冗余的哈希表,关键字不可重复,因此需要补充插入逻辑,如果查找到相同的关键字则插入失败。
2的整数幂方案

选取2的整数幂作为哈希表的大小M,可以方便二倍扩容,但后X位相同就会冲突,导致更多关键字会发生冲突,冲突次数增加,哈希表效率降低。因此哈希函数不能只是单纯的取模,我们必须让 key 的所有比特位都参与计算。

关于这个问题,简单给出两个解决方案:

  • 第一种就是图中提及的,M必须至少为。直接用作为哈希表的初始大小即可。问题就是作为初始大小来说太大了,很浪费内存空间。
  • 第二种就是M小于,可以切割分组进行异或。将27位以5位为一组,不足5位也算作一组,最后会剩下两位为一组。每组依次与 key 进行异或,这样 key 的前27位就都参与到了 key 的后5位里面。key 的32位同样全部包含在了该异或结果中。

这里用第一种方案简单实现一下。

代码示例 5.2.4

cpp 复制代码
#pragma once
#include <utility>
#include <vector>
#include <cmath>

namespace david
{
	enum BucketState
	{
		EMPTY,
		OCCUPIED,
		TOMBSTONE
	};

	template<class K, class V>
	struct HashData
	{
		std::pair<K, V> _kv;
		BucketState _state = EMPTY;
	};

	template<class K, class V>
	class HashTable
	{
	public:
        // 构造函数,哈希表初始大小为2的16次方
		HashTable()
			: _table(std::pow(2, _m))
			, _n(0)
		{ }
        
        // 哈希函数,给关键字key返回哈希值
		size_t HashFunc(const K& key)
		{
            // M为2的整数幂,取模相当于取二进制的后X位,即后_m位
            // 相当于key需要保留后_m位,用与&运算即可
            // pow(2, _m) - 1,即_table.size() - 1,后_m位都是1,前面都是0
            // key & (_table.size() - 1)
            // _m位以前都是0,与key的_m位以前进行与&运算后都是0
            // 后_m位都是1,与key的后_m位进行与&运算,就保留了后_m位
			size_t hash = key & (_table.size() - 1);
            
            // 此时需要让key前面的32 - _m位,与hash保留的_m位进行异或
            // 注意_m大于等于16,因此异或结果一定能全部提取,所以直接异或,不切割分组
            hash ^= (key >> _m);
			return hash;
		}
 
        // 插入
		bool Insert(const std::pair<K, V>& kv)
		{
			if (Find(kv.first)) return false;

			// 负载因子 >= 0.7,扩容
			if (_n*10 / _table.size() >= 7)
			{
				HashTable<K, V> newht;
                // M为2的整数幂,直接按二倍扩容即可,不需要找质数
                _m++;
            	newht._table.resize(std::pow(2, _m));

				for (auto& data : _table)
					if (OCCUPIED == data._state) newht.Insert(data._kv);
				_table.swap(newht._table);
			}
            
            // 计算哈希值的都改用HashFunc
			size_t hash0 = HashFunc(kv.first);
            //size_t hash0 = kv.first % _table.size();
            size_t hashi = hash0;
			size_t i = 1;
			while (OCCUPIED == _table[hashi]._state)
			{
				hashi = (hash0 + i) % _table.size();
				i++;
			}
			_table[hashi]._kv = kv;
			_table[hashi]._state = OCCUPIED;
			_n++;
			return true;
		}

		HashData<K, V>* Find(const K& key)
		{
			// 计算哈希值都改用HashFunc
            size_t hash0 = HashFunc(key);
            //size_t hash0 = key % _table.size();
			size_t hashi = hash0;
			size_t i = 1;
			while (EMPTY != _table[hashi]._state)
			{
				if (OCCUPIED == _table[hashi]._state 
					&& _table[hashi]._kv.first == key)
					return &_table[hashi];
				// 没查找到,线性探测
				hashi = (hash0 + i) % _table.size();
				i++;
			}

			return nullptr;
		}

		bool Erase(const K& key)
		{
			HashData<K, V>* ret = Find(key);
			if (nullptr == ret) return false;
			else ret->_state = TOMBSTONE;
			return true;
		}

	private:
		std::vector<HashData<K, V>> _table;
		size_t _n = 0;  // 记录有效数据个数
        size_t _m = 16; // 哈希表初始大小为2的16次方
	};
}

要点八:

  • 这里将模%运算 改成了位运算。实践中通常不喜欢模运算或除运算,效率比加减乘要低。模运算和除运算,包括一些乘运算,通常都喜欢转成位运算。乘其实可以转成加,除和模也可以转成减。
key 不能取模问题

前面我们默认关键字都是整型,或已完成整型转换,但当 key 是字符串或者自定义类型时,key 并不能直接取模。例如 string,即使强制类型转换也不能转成整型。

  • 需要给 HashTable 增加⼀个模板参数 Hash,即仿函数,⽀持把 key 转换成⼀个可以取模的整型。
  • 如果 key 本身可以转换为整型并且不容易冲突,那么该仿函数⽤默认参数即可;如果这个 key 不能转换为整型,就需要⾃⼰实现⼀个仿函数传给这个参数。

代码示例 5.2.5

cpp 复制代码
#pragma once
#include <utility>
#include <vector>

namespace david
{
	enum BucketState
	{
		EMPTY,
		OCCUPIED,
		TOMBSTONE
	};

	template<class K, class V>
	struct HashData
	{
		std::pair<K, V> _kv;
		BucketState _state = EMPTY;
	};

	// 默认的整型转换仿函数
	template<class K>
	struct Hasher
	{
		size_t operator()(const K& key)
		{
			// 默认关键字支持转换成整型,直接强转成无符号整型返回即可
			// 如果不支持需要外部自行实现,传给HashTable的模板参数Hash
			return (size_t)key;
		}
	};

    // 增加模板参数Hash,即仿函数,支持关键字转成整型
	template<class K, class V, class Hash = Hasher<K>>
	class HashTable
	{
	public:
		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()
			: _table(__stl_next_prime(0))
			, _n(0)
		{ }

		bool Insert(const std::pair<K, V>& kv)
		{
			if (Find(kv.first)) return false;

			if (_n*10 / _table.size() >= 7)
			{
				HashTable<K, V> newht;
				newht._table.resize(__stl_next_prime(_table.size() + 1));
				for (auto& data : _table)
					if (OCCUPIED == data._state) newht.Insert(data._kv);
				_table.swap(newht._table);
			}

            // 定义仿函数对象
			Hash hash;
            // 先用hash将关键字转为整型
			size_t hash0 = hash(kv.first) % _table.size();
			size_t hashi = hash0;
			size_t i = 1;
			while (OCCUPIED == _table[hashi]._state)
			{
				hashi = (hash0 + i) % _table.size();
				i++;
			}
			_table[hashi]._kv = kv;
			_table[hashi]._state = OCCUPIED;
			_n++;
			return true;
		}

		HashData<K, V>* Find(const K& key)
		{
            // 定义仿函数对象
			Hash hash;
            // 先用hash将关键字转为整型
			size_t hash0 = hash(key) % _table.size();
			size_t hashi = hash0;
			size_t i = 1;
			while (EMPTY != _table[hashi]._state)
			{
				if (OCCUPIED == _table[hashi]._state 
					&& _table[hashi]._kv.first == key)
					return &_table[hashi];

				hashi = (hash0 + i) % _table.size();
				i++;
			}
			return nullptr;
		}

		bool Erase(const K& key)
		{
			HashData<K, V>* ret = Find(key);
			if (nullptr == ret) return false;
			else ret->_state = TOMBSTONE;
			return true;
		}

	private:
		std::vector<HashData<K, V>> _table;
		size_t _n = 0;
	};
}
  • 实现仿函数的要求是尽量让 key 的每个值都参与到计算中,让不同的 key 转换出的整型值不同。
  • key 转换成整型,准确来说是转换成无符号整型,最终应该是无符号整型 (x86下32位,最大为) 去取模进行映射,否则负数去取模会得到负数本身。

不光是浮点型和字符串,即使像有符号整型也必须多做一层映射,负数直接去取模是不行的,必须像上面默认仿函数里一样强制类型转换成无符号整型。浮点型更明显了,如1.1和1.2直接强制类型转换成整型,整数位就都是1,小数位没有参与进转换结果。

因此,除了无符号整型,其他类型其实都需要仿函数多做一层映射,完善转换逻辑。浮点型需要用仿函数让小数部分也参与映射,有符号整型需要用默认的仿函数强转成无符号整型。

string 同理,它不能强转成整型,必须自行实现仿函数。string 做哈希表的 key 是非常常⻅的。string 的仿函数实现不能直接取首字母,这样首字母相同的字符串转成整型结果都是一样的,就会冲突。

这里给出一个方法:

  • 把字符串中各个字符的ASCII码相加。但直接相加,类似"abcd"和"bcad"这样的字符串就会重复。这⾥我们使⽤BKDR哈希的思路,⽤上次的计算结果乘以⼀个质数,⼀般取31或131比较好。

注:

  • 字符串转成整型是必然冲突的。假设只有小写字母,26个,字符串长度为10,可能的字符串就有种。这么多字符串转成整型,x86下最多只有个无符号整型数字。就达到了80亿,约为42亿9千万,不论大写字母和其他字符,字符串的种类都是远大于整型范围的,映射必然产生冲突。

代码示例 5.2.6

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

#include "HashTable.h"

struct StringHasher
{
	size_t operator()(const string& s)
	{
		size_t hash = 0;
		for (auto& ch : s)
		{
            // 这里hash可能溢出,但不影响,可以认为溢出相当于一次取模hash%整型最大值
			hash *= 131;
			hash += ch;
		}
		return hash;
	}
};

int main()
{
	const char* str[] = { "abcd", "dcba", "sort", "insert" };

    // 定义HashTable对象时传入自行实现的仿函数即可,需要额外支持什么类型就全凭用户自己
	david::HashTable<string, string, StringHasher> ht;

	for (auto& e : str)
	{
		ht.Insert({ e, e });
	}

	return 0;
}

当然,unordered_map/unordered_set 等都是直接支持 string 的,因为太常用了,专门实现了特化版本可以直接用。例如。

代码示例 5.2.7

cpp 复制代码
#pragma once
#include <utility>
#include <vector>

namespace david
{
	enum BucketState
	{
		EMPTY,
		OCCUPIED,
		TOMBSTONE
	};

	template<class K, class V>
	struct HashData
	{
		std::pair<K, V> _kv;
		BucketState _state = EMPTY;
	};

	// 默认的整型转换仿函数
	template<class K>
	struct Hasher
	{
		size_t operator()(const K& key)
		{
			// 默认强转成无符号整型
			return (size_t)key;
		}
	};

    // Hasher关于string的特化版本
    // 当关键字类型是string时,使用仿函数对象转换整型自动调用该特化版本
    // 内部已经实现了,外部就不需要再实现,然后传入仿函数给模板参数Hash了
	template<>
	struct Hasher<string>
	{
		size_t operator()(const string& s)
		{
			size_t hash = 0;
			for (auto& ch : s)
			{
				hash *= 131;
				hash += ch;
			}
			return hash;
		}
	};    

	template<class K, class V, class Hash = Hasher<K>>
	class HashTable
	{
	public:
        // ...

	private:
		std::vector<HashData<K, V>> _table;
		size_t _n = 0;
	};
}
  • 因此,往后实现自定义类型,如日期类 Date 时,必须自行实现对应仿函数。同样可以考虑依次加上年月日同时乘以131这种方式。
  • 其次必须为日期类 Date 实现等于比较 operator==,方便进行线性探测等。库里的第三个参数 Pred 就是为了支持各种类型的等于比较而添加的。有些自定义类型没有实现等于比较,而且不好修改代码直接在自定义类型里面添加等于比较。所以同样可以在外部实现一个等于比较的仿函数,传入给模板参数 Pred。

5.3 链地址法

开放地址法所有的元素都放到哈希表里,本质是一种**"内耗式"** 的方式,永远是抢占其他数据的空间,这种方式还是不够好。线性探测/二次探测/双重探测都是在缺陷的基础上修修补补,线性探测产生堆积问题,二次探测跳跃着探测改善堆积但无法充分利用散列表,双重探测同样跳跃着探测但比二次探测更充分利用散列表,始终没有解决根本问题。

实践用的更多的是一种叫哈希桶 的方式,更倾向于**"自我消化"**。

解决冲突的思路

链地址法,也叫做拉链法或者哈希桶

  • 所有数据不再直接存储在哈希表中。
  • 哈希表中只存储指针,没有数据映射这个位置,指针就为空。有多个数据映射这个位置,就把这些冲突数据链接形成一个链表 (单链表或双链表),将该链表挂在该位置下。

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

扩容
  • 开放定址法的负载因⼦必须⼩于1 (一般超过0.7扩容)。链地址法的负载因⼦没有限制,甚至可以⼤于1。同样地,负载因⼦越⼤,哈希冲突的概率越⾼,空间利⽤率越⾼;负载因⼦越⼩,哈希冲突的概率越低,空间利⽤率越低。C++STL中 unordered_xxx 系列的负载因⼦基本控制在1,超过1就扩容。

我们后续实现时也使⽤上面这种⽅式。

极端场景

如果极端场景下,某个桶 (链表) 特别⻓怎么办?这种概率是极其低的,需要在整个哈希表的某个特定位置发生N多次冲突,除非是被恶意攻击了。那我们就可以考虑使⽤全域散列法,这样就不容易被针对了。

假设不是被针对,偶然情况下某个桶就是很⻓,查找效率很低怎么办?其实数据继续插入,平衡因子超过特定值,就可以触发扩容,同样可以让数据重新变分散。因为哈希表的大小M发生变化,全部数据必须重新映射。

不过这里也有专门的方案,例如:

  • 在Java8的HashMap中,当桶的⻓度超过一定阀值8后,就把链表转换成红⿊树挂在该位置上。⼀般情况下,这种问题是很难发生的,何况还有扩容的重新映射。

下⾯实现就不搞这么复杂了,这里主要拓展一下解决极端场景的思路。

5.4 链地址法代码实现

HashTable.h
cpp 复制代码
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)
		{ }
	};

	// 默认的整型转换仿函数
	template<class K>
	struct Hasher {
		size_t operator()(const K& key) { return (size_t)key; }
	};

	// Hasher关于string的特化版本
	template<>
	struct Hasher<string>
	{
		size_t operator()(const string& s)
		{
			size_t hash = 0;
			for (auto& ch : s)
			{
				hash *= 131;
				hash += ch;
			}
			return hash;
		}
	};

	template<class K, class V, class Hash = Hasher<K>>
	class HashTable
	{
		using Node = HashNode<K, V>;
	public:
		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;
		}

		// 构造函数:除法散列法,哈希表大小M置为质数
		HashTable()
			: _table(__stl_next_prime(0))
			, _n(0)
		{ }

		// 析构
		// 拷贝构造和赋值重载也需要
		~HashTable()
		{
			for (size_t i = 0; i < _table.size(); i++)
			{
				Node* cur = _table[i];
				while (cur)
				{
					_table[i] = cur->_next;
					delete cur;
					cur = _table[i];
				}
			}
		}
		
		bool Insert(const std::pair<K, V>& kv)
		{
			// 不允许冗余
			if (Find(kv.first)) return false;

			// 定义仿函数对象,将关键字转换成整型
			Hash hash;

			// 负载因子 == 1 扩容
			// 注意:哈希表的大小M是_table.size(),也是链表的个数
			// _n是数据个数,每个链表可能有多个数据
			// 极端情况也就是M个数据同时M个链表都只有1个结点
			if (_n == _table.size())
			{
				// 上面的扩容方法这里不适用
				//HashTable<K, V, Hash> newht;
				//newht._table.resize(__stl_next_prime(_table.size() + 1));
				//for (size_t i = 0; i < _table.size(); i++)
				//{
				//	Node* cur = _table[i];
				//	while (cur)
				//	{
				//		// 这里需要重新拷贝结点,增加消耗
				//		newht.Insert(cur->_kv);
				//		cur = cur->_next;
				//	}
				//}
				//// 交换之后旧表销毁,但链表没有销毁
                //// 因为底层是Node*,而不是list容器,没有析构
				//_table.swap(newht._table);
				
				// 这里应该直接定义新表
				std::vector<Node*> newtab(__stl_next_prime(_table.size()+1));
				// M变化,必须重新映射,不能直接将链表拷贝过来
				// 遍历各个链表以及链表中的各个结点
				for (size_t i = 0; i < _table.size(); i++)
				{
					// 不断获取下一链表
					Node* cur = _table[i];
					// 链表不为空就遍历各个结点重新映射
					while (cur)
					{
						// 1、先记录后继结点
						_table[i] = cur->_next;
						// 2、再获取当前结点在新表的映射位置
						size_t hashi = hash(cur->_kv.first) % newtab.size();
						// 3、然后取出旧表的链表结点头插到新表
						cur->_next = newtab[hashi];
						newtab[hashi] = cur;
						cur = _table[i];
					}
				}
				_table.swap(newtab); // 最后将新表交换过来
			}
			// 1、根据关键字进行哈希函数映射
			// 除法散列法:取模,不抢占/探测,如果冲突直接形成链表
			size_t hashi = hash(kv.first) % _table.size();
			// 2、在映射位置插入 (进行链表的头插)
			// 可进行链表的头插,也可以进行尾插
			// 链表的头部找的快,但数据在前在后是无法确定的,头插尾插没有区别
			Node* newnode = new Node(kv);
			newnode->_next = _table[hashi]; // 指向i位置链表的头结点的指针
			_table[hashi] = newnode;        // 更新i位置的链表的头结点
			_n++;                           // 哈希表中数据个数加1
			return true;
		}

		Node* Find(const K& key)
		{
			Hash hash;
			// 根据关键字进行哈希函数映射
			size_t hashi = hash(key) % _table.size();
			Node* cur = _table[hashi];
			while (cur)
			{
				if (cur->_kv.first == key) return cur;
				cur = cur->_next;
			}
			return nullptr;
		}

		bool Erase(const K& key)
		{
			Hash hash;
			// 根据关键字进行哈希函数映射
			size_t hashi = hash(key) % _table.size();
			Node* prev = nullptr;
			Node* cur = _table[hashi];
			while (cur)
			{
				if (cur->_kv.first == key)
				{
					if (nullptr == prev)
                        _table[hashi] = cur->_next; // 删除头结点
					else
                        prev->_next = cur->_next;   // 删除中间或尾结点
					delete cur;
					_n--; // 哈希表中数据个数减1
					return true;
				}
				else
				{
					prev = cur;
					cur = cur->_next;
				}
			}
			return false;
		}

	private:
		// 哈希表每个位置存储对应链表指针,指针指向链表头结点
		// 可以存储list容器对象,但迭代器实现复杂
		std::vector<Node*> _table; // 指针数组
		size_t _n = 0;             // 记录表中数据个数
	};
}
要点

要点一:

  • 哈希桶每个位置上都是链表,需要自定义结点类型 HashNode,这里实现为单链表 ,结点中只需要一个后继指针 HashNode<K, V>* _next 即可。库中的 unordered_xxx 系列都是单向迭代器也是因为是单链表

要点二:

  • 仿函数,整型转换的逻辑和前面一致,照搬过来即可。

要点三:

  • 前面哈希表不需要析构,因为表 _table 是 vector 容器,存储 HashData 类型,这是我们自定义的类型,存储了一个 pair 对象 _kv 和枚举类型对象 BucketState _state,都不用析构,_kv 自动调用 pair 的析构。
  • 哈希桶需要析构,因为链表是我们用结点指针自行管理的,存在资源申请。这里演示一下析构。主要是遍历每个链表,如果链表不为空,再遍历各个结点逐一释放删除。
  • 当然,哈希表中也可以直接存储 list 容器对象,搞成对象数组,但会让迭代器实现变得复杂,不好拿底层链表与指针。

要点四:

  • 哈希桶的插入同样使用除法散列法,但处理冲突的方式是直接形成链表,没有抢占或探测。
  • 使用除法散列法必须注意哈希表大小M的选取,这里同样沿用质数方案,预设质数表,配合 __stl_next_prime 获取质数。
  • 插入逻辑是:1、判断冗余;2、判断扩容;3、根据关键字进行哈希函数映射,获取链表地址;4、向链表插入新结点;5、哈希桶数据个数加1。

要点五:

  • 平衡因子达到1就扩容,即 _n == _table.size()。
  • _table.size() 是哈希表的大小M,也是链表的个数,_n 是数据个数,每个链表可能有多个数据。极端情况就是有M个数据同时有M个链表,每个链表只有1个结点。
  • 前面扩容借 HashTable<K, V, Hash> newtable 复用插入逻辑的方法不行了。主要问题有两点:1、哈希桶用链表结点管理数据,存在资源申请,newtable 要拷贝各个结点,降低效率;2、扩容的最后需要交换新表与旧表,让旧表自动释放,但之前哈希表的 HashTable 没有实现析构,而且我们通过指针管理链表,所以链表无法释放 (实现了HashTable析构就解决了,但还存在拷贝效率问题)。
  • 因此,扩容这里干脆自己重新走一遍插入逻辑,从旧表依次取出各个结点,重新在新表中映射后,将旧表结点直接移动到新表。最后再将新表交换过来,旧表也不用释放了,资源都转移到新表了。

要点六:

  • Find 查找和 Erase 删除都是遍历各个链表和结点,找到目标结点然后将其返回或删除即可,逻辑同上。删除需要注意两个分支:1、当删除结点是链表的根结点;2、当删除结点是中间结点或尾结点。
相关推荐
辣知1 小时前
辣知·化智53 榆林石峁遗址的石城文明
学习
广州山泉婚姻1 小时前
列表初始化:C++11全新初始化体系
c++·人工智能
江湖人称菠萝包1 小时前
【Windows】《深入浅出Windows API程序设计:核心编程篇》笔记-Chapter6-动态链接库
windows·笔记
莫生灬灬1 小时前
灵码 LingBuilder:用中文写代码,确定性生成真实 C++ 工程(开源)
c++·开源·mfc
浅念-1 小时前
C++ Protobuf 入门实战:基于通讯录项目吃透proto3语法与序列化
linux·服务器·网络·c++·protobuf·proto·proto3
m4Rk_1 小时前
【论文阅读】Agent 记忆机制(68):Memp——把历史轨迹沉淀为可检索、可纠错的程序性记忆
论文阅读·人工智能·学习·开源·github
知识分享小能手1 小时前
深度学习学习教程,从入门到精通,自编码器 — 完整知识点与代码案例(14)
人工智能·深度学习·学习
我命由我123452 小时前
人脸识别 - 去重时间窗口
java·开发语言·python·学习·java-ee·学习方法·python3.11
minglie12 小时前
espidf的espnow的配对
学习