一.unordered 系列关联式容器
在 C++98 中,STL 的map、set底层基于红黑树实现,查找复杂度O(logN),查找次数等于树高,数据量很大时查询效率存在局限。为了获得更快的平均查找速度,C++11 引入 4 种 unordered 哈希关联容器:unordered_map/unordered_set/unordered_multimap/unordered_multiset,底层是哈希表。
遍历特性:不保证 key 有序;迭代器为单向前向迭代器,不能逆向遍历。
Key 重复规则
unordered_map、unordered_set:key 唯一,不能重复
unordered_multimap、unordered_multiset:允许 key 重复
[] 下标操作
unordered_map支持[];unordered_set 没有 [] 运算符
multiset、multimap 由于 key 可重复,不能使用[]
- 复杂度:平均(O(1),最坏哈希冲突严重时O(N);红黑树容器稳定O(logN)。
- 使用取舍:需要有序选 map/set;追求平均查找速度、无需有序选 unordered 系列。

unordered 系列的关联式容器之所以效率比较高,是因为其底层使用了哈希结构。 在一般情况下,建议使用 unordered 系列的关联式容器。
1.unordered_map
(1)unordered_map介绍
unordered_map – C++ Reference

【翻译如下】
(2)unordered_map 的接口说明
a. 构造函数
https://cplusplus.com/reference/unordered_map/unordered_map/unordered_map/


#include <iostream>
#include <unordered_map>
using namespace std;
int main() {
// 1. 空构造,后面再赋值
unordered_map<string, int> m1;
m1["a"] = 1;
m1["b"] = 2;
// 2. 直接初始化
unordered_map<string, int> m2 = {{"x", 10}, {"y", 20}};
// 3. 拷贝构造
unordered_map<string, int> m3 = m2;
// 输出看看
cout << "m1[a] = " << m1["a"] << endl;
cout << "m2[x] = " << m2["x"] << endl;
cout << "m3[y] = " << m3["y"] << endl;
return 0;
}

b. 容量函数

#include <iostream>
#include <unordered_map>
using namespace std;
int main() {
unordered_map<string, int> score;
// 判断是否为空的
if (score.empty()) {
cout << "还没人得分呢" << endl;
}
// 放几个数据进去
score["hjq"] = 95;
score["hz"] = 87;
// 再瞅瞅
cout << "现在有 " << score.size() << " 个人的成绩" << endl;
if (!score.empty()) {
cout << "有人得分了" << endl;
}
return 0;
}

c. 迭代器

#include <iostream>
#include <unordered_map>
#include <string>
using namespace std;
int main()
{
unordered_map<int, string> m;
m[1] = "张三";
m[2] = "李四";
m[3] = "王五";
cout << "使用begin()和end()遍历:" << endl;
//用迭代器遍历,begin开始,end作为结束位置
for (auto it = m.begin(); it != m.end(); it++)
{
cout << it->first << " " << it->second << endl;
}
cout << "\\n使用cbegin()和cend()只读遍历:" << endl;
for (auto it = m.cbegin(); it != m.cend(); it++)
{
cout << it->first << " " << it->second << endl;
}
return 0;
}

d. 元素访问
https://cplusplus.com/reference/unordered_map/unordered_map/operator%5B%5D/

#include <iostream>
#include <unordered_map>
#include <string>
using namespace std;
int main()
{
unordered_map<int, string> mp;
//1.key不存在,[]会自动插入,value为默认空字符串
cout << mp[10] << endl;
//此时容器里已经多了(10,"")
//2.key存在,[]直接取出对应value并修改
mp[10] = "hello";
cout << mp[10] << endl;
return 0;
}

注意:operator[]内部会尝试执行插入。若容器中没有该 key,则插入一条 key 和默认值V()组成的元素,返回默认值;若 key 已经存在,插入不会生效,直接返回该 key 对应的 value。
unordered_map<int,string> mp;
cout << mp[1]; //没有key=1,自动插入(1,""),返回空字符串
mp[1] = "hi"; //key已存在,不会新增,直接修改value
e. 查询操作
https://cplusplus.com/reference/unordered_map/unordered_map/find/
https://cplusplus.com/reference/unordered_map/unordered_map/count/

#include <iostream>
#include <unordered_map>
#include <string>
int main() {
std::unordered_map<std::string, int> hashTable;
// 插入数据
hashTable["apple"] = 5;
hashTable["banana"] = 3;
hashTable["cherry"] = 7;
// 1. 使用 find: 查找 key 为 "banana" 的元素
auto it = hashTable.find("banana");
if (it != hashTable.end()) {
std::cout << "找到 banana,值为: " << it->second << std::endl;
}
else {
std::cout << "未找到 banana" << std::endl;
}
// 查找不存在的 key
auto it2 = hashTable.find("grape");
if (it2 != hashTable.end()) {
std::cout << "找到 grape,值为: " << it2->second << std::endl;
}
else {
std::cout << "未找到 grape" << std::endl;
}
//2. 使用 count:统计 key 为 "apple" 的个数
size_t count1 = hashTable.count("apple");
std::cout << "apple 的个数: " << count1 << std::endl;
size_t count2 = hashTable.count("grape");
std::cout << "grape 的个数: " << count2 << std::endl;
return 0;
}

unordered_map 的 key 是唯一的,不会存在重复,所以它的 count 方法最多返回 1。找到目标 key 就返回 1,找不到返回 0。这点和支持重复键的 multimap 不一样,后者 count 可以大于 1。
f. 修改操作
https://cplusplus.com/reference/unordered_map/unordered_map/insert/
https://cplusplus.com/reference/unordered_map/unordered_map/erase/
https://cplusplus.com/reference/unordered_map/unordered_map/clear/
https://cplusplus.com/reference/unordered_map/unordered_map/swap/

#include <iostream>
#include <unordered_map>
#include <string>
using namespace std;
int main() {
// 1. insert:插入键值对
unordered_map<string, int> m;
m.insert({ "apple", 5 });
m.insert({ "banana", 3 });
m.insert({ "cherry", 7 });
m["date"] = 10; // 另一种插入方式
cout << "插入后: ";
for (auto& p : m) {
cout << p.first << ":" << p.second << " ";
}
cout << endl;
// 2. erase:删除
m.erase("banana"); // 根据key删除
cout << "删除banana后: ";
for (auto& p : m) {
cout << p.first << ":" << p.second << " ";
}
cout << endl;
// 3. clear:清空
unordered_map<string, int> m2 = { {"x", 1}, {"y", 2} };
cout << "清空前大小: " << m2.size() << endl;
m2.clear();
cout << "清空后大小: " << m2.size() << endl;
// 4. swap:交换
unordered_map<string, int> a = { {"A", 1}, {"B", 2} };
unordered_map<string, int> b = { {"C", 3}, {"D", 4}, {"E", 5} };
cout << "交换前 a大小:" << a.size() << " b大小:" << b.size() << endl;
a.swap(b);
cout << "交换后 a大小:" << a.size() << " b大小:" << b.size() << endl;
return 0;
}

g. 桶操作

#include <iostream>
#include <unordered_map>
#include <string>
using namespace std;
int main() {
unordered_map<string, int> m;
// 插入一些数据
m["apple"] = 5;
m["banana"] = 3;
m["cherry"] = 7;
m["date"] = 10;
m["grape"] = 8;
// 1. bucket_count():返回桶的总个数
cout << "桶的总个数: " << m.bucket_count() << endl;
// 2. bucket_size(n):返回n号桶中有效元素个数
cout << "\\n各个桶的元素个数:" << endl;
for (size_t i = 0; i < m.bucket_count(); i++) {
cout << i << "号桶: " << m.bucket_size(i) << "个元素" << endl;
}
// 3. bucket(key):返回元素key所在的桶号
cout << "\\n各元素所在的桶号:" << endl;
cout << "apple 在 " << m.bucket("apple") << " 号桶" << endl;
cout << "banana 在 " << m.bucket("banana") << " 号桶" << endl;
cout << "cherry 在 " << m.bucket("cherry") << " 号桶" << endl;
cout << "date 在 " << m.bucket("date") << " 号桶" << endl;
cout << "grape 在 " << m.bucket("grape") << " 号桶" << endl;
return 0;
}

2.unordered_set
unordered_set – C++ Reference
(1)unordered_set 的介绍

【翻译如下】:
无序集合(Unordered Set)
(2)unordered_set 的接口说明
a. 构造函数
https://cplusplus.com/reference/unordered_set/unordered_set/unordered_set/

#include <iostream>
#include <unordered_set>
using namespace std;
int main() {
// 1. 默认构造:空集合
unordered_set<int> s1;
s1.insert(1);
s1.insert(2);
s1.insert(3);
cout << "s1: ";
for (int x : s1) cout << x << " ";
cout << endl;
// 2. 拷贝构造:复制另一个集合
unordered_set<int> s2(s1);
cout << "s2(拷贝s1): ";
for (int x : s2) cout << x << " ";
cout << endl;
// 3. 迭代器区间构造:用数组初始化
int arr[] = { 10, 20, 30, 40, 50 };
unordered_set<int> s3(arr, arr + 5);
cout << "s3(从数组): ";
for (int x : s3) cout << x << " ";
cout << endl;
// 4. 列表初始化:直接指定元素
unordered_set<int> s4 = { 100, 200, 300, 400 };
cout << "s4(列表初始化): ";
for (int x : s4) cout << x << " ";
cout << endl;
// 5. 指定桶的数量
unordered_set<int> s5(20); // 创建20个桶
s5.insert(1);
s5.insert(2);
cout << "s5桶数: " << s5.bucket_count() << endl;
return 0;
}

b. 容量函数

#include <iostream>
#include <unordered_map>
using namespace std;
int main() {
unordered_map<string, int> m;
cout << m.empty() << endl;
cout << m.size() << endl;
m["a"] = 1;
m["b"] = 2;
cout << m.empty() << endl;
cout << m.size() << endl;
m.clear();
cout << m.empty() << endl;
cout << m.size() << endl;
return 0;
}

- empty() 空返回1,非空返回0
- size() 元素个数
c. 迭代器

#include <iostream>
#include <unordered_map>
#include <string>
using namespace std;
int main() {
unordered_map<string, int> m;
m["apple"] = 5;
m["banana"] = 3;
m["cherry"] = 7;
// begin() / end():遍历
cout << "begin/end遍历: ";
for (auto it = m.begin(); it != m.end(); it++) {
cout << it->first << ":" << it->second << " ";
}
cout << endl;
// cbegin() / cend():const遍历(不能修改)
cout << "cbegin/cend遍历: ";
for (auto it = m.cbegin(); it != m.cend(); it++) {
cout << it->first << ":" << it->second << " ";
}
cout << endl;
// 范围for(底层也是用begin/end)
cout << "范围for遍历: ";
for (auto& p : m) {
cout << p.first << ":" << p.second << " ";
}
cout << endl;
return 0;
}

d. 查询操作
https://cplusplus.com/reference/unordered_set/unordered_set/find/
https://cplusplus.com/reference/unordered_set/unordered_set/count/
#include <iostream>
#include <unordered_map>
using namespace std;
int main() {
unordered_map<string, int> m;
m["a"] = 1;
m["b"] = 2;
auto it = m.find("a");
cout << (it != m.end()) << endl; // 1(找到)
it = m.find("c");
cout << (it != m.end()) << endl; // 0(没找到)
cout << m.count("a") << endl; // 1
cout << m.count("c") << endl; // 0
return 0;
}

注意:unordered_set 存储的元素本身就作为 key,不允许存在重复元素。所以它的 count() 函数返回值只能是 0 或者 1,最大值就是 1。 返回 1 代表该元素存在,返回 0 代表不存在。
对比:unordered_multiset 允许存放重复元素,它的count返回值可以大于 1,代表这个值一共出现了多少次。
e. 修改操作
https://cplusplus.com/reference/unordered_set/unordered_set/insert/
https://cplusplus.com/reference/unordered_set/unordered_set/erase/
https://cplusplus.com/reference/unordered_set/unordered_set/clear/
https://cplusplus.com/reference/unordered_set/unordered_set/swap/

#include <iostream>
#include <unordered_map>
using namespace std;
int main() {
unordered_map<string, int> m;
// insert
m.insert({"a", 1});
m.insert({"b", 2});
m["c"] = 3;
// erase
m.erase("b");
// clear
m.clear();
cout << m.size() << endl;
// swap
unordered_map<string, int> m1 = {{"x", 10}, {"y", 20}};
unordered_map<string, int> m2 = {{"z", 30}};
m1.swap(m2);
cout << m1.size() << " " << m2.size() << endl;
return 0;
}

f. 桶操作

#include <iostream>
#include <unordered_map>
using namespace std;
int main() {
unordered_map<string, int> m;
m["a"] = 1;
m["b"] = 2;
m["c"] = 3;
m["d"] = 4;
cout << m.bucket_count() << endl; // 桶总数
cout << m.bucket_size(0) << endl; // 0号桶元素个数
cout << m.bucket_size(1) << endl; // 1号桶元素个数
cout << m.bucket("a") << endl; // a在几号桶
cout << m.bucket("b") << endl; // b在几号桶
return 0;
}
二.底层结构
之所以unordered 系列的关联式容器效率比较高,是因为其底层使用了哈希结构。
1.哈希概念
顺序结构、二叉平衡树这两种结构里,元素关键码和它实际存放的存储位置没有预先绑定的映射关系。所以查找目标元素时,只能依靠多次关键码比对来寻找。 顺序查找的时间复杂度是 O(N);二叉平衡树查找的时间复杂度是树高O(log₂ N) 。这类查找方式的效率,由查找过程中关键码的比较次数决定。
于是就有了一种理想查找思路:不需要多次比较,通过计算直接定位到目标元素的存储位置,一次访问就能拿到元素。 思路是这样:设计一个哈希函数hashFunc,建立关键码与存储位置之间的映射关系。查找的时候,直接用这个函数算出位置,快速定位元素。
【插入元素】 用待插入元素的关键码代入哈希函数,算出对应的存储位置,再把元素存到这个位置上。
【搜索元素】 对目标关键码执行同样的哈希计算,将算出的结果当作存储地址,到该位置取出元素,再比对关键码。如果关键码一致,查找成功。
这种查找方式就叫做哈希(散列)方法;其中用来做转换的函数叫哈希(散列)函数;用这套规则构建出来的存储结构,叫做哈希表(Hash Table,也叫散列表)。
举个例子:现有数据集合 {1,7,6,4,5,9}。 我们设定哈希函数:hash(key) = key % capacity。 其中 capacity 代表哈希表底层数组的总空间大小。用取模作为哈希函数,计算出来的结果就是元素要存放的数组下标。

用该方法进行搜索查找不必进行多次关键码的比较,因此搜索的速度比较快。
2.哈希冲突
在搜索过程总,对于两个数据元素的关键字 ki 和 kj (i != j),有 ki != kj,但有:Hash(ki) == Hash(kj) 即:不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为 哈希冲突 或 哈希碰撞 。把具有不同关键码而具有相同哈希地址的数据元素称为 “同义词”。
举例如下:
// 假设哈希函数:hash(key) = key % 10
hash(11) = 1
hash(21) = 1 // 11 ≠ 21,但哈希地址相同 –> 哈希冲突
// 11 和 21 互为同义词
3.哈希函数
引起哈希冲突,很常见的一个原因就是哈希函数本身设计得不好。
设计哈希函数一般要把握这几条原则:
就算哈希函数设计得很不错,也做不到完全消除冲突。如果哈希表里存的数据越来越多,装填因子变大,冲突照样会变多。
4.常见哈希函数
a.直接定址法(常用)
直接定址法是比较基础的哈希构造方法,取关键字的线性函数结果作为哈希地址:Hash(Key)= A*Key + B,A、B 为自定义常数,A 也可以取 1,此时就是关键字本身直接作为哈希地址。
优点:实现简单,不会产生哈希冲突,地址分布天然均匀。
缺点:必须提前掌握关键字的整体分布范围;当关键字离散、跨度很大时,会造成哈希表数组空间巨大,大量位置闲置浪费内存。
适用场景:关键字范围较小、数值连续的场景。
举例:
代码示例如下:
#include <iostream>
using namespace std;
// 直接定址法 Hash(Key) = A*Key + B
int hashFunc(int key, int A, int B)
{
return A * key + B;
}
int main()
{
const int A = 2;
const int B = 1;
int hashTable[20] = { 0 };
int keys[] = { 1,2,3,4 };
for (int k : keys)
{
int pos = hashFunc(k, A, B);
hashTable[pos] = k;
cout << "key=" << k << " 哈希地址=" << pos << endl;
}
return 0;
}

for (int k : keys) 这是 C++11 引入的范围 for(范围 for 循环)
2.除留余数法(常用)
开辟一块固定大小的哈希表空间,假设哈希表地址总数为 n。 哈希函数公式就是说:Hash(key) = key % n,把关键字对 n 取模,得到的余数就是哈希地址,将元素存入哈希表对应下标位置。
补充: 这里的n不是随便选的,尽量选质数,可以有效降低冲突发生概率;如果 n 取偶数,很多关键字的取模结果会扎堆,冲突会变多。
缺点
适用场景:绝大多数整数关键字哈希场景,实际写代码、考试出现频率很高。
代码示例如下:
#include <iostream>
using namespace std;
//除留余数法哈希函数
int hashFunc(int key, int n)
{
return key % n;
}
int main()
{
int n = 11; //哈希表容量,选质数,降低冲突
int hashTable[11] = { 0 };
int keys[] = { 1,4,5,6,7,9 };
int len = sizeof(keys) / sizeof(keys[0]);
for (int i = 0; i < len; i++)
{
int k = keys[i];
int addr = hashFunc(k, n);
cout << "key=" << k << " 哈希地址:" << addr << endl;
hashTable[addr] = k;
}
return 0;
}

问题补充:这里为什么要选择质数呢?
公式:Hash(key) = key % n
简单说:如果 n 是合数(非质数),关键字如果含有 n 的因子,取模结果就会大量聚集在部分下标,冲突变多;选质数可以打散这种聚集,让地址分布更均匀。
给大家举个通俗例子: 假设 n=10(合数,因子 2、5) 如果一组 key 全是偶数:2,4,6,8,12,14 key%10 结果只会落到 0,2,4,6,8,奇数下标 1、3、5、7、9 永远用不上,一半空间直接浪费,大量数据挤在少数位置,冲突暴增。
如果换成质数 n=11: 同样这组偶数做取模,结果会分散到 0‑10 全部下标,不会扎堆。
简单图示一下:

3.平方取中法(了解)
把关键字做平方,再从平方后的结果里挑中间的几位,当作哈希地址。
举两个例子: 关键字 1234,平方之后得到 1522756,截取中间 3 位 227,就是哈希地址。 关键字 4321,平方得到 18671041,可以截取中间 3 位 671,也可以取 710。
之所以取中间部分,是因为数字平方后,中间几位会受原数字每一位的影响,更容易把地址打散开来。
适用情况: 我们事先不清楚关键字的分布,并且关键字本身位数不算太大。
缺点:
4.折叠法(了解)
把一个长关键字,从左到右切成好几段,每段位数保持一致,最后一段不够长也没关系。把切出来的几段数字全部加在一起,最后按照哈希表的长度,拿相加结果的后面几位当作哈希地址。
折叠法有两种做法:
- 移位叠加:切出来的几段直接对齐相加。
- 间界叠加(翻转折叠):相邻的分段首尾颠倒之后再相加,打散数据的效果会更好。
适用场景: 不知道关键字的分布情况,而且关键字本身位数特别多,像手机号、长串编号这类数据就适合用。
缺点:
举个例子: 关键字 5824232416,我们需要 3 位的哈希地址,按 3 位一段切开:582、423、241、6 移位叠加计算:582 + 423 + 241 + 6 = 1252,取后 3 位 252,这就是哈希地址。
5.随机数法(了解)
挑选一个随机函数,把关键字传入随机函数,用函数返回的结果当作哈希地址:H(key) = random(key)。一般在各个关键字长度参差不齐的时候会考虑这个办法。
缺点:
注意点:不能用真正完全随机的函数,不然同一个 key 每次算出来地址不一样,后续查找就找不到数据了。
#include <iostream>
using namespace std;
//简单伪随机,同一个key输出固定值
int randomHash(int key)
{
//简单伪随机算法
return (key * 1103515245 + 12345) % 100;
}
int main()
{
int keys[] = { 12, 345, 6789 };
int len = sizeof(keys) / sizeof(keys[0]);
for (int i = 0; i < len; i++)
{
int k = keys[i];
int addr = randomHash(k);
cout << "key=" << k << " 哈希地址=" << addr << endl;
}
return 0;
}

6.数学分析法(了解)
现有一批 d 位的关键字,每一位上可以出现 r 种符号。这些符号在每一位上出现频次并不一样:有些位各个符号出现概率差不多,分布均匀;有些位就很集中,只有少数几种数值反复出现。
数字分析法就是结合哈希表的实际大小,挑选出符号分布比较均匀的若干位,直接拿这部分作为哈希地址。
举个实际例子: 保存公司员工信息,拿手机号当作关键字。手机号前 7 位大多是运营商号段,大量员工的这部分数字会一模一样,这几位分布极不均匀,不能选用。我们就选取手机号末尾 4 位当作哈希地址。 如果只截取之后冲突还是比较多,还可以对截取出来的数字做二次加工:
- 数字反转:1234 –> 4321
- 循环右移:1234 –> 4123
- 循环左移:1234–> 1432
- 分段叠加:1234,前两位加后两位,12+34=46
适用场景 关键字位数比较大,并且我们提前掌握这批关键字的整体分布,其中存在部分数位分布均匀。
缺点 必须事先分析全部关键字每一位的分布情况;换另一批数据,之前选好的位可能就不再均匀,这套哈希函数就失效了。
#include <iostream>
#include <string>
#include <algorithm>
using namespace std;
//数字分析法示例:截取手机号后4位,可选择反转
int numAnalyseHash(string phone, bool reverse_flag = false)
{
string seg = phone.substr(phone.size() – 4, 4); //截取末尾4位
if (reverse_flag)
{
reverse(seg.begin(), seg.end());
}
return stoi(seg);
}
int main()
{
string phone1 = "13800138000";
string phone2 = "13800138123";
cout << phone1 << " 截取后4位哈希地址:" << numAnalyseHash(phone1) << endl;
cout << phone2 << " 截取后4位反转哈希地址:" << numAnalyseHash(phone2, true) << endl;
return 0;
}

但是:哈希函数设计再巧妙,只能降低冲突发生的概率,不可能完全消除哈希冲突。
5.哈希冲突解决
哈希冲突主要有两种解决方式:闭散列(开放定址法) 和 开散列(链地址法)。
a. 闭散列(开放定址法)
所有数据全部存放在哈希表数组内部,不新开其他空间。 一旦出现哈希冲突,就按照固定规则,在原数组里继续往后找空位,把数据存进去。
特点:
- 只用一个数组,不借助链表
- 冲突多了容易扎堆、聚集
- 不能直接删除元素,直接删会打断查找路径,需要做删除标记
- 装填因子不能太大,否则冲突爆炸
b.开散列(链地址法)
哈希表数组只存每个位置的链表头结点。 只要哈希地址相同的元素,直接挂在同一条链表后面。
特点:
- 冲突元素直接进链表,不会挤占数组空间
- 不会出现数据扎堆,效率更稳定
- 支持装填因子大于 1
- 需要额外开辟链表空间
三.解决哈希冲突两种常见方法的介绍
1.闭散列(开放定址法)
闭散列也叫开放定址法,是解决哈希冲突的常用方式。 主要逻辑很简单:所有数据都只存在哈希表的数组里,不额外开链表。
当我们通过哈希函数算出一个存储位置时,如果这个位置已经被占了,就说明发生了哈希冲突。 只要哈希表还没存满,数组里一定还有空位,我们就按照固定规则,向后不断寻找下一个空闲位置,把当前要存储字存进去。
插入数据流程: 先通过哈希函数算出基准下标。如果该位置为空,直接存入;如果已经有数据,就持续往后找空位,找到就插入。如果数组全部占满,没有空位,就必须扩容哈希表。
查找数据流程: 先计算哈希下标,直接定位到对应位置。 取出数据后必须比对关键字: 如果刚好是要找的数据,直接返回; 如果不是,说明这里发生过哈希冲突,需要继续往后遍历探测位置,逐一比对,直到找到目标数据,或者遇到空位(说明数据不存在)。
核心特点:
(1)找下一个空位置(闭散列探测方式)
当两个不同关键字,算出的哈希地址一模一样,就会出现哈希冲突。 如果当前位置已经存了数据,就不能直接覆盖,需要往后找新的空位存放。
只要哈希表没有存满,数组里一定还有空闲位置,按照规则继续往后探测、依次查找空位。 如果数组全部被占满,没有任何空位,就需要对哈希表进行扩容。
常用的找空位方式只有两种:
没有 “二次线性探测” 这个说法,标准名称是二次探测。
① 线性探测(闭散列常用)
线性探测的规则很简单:一旦当前哈希位置发生冲突、已经被占用,就从冲突位置开始,挨个向后依次查找,一直找到第一个空位置为止,把数据存进去。
【插入】
大致插入流程

当我们在计算:
hash(44) = 44%10 = 4 哈希值(44) = 44%10 = 4
会出现哈希冲突:哈希地址 4 已经存放了数据,从冲突位置开始往后找空位置。
【删除】(闭散列)
使用闭散列解决哈希冲突的时候,不能直接物理删掉数组里的元素。 如果直接把元素清空,会破坏线性探测的查找链路,导致其他发生过冲突而向后存放的数据找不到。
举例子: 4 存放在下标 4,44 哈希算出来也是下标 4,发生冲突,线性探测往后放到下标 8。 如果直接把下标 4 的 4 清空,之后查找 44 的时候,探测走到下标 4 发现是空位,就会判定 44 不存在,直接结束查找,找不到真正存在下标 8 的 44。
所以闭散列一般用伪删除(标记删除),不去抹除数据,只给位置打上状态标记。给哈希表的每个位置设置三种状态:
enum State{
EMPTY, // 该位置为空,从未存放过数据
EXIST, // 该位置存有有效元素
DELETE // 元素曾经存在,已经被逻辑删除
};
- 插入:只可以向EMPTY的位置存放;遇到DELETE的位置,也可以复用该位置存入新数据。
- 查找:碰到DELETE标记不能停止,要继续向后探测;只有遇到EMPTY,才代表数据不存在。
- 删除:不把数据擦掉,仅仅把状态改成DELETE。
删除这里有一个缺点:就是说:伪删除只是打标记,不会释放空间。如果大量删除,表中DELETE标记会越来越多,降低查找效率,这种情况需要对哈希表扩容并重新整理。
【线性探测的实现】
代码如下:
// 注意:假如实现的哈希表中元素唯一,即key相同的元素不再进行插入
// 为了实现简单,此哈希表中我们将比较直接与元素绑定在一起
// 哈希表每个位置状态枚举
enum State{EMPTY, EXIST, DELETE};
template<class K, class V>
class HashTable
{
//存储单元结构体:保存键值对 + 位置状态标记
struct Elem
{
pair<K, V> _val; // key‑value键值对
State _state; // 标记:EMPTY空 / EXIST有效数据 / DELETE逻辑删除
};
public:
//构造函数:初始化哈希表,默认容量3
HashTable(size_t capacity = 3)
: _ht(capacity), _size(0)
{
//全部位置初始化为空状态
for(size_t i = 0; i < capacity; ++i)
_ht[i]._state = EMPTY;
}
//插入键值对,key重复返回false,插入成功返回true
bool Insert(const pair<K, V>& val)
{
_CheckCapacity(); //检测装填因子,达到阈值进行扩容
size_t hashAddr = HashFunc(val.first); //计算初始哈希地址
size_t startAddr = hashAddr;
//线性探测:跳过已经存有有效数据的位置;EMPTY、DELETE位置可以用于插入
while(_ht[hashAddr]._state != EMPTY && _ht[hashAddr]._state != DELETE)
{
//探测途中遇到相同key,代表重复,禁止插入
if(_ht[hashAddr]._state == EXIST && _ht[hashAddr]._val.first == val.first)
return false;
hashAddr++;
if(hashAddr == _ht.capacity())
hashAddr = 0; //到达数组末尾,回绕到数组头部
//绕回起点,代表表已经存满,无法插入
if(hashAddr == startAddr)
return false;
}
//找到合适位置,写入数据,标记为有效
_ht[hashAddr]._state = EXIST;
_ht[hashAddr]._val = val;
_size++; //有效元素计数+1
return true;
}
//查找key,找到返回数组下标,找不到返回-1
int Find(const K& key)
{
size_t hashAddr = HashFunc(key);
size_t startAddr = hashAddr;
//遇到EMPTY空位直接停止查找
while(_ht[hashAddr]._state != EMPTY)
{
//找到有效匹配key,返回下标
if(_ht[hashAddr]._state == EXIST && _ht[hashAddr]._val.first == key)
return (int)hashAddr;
hashAddr++;
if(hashAddr == _ht.capacity())
hashAddr = 0; //探测越界,回绕头部
//完整遍历一圈,没有找到目标
if(hashAddr == startAddr)
break;
}
return -1; //没有该元素返回-1
}
//伪删除:只打DELETE标记,不清空数据,保护探测链路
bool Erase(const K& key)
{
int index = Find(key);
if(-1 != index)
{
_ht[index]._state = DELETE;
_size–; //有效元素数量减一
return true;
}
return false;
}
//返回当前有效元素个数
size_t Size()const
{
return _size;
}
//判断哈希表是否为空
bool Empty() const
{
return _size == 0;
}
void Swap(HashTable<K, V>& ht);
private:
//扩容检测,装填因子超标则扩容
void _CheckCapacity()
{
//此处仅声明,扩容逻辑:开辟更大vector,把EXIST状态元素重新哈希插入新表
}
//哈希函数:除留余数法,计算哈希地址
size_t HashFunc(const K& key)
{
return key % _ht.capacity();
}
private:
vector<Elem> _ht; //底层数组,存放所有哈希表单元
size_t _size; //记录有效元素(EXIST)的数量
};
线性探测
优点:实现逻辑简单,编码容易。
缺点:容易产生聚集(数据堆积)。 发生冲突后,后续元素会顺着探测路径连续占用位置,就算关键字原本哈希地址并不相同,也会挤在一片连续区域。堆积出现之后,插入、查找、删除都要执行更多次探测比较,哈希表整体搜索效率明显下降。
如何缓解呢?
出现堆积之后,插入和查找的效率都会大幅下降。 插入元素时,要从冲突位置不断向后遍历,才能找到空闲位置; 查找元素时,同样需要顺着探测路径多次比对,只有探测遇到空位置,才能判定目标元素不存在。大量的探测比较,直接拉低整体搜索效率。
这里我先给大家结论,后面会有解答:
缓解手段: ①使用二次探测,缓解数据聚集; ②控制装填因子,及时扩容; ③改用开散列链地址法。

② 二次探测
线性探测会出现数据堆积,原因就是冲突之后只能挨着往后一个一个找空位,冲突的数据就挤到一块了。
二次探测就是用来改善这个堆积问题。 探测位置公式:Hash(key) = key % n + i² ( i = 1,2,3… ),通过哈希函数 Hash(key) 计算出元素的关键码 key 对应的位置再加上 i 的平方,n 是表的大小。
注意:不是直接 key%n+i^2。出原始下标先用除留余数算,发生冲突后,再叠加i的平方做偏移,一般正负偏移都会用。
简单流程:
优点: 多个数据冲突到同一个起始位置时,二次探测靠平方偏移把数据打散,不会扎堆挤在相邻位置,缓解线性探测的数据堆积问题。
缺点:
如果上面要插入 44,产生冲突,使用解决后的情况为:

二次探测装填因子说明:
研究表明:当哈希表长度为质数,且装填因子 α ≤ 0.5时,二次探测一定能找到空位完成插入,并且每个位置最多只会被探测一次。
也就是说只要哈希表还留有一半空闲位置,就不会出现找不到插入位置的问题。 搜索的时候不用考虑表存满的情况,但做插入操作时,要保证装填因子不能超过 0.5;一旦超过,就必须扩容。
但有缺点:二次探测空间利用率不高,最多只能用到一半的表空间,这是闭散列(开放定址)哈希表的短板
公式:有效元素个数/哈希表的总长度
【二次探测相比线性探测的好处】
如果多个数据冲突到同一个位置,二次探测会把这些数据打散存到不同地方,不会像线性探测那样,冲突的数据挤成一大片,出现数据堆积。
【插入】
假设要插入 333 和 33,产生冲突,分别使用线性探测 和二次探测,解决后的情况为:

【闭散列的实现】
哈希表其实就是数组,只不过是按照某种映射关系把元素存放进去的数组。

1.怎么往哈希表里插入元素?
- 先看要不要扩容:算一下负载因子,如果超过规定阈值,就先做扩容。
- 接着用哈希函数算出元素一开始该放的下标。
- 如果这个位置已经存有数据(状态 EXIST),代表出现哈希冲突,用线性探测或者二次探测继续找可用空位,找到之后再插入。
- 如果位置是空的(EMPTY),或者是之前删除留下的位置(DELETE),就直接把元素插进去。
2.怎么在哈希表里查找元素?
先判断哈希表是不是空表,如果是空,直接查找失败,返回 nullptr。
用哈希函数算出目标元素对应的起始下标。 如果这个位置不是空白(状态是 EXIST 或者 DELETE),就开始向后探测查找,碰到 EMPTY 空位就直接停止。
- 探测途中遇到状态为 EXIST 的元素,就对比是不是要找的值,匹配上就查找成功,返回元素地址;不匹配就继续往下找。
- 如果一路查到 EMPTY 空位都没找到目标,说明表里没有这个元素,返回 nullptr。
如果一开始算出来的位置就是 EMPTY,直接返回 nullptr。
3.怎么标记哈希表每个位置的存放状态?
不能直接拿 0 或者‑1 当做空位置标记,因为实际存储的数据本身就有可能是 0、‑1,会出现混淆。
解决办法:哈希表的每个位置,除了存数据本身,额外再加一个状态标记,用来记录这个位置是三种状态之一:空、已有元素、已删除。
4.怎么删除哈希表里的元素?
使用开放定址(闭散列)解决冲突的时候,不能直接把元素物理删掉。如果直接清空该位置,会打断探测链路,导致其他元素找不到。
举个例子,如果直接删掉 333,这个位置直接变成空。后续去查找 44 的时候,探测到这个空位就直接停止,就会误以为 44 不存在。
所以线性探测一般用伪删除(标记删除)。 每个位置除了存数据,额外带一个状态标记:空、存在、已删除。删除的时候只把状态改成 “已删除”,数据不动,不直接清空位置。查找时遇到 “已删除” 标记会继续往后探测,不会直接终止查找。
问题思考:
【思考 1】
除留余数法要求 key 得是整数才能做取模运算,那是不是意味着闭散列哈希表只能存整型 key?如果是字符串或者自定义类型该怎么办?
像 string 这类类型,没法直接进行取模计算哈希下标。解决方案就是提供对应的哈希仿函数,把非整型的 key 转换成一个整数,之后再用除留余数法算出存储位置。
// 默认哈希仿函数:用于size_t,以及可以隐式转换为size_t的整型类型
template<class K>
struct HashFunc
{
// key:元素的关键码
// 如果key是整型,直接转成size_t返回
// 如果是浮点数,会发生隐式转换为size_t(浮点数转整数会截断小数部分)
size_t operator()(const K& key)
{
return key;
}
};
// 专门处理string类型的哈希仿函数,把string转为可用于取模的size_t
struct HashFuncString
{
size_t operator()(const string& key)
{
// 方式1:累加每个字符的ASCII码
// 缺点:不同字符串有可能累加得到相同哈希值,发生哈希冲突,不能保证key唯一
size_t hash_key = 0;
for (size_t i = 0; i < key.size(); i++)
{
hash_key += key[i];
}
return hash_key;
}
};
【思考2】
unordered 系列容器底层是哈希表,但平时使用的时候,我们并没有手动传哈希仿函数。 string 作为 key 又特别常用,总不能每次用 string 都自己手写一份哈希仿函数,那它内部是怎么处理的?
答案:对 string 类型,给哈希仿函数写模板特化版本。 当传入的 key 是 string 时,编译器就会匹配到这个特化版本,自动调用对应的哈希转换逻辑,不用我们手动传仿函数。
// 默认仿函数类(针对size_t类型和能够隐式类型转换成size_t的类型)
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return key;
}
};
// 特化仿函数(把string类型转换成可以取模的size_t类型)
template<>
struct HashFunc<string>
{
size_t operator()(const string& key)
{
size_t hash_key = 0;
for (size_t i = 0; i < key.size(); i++)
{
hash_key *= 131;
hash_key += key[i];
}
return hash_key;
}
};
通用模板 HashFunc<K> 适用于整型,还有能隐式转成 size_t 的类型。直接返回 key,后面拿这个值做除留余数取模,就能算出哈希下标。
注意:string 不能用这个通用模板,string 没法隐式转换成 size_t,直接用会编译报错。
模板特化 HashFunc<string> template<> 表示这是全特化。当模板参数 K 是 string 的时候,编译器会优先选用这个特化版本,不会再跑上面的通用模板。
算法逻辑:hash_key = hash_key * 131 + 当前字符的 ASCII 值 先乘质数 131,再加字符。相比直接把字符 ASCII 简单相加,能明显减少不同字符串算出同一个哈希值的情况,降低冲突。最后返回 size_t 类型的哈希值,外部拿到这个值再对哈希表容量取模,得到存放的下标。
为什么不需要手动传仿函数?
用 string 当 key 的时候,编译器自动匹配这个特化仿函数,我们写代码时不用手动传,效果和 unordered_map 里面的逻辑差不多。
小提示:就算用 131 加权算法,也没法彻底消除哈希冲突,只是冲突变少。出现冲突,还是要靠线性探测或者二次探测这类开放定址方案处理。
【闭散列的结构(KV模型)】
// 闭散列
namespace close_hash
{
// 标记哈希表中某个位置的存储状态
enum Status
{
EMPTY, // 此位置空
EXIST, // 此位置已有元素
DELETE // 此位置元素已被删除
};
// 定义哈希表中元素的结构
template<class K, class V>
struct HashData
{
pair<K, V> _kv; // 键值对
Status _status = EMPTY; // 存储状态标记,默认为空
};
// 仿函数(解决哈希函数采用除留余数法时,将不能取模的类型转换成可以取模的size_t类型)
// 默认仿函数类
template<class K>
struct HashFunc
{
// 针对size_t类型和能够隐式类型转换成size_t的类型
size_t operator()(const K& key)
{
return key;
}
};
// 特化仿函数
template<>
struct HashFunc<string>
{
// 把string类型转换成可以取模的size_t类型
size_t operator()(const string& key)
{
size_t hash_key = 0;
for (size_t i = 0; i < key.size(); i++)
{
hash_key *= 131;
hash_key += key[i];
}
return hash_key;
}
};
// 定义哈希表(KV模型)
// Hash = HashFunc<K>:仿函数,给一个默认的仿函数
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
// 构造、拷贝构造、赋值重载、析构都不需要写,调用vector的就行了
HashData<K, V>* Find(const K& key); // 查找元素
bool Insert(const pair<K, V>& kv); // 插入元素
bool Erase(const K& key); // 删除元素
private:
vector<HashData<K, V>> _tables; // 哈希表底层容器
size_t _n = 0; // 存储的有效元素个数,默认为0
// 注意:因为元素不是挨着挨着存的,所以需要一个变量去表示有效元素个数
};
}
【查找元素】
这里写了线性探测 / 二次探测两个版本。
HashData<K, V>* Find(const K& key)
{
// 先检查哈希表是否为空
if (_tables.size() == 0) // 说明查找失败
{
return nullptr;
}
// 1、先通过哈希函数计算出要查找元素在哈希表中对应的位置
size_t start = Hash()(key) % _tables.size();
size_t i = 0;
size_t index = start;
// 2、该位置不为空
while (_tables[index]._status != EMPTY)
{
/*
* 当前位置存储状态为存在,才去判断当前位置是不是要查找的元素。为什么呢?
* 因为我们采用标记的伪删除法来删除一个元素,并没有清除元素的关键码,所以还可以被查找到
*/
if (_tables[index]._status == EXIST && key == _tables[index]._kv.first)
{
return &_tables[index]; // 返回该元素的地址
}
i++;
index = start + i; // 线性探测
// index = start + i * i; // 二次探测
index %= _tables.size(); // 如果超出表尾了,又从表头继续开始找
}
// 3、该位置为空
return nullptr;
}
【插入元素】
1.为什么闭散列要控制存放的元素数量?
如果表里元素太多,插入的时候就很容易出现哈希冲突。 冲突一多,不管是插入还是查找,效率都会掉得很厉害。
查找的时候,要一直探测直到碰到真正的空位才会停下。所以闭散列不能把表存满。一旦全部占满,没有 EMPTY 空位,去查一个不存在的 key,代码就会陷入死循环。
所以有效元素数量涨到一定程度,就必须做扩容。减少冲突,保证读写的效率。
2.哈希表什么情况下进行扩容?又是如何扩容?
这里我们引入一个东西:负载因子

