【C++】24. 哈希表的实现
1. 哈希概念
哈希(Hash)又称散列,是一种高效的数据组织方式。从其名称可以看出,它体现了数据分散排列的特点。本质上,哈希是通过哈希函数建立关键字Key与存储位置之间的映射关系,查找时只需通过哈希函数快速计算出Key对应的存储位置即可。
1.1 直接定址法
当关键字的取值范围相对集中时,直接定址法是一种简单高效的解决方案。例如:
- 若所有关键字都在[0,99]范围内,只需创建一个包含100个元素的数组,关键字值本身即可作为数组下标
- 若关键字均为小写字母[a,z],建立一个26元素的数组,通过"字母ASCII码 - 'a'的ASCII码"即可确定存储位置
由此可见,直接定址法的核心思想是利用关键字直接计算出绝对位置或相对位置。
1.2 哈希冲突
直接定址法存在明显缺陷:当关键字分布范围较分散时,会导致内存浪费甚至内存不足。例如,假设我们有N个取值范围在[0,9999]的数据需要存储在容量为M的数组中(通常M≥N),就需要借助哈希函数hf。通过h(key)计算的位置必须满足h(key)∈[0,M)。
此时可能产生的问题是:不同key可能被映射到同一位置,这种现象称为哈希冲突或哈希碰撞。虽然理想情况下我们希望设计出无冲突的完美哈希函数,但现实中冲突不可避免。因此,我们需要设计优秀的哈希函数来减少冲突频率,同时制定有效的冲突解决方案。
1.3 负载因子
假设哈希表当前存储了N个元素,表容量为M,则负载因子α=N/M(某些文献也译为载荷因子或装载因子,英文为load factor)。负载因子反映了哈希表的空间利用率:
- 负载因子越大,哈希冲突概率越高,空间利用率越高
- 负载因子越小,哈希冲突概率越低,空间利用率越低
1.4 关键字整数化
为了将关键字映射到数组位置,通常需要先将关键字转换为整数形式。若非整数类型的关键字,需要经过特定转换处理,具体实现细节将在后续代码示例中展示。在后续讨论哈希函数时,如无特殊说明,Key均指已转换为整数形式的关键字。
1.5 哈希函数
一个设计良好的哈希函数应该具备以下特性:
- 确定性:相同输入总是产生相同输出
- 高效性:计算速度快
- 均匀性:能将N个关键字尽可能均匀地分布在哈希表的M个槽位中
- 抗碰撞性:尽量避免不同输入映射到同一输出
虽然理论上完美均匀的分布很难实现,但在实际应用中我们可以通过精心设计来接近这个目标。
1.5.1 除法散列法/除留余数法
除法散列法是最简单直接的哈希方法之一,其工作原理如下:
- 确定哈希表大小M
- 对于任意键值key,计算h(key) = key % M
- 映射结果范围在[0,M-1]之间
选择M的注意事项
不建议选择的值:
- 2的幂次方(2^x):会导致只保留key的后x位二进制值
- 示例:当M=16(2^4)时
- 63(00111111)和31(00011111)都映射到15
- 示例:当M=16(2^4)时
- 10的幂次方(10^x):会导致只保留key的后x位十进制值
- 示例:当M=100(10^2)时
- 112和12312都映射到12
- 示例:当M=100(10^2)时
推荐选择的值:
- 与2的幂次方距离较远的质数
- 例如:M=101比M=128(2^7)更不容易产生冲突
实际应用案例
Java的HashMap采用了一种优化的除法散列法:
- 仍然使用2的幂次方作为M
- 但通过位运算优化:
- 计算key' = key >> 16
- 将key与key'异或作为哈希值
- 优点:
- 避免了除法运算的高开销
- 使key的所有位都参与哈希计算
- 仍然保持较好的均匀性
1.5.2 乘法散列法
乘法散列法适用于任意大小的M,其计算步骤如下:
- 选择常数A(0<A<1),推荐使用黄金分割点0.6180339887...
- 计算A × key
- 取小数部分:(A × key)%1.0
- 计算M × (小数部分)
- 向下取整得到最终哈希值
公式表示: h(key) = floor(M × ((A × key)%1.0))
计算示例
假设:
- M = 1024
- key = 1234
- A = 0.6180339887
计算过程:
- A × key = 0.6180339887 × 1234 ≈ 762.6539420558
- 小数部分 = 0.6539420558
- M × 小数部分 = 1024 × 0.6539420558 ≈ 669.6366651392
- floor(669.6366651392) = 669 因此h(1234) = 669
1.5.3 全域散列法
全域散列法是为防止恶意攻击而设计的随机化哈希技术。
基本原理
定义一组哈希函数H,随机选择一个用于当前哈希表: hab(key) = ((a × key + b) % P) % M
参数要求:
- P:足够大的质数
- a:随机选择[1,P-1]的整数
- b:随机选择[0,P-1]的整数
示例计算
假设:
- P = 17
- M = 6
- a = 3
- b = 4
- key = 8
计算过程:
- a × key + b = 3×8 + 4 = 28
- 28 % 17 = 11
- 11 % 6 = 5 因此h34(8) = 5
使用注意事项
- 初始化时随机选择一个哈希函数
- 后续操作必须使用同一个函数
- 重新初始化时才可更换函数
1.5.4 其他哈希方法
除上述方法外,常见哈希方法还包括:
-
平方取中法:
- 将key平方后取中间几位作为哈希值
- 适合key分布不均匀的情况
-
折叠法:
- 将key分成几部分后相加
- 示例:key=123456789,分成123、456、789相加得1368
-
随机数法:
- 使用key作为随机数种子生成哈希值
- 适用于安全性要求较高的场景
-
数学分析法:
- 分析key的数学特征设计哈希函数
- 需要针对特定数据集定制
这些方法在《数据结构》(严蔚敏)、《数据结构》(殷人昆)等经典教材中有详细介绍,可根据实际应用场景选择合适的方法。
1.6 处理哈希冲突
哈希表是一种高效的数据结构,但在实际应用中不可避免地会遇到哈希冲突问题。所谓哈希冲突,是指两个不同的键通过哈希函数计算后得到相同的哈希值,试图存储在同一个位置的情况。为了确保哈希表的正确性和性能,必须采用有效的冲突处理方法。
1.6.1 开放定址法
开放定址法(Open Addressing)是一种将所有元素都存储在哈希表本身中的冲突解决方法。当发生冲突时,系统会按照预定的探测序列(probe sequence)在哈希表中寻找下一个可用的空位。这种方法要求哈希表的负载因子(已存储元素数与表大小的比值)必须始终小于1,以确保总能找到可用的空位。
线性探测(Linear Probing)
线性探测是最简单的开放定址方法,其工作原理如下:
- 计算初始哈希值:hash0 = key % M
- 如果hash0位置已被占用,则按顺序检查后续位置:
- hash1 = (hash0 + 1) % M
- hash2 = (hash0 + 2) % M
- ...
- 直到找到一个空位或检查完所有位置
示例流程: 假设M=11,处理键值序列{19,30,5,36,13,20,21,12}
- h(19)=8 → 存储在位置8
- h(30)=8 → 冲突,探测位置9 → 存储在位置9
- h(5)=5 → 存储在位置5
- h(36)=3 → 存储在位置3
- h(13)=2 → 存储在位置2
- h(20)=9 → 冲突,探测位置10 → 存储在位置10
- h(21)=10 → 冲突,探测位置0 → 存储在位置0
- h(12)=1 → 存储在位置1
线性探测的优点在于实现简单,但存在明显的缺点:
- 容易形成"群集"(primary clustering)现象,即连续的已占用位置会吸引更多冲突,导致性能下降
- 查找时间会随着群集的增大而线性增加
二次探测(Quadratic Probing)详解
背景与核心思想
二次探测是开放寻址法中解决哈希冲突的重要策略,专门针对线性探测的主聚集(Primary Clustering)问题而设计。在线性探测中,冲突元素会紧密聚集形成长连续块,导致后续插入效率急剧下降。二次探测通过平方跳跃模式打破这种聚集,使探测序列呈非线性分散,显著改善数据分布。
核心机制与数学表达
-
初始位置计算:
hash0=h1(key)%M其中 M 为哈希表大小(通常选择质数)。
-
冲突解决策略:
h(key,i) = hashi = (hash0 ± i^2 ) % M
当位置 hash0 冲突时,按以下公式交替探测:-
i 为探测次数(i = 1, 2, 3, ..., M/2)
-
对称跳跃模式:
-
i=1: hash0+1^2
-
i=2: hash0−1^2
-
i=3: hash0+2^2
-
i=4: hash0−2^2
-
以此类推...
-
示例:若 hash0=5,探测序列为:
5→6→4→9→1→10→⋯ -
优势与创新
-
显著减少主聚集
平方步长(1, 4, 9, 16,...)使冲突元素指数级分散,避免线性探测的连续块问题。-
数据支持:当负载因子 α<0.5 时,平均探测次数接近理论最优值。
-
-
高效的空间局部性
跳跃距离随冲突次数增加而增大,兼顾缓存效率与分布均匀性。 -
计算优化技巧
增量计算避免重复平方运算:- d[i]=d[i−1]+2i−1(正方向)
- di=d[i−1]−2i+1(负方向)
(例如:从 i=1i=1 到 i=2i=2,步长从 +1 到 -3 只需一次加减法)
关键约束与挑战
-
表大小 M 必须为质数
-
原因:若 M 为合数(如偶数),平方跳跃可能仅覆盖50%的桶。
-
反例:当 M=8(非质数),初始位置为0的关键字探测序列:
0→1→7→4→4(陷入循环)
-
-
负载因子阈值 α<0.5
-
定理证明:当 M 为质数且 α<0.5 时,二次探测总能找到空位。
-
高风险场景:若 α≥0.5,找到空位的概率呈指数级下降(见下表):
负载因子 (αα) 平均探测次数 0.5 2.0 0.7 5.0 0.9 50.0 -
-
二次聚集(Secondary Clustering)
-
本质:所有映射到同一 hash0 的关键字遵循完全相同的探测序列。
-
影响:虽比主聚集轻微,但仍导致局部性能下降。
-
完整示例演示(M=11)
插入键值:{19,30,52,63,11,22}
-
19: 19%11=8 → 存入桶8
[_,_,_,_,_,_,_,_,19,_,_] -
30: 30%11=8(冲突)
-
i=1: (8+12)%11=9→ 成功
-
-
52: 52%11=8(冲突)
-
i=1: 9(冲突)
-
i=2: (8−12)%11=7 → 成功
-
-
63: 63%11=8(冲突)
-
i=1: 9(冲突)
-
i=2: 7(冲突)
-
i=3: (8+22)%11=1 → 成功
-
-
11: 11%11=0→ 存入桶0
[11,63,_,_,_,_,_,52,19,30,_] -
22: 22%11=0(冲突)
-
i=1: (0+12)%11=1(冲突)
-
i=2: (0−12)%11=10→ 成功
-
工程实践建议
-
表扩容策略
-
当 α≥0.5α≥0.5 时立即扩容(通常扩容至大于 2M2M 的最小质数)。
-
-
删除操作的特殊处理
-
需使用 墓碑标记(Tombstone) 而非直接清空桶,防止中断探测链。
-
墓碑在插入时可复用,但会增加查找长度。
-
总结
二次探测通过平方跳跃策略有效解决了线性探测的主聚集问题,在负载因子低于0.5时提供接近最优的性能。其实现简单且缓存友好,但受限于二次聚集现象和严格的空间利用率要求。在实际系统中,常作为开放寻址法的折中方案,尤其适合内存受限且负载可控的场景(如嵌入式数据库)。对于高性能场景,可优先考虑双重散列以突破负载因子限制。
双重散列(Double Hashing)详解
双重散列是一种用于解决哈希表中冲突(Collision)的开放寻址法(Open Addressing)。它的核心思想是:当第一个哈希函数计算出的位置已经被占用(发生冲突)时,使用第二个不同的哈希函数计算出一个“探测步长”(或偏移量),然后按照这个步长在哈希表中进行跳跃式探测,直到找到一个空闲的槽位(Slot)来存放数据。
这种方法旨在减少线性探测(Linear Probing)和二次探测(Quadratic Probing)中可能出现的“聚集”(Clustering)现象,使得探测序列更加均匀地分布在整个哈希表中。
核心机制:
-
初始位置计算: 使用第一个哈希函数
h1(key)
计算关键字key
的初始存储位置:
hash0 = h1(key) % M
其中M
是哈希表的大小(桶的数量)。 -
冲突检测: 如果位置
hash0
已经被占用(发生冲突),则需要探测下一个位置。 -
探测步长计算: 使用第二个哈希函数
h2(key)
计算出一个与key
相关的固定偏移量offset
。这个偏移量决定了每次探测向后跳跃的距离。
offset = h2(key)
-
迭代探测: 双重散列的探测公式定义了第
i
次(i
从 1 开始)尝试的位置:
hashi = (hash0 + i * offset) % M
或者更完整地写成:
hc(key, i) = (h1(key) + i * h2(key)) % M
其中:-
hc(key, i)
是第i
次探测的目标位置。 -
h1(key)
是第一个哈希函数计算的结果。 -
h2(key)
是第二个哈希函数计算出的步长(偏移量)。 -
i
是探测序列号(i = 1, 2, 3, ..., M-1
)。 -
% M
确保计算结果落在[0, M-1]
的有效表范围内。
-
-
终止条件: 依次计算
hashi
(i=1,2,3,...
),直到遇到以下情况之一停止:-
找到
hashi
位置为空闲(成功找到插入位置)。 -
探测次数
i
达到表大小M
(表明表已满或探测序列未能覆盖所有位置)。 -
回到初始位置
hash0
(在双重散列设计良好时通常不会发生,除非表满)。
-
关键约束:h2(key)
与 M
必须互质
这是双重散列有效工作的核心要求。即:gcd(h2(key), M) = 1
(h2(key)
和 M
的最大公约数为 1)。
为什么需要互质?
-
保证探测序列覆盖所有桶: 如果
h2(key)
(设为δ
)和M
有大于 1 的公约数p
(gcd(δ, M) = p > 1
),那么探测序列(hash0 + i * δ) % M
所能访问到的位置,其索引模p
的结果是固定的(等于hash0 % p
)。 -
后果: 探测序列无法遍历整个哈希表,它只会访问到
M / p
个位置(约为M
的1/p
)。剩下的(p-1)/p * M
个桶永远不会被这个探测序列访问到。 -
示例说明: 如原文所述,
M = 12
,δ = h2(key) = 3
,gcd(12, 3) = 3 > 1
。如果初始位置hash0 = 1
,探测序列将是:
(1 + 1*3) % 12 = 4
(1 + 2*3) % 12 = 7
(1 + 3*3) % 12 = 10
(1 + 4*3) % 12 = 13 % 12 = 1
(回到起点)
只能访问位置{1, 4, 7, 10}
,共12 / gcd(12, 3) = 12 / 3 = 4
个位置。其他位置(如 0, 2, 3, 5, 6, 8, 9, 11)永远无法被探测到,即使它们是空的。 -
互质的优势: 当
δ
和M
互质时 (gcd(δ, M) = 1
),探测序列(hash0 + i * δ) % M
(i=0,1,2,...,M-1
) 能够生成一个0
到M-1
的完整排列。这意味着在找到空位或遍历完整个表 (i
从0
到M-1
) 之前,序列会访问哈希表中的每一个位置恰好一次。这最大限度地利用了哈希表空间,避免了上述无法访问部分桶的问题。
h2(key)
的简单取值方法:
为了满足互质要求,针对不同的 M
,有两种常用的简单策略来定义 h2(key)
:
-
M
是 2 的整数幂 (e.g., 16, 32, 64):-
令
h2(key)
为[1, M-1]
区间内的任意一个奇数。 -
原因: 如果
M
是 2^k,那么任何奇数δ
都与M
互质(因为奇数和 2^k 的唯一公因子是 1)。例如M=16 (2^4)
,δ
可以是 1, 3, 5, 7, 9, 11, 13, 15。
-
-
M
是质数 (e.g., 11, 13, 17, 101):-
令
h2(key) = (key % (M - 1)) + 1
-
原因:
-
key % (M - 1)
的结果在[0, M-2]
范围内。 -
+1
将其映射到[1, M-1]
范围内。 -
因为
M
是质数,M-1
是其前一个整数。[1, M-1]
区间内的任何整数δ
都与质数M
互质(δ
小于M
且不等于 0,它们没有共同的质因子)。 -
公式确保了
h2(key)
永远不会是 0(如果h2(key)=0
,探测步长为 0,会卡在冲突位置无限循环),并且落在有效的互质范围内。
-
-
示例演算:{19, 30, 52, 74} 插入 M=11 的表,h2(key) = key % 10 + 1
-
给定条件:
-
表大小
M = 11
(质数) -
第一个哈希函数
h1(key) = key % M
-
第二个哈希函数
h2(key) = key % 10 + 1
(符合M
为质数时的建议公式:key % (11-1) + 1 = key % 10 + 1
) -
探测公式
hashi = (h1(key) + i * h2(key)) % 11
-
插入关键字序列:19, 30, 52, 74
-
-
步骤分解:
-
插入 key=19:
-
h1(19) = 19 % 11 = 8
-
位置 8 是空的? 是。
-
直接插入位置 8。
当前表状态:
[ _, _, _, _, _, _, _, _, 19, _, _ ]
(索引 0 到 10) -
-
插入 key=30:
-
h1(30) = 30 % 11 = 8
-
位置 8 是空的? 否 (已被 19 占用,冲突!)
-
计算步长
h2(30) = 30 % 10 + 1 = 0 + 1 = 1
-
进行第一次探测 (
i=1
):
hash1 = (8 + 1 * 1) % 11 = 9 % 11 = 9
-
位置 9 是空的? 是。
-
插入位置 9。
当前表状态:
[ _, _, _, _, _, _, _, _, 19, 30, _ ]
-
-
插入 key=52:
-
h1(52) = 52 % 11 = 52 - 4*11 = 52 - 44 = 8
-
位置 8 是空的? 否 (被 19 占用,冲突!)
-
计算步长
h2(52) = 52 % 10 + 1 = 2 + 1 = 3
-
第一次探测 (
i=1
):
hash1 = (8 + 1 * 3) % 11 = 11 % 11 = 0
-
位置 0 是空的? 是。
-
插入位置 0。
当前表状态:
[ 52, _, _, _, _, _, _, _, 19, 30, _ ]
-
-
插入 key=74:
-
h1(74) = 74 % 11 = 74 - 6*11 = 74 - 66 = 8
-
位置 8 是空的? 否 (被 19 占用,冲突!)
-
计算步长
h2(74) = 74 % 10 + 1 = 4 + 1 = 5
-
第一次探测 (
i=1
):
hash1 = (8 + 1 * 5) % 11 = 13 % 11 = 2
-
位置 2 是空的? 是 (假设之前没有其他元素)。
-
插入位置 2。
最终表状态:
[ 52, _, 74, _, _, _, _, _, 19, 30, _ ]
(索引 1, 3, 4, 5, 6, 7, 10 为空) -
-
-
过程总结:
-
19 直接插入 h1(19)=8。
-
30 与 19 在位置 8 冲突,使用 h2(30)=1 探测到位置 9 (空) 插入。
-
52 与 19 在位置 8 冲突,使用 h2(52)=3 探测到位置 0 (空) 插入。
-
74 与 19 在位置 8 冲突,使用 h2(74)=5 探测到位置 2 (空) 插入。
-
双重散列的优势与劣势:
-
优势:
-
减少聚集: 不同的关键字使用不同的步长进行探测,有效减少了线性探测和二次探测中常见的初级聚集和次级聚集现象。
-
均匀分布: 在
h2(key)
设计良好(与M
互质)的情况下,探测序列能相对均匀地分布在整个哈希表中。 -
空间利用率高: 属于开放寻址法,所有元素都存储在表本身,不需要额外的链表或存储结构。
-
-
劣势:
-
计算开销稍大: 每次冲突需要计算两个哈希函数的值。
-
性能依赖哈希函数:
h1
和h2
的质量对性能影响很大。糟糕的h2
可能导致步长效果不佳或无法满足互质要求。 -
删除操作复杂: 开放寻址法通用的缺点,删除元素不能简单置空,需要用特殊标记(如墓碑标记
Deleted
)以避免中断后续关键字的探测序列。这增加了实现的复杂性和查找时间。 -
可能探测较长: 在最坏情况下,仍然可能需要探测很多位置才能找到空位或确认不存在。
-
总之,双重散列是一种强大的冲突解决方法,通过引入第二个哈希函数来计算探测步长,显著改善了探测序列的随机性和分布性,从而提高了哈希表的性能,尤其是在装载因子较高时。确保 h2(key)
与表大小 M
互质是其高效工作的关键保证。
1.6.2 开放定址法代码实现
开放定址法的实现相比链地址法在实际应用中存在以下局限性:
-
空间占用问题:所有元素都必须存储在哈希表内部,当冲突发生时,后续插入的元素会占用其他位置的存储空间。例如,在一个大小为10的哈希表中,即使只存储5个元素,这些元素也可能散布在整个表中。
-
相互影响问题:每个冲突解决操作都会影响到其他位置的查找路径。假设元素A和B发生哈希冲突,采用线性探测将B放在A的下一个位置,那么后续查找B时就不得不先经过A的位置。
-
删除操作复杂:删除元素时不能简单地将位置置空,否则会破坏后续元素的查找路径。例如,如果删除了上述例子中的元素A,查找B时就会误判为空位置而终止。
鉴于这些限制,在工程实践中通常会优先考虑链地址法。对于开放定址法,我们选择实现相对简单的线性探测法,其基本实现步骤如下:
开放定址法的哈希表结构:
1. 状态枚举 State
enum State
{EXIST, // 槽位已被占用(存在有效数据)EMPTY, // 槽位为空(可插入数据)DELETE // 槽位为"墓碑"标记(已删除数据)
};
-
EXIST:表示该位置存储了有效的键值对
-
EMPTY:初始化状态或显式清除后的状态,可插入新数据
-
DELETE:关键设计!解决删除导致的问题(称为"墓碑")
📌 墓碑标记的重要性:
直接设置为EMPTY
会中断后续键值的探测链。例如:
位置序列[A → B → C]
中删除B
后若置为EMPTY
,
查找C
时会在原B
位置错误终止。
墓碑标记允许探测继续向后进行。
2. 哈希数据单元 HashDate
template<class K, class V>
struct HashDate
{pair<K, V> _kv; // 存储键值对State _state = EMPTY; // 状态标记(默认EMPTY)
};
每个槽位包含:
-
_kv
:实际的键值对数据(类型为std::pair<K, V>
) -
_state
:状态标记(初始化为EMPTY
)
3. 哈希表主体 HashTable
template<class K, class V>
class HashTable
{
public:// 公有方法将在此声明:// Insert(), Find(), Erase()
private:vector<HashDate<K, V>> _tables; // 底层存储容器size_t _n = 0; // 有效数据计数器
};
🛠 核心组件解析:
-
底层容器
_tables
-
使用
std::vector
存储HashDate
对象数组 -
每个元素代表一个槽位(slot),包含数据和状态标记
-
-
数据计数器
_
n
-
记录有效数据数量(
EXIST
状态的数量) -
关键作用:计算负载因子
α =
_
n / _tables.size()
-
扩容触发条件:通常当
α ≥ 0.7
时扩容
-
扩容
我们将哈希表的负载因子控制在0.7,当达到这个阈值时就需要进行扩容。采用两倍扩容机制的同时,需要确保哈希表大小始终保持为质数。然而两倍扩容会导致原质数变为合数,为此我们提供两种解决方案:
-
采用类似Java HashMap的方法,使用2的整数幂作为表大小,但需要改进取模运算方式(详见1.4.1节除法散列法说明)。
-
参考sgi版本的哈希表实现,使用一个预置的近似两倍质数表,每次扩容时直接从表中获取下一个合适的质数大小。
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;
}
代码功能解析
-
核心目的:
-
为哈希表提供下一个合适的质数容量(通常是当前容量的约2倍)
-
确保哈希表大小始终为质数(减少哈希冲突)
-
-
实现机制:
const unsigned long* pos = lower_bound(first, last, n); return pos == last ? *(last - 1) : *pos;
-
使用二分查找在质数表中找到第一个 ≥ n 的质数
-
若n超过表中最大值,则返回最大质数(4294967291)
-
质数表设计分析
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
};
-
数学特性:
-
近似2倍增长:每个质数 ≈ 前一个质数 × 2(误差 < 3%)
53 × 2 = 106 → 实际取97 (小9%) 97 × 2 = 194 → 实际取193 (小0.5%)
-
覆盖32位范围:从53到4294967291(2³² - 5)
-
-
扩容步长优化:
当前容量 下一容量 扩容倍数 53 97 1.83x 389 769 1.98x 1572869 3145739 2.00x 小容量时扩容更激进(减少频繁扩容),大容量时接近2倍
-
最大质数选择:
-
4294967291 = 2³² - 5(最大的32位质数之一)
-
确保在32位系统上不会溢出
-
核心接口
1. 构造函数 HashTable()
HashTable():_tables(__stl_next_prime(0))
{}
-
功能:初始化哈希表
-
关键操作:
-
调用
__stl_next_prime(0)
获取第一个质数(53) -
使用该质数初始化存储容器
_tables
-
-
设计意图:
-
确保哈希表初始大小为质数(减少冲突)
-
避免空表时的边界情况处理
-
2. 插入操作 Insert()
bool Insert(const pair<K, V>& kv)
{// 存在性检查if (Find(kv.first)) return false;// 负载因子检查与扩容(α ≥ 0.7)if (_n * 10 / _tables.size() >= 7){HashTable<K, V> newtables;newtables._tables.resize((__stl_next_prime(_tables.size() + 1)));// 数据迁移(仅迁移EXIST状态数据)for (auto& data : _tables){if (data._state == EXIST) {newtables.Insert(data._kv);}}_tables.swap(newtables._tables);}// 线性探测插入size_t hash0 = kv.first % _tables.size();size_t hashi = hash0;size_t i = 1;while (_tables[hashi]._state == EXIST) {hashi = (hash0 + i) % _tables.size();i++;}_tables[hashi]._kv = kv;_tables[hashi]._state = EXIST;++_n;return true;
}
关键流程:
-
存在性验证:通过
Find()
检查键是否已存在 -
扩容触发:
-
条件:负载因子 α ≥ 0.7(
_n/size() ≥ 0.7
) -
新容量:
__stl_next_prime(_tables.size() + 1)
-
-
数据迁移:
-
创建新表 → 遍历旧表 → 仅迁移
EXIST
状态数据 -
使用
swap
高效替换容器
-
-
线性探测插入:
-
计算初始位置:
hash0 = key % size
-
顺序探测:
(hash0 + i) % size
-
找到首个非
EXIST
位置(含EMPTY/DELETE
)
-
-
状态更新:
-
存储键值对
-
标记状态为
EXIST
-
更新元素计数
_n
-
⚠️ 潜在问题:扩容时递归调用
Insert()
可能导致栈溢出(大表迁移时)
3. 查找操作 Find()
HashDate<K, V>* Find(const K& key)
{size_t hash0 = key % _tables.size();size_t hashi = hash0;size_t i = 1;while (_tables[hashi]._state != EMPTY) {if (_tables[hashi]._state == EXIST && _tables[hashi]._kv.first == key) {return &_tables[hashi];}// 线性探测hashi = (hash0 + i) % _tables.size();i++;}return nullptr;
}
探测逻辑:
-
起始位置:
hash0 = key % size
-
循环条件:遇到
EMPTY
才停止(跳过DELETE
) -
匹配条件:
-
状态为
EXIST
-
键值完全匹配
-
-
终止条件:
-
找到匹配项 → 返回元素指针
-
遇到
EMPTY
→ 返回nullptr
-
📌 设计特点:正确处理墓碑状态(
DELETE
不影响探测链)
4. 删除操作 Erase()
bool Erase(const K& key)
{HashDate<K, V>* ret = Find(key);if (ret == nullptr) return false;ret->_state = DELETE;--_n;return true;
}
关键操作:
-
定位元素:通过
Find()
获取目标 -
惰性删除:
-
仅修改状态为
DELETE
-
不释放内存(避免破坏探测链)
-
-
更新计数:
_n--
减少元素计数
关于key不能取模的问题及解决方案
当我们需要在HashTable中使用string、Date等非整型类型作为key时,会遇到无法直接取模的问题。针对这种情况,我们需要通过以下方式解决:
仿函数方案
- 为HashTable增加一个仿函数(functor)作为模板参数
- 仿函数需要实现把任意类型key转换为可模的整形数的功能
- 对于默认情况(如int类型key),可以使用标准库提供的默认仿函数
仿函数设计要求
- 转换过程应保证不同key尽可能映射到不同的整数值
- key的所有特征值都应参与计算(如字符串的每个字符)
- 转换结果应具有良好的离散性,避免哈希冲突
特殊类型处理
- 对于string这种常见key类型,可以考虑进行模板特化
- 示例string仿函数实现:
template <>
struct HashFunc<std::string> {size_t operator()(const std::string& key) {size_t hash = 0;for(auto ch : key) {hash += ch;hash *= 131; // 使用经典字符串哈希算法}return hash;}
};
应用场景示例
- 当使用自定义类型作为key时:
struct Date {int year, month, day;
};struct DateHash {size_t operator()(const Date& d) {return d.year*10000 + d.month*100 + d.day;}
};
注意事项
- 对于复杂类型,建议使用更复杂的哈希算法(如MurmurHash)
- 要确保仿函数的计算效率,避免成为性能瓶颈
- 在模板设计时应提供默认仿函数和自定义仿函数两种选择
具体完整代码如下:
#pragma once
#include <vector>
#include <string>
using namespace std;enum State
{EXIST,EMPTY,DELETE
};template<class K, class V>
struct HashDate
{pair<K, V> _kv;State _state = EMPTY;
};template<class K>
struct HashFunc
{size_t operator()(const K& key){return (size_t)key;}
};template<>
struct HashFunc<string>
{size_t operator()(const string& s){// BKDRsize_t hash = 0;for (auto ch : s){hash += ch;hash *= 131;}return hash;}
};template<class K, class V, class Hash = HashFunc<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(): _tables(__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> newtables;newtables._tables.resize((__stl_next_prime(_tables.size() + 1)));// 旧表的数据映射到新表for (auto& data : _tables){if (data._state == EXIST){newtables.Insert(data._kv);}}_tables.swap(newtables._tables);}Hash hash;size_t hash0 = hash(kv.first) % _tables.size();size_t hashi = hash0;size_t i = 1;while (_tables[hashi]._state == EXIST){// 线性探测hashi = (hash0 + i) % _tables.size();i++;}_tables[hashi]._kv = kv;_tables[hashi]._state = EXIST;++_n;return true;}HashDate<K, V>* Find(const K& key){Hash hash;size_t hash0 = hash(key) % _tables.size();size_t hashi = hash0;size_t i = 1;while (_tables[hashi]._state != EMPTY){if (_tables[hashi]._state == EXIST && _tables[hashi]._kv.first == key){return &_tables[hashi];}// 线性探测hashi = (hash0 + i) % _tables.size();i++;}return nullptr;}bool Erase(const K& key){HashDate<K, V>* ret = Find(key);if (ret == nullptr) return false;ret->_state = DELETE;--_n;return true;}
private:vector<HashDate<K, V>> _tables;size_t _n = 0; // 记录数据个数
};
1.6.3 链地址法(Separate Chaining)
冲突解决思路
链地址法(又称拉链法或哈希桶)采用了一种与开放定址法完全不同的冲突解决策略。其核心思想是将数据存储在哈希表外部,通过链表组织冲突元素,具体实现方式如下:
-
哈希表结构:
-
哈希表的每个槽位(bucket)存储一个头指针
-
当没有数据映射到该位置时,指针为空(
nullptr
) -
当多个数据映射到同一位置时,形成单链表结构
-
-
冲突处理:
-
相同哈希值的元素通过链表连接
-
新元素通常插入链表头部(O(1)时间复杂度)
-
方法优势
特性 | 链地址法 | 开放定址法 |
---|---|---|
内存利用率 | 动态扩展,无空间浪费 | 需预留空位 |
负载因子 | 可>1(理论无上限) | 通常≤0.7 |
删除操作 | 直接链表节点删除 | 需墓碑标记 |
聚集问题 | 无聚集现象 | 存在主/二次聚集 |
极端情况 | 链表过长导致O(n)查找 | 全表遍历O(n) |
示例演示
将 {19,30,5,36,13,20,21,12,24,96}
映射到 M=11
的哈希表(哈希函数:h(key)=key % M
)
步骤分解:
-
计算哈希值:
19 % 11 = 8 30 % 11 = 8 5 % 11 = 5 36 % 11 = 3 13 % 11 = 2 20 % 11 = 9 21 % 11 = 10 12 % 11 = 1 24 % 11 = 2 96 % 11 = 8
-
构建哈希表:
0: ∅ 1: 12 → ∅ 2: 24 → 13 → ∅ // 冲突解决(24和13都映射到2) 3: 36 → ∅ 4: ∅ 5: 5 → ∅ 6: ∅ 7: ∅ 8: 96 → 30 → 19 → ∅ // 三重冲突(19,30,96) 9: 20 → ∅ 10: 21 → ∅
扩容
在哈希表的实现中,负载因子的选择和处理是影响性能和空间利用率的关键因素:
-
开放地址法与链地址法的负载因子差异
- 开放地址法必须保证负载因子小于1(通常建议在0.7-0.8之间),因为所有元素都必须存储在数组内部。例如,当负载因子达到0.75时,查找性能会显著下降,这时就需要进行扩容。
- 链地址法的负载因子可以大于1,因为冲突元素可以通过链表存储在桶的外部。比如在Java的HashMap中,默认初始负载因子就是0.75,但允许超过1。
-
负载因子与性能的关系
- 高负载因子(如0.9):
- 优点:空间利用率高,内存使用更充分
- 缺点:冲突概率显著增加,查找性能下降
- 低负载因子(如0.5):
- 优点:冲突概率低,查找速度快
- 缺点:内存浪费严重,空间利用率低
- 高负载因子(如0.9):
-
STL的实现策略
- unordered系列容器采用"负载因子达到1即扩容"的策略
- 扩容时通常将容量扩大为原来的2倍左右
- 这种设计在空间和性能之间取得了较好的平衡
-
极端场景的解决方案
a) 全域散列法
- 使用一组哈希函数而非单一哈希函数
- 每次运行时随机选择一个哈希函数使用
- 可以有效防止人为构造的恶意数据攻击
b) Java 8的优化方案
- 当链表长度超过阈值(默认为8)时:
- 将链表转换为红黑树(查找复杂度从O(n)降到O(log n))
- 当元素减少到6时再转回链表
- 这种优化特别适合处理随机出现的极端情况
-
实际实现建议
- 对于一般应用场景,采用类似STL的策略即可
- 即设定最大负载因子为1,超过时自动扩容
- 不必过度优化极端情况,以保持代码简洁性
- 但需要了解这些优化技术,以备特殊场景之需
示例:假设一个哈希表有10个桶,采用链地址法
- 负载因子为0.5时:平均每个桶0.5个元素
- 负载因子为2时:平均每个桶2个元素
- 负载因子为5时:平均每个桶5个元素,但查找性能会明显下降
下面我们来实现链地址法的代码,我们采用STL的扩容策略
1.6.4 链地址法代码实现
核心结构解析
1. 哈希节点 (HashNode
)
template<class K, class V>
struct HashNode
{HashNode(const pair<K, V>& kv):_kv(kv),_next(nullptr){}pair<K, V> _kv; // 存储键值对数据HashNode<K, V>* _next; // 指向下一个节点的指针
};
-
功能:表示哈希桶中的单个元素节点
-
数据成员:
-
_kv
:存储实际的键值对数据(pair<K, V>
) -
_next
:指向冲突链中下一个节点的指针
-
-
构造函数:
-
初始化键值对
-
将
_next
指针设为nullptr
(链表尾部)
-
2. 哈希表主体 (HashTable
)
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{using Node = HashNode<K, V>; // 类型别名简化
public:HashTable():_tables(__stl_next_prime(0)) // 初始化为第一个质数大小{}
private:vector<Node*> _tables; // 桶数组(存储链表头指针)size_t _n = 0; // 存储的有效键值对数量
};
关键设计要点
-
桶数组设计:
vector<Node*> _tables;
-
使用
vector
作为底层容器存储桶 -
每个元素是指向
HashNode
的指针(链表头指针) -
空桶用
nullptr
表示
-
-
容量管理:
-
_n
:记录当前存储的键值对数量 -
初始容量:
__stl_next_prime(0)
返回第一个质数(如53) -
后续扩容应基于负载因子(α = _n / _tables.size())
-
-
类型别名优化:
using Node = HashNode<K, V>;
-
简化代码,提高可读性
-
避免重复书写复杂类型名
-
-
泛型哈希函数支持:
template<class K, class V, class Hash = HashFunc<K>>
-
默认使用
HashFunc<K>
(需要额外定义) -
支持自定义哈希函数(通过模板参数)
-
核心接口
1. 插入操作 Insert
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)); // 创建新桶数组// 重新哈希所有节点for (size_t i = 0; i < _tables.size(); i++) {Node* cur = _tables[i];while (cur) {Node* next = cur->_next;size_t hashi = hash(cur->_kv.first) % newSize; // 计算新位置// 头插到新桶cur->_next = newTables[hashi];newTables[hashi] = cur;cur = next;}_tables[i] = nullptr; // 旧桶置空}_tables.swap(newTables); // 交换新旧哈希表}// 插入新节点size_t hashi = hash(kv.first) % _tables.size();Node* newnode = new Node(kv);newnode->_next = _tables[hashi]; // 头插_tables[hashi] = newnode;++_n;return true;
}
关键点:
-
扩容时机:当元素数量
_n
等于桶数量时(负载因子=1) -
扩容操作:
-
计算新容量(通常为大于当前容量的最小质数)
-
遍历所有节点,重新计算哈希位置
-
使用头插法将节点迁移到新桶
-
交换新旧桶数组(旧表自动销毁)
-
-
插入方式:头插法(时间复杂度 O(1))
2. 查找操作 Find
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. 删除操作 Erase
bool Erase(const K& key) {size_t hashi = hash(key) % _tables.size();Node* prev = nullptr;Node* cur = _tables[hashi];while (cur) {if (cur->_kv.first == key) {// 删除头节点if (!prev) {_tables[hashi] = cur->_next;} // 删除中间/尾节点else {prev->_next = cur->_next;}delete cur;--_n;return true;}prev = cur;cur = cur->_next;}return false; // 键不存在
}
关键点:
-
需要维护
prev
指针处理链表连接 -
区分删除头节点和非头节点的情况
析构函数
~HashTable() // 哈希表的析构函数
{// 遍历哈希表中的所有桶(每个桶是一个链表)for (size_t i = 0; i < _tables.size(); i++){// 获取当前桶的头节点指针Node* cur = _tables[i];// 遍历当前桶的链表while (cur){// 1. 保存下一个节点的指针(因为当前节点即将被删除)Node* next = cur->_next;// 2. 删除当前节点(释放内存)delete cur;// 3. 移动到下一个节点cur = next;}// 将当前桶的头指针置为空(避免悬垂指针)_tables[i] = nullptr;}
}
关键点解析:
-
内存释放的核心逻辑:
-
外层循环遍历哈希桶数组(
_tables
) -
内层循环遍历每个桶中的链表
-
对于每个节点:
-
先保存
next
指针(否则删除当前节点后会丢失链表后续信息) -
用
delete
释放当前节点内存 -
移动到下一个节点继续处理
-
-
-
链表删除的安全操作:
Node* next = cur->_next; // 必须先保存下一个节点 delete cur; // 再删除当前节点 cur = next; // 最后移动到下一个节点
这个顺序至关重要,如果先
delete cur
再访问cur->_next
会导致未定义行为(野指针访问) -
桶指针置空:
_tables[i] = nullptr; // 将处理完的桶置空
虽然哈希表即将销毁,但这是个好习惯:
-
防止可能的悬垂指针(dangling pointer)
-
使哈希表处于明确的状态(所有桶为空)
-
-
时间复杂度:
-
O(N + M),其中 N 是元素数量,M 是桶数量
-
每个节点只被删除一次
-
每个桶只被访问一次
-
拷贝构造函数
// 深拷贝构造函数
HashTable(const HashTable& ht): _n(ht._n), _tables(ht._tables.size()) // 初始化桶大小和元素计数
{// 遍历原哈希表的所有桶for (size_t i = 0; i < ht._tables.size(); i++) {Node* cur = ht._tables[i];Node* tail = nullptr; // 用于尾插法保持顺序// 复制当前桶的链表while (cur) {Node* newnode = new Node(cur->_kv); // 创建新节点// 处理链表头节点if (_tables[i] == nullptr) {_tables[i] = tail = newnode;}// 添加到链表尾部else {tail->_next = newnode;tail = newnode;}cur = cur->_next;}}
}
关键点说明:
-
深拷贝:为每个节点创建新副本,不共享指针
-
尾插法:保持节点顺序与原链表一致
-
桶初始化:创建相同大小的桶数组,初始化为
nullptr
-
元素计数:直接复制
_n
值
赋值运算符重载
// 赋值运算符重载(现代写法)
HashTable& operator=(HashTable ht) // 传值调用拷贝构造
{// 交换当前对象与临时对象的内容_tables.swap(ht._tables);swap(_n, ht._n);return *this; // 临时对象析构自动释放旧资源
}
现代写法优势:
-
异常安全:拷贝操作在传参时完成,不影响原对象
-
自动资源管理:利用临时对象析构自动清理旧资源
-
代码简洁:避免手动资源释放和检查自赋值
-
自赋值安全:天然处理
a = a
的情况
由于我们还没有实现迭代器,所以不方便打印数据测试,但是我们可以通过调试窗口来查看,为了方便查看,我们稍微把哈希表的大小改为11,不然如果按照素数表的第一个53来调试的话,太大了不方便观察。
测试
测试每个接口函数
int main()
{int a[] = { 19,30,52,63,11,22 };hash_bucket::HashTable<int, int> ht1;for (auto e : a){ht1.Insert({ e, e });}hash_bucket::HashTable<int, int> ht2 = ht1;ht1.Erase(30);if (ht1.Find(20)){cout << "找到了" << endl;}if (ht1.Find(30)){cout << "找到了" << endl;}else{cout << "没有找到" << endl;}return 0;
}
拷贝构造:
核心接口:
30从表中被删除
测试扩容
int main()
{int a[] = { 19,30,5,36,13,20,21,12,24,96 };hash_bucket::HashTable<int, int> ht1;for (auto e : a){ht1.Insert({ e, e });}ht1.Insert({ 15, 15 });ht1.Insert({ 100, 100 });//测试扩容return 0;
}
扩容前:
扩容后:
源代码
HashTable.h
#pragma once
#include <vector>
#include <string>
using namespace std;enum State
{EXIST,EMPTY,DELETE
};template<class K, class V>
struct HashDate
{pair<K, V> _kv;State _state = EMPTY;
};template<class K>
struct HashFunc
{size_t operator()(const K& key){return (size_t)key;}
};template<>
struct HashFunc<string>
{size_t operator()(const string& s){// BKDRsize_t hash = 0;for (auto ch : s){hash += ch;hash *= 131;}return hash;}
};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;
}namespace open_address
{template<class K, class V, class Hash = HashFunc<K>>class HashTable{public:HashTable(): _tables(__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> newtables;newtables._tables.resize((__stl_next_prime(_tables.size() + 1)));// 旧表的数据映射到新表for (auto& data : _tables){if (data._state == EXIST){newtables.Insert(data._kv);}}_tables.swap(newtables._tables);}Hash hash;size_t hash0 = hash(kv.first) % _tables.size();size_t hashi = hash0;size_t i = 1;while (_tables[hashi]._state == EXIST){// 线性探测hashi = (hash0 + i) % _tables.size();i++;}_tables[hashi]._kv = kv;_tables[hashi]._state = EXIST;++_n;return true;}HashDate<K, V>* Find(const K& key){Hash hash;size_t hash0 = hash(key) % _tables.size();size_t hashi = hash0;size_t i = 1;while (_tables[hashi]._state != EMPTY){if (_tables[hashi]._state == EXIST && _tables[hashi]._kv.first == key){return &_tables[hashi];}// 线性探测hashi = (hash0 + i) % _tables.size();i++;}return nullptr;}bool Erase(const K& key){HashDate<K, V>* ret = Find(key);if (ret == nullptr) return false;ret->_state = DELETE;--_n;return true;}private:vector<HashDate<K, V>> _tables;size_t _n = 0; // 记录数据个数};
}namespace hash_bucket
{template<class K, class V>struct HashNode{HashNode(const pair<K, V>& kv):_kv(kv),_next(nullptr){}pair<K, V> _kv;HashNode<K, V>* _next;};template<class K, class V, class Hash = HashFunc<K>>class HashTable{using Node = HashNode<K, V>;public:HashTable()//:_tables(__stl_next_prime(0)):_tables(11){}HashTable(const HashTable& ht):_tables(ht._tables.size()), _n(ht._n){for (size_t i = 0; i < ht._tables.size(); i++){Node* cur = ht._tables[i];Node* tail = nullptr; // 方便尾插while (cur){Node* newnode = new Node(cur->_kv);// 处理头节点if (_tables[i] == nullptr){_tables[i] = tail = newnode;}else{// 尾插tail->_next = newnode;tail = tail->_next;}cur = cur->_next;}}}// 现代写法HashTable& operator=(HashTable ht){_tables.swap(ht._tables);swap(_n, ht._n);return *this;}~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;Hash hash;// 负载因子等于1时扩容if (_n == _tables.size()){vector<Node*> newtables(__stl_next_prime(_tables.size()) + 1);for (size_t i = 0; i < _tables.size(); i++){Node* cur = _tables[i];while (cur){Node* next = cur->_next;// 头插到新表size_t hashi = hash(cur->_kv.first) % newtables.size();cur->_next = newtables[hashi];newtables[hashi] = cur;cur = next;}_tables[i] = nullptr;}_tables.swap(newtables);}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){Hash hash;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){Hash hash;size_t hashi = hash(key) % _tables.size();Node* prev = nullptr;Node* cur = _tables[hashi];while (cur){if (cur->_kv.first == key){if (prev == nullptr){// 头节点_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 = 0;};
}