C++ 哈希详解(二):开放定址、哈希桶与 C++ 哈希表底层实现

🔥 星恒随风: 个人主页 ❄️ 个人专栏: 《指针合集》 | 《C语言基础》 | 《数据结构》 | 《机器学习导论》 | 《前端基础》 | 《python基础》 | 《C++从入门到入土》 | 《Linux的学习之旅》 ✨ 数据即知识,压缩即智能
前言:哈希表真正难在哪里?
1. Hash 本身并不复杂
上一篇已经知道,哈希表的核心思路是:
text
Key
↓
Hash Function
↓
Bucket
例如:
text
h(key) = key % M
真正的问题在于:
text
不同 Key
可能得到相同位置
也就是:
text
哈希冲突
所以真正实现哈希表时,大量代码其实都围绕:
text
如何处理 Collision
展开。
经典解决方案主要有:
text
开放定址法
链地址法
这篇文章就从这两个方案出发,逐步手写哈希表。
一、开放定址法是什么?
1. 所有数据都存储在数组中
开放定址法有一个非常明显的特点:
哈希表中的所有元素都直接存放在数组本身。
例如数组大小:
text
M = 11
开始时:
text
0 1 2 3 4 5 6 7 8 9 10
□ □ □ □ □ □ □ □ □ □ □
插入:
text
19
得到:
text
19 % 11 = 8

二、发生冲突怎么办?
继续插入:
text
30
计算:
text
30 % 11 = 8
但是:
text
bucket[8]
已经存在:
text
19
此时开放定址法不会创建链表,而是:
text
继续寻找其他空位置
寻找策略不同,就形成不同探测方法。
三、线性探测
1. 基本思想
如果:
text
hash0 = key % M
发生冲突,就依次尝试:
text
hash0 + 1
hash0 + 2
hash0 + 3
...
并通过 % M 回绕。
公式:
text
hash_i = (hash0 + i) % M
其中:
text
i = 0,1,2,3...
2. 示例
假设:
text
M = 11
插入:
text
19
30
首先:
text
19 % 11 = 8
于是:
text
bucket[8] = 19
然后:
text
30 % 11 = 8
冲突。
尝试:
text
(8 + 1) % 11 = 9
如果:
text
bucket[9]
为空,就存:
text
bucket[9] = 30
最终:
text
8 → 19
9 → 30
四、线性探测的问题:群集
假设已经出现:
text
7 → Data
8 → Data
9 → Data
10 → Data
后面任何原本映射到:
text
7
8
9
10
附近的数据,都可能不断往后移动。
结果逐渐形成:
text
████████
一大片连续被占用的区域。
这种现象称为:
text
Primary Clustering
群集 / 堆积
群集越严重:
text
探测次数越多
查询和插入效率也就越低。

五、二次探测
为减轻连续群集,可以不再:
text
+1
+2
+3
而改成平方距离,例如:
text
hash0 + 1²
hash0 - 1²
hash0 + 2²
hash0 - 2²
hash0 + 3²
hash0 - 3²
可以抽象为:
text
hash_i =
(hash0 ± i²) % M
相比线性探测:
text
连续向后走
二次探测会:
text
跳跃寻找位置
因此可以在一定程度上减少一次聚集。

六、双重散列
还有一种方法是利用:
text
第二个哈希函数
计算步长。
例如:
text
hash_i
=
(hash0 + i × hash2(key)) % M
也就是说:
text
第一个 Hash
决定起点
第二个 Hash
决定每次走多少步
如果不同 Key 具有不同步长,就可以进一步降低探测路径集中在一起的概率。
不过对于自己第一次实现哈希表来说:
text
线性探测
最容易理解,因此下面使用线性探测实现。
七、为什么数组不能只保存 pair<K,V>?
我们可能最开始想:
cpp
vector<pair<K, V>> _tables;
但是很快会遇到一个问题:
text
某个位置究竟是:
从来没有使用?
还是:
原来有数据后来被删除?
这两个状态对查找来说完全不同。
因此每个位置还需要一个:
text
State
八、三个状态:EMPTY、EXIST、DELETE
定义:
cpp
enum State
{
EMPTY,
EXIST,
DELETE
};
然后:
cpp
template<class K, class V>
struct HashData
{
pair<K, V> _kv;
State _state = EMPTY;
};
三个状态含义:
text
EMPTY
↓
这个位置从来没有有效元素
EXIST
↓
当前有有效元素
DELETE
↓
这里曾经有元素,但已经被删除