- 负载因子越大,哈希表里存放的元素就越多,出现哈希冲突的概率越高,但内存空间浪费更少。
- 负载因子越小,哈希表里存放的元素就越少,哈希冲突概率越低,但会浪费更多存储空间。
void CheckCapacity()
{
if(_size * 10 / _ht.capacity() >= 7)
{
HashTable<K, V, HF> newHt(GetNextPrime(_ht.capacity()));
for(size_t i = 0; i < _ht.capacity(); ++i)
{
if(_ht[i]._state == EXIST)
newHt.Insert(_ht[i]._val);
}
Swap(newHt);
}
}
1.if(_size * 10 / _ht.capacity() >= 7) 判断扩容条件 这是负载因子的判断,用整数运算避免浮点数。 负载因子 = _size / capacity,这里判断负载因子≥0.7 就扩容。 _size *10 / capacity >=7等价于 _size / capacity >= 0.7。
注意:线性探测一般阈值取 0.7;二次探测阈值一般取 0.5。
2.HashTable<K, V, HF> newHt(GetNextPrime(_ht.capacity())); 创建新哈希表,调用GetNextPrime()获取比原容量更大的质数作为新表容量。 闭散列除留余数法,容量选质数,有助于降低哈希冲突。
3.遍历旧表做重新插入
for(size_t i = 0; i < _ht.capacity(); ++i)
{
if(_ht[i]._state == EXIST)
newHt.Insert(_ht[i]._val);
}
只搬移状态为EXIST的有效元素。 DELETE、EMPTY位置直接丢弃,伪删除标记不会拷贝到新表。 放到新表要调用Insert,因为新表容量变了,哈希下标全部要重新计算,不能直接拷贝下标。
4.Swap(newHt); 交换新旧哈希表内部资源。底层容器、有效元素数量全部交换。 交换完成后,旧表的数据就交给局部对象newHt,函数结束局部对象析构,自动释放旧内存。
比如闭散列的容量是10,负载因子是 0.7,10 * 0.7 = 7,也就是说,当容量超过 7 的时候就会进行扩容操作。
这里有几点注意事项:
【注意1】
如果闭散列容量是 10,里面有 n 个有效元素,怎么判断负载因子是否超过 0.7?
不能直接写 if (n / 10 >= 0.7)。 n 和 10 都是 int,整数除法会直接取整,算出来结果只能是 0,判断失效。
注意:(double)(n / 10) >= 0.7 这行写法是错的!括号位置不对,是先做整数除法,再强转 double,依旧会出错。
两种正确写法:
- 强转版本(把 n 先转 double)
if ((double)n / 10 >= 0.7)
- 整数运算,避免浮点数:两边同时乘 10
if (n * 10 / 10 >= 7)
我在这里示例容量是 10,实际代码要替换成真实的表容量。
【注意 2】
如果允许插入重复的 key,闭散列表里就会多个位置存一样的关键码,后续查找、删除的时候就会出现逻辑混乱。那怎么避免存重复数据?
插入之前先调用 Find (key) 查一遍。 如果查到这个 key 已经存在哈希表里面,就直接放弃插入;不存在才执行插入操作。

