哈希表的实现
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,不能被其他数整除),有两种简单的取质数的⽅法:
- 当M为2的整数幂时,h2(key) 从0,M-1任选⼀个奇数;
- 当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,必须考虑到两种方案的选择问题:
- 取2的整数幂。参考上⾯除法散列法中Java的HashMap的改进方法:哈希函数计算时先进行位运算得到异或结果再取模。
- 取不太接近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、当删除结点是中间结点或尾结点。