九、为什么删除不能直接设成 EMPTY?
假设:
text
M = 11
有:
text
19 % 11 = 8
30 % 11 = 8
20 % 11 = 9
经过线性探测以后可能得到:
text
8 → 19
9 → 30
10 → 20
现在删除:
text
30
如果直接把位置 9 设置为:
text
EMPTY
查找 20 时:
text
20 % 11 = 9
从:
text
bucket[9]
开始。
如果程序规定:
text
遇到 EMPTY
→ 说明后面不可能有元素
→ 停止
那么就无法找到真正位于:
text
bucket[10]
的 20。
所以删除位置不能直接当成:
text
从来没有使用过
而应该留下一个:
text
墓碑
Tombstone
也就是:
text
DELETE
十、开放定址哈希表结构
可以得到:
cpp
template<
class K,
class V,
class Hash = HashFunc<K>
>
class HashTable
{
private:
vector<HashData<K, V>> _tables;
size_t _n = 0;
};
其中:
text
_tables
是真正的哈希数组。
而:
text
_n
记录当前有效元素数量。

十一、HashFunc:解决 Key 不能直接取模的问题
1. 默认版本
对于整数类型,可以先设计:
cpp
template<class K>
struct HashFunc
{
size_t operator()(const K& key) const
{
return static_cast<size_t>(key);
}
};
于是:
text
Key
↓
HashFunc
↓
size_t
之后再:
cpp
hash(key) % tableSize
2. string 特化
字符串显然不能:
cpp
string % tableSize;
因此单独处理:
cpp
template<>
struct HashFunc<string>
{
size_t operator()(
const string& key) const
{
size_t hash = 0;
for (auto ch : key)
{
hash *= 131;
hash += ch;
}
return hash;
}
};
这种写法利用:
text
之前的计算结果
×
固定因子
+
当前字符
让字符顺序也参与哈希计算。
十二、开放定址的 Find
首先:
cpp
Hash hash;
计算初始位置:
cpp
size_t hash0 =
hash(key) % _tables.size();
然后线性探测。
cpp
HashData<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;
}
十三、Find 为什么遇到 DELETE 不能停?
例如:
text
8 → 19 EXIST
9 → DELETE
10 → 20 EXIST
寻找 20:
text
hash0 = 9
先看到:
text
DELETE
这并不能证明:
text
20 不存在
因为 20 可能是由于之前的冲突被推到了后面。
所以:
text
DELETE
→ 继续找
只有:
text
EMPTY
才能说明:
从当前哈希探测序列来看,后面已经不可能存在目标元素。
十四、开放定址的 Insert
插入之前先判断:
text
Key 是否已经存在
然后判断负载因子。
cpp
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
{
return false;
}
// 后面处理扩容
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;
}
注意:
text
DELETE
位置可以重新利用。
所以循环条件是:
cpp
_state == EXIST
而不是:
cpp
_state != EMPTY
十五、开放定址为什么必须控制负载因子?
开放定址中:
text
所有数据
都必须直接存入:
text
数组位置
因此必须保证:
text
至少还有空位置
所以负载因子:
text
α < 1
否则数组已经全部占满:
text
无论怎么探测
都不可能找到新的空位置
1. 一个常见教学实现
例如可以自己规定:
text
α >= 0.7
就扩容。
判断:
cpp
if (_n * 10
>= _tables.size() * 7)
{
// rehash
}
不要写成:
cpp
_n / _tables.size() >= 0.7
因为如果都是整数:
text
整数除法
会丢失小数部分。
十六、扩容为什么不能简单 resize?
假设:
text
旧表大小 = 11
元素:
text
19
原位置:
text
19 % 11 = 8
如果直接:
cpp
_tables.resize(23);
19 依然留在:
text
位置 8
但是以后查找:
text
19 % 23 = 19
会从:
text
位置 19
开始寻找。
自然找不到原来的:
text
位置 8
所以:
哈希表扩容一定意味着重新映射已有元素。
十七、开放定址扩容
一种比较清晰的实现是创建新表:
cpp
HashTable<K, V, Hash> newHT;
调整新空间:
cpp
newHT._tables.resize(newSize);
然后把旧表所有:
text
EXIST
元素重新插入:
cpp
for (size_t i = 0;
i < _tables.size();
++i)
{
if (_tables[i]._state == EXIST)
{
newHT.Insert(
_tables[i]._kv
);
}
}
最后交换:
cpp
_tables.swap(
newHT._tables
);
这里本质执行的是:
text
Old Table
↓
重新 hash
↓
New Table
十八、Erase 为什么非常简单?
开放定址不能真正把位置设为空。
所以找到元素以后:
cpp
bool Erase(const K& key)
{
HashData<K, V>* pos =
Find(key);
if (pos == nullptr)
{
return false;
}
pos->_state = DELETE;
--_n;
return true;
}
这叫:
text
逻辑删除
并没有立即消除:
text
pair<K,V>
本身。
真正有效性由:
text
_state
决定。
十九、开放定址完整核心代码
将前面的逻辑组合:
cpp
namespace open_address
{
enum State
{
EMPTY,
EXIST,
DELETE
};
template<class K, class V>
struct HashData
{
pair<K, V> _kv;
State _state = EMPTY;
};
template<
class K,
class V,
class Hash = HashFunc<K>
>
class HashTable
{
public:
HashTable(size_t size = 11)
: _tables(size)
{}
bool Insert(
const pair<K, V>& kv)
{
if (Find(kv.first))
{
return false;
}
if (_n * 10
>= _tables.size() * 7)
{
size_t newSize =
_tables.size() * 2 + 1;
HashTable<
K,
V,
Hash
> newHT(newSize);
for (auto& data :
_tables)
{
if (data._state
== EXIST)
{
newHT.Insert(
data._kv
);
}
}
_tables.swap(
newHT._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;
}
HashData<K, V>* Find(
const K& key)
{
if (_tables.empty())
{
return nullptr;
}
Hash hash;
size_t hash0 =
hash(key)
% _tables.size();
size_t hashi = hash0;
for (size_t i = 0;
i < _tables.size();
++i)
{
if (_tables[hashi]._state
== EMPTY)
{
return nullptr;
}
if (_tables[hashi]._state
== EXIST
&& _tables[hashi]
._kv.first
== key)
{
return
&_tables[hashi];
}
hashi =
(hash0 + i + 1)
% _tables.size();
}
return nullptr;
}
bool Erase(const K& key)
{
HashData<K, V>* pos =
Find(key);
if (!pos)
{
return false;
}
pos->_state = DELETE;
--_n;
return true;
}
private:
vector<HashData<K, V>>
_tables;
size_t _n = 0;
};
}
二十、开放定址法有什么缺点?
最大问题在于:
text
冲突的数据
仍然会占用数组中的其他位置
一个位置发生大量冲突,会影响:
text
附近其他 Key
再加上线性探测会产生:
text
群集
因此开放定址的性能会受到:
text
负载因子
非常明显的影响。
这就引出了另一种更经典的方案:
text
链地址法
二十一、链地址法是什么?
链地址法不再要求:
text
所有数据都直接存在数组中
数组只保存:
text
Node*
例如:
text
vector<Node*> _tables;
如果:
text
19
30
96
都映射到:
text
bucket 8
那么:
text
bucket[8]
|
v
96
|
v
30
|
v
19
形成链表。
这就是:
text
Separate Chaining
也经常叫:
text
链地址法
拉链法
哈希桶

二十二、哈希桶结点
结点非常简单:
cpp
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)
{}
};
每个结点只需要:
text
数据
+
next
因为一个桶本质就是:
text
单链表
二十三、哈希桶整体结构
cpp
template<
class K,
class V,
class Hash = HashFunc<K>
>
class HashTable
{
typedef HashNode<K, V> Node;
private:
vector<Node*> _tables;
size_t _n = 0;
};
例如:
text
_tables
0 → nullptr
1 → Node → Node
2 → nullptr
3 → Node
4 → Node → Node → Node
二十四、哈希桶的 Find
查找先定位桶:
cpp
Hash hash;
size_t index =
hash(key) % _tables.size();
然后:
text
只遍历这个桶
代码:
cpp
Node* Find(const K& key)
{
Hash hash;
size_t index =
hash(key)
% _tables.size();
Node* cur =
_tables[index];
while (cur)
{
if (cur->_kv.first
== key)
{
return cur;
}
cur = cur->_next;
}
return nullptr;
}
核心复杂度就取决于:
text
这个 bucket 有多长
如果 Hash 分布均匀:
text
桶很短
查找就很快。
二十五、哈希桶的 Insert
首先防止重复:
cpp
if (Find(kv.first))
{
return false;
}
然后算位置:
cpp
size_t index =
hash(kv.first)
% _tables.size();
插入桶中。
使用头插即可:
cpp
Node* newnode =
new Node(kv);
newnode->_next =
_tables[index];
_tables[index] =
newnode;
于是原来:
text
bucket
↓
A
↓
B
插入 C 后:
text
bucket
↓
C
↓
A
↓
B
时间复杂度:
text
O(1)
不需要找到链表尾部。
二十六、链地址法的负载因子可以大于 1
开放定址:
text
数组有 M 个位置
最多也只能存:
text
M 个元素
所以:
text
α < 1
才有继续插入的空间。
但链地址法:
text
一个 bucket
可以挂:
text
多个 Node
所以:
text
N > M
是完全允许的。
例如:
text
10 个 bucket
20 个元素
负载因子:
text
α = 20 / 10 = 2
依然能够存储。
但是负载因子继续变大意味着:
text
平均链长度增加
查找效率仍然会下降,所以实际实现依然会控制负载因子并适时 rehash。
二十七、哈希桶如何扩容?
假设:
text
Old Bucket Count = 5
扩容成:
text
New Bucket Count = 11
那么旧结点必须:
text
重新计算哈希位置
1. 一个低效但简单的方法
可以创建:
cpp
HashTable newHT;
然后把旧数据逐个:
cpp
newHT.Insert(...)
这样可以工作。
但是:
text
重新 new Node
+
旧 Node 最后再 delete
存在额外开销。
2. 更好的方法:直接搬结点
哈希桶本来就保存:
text
Node*
所以完全可以:
text
旧 Node
直接重新链接到新 bucket
而不重新创建结点。
流程:
text
Old Bucket
↓
取出 Node
↓
根据 New M 重新 Hash
↓
头插 New Bucket