bool Insert(const pair<K, V>& kv)
{
// 防止数据冗余
if (Find(kv.first) != nullptr)
{
return false; // 若待插入元素的关键码已存在表中,则插入失败
}
// 1、如果表为空或表的负载因子>=0.7,那么需要检查哈希表是否需要扩容
if (_tables.size() == 0 || _n * 10 / _tables.size() >= 7) // 注意
{
// 计算新容量(按2倍扩容)
size_t new_size = _tables.size() == 0 ? 10 : _tables.size() * 2;
// 开始扩容
// 定义一个新的哈希表(局部变量)
HashTable<K, V, Hash> new_hash_table;
new_hash_table._tables.resize(new_size); // 更改新表容量
// 遍历旧表中的所有元素,重新计算它在新表中的位置,一一插入到新表中
for (size_t i = 0; i < _tables.size(); i++)
{
if (_tables[i]._status == EXIST) // EXIST: 此位置已有元素
{
new_hash_table.Insert(_tables[i]._kv); // 递归调用Insert,复用代码
}
}
// 交换新表和旧表的内容(即交换新旧表vector的内容)
_tables.swap(new_hash_table._tables);
}
// 2、再通过哈希函数计算出待插入元素在哈希表中的位置
// 这里要模size(),不能模capacity(),因为vector中能存放元素的个数为size()
size_t start = Hash()(kv.first) % _tables.size();
size_t i = 0;
size_t index = start;
// 3、该位置有元素,说明发生了哈希冲突,则使用线性检测找到下一个空位置
while (_tables[index]._status == EXIST) // EXIST: 表示该位置已经有元素
{
i++; // 往后找
index = start + i; // 线性探测
// index = start + i * i; // 二次探测
// 如果超出表尾了,又从表头继续开始找
index %= _tables.size();
}
// 4、该位置没有元素则直接插入
_tables[index]._kv = kv;
_tables[index]._status = EXIST; // 标记该位置的存储状态:存在
_n++; // 有效元素个数+1
// 5、插入成功,返回true
return true;
}
/*
1.去重判断:调用Find查找key,key已存在直接返回false,禁止重复key。
2.扩容判断:表为空 或者 负载因子>=0.7触发扩容;使用整数运算_n*10/_tables.size()>=7,规避浮点数。
3.扩容逻辑:创建局部新哈希表,resize开辟新空间;遍历旧表,只迁移_status为EXIST的有效元素,调用Insert,在新表重新计算哈希下标。
4.swap交换vector,局部对象出作用域自动释放旧表资源。
5.计算起始下标:仿函数算出哈希值,对_tables.size()取模,不能用capacity。
6.线性探测:只避开EXIST已占用位置,EMPTY、DELETE位置均可插入;index取模实现循环回绕。
7.写入键值对,状态置EXIST,有效元素计数_n++,返回true。
注意:扩容后旧表DELETE伪删除标记全部丢失;二次探测替换index那一行代码即可。
*/
【测试如下】插入元素过程中,分别用线性探测和二次探测来找下一个空位置。

【删除元素】
// 删除元素
bool Erase(const K& key)
{
// 查找,判断该元素是否在表中
HashData<K, V>* ret = Find(key);
// 待删除元素的关键码不在表中
if(ret == nullptr)
{
return false;
}
// 待删除元素的关键码在表中
ret->_status = DELETE; // 伪删除,标记该位置的存储状态为:删除
_n–; // 有效元素个数-1
return true;
}
// /*
// 解析:
// 1.调用Find(key)查找目标节点,找不到返回nullptr,直接返回false代表删除失败。
// 2.闭散列不能直接清空节点内容,使用伪删除,仅把状态置为DELETE,不清空kv。
// 3.不能直接清空节点,否则会破坏探测链,导致后面的元素查找失败。
// 4._n减1,DELETE状态不计入有效元素数量。
// 5.返回true代表删除成功。
// 注意:扩容拷贝元素时,DELETE标记的节点会被丢弃,不会迁移到新表。
// */
【闭散列的效率】 从上面能够看出,开放寻址法(闭散列)并不是完美的方案。 最好情况下,一次命中,时间复杂度为 O (1); 最坏情况下需要遍历整张哈希表,时间复杂度退化到 O (n); 平均时间复杂度为 O (1),前提是控制好负载因子。
2.开散列(链地址法)
(1)开散列概念
开散列也叫哈希桶、链地址法。先用哈希函数算出关键码对应的哈希位置,哈希地址一样的元素就归到同一组,这一组就叫一个桶。桶里面的元素用单链表串起来,各个链表的头结点存放在哈希表数组中。
查找数据的时候,先做哈希运算拿到数组下标,再遍历这个桶的链表比对 key 就行。看上去复杂度跟闭散列差不多,实际不一样。哈希冲突不会频繁发生,再加上会动态扩容,冲突概率进一步降低,桶里的链表不会像闭散列那样出现大量元素扎堆堆积的现象。