二十八、直接移动结点完成 rehash
cpp
vector<Node*> newTables(
newSize,
nullptr
);
Hash hash;
for (size_t i = 0;
i < _tables.size();
++i)
{
Node* cur =
_tables[i];
while (cur)
{
Node* next =
cur->_next;
size_t index =
hash(cur->_kv.first)
% newTables.size();
cur->_next =
newTables[index];
newTables[index] =
cur;
cur = next;
}
_tables[i] = nullptr;
}
_tables.swap(newTables);
这里一定要提前:
cpp
Node* next = cur->_next;
因为后面:
cpp
cur->_next = newTables[index];
会修改原来的 _next。
如果不提前保存:
text
原链表后面的结点
就找不到了。
二十九、哈希桶的 Erase
找到桶以后:
cpp
Node* prev = nullptr;
Node* cur =
_tables[index];
遍历:
cpp
while (cur)
找到目标。
如果删除的是头结点:
cpp
if (prev == nullptr)
{
_tables[index] =
cur->_next;
}
否则:
cpp
prev->_next =
cur->_next;
最后:
cpp
delete cur;
--_n;
完整代码:
cpp
bool Erase(const K& key)
{
Hash hash;
size_t index =
hash(key)
% _tables.size();
Node* prev = nullptr;
Node* cur =
_tables[index];
while (cur)
{
if (cur->_kv.first
== key)
{
if (prev == nullptr)
{
_tables[index] =
cur->_next;
}
else
{
prev->_next =
cur->_next;
}
delete cur;
--_n;
return true;
}
prev = cur;
cur = cur->_next;
}
return false;
}
三十、析构函数为什么必须自己释放结点?
我们的:
cpp
vector<Node*> _tables;
只保存:
text
指针
当 vector 析构时,只会销毁:
text
Node*
本身。
不会自动:
cpp
delete Node
所以必须遍历所有桶。
cpp
~HashTable()
{
for (size_t i = 0;
i < _tables.size();
++i)
{
Node* cur =
_tables[i];
while (cur)
{
Node* next =
cur->_next;
delete cur;
cur = next;
}
_tables[i] = nullptr;
}
}
否则就会产生:
text
内存泄漏
三十一、链地址哈希表完整核心版本
cpp
namespace hash_bucket
{
template<class K, class V>
struct HashNode
{
pair<K, V> _kv;
HashNode<K, V>* _next;
HashNode(
const pair<K, V>& kv)
: _kv(kv)
, _next(nullptr)
{}
};
template<
class K,
class V,
class Hash = HashFunc<K>
>
class HashTable
{
typedef HashNode<K, V>
Node;
public:
HashTable(size_t size = 11)
: _tables(
size,
nullptr)
{}
~HashTable()
{
for (size_t i = 0;
i < _tables.size();
++i)
{
Node* cur =
_tables[i];
while (cur)
{
Node* next =
cur->_next;
delete cur;
cur = next;
}
}
}
Node* Find(const K& key)
{
Hash hash;
size_t index =
hash(key)
% _tables.size();
Node* cur =
_tables[index];
while (cur)
{
if (cur->_kv.first
== key)
{
return cur;
}
cur = cur->_next;
}
return nullptr;
}
bool Insert(
const pair<K, V>& kv)
{
if (Find(kv.first))
{
return false;
}
Hash hash;
// 这里使用负载因子达到 1
// 就扩容的教学策略
if (_n == _tables.size())
{
size_t newSize =
_tables.size()
* 2 + 1;
vector<Node*>
newTables(
newSize,
nullptr
);
for (size_t i = 0;
i < _tables.size();
++i)
{
Node* cur =
_tables[i];
while (cur)
{
Node* next =
cur->_next;
size_t index =
hash(
cur
->_kv
.first)
% newSize;
cur->_next =
newTables[
index];
newTables[index]
= cur;
cur = next;
}
_tables[i] =
nullptr;
}
_tables.swap(
newTables
);
}
size_t index =
hash(kv.first)
% _tables.size();
Node* newnode =
new Node(kv);
newnode->_next =
_tables[index];
_tables[index] =
newnode;
++_n;
return true;
}
bool Erase(const K& key)
{
Hash hash;
size_t index =
hash(key)
% _tables.size();
Node* prev = nullptr;
Node* cur =
_tables[index];
while (cur)
{
if (cur->_kv.first
== key)
{
if (prev == nullptr)
{
_tables[index] =
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;
};
}
三十二、开放定址与链地址对比
现在两种方式都实现完了,可以放到一起比较。
| 特点 | 开放定址 | 链地址 |
|---|---|---|
| 元素位置 | 数组内部 | 独立结点 |
| 冲突处理 | 找其他空槽 | 挂到同一个桶 |
| 删除 | DELETE 墓碑 | 真正删除结点 |
| 负载因子 | 必须小于 1 才有空槽 | 可以大于 1 |
| 内存局部性 | 较好 | 指针跳转较多 |
| 实现 | 探测逻辑复杂 | 链表逻辑较直观 |
| 冲突影响 | 不同 Key 可能相互挤占 | 主要影响本桶 |
学习数据结构时,两种方法都必须理解。
而传统的:
text
哈希桶
是理解 unordered_map / unordered_set 实现思想非常重要的一种结构。
三十三、为什么哈希桶平均能够做到 O(1)?
假设:
text
M 个 bucket
N 个元素
负载因子:
text
α = N / M
如果哈希函数比较均匀:
text
每个桶
平均大约 α 个元素
查找:
text
1. Hash O(1)
2. 定位 bucket O(1)
3. 遍历桶 平均 O(α)
如果实现把:
text
α
控制在常数范围内,那么:
text
O(1 + α)
仍然可以看成平均:
text
O(1)
这就是哈希表平均常数复杂度背后的真正原因。
三十四、最坏情况下为什么是 O(N)?
如果 Hash 很差,例如:
text
所有 Key
↓
bucket 5
那么:
text
bucket[5]
↓
Node
↓
Node
↓
Node
↓
...
↓
N 个
查找最后一个元素:
text
需要检查 N 个结点
所以最坏:
text
O(N)
这再次说明:
text
哈希函数的均匀性
对哈希表极其重要。
三十五、rehash 为什么是一次比较昂贵的操作?
扩容时需要把:
text
所有已有元素
重新计算 bucket。
所以一次 rehash 的成本大约是:
text
O(N)
那么为什么还说:
text
insert 平均 O(1)
?
原因是扩容不是:
text
每插入一次都发生
而是间隔很多次插入才出现。
如果容量按一定比例增长,例如:
text
11
↓
23
↓
47
↓
95
...
那么大量插入操作共同分摊扩容成本。
这种分析通常叫:
text
Amortized Analysis
摊还分析
所以哈希表插入可以保持较好的平均/摊还性能。