从上面两幅图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素。
(2)开散列实现
【开散列的结构(KV模型)】
【思考】
除留余数法要求 key 是整型才能做取模运算,那是不是代表开散列只能存整型的 key?那字符串、自定义类型这类 key 该怎么处理?
如果 key 是 string 或者其他自定义类型,可以传入对应的仿函数,把无法直接取模的类型,转换成 size_t 类型的哈希值,之后再做取模计算。
namespace hash_bucket
{
// 定义哈希表节点结构(KV模型)
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)
{}
};
/*
* 哈希仿函数:除留余数法只支持整型取模,
* 对于string、自定义类等非整型key,通过该仿函数转换得到size_t类型哈希值
*/
// 默认仿函数类,处理整型,可以直接强转为size_t的类型
template<class K>
struct HashFunc
{
// 针对size_t类型和能够隐式类型转换成size_t的类型
size_t operator()(const K& key)
{
return key;
}
};
/*
* string特化版本:字符串不能直接取模,遍历字符计算哈希值返回size_t
* 使用131作为乘数,减少哈希冲突概率
*/
// 特化仿函数
template<>
struct HashFunc<string>
{
// 把string类型转换成可以取模的size_t类型
size_t operator()(const string& key)
{
size_t hash_key = 0;
for (size_t i = 0; i < key.size(); i++)
{
hash_key *= 131;
hash_key += key[i];
}
return hash_key;
}
};
/*
* 开散列哈希桶(链地址法)
* K:键类型,V:值类型,Hash:哈希仿函数,提供默认HashFunc<K>
* _tables:vector存放每个桶链表的头指针
* _n:统计哈希表里有效节点总数量,用于计算负载因子
*/
// 定义哈希表结构(KV模型)
// Hash = HashFunc<K>:仿函数,给一个默认的仿函数
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
typedef HashNode<K, V> Node;
public:
// 构造、拷贝构造、赋值重载需要自己写,因为这里是哈希桶结构
// …
/*
* 析构函数:释放全部桶内链表节点
* 遍历vector数组,对每个不为空的桶,遍历单链表逐个delete释放节点,防止内存泄漏
*/
~HashTable() // 析构函数
{
// 遍历哈希表,找到不为空的哈希桶
for (size_t i = 0; i < _tables.size(); i++)
{
Node* cur = _tables[i];
while (cur) // 哈希桶不为空,释放哈希桶中的所有节点
{
Node* next = cur->_next; // 记录cur指向节点的下一个节点
delete cur; // 释放节点
cur = next; // 继续去释放下一个节点
}
_tables[i] = nullptr;
}
_n = 0;
}
Node* Find(const K& key); // 查找节点
bool Insert(const pair<K, V>& kv); // 插入节点
bool Erase(const K& key); // 删除节点
private:
vector<Node*> _tables; // 哈希表存储各链表的头结点地址
size_t _n = 0; // 哈希表中有效节点的个数,缺省为0
};
}
【查找节点】
开散列的查找效率,主要取决于对应桶里面链表的长度,链表越短查找越快。
查找节点思路: 先判断哈希表是不是空表,如果是空,直接返回 nullptr。 用哈希函数算出 key 对应的桶下标,接着遍历这个桶里的链表挨个比对 key。找到就返回该节点的地址,遍历完没找到就返回空。
Node* Find(const K& key)
{
// 1、先检查哈希表是否为空
if (_tables.size() == 0)
{
return nullptr;
}
// 哈希表数组还没有开辟空间,不存在任何数据,直接返回空。
// 2、再通过哈希函数计算出该元素所映射的位置(即映射的哈希桶位置)
size_t index = Hash()(key) % _tables.size();
// 调用哈希仿函数得到哈希值,对数组长度取模,算出key落在哪个桶。
// 3、遍历该哈希桶,查找节点
Node* cur = _tables[index]; // cur指向该哈希桶
while (cur) // 遍历该哈希桶的所有节点
{
if (key == cur->_kv.first)
{
return cur; // 找到了,返回节点地址
}
cur = cur->_next;
}
// 只遍历当前桶的单链表,挨个比对key,匹配就返回节点地址。
// 4、没找到,返回空
return nullptr;
// 链表遍历完,没有匹配的key,返回nullptr。
}
【插入节点】
1.负载因子
哈希桶一般把负载因子控制在 1 以内,代表平均每个桶挂 1 个节点;负载因子等于 1 的时候就要做扩容。
2.开散列扩容逻辑
桶的总数量固定,不停插入元素后,部分桶里的链表会越变越长。极端情况下一个桶挂大量节点,查找效率会明显下降,所以要在合适时机扩容。 开散列理想状态是每个桶只挂一个节点,再插入新元素就极易产生冲突。所以约定:有效元素总数等于桶的数量时,触发扩容。
思路:
- 先判断要不要扩容:哈希表为空,或者负载因子达到 1,就执行扩容。
- 扩容时,把旧表里全部有效节点重新计算哈希,迁移到新桶数组,再交换新旧哈希表。
- 接着用哈希函数算出待插入 key 对应的桶下标。
- 遍历当前桶检查 key 是否已经存在: 不存在就头插新节点; key 已经存在,不允许重复插入,直接插入失败。


bool Insert(const pair<K, V>& kv)
{
// 1、扩容检查
// 当哈希表为空,或者负载因子(元素个数/桶个数)>= 1 时,需要扩容
if (_n == _tables.size())
{
// 计算新容量:如果当前为0则设为10,否则扩大为2倍
size_t newSize = _tables.size() == 0 ? 10 : _tables.size() * 2;
// (注:也可以用素数表扩容,这里注释掉了)
// size_t newSize = GetNextPrime(_tables.size());
// 创建新哈希表
vector<Node*> newTables;
newTables.resize(newSize);
// 2、转移旧表节点到新表
// 遍历旧表的每一个桶
for (size_t i = 0; i < _tables.size(); i++)
{
Node* cur = _tables[i]; // cur指向当前桶的第一个节点
// 如果当前桶不为空,开始转移
while (cur != nullptr)
{
Node* next = cur->_next; // 保存下一个节点,防止丢失
// 重新计算当前节点在新表中的位置(哈希地址)
size_t index = Hash()(cur->_kv.first) % newSize;
// 头插法将当前节点插入新表的对应桶中
cur->_next = newTables[index];
newTables[index] = cur;
cur = next; // 继续处理下一个节点
}
// 当前桶所有节点转移完毕,将旧表指针置空
_tables[i] = nullptr;
}
// 旧表和新表交换,旧表(vector)会自动释放
_tables.swap(newTables);
}
// 3、计算插入位置
// 用哈希函数计算 key 对应的桶号
size_t index = Hash()(kv.first) % _tables.size();
// 4、查重
// 遍历该桶,检查 key 是否已存在(不允许重复)
Node* cur = _tables[index];
while (cur)
{
if (kv.first == cur->_kv.first)
{
return false; // key 已存在,插入失败
}
cur = cur->_next;
}
// 5、头插新节点
Node* newNode = new Node(kv); // 创建新节点
newNode->_next = _tables[index]; // 新节点指向原第一个节点
_tables[index] = newNode; // 桶头指向新节点
_n++; // 元素个数+1
return true; // 插入成功
}
(3)开散列的思考
1.只能存储key为整形的元素,其他类型怎么解决?
//1、默认哈希函数(处理整型)
// 哈希函数采用处理余数法,被模的key必须要为整形才可以处理,此处提供将key转化为整形的方法
// 整形数据不需要转化
template<class T>
class DefHashF
{
public:
size_t operator()(const T& val)
{
return val; // 整型直接返回自身
}
};
// 2、字符串哈希函数(处理字符串)
// key为字符串类型,需要将其转化为整形
class Str2Int
{
public:
size_t operator()(const string& s)
{
const char* str = s.c_str(); // 获取C风格字符串
unsigned int seed = 131; // 种子值(常用质数:31、131、1313等)
unsigned int hash = 0; // 哈希值初始为0
// 遍历字符串每个字符
while (*str)
{
hash = hash * seed + (*str++); // 累乘加字符
}
return (hash & 0x7FFFFFFF); // 取31位,保证非负(去掉符号位)
}
};
// 3、哈希桶类(使用哈希函数)
// 为了实现简单,此哈希表中我们将比较直接与元素绑定在一起
template<class V, class HF>
class HashBucket
{
// …
private:
// 哈希函数:将key转为整型,再模容量得到桶号
size_t HashFunc(const V& data)
{
return HF()(data.first) % _ht.capacity(); // HF()是哈希函数对象
}
};
2.除留余数法,模数最好选用素数。那扩容时怎么快速拿到接近两倍大小的素数?
哈希表扩容的时候,调用GetNextPrime(_tables.size())获取下一个素数。这样既能让哈希表大小保持为素数,实现近似二倍的扩容效果,计算哈希下标时也可以对素数取模,减少哈希冲突。
// 素数表扩容 =
// 素数表,放了经过筛选的28个素数
// 作用:哈希表扩容时,选择素数作为桶的个数,可以减少哈希冲突
// 原因:取模运算时,素数的模数能更好地分散数据,避免周期性聚集
size_t GetNextPrime(size_t prime)
{
// 素数表(28个经过筛选的素数)
const int PRIMECOUNT = 28;
static const size_t primeList[PRIMECOUNT] =
{
53ul, 97ul, 193ul, 389ul, 769ul,
1543ul, 3079ul, 6151ul, 12289ul, 24593ul,
49157ul, 98317ul, 196613ul, 393241ul, 786433ul,
1572869ul, 3145739ul, 6291469ul, 12582917ul, 25165843ul,
50331653ul, 100663319ul, 201326611ul, 402653189ul, 805306457ul,
1610612741ul, 3221225473ul, 4294967291ul
};
// 遍历素数表,找到第一个大于当前容量的素数
size_t i = 0;
for (; i < PRIMECOUNT; ++i)
{
if (primeList[i] > prime)
return primeList[i]; // 返回合适的素数作为新容量
}
return primeList[i]; // 若都比当前容量小,返回最后一个
}
3.扩容问题
哈希桶负载因子一般控制在 1 以内,也就是平均每个桶挂一个节点,负载因子等于 1 就需要触发扩容。
如果遇到极端情况,所有节点全都挂在同一个桶里,链表会越变越长。查找、删除的效率会大幅下降;但插入是头插,受这个问题的影响不大。

那GetNextPrime(_tables.size())是解决扩容大小的问题。还有另一种处理思路:当单个桶里链表长度过长时,可以把链表转换成红黑树来提升查询性能。
不过 C++ 标准库的哈希桶没有采用这套方案,Java 的 HashMap 是这么实现的。红黑树的查询效率比长链表更高,JDK 里默认规则:单个桶节点数量超过 8,链表就转为红黑树。

【删除节点】
思路:
- 先判断哈希表是不是空表,如果是空,直接删除失败。
- 用哈希函数算出 key 对应的桶下标。
- 遍历当前桶的链表,找到目标节点,同时记录它的前驱节点。 如果找到了目标节点,区分是头节点还是普通中间节点,按对应逻辑完成删除;
- 遍历完没找到该节点,删除失败。

// 删除节点
bool Erase(const K& key)
{
// 1、先判断哈希表是否为空
if (_tables.size() == 0)
{
return false; // 表为空,删除失败
}
// 2、通过哈希函数计算出待删除节点所映射哈希桶的位置
size_t index = Hash()(key) % _tables.size();
// 3、遍历该哈希桶,查找待删除节点,以及它的前驱节点
Node* cur = _tables[index]; // cur指向当前桶的第一个节点
Node* prev = nullptr; // 记录前驱节点(用于删除非头节点)
while (cur)
{
if (key == cur->_kv.first) // 找到该节点了
{
//情况1:删除头节点
if (cur == _tables[index]) // cur是头节点
{
_tables[index] = cur->_next; // 桶头指向下一个节点
}
//情况2:删除非头节点
else // cur不是头节点
{
prev->_next = cur->_next; // 前驱节点跳过cur,指向cur的下一个
}
delete cur; // 释放节点内存
cur = nullptr; // 置空,避免野指针
_n–; // 有效节点个数-1
return true; // 删除成功
}
prev = cur; // 更新前驱指针
cur = cur->_next; // 继续遍历下一个节点
}
return false; // 遍历完没找到,删除失败
}
【开散列的效率】
开散列扩容时选用接近两倍大小的素数,目的就是尽可能降低哈希冲突。 理想情况下哈希表的时间复杂度可以达到 O (1),一旦发生大量哈希冲突,桶内链表变长,实际的查询效率就不再是 O (1)。
(4)开散列与闭散列对比
链地址法(开散列)每个节点要多存一个指针,看上去会多消耗一点内存。
但闭散列(开放地址法)必须预留大量空闲位置,才能保证查找性能,比如二次探查法要求负载因子不能超过 0.7。数组里每个存储单元占用的空间远大于一个指针。综合算下来,开散列实际反而更省内存。


