目录
- 前言
- 一、链地址法的核心思想
- 二、节点设计:链表节点 + 指针数组
- 三、哈希函数——复用上篇的成果
- 四、构造函数——初始桶数有讲究
- 五、插入——头插法,快就一个字
- 六、扩容——搬节点,不是搬数据
- 七、查找——三步定位,链表遍历
- 八、删除——标准单链表删除
- 九、析构函数——释放所有堆内存
- 十、测试验证
- 十一、开放定址法 vs 链地址法
- 十二、总结
前言
上篇文章我们用开放定址法手撕了一个哈希表,其核心思想是通过线性探测解决哈希冲突。
但开放定址法有其缺点:负载因子一大,冲突率飙升,探测路径变长,效率下降。因为开放定址法本质是“鸠占鹊巢”,没有从根本上解决冲突的问题。那么有没有不占别人位置就能解决冲突的办法?
有,这就是 C++ std::unordered_map 采用的链地址法(Separate Chaining)。
本文带你对照源码,从头实现一个链地址法哈希表。
一、链地址法的核心思想
开放定址法是"这个坑被占了,我换个坑"。链地址法是"这个桶被占了?没关系,同一个桶里可以挂一堆元素"。
数组的每个位置(桶)不再直接存数据,而是存一个链表的头指针。
[0] -> nullptr
[1] -> (key=19) -> (key=30) -> nullptr ← 冲突元素挂在同一个链表上
[2] -> (key=5) -> (key=36) -> nullptr
[3] -> nullptr
[4] -> (key=13) -> nullptr
...
这样一来,冲突的元素只是挂在同一个桶的链表后面,不影响其他桶的查找。这才是真正的"冲突无害化"。
二、节点设计:链表节点 + 指针数组
链地址法需要两种数据结构:
| 结构 | 作用 |
|---|---|
HashNode<K, V> | 链表节点,存 key-value + next 指针 |
vector<Node*> | 指针数组,每个元素是一个桶的头指针 |
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:
// ...
private:
vector<Node*> _tables; // 指针数组,每个桶是一个链表头
size_t _n = 0; // 当前存储的元素个数
};
关键区别:开放定址法的
_tables存的是HashData对象本身;链地址法的_tables存的是链表头指针。元素用new分配在堆上,是链表的节点。
三、哈希函数——复用上篇的成果
哈希函数和上篇开放定址法完全一致——默认支持整型,特化支持 string(BKDR Hash),支持自定义仿函数。这里不再赘述,直接用:
// 默认:整型直接转 size_t
template<class K>
struct HashFunc
{
size_t operator()(const K& key) { return (size_t)key; }
};
// 特化:string 用 BKDR Hash
template<>
struct HashFunc<string>
{
size_t operator()(const string& s)
{
size_t hash = 0;
for (auto ch : s)
{
hash += ch;
hash *= 131;
}
return hash;
}
};
四、构造函数——初始桶数有讲究
HashTable()
: _tables(__stl_next_prime(0))
, _n(0)
{}
__stl_next_prime(0) 会返回质数表中第一个大于等于 0 的质数——53。为什么不用 0 或者 4、8 这种 2 的幂?因为质数表长能让哈希值取模后分布更均匀,这一点上篇已经详细分析过了。
五、插入——头插法,快就一个字
5.1 为什么要头插
往链表尾部插入需要先遍历到末尾——O(n)。而头插法直接把新节点插在链表最前面:
插入前: [桶] -> A -> B -> nullptr
插入后: [桶] -> NEW -> A -> B -> nullptr
两步搞定,O(1)。
5.2 插入流程
bool Insert(const pair<K, V>& kv)
{
Hash hash;
// 第一步:查重
if (Find(kv.first))
return false;
// 第二步:检查是否需要扩容
if (_n == _tables.size()) // 负载因子 == 1
{
// ... 扩容逻辑(下一节详解)
}
// 第三步:头插
size_t hashi = hash(kv.first) % _tables.size();
Node* newnode = new Node(kv);
newnode->_next = _tables[hashi]; // 新节点指向当前头节点
_tables[hashi] = newnode; // 桶指针指向新节点
++_n;
return true;
}
三步走:查重 → 扩容检查 → 头插。逻辑清晰,代码简洁。
5.3 负载因子为什么是 1
上篇开放定址法的扩容阈值是 0.7,为什么链地址法可以放到 1?
因为冲突的代价不同:
- 开放定址法:冲突了要线性探测,越满探测路径越长,冲突会传染
- 链地址法:冲突了只是链表变长,不影响其他桶,每个桶的查找只遍历自己的链表
所以链地址法的负载因子可以放到 1 甚至更高才扩容。本文取 1.0(元素数 == 桶数时扩容),这是一个平衡选择。
六、扩容——搬节点,不是搬数据
6.1 两种扩容方式
扩容的本质是新申请一个更大的数组,然后把旧表的所有元素搬到新表。搬法有两种:
方式一:创建新表 + 重新 Insert(低效版)
// 我们在开放定址法中用的写法——在这里不推荐!
HashTable<K, V, Hash> newht;
newht._tables.resize(__stl_next_prime(_tables.size() + 1));
for (size_t i = 0; i < _tables.size(); i++)
{
Node* cur = _tables[i];
while (cur)
{
newht.Insert(cur->_kv); // 每次都 new 新节点!
cur = cur->_next;
}
}
_tables.swap(newht._tables);
这种写法的问题:每次 Insert 都要 new Node,旧表的节点本该复用却被丢弃,造成了大量无意义的 new 和后续的 delete。纯纯的资源浪费。
方式二:节点迁移(高效版,推荐)
不创建新节点,而是把旧表的节点摘下来、重新链接到新表:
if (_n == _tables.size())
{
// 1. 创建新的桶数组
vector<Node*> newTable(__stl_next_prime(_tables.size() + 1));
// 2. 遍历旧表,把每个节点迁移到新表
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) % newTable.size();
cur->_next = newTable[hashi];
newTable[hashi] = cur;
cur = next; // 处理下一个节点
}
_tables[i] = nullptr; // 旧桶置空,避免野指针
}
// 3. 交换新旧表,O(1) 完成"搬家"
_tables.swap(newTable);
}
整个过程图解:
旧表[1]: A -> B -> nullptr 新表[1]: nullptr
新表[5]: nullptr
处理 A: hash(A) % newSize = 5
新表[5]: A -> nullptr
处理 B: hash(B) % newSize = 1
新表[1]: B -> nullptr
旧表[2]: C -> D -> nullptr 新表[1]: B -> nullptr
新表[5]: A -> nullptr
处理 C: hash(C) % newSize = 5
新表[5]: C -> A -> nullptr (头插,C 在 A 前面)
处理 D: hash(D) % newSize = 1
新表[1]: D -> B -> nullptr (头插,D 在 B 前面)
每次只做指针重定向,不需要new和delete。
6.2 节点迁移的好处
| 重新 Insert | 节点迁移(本文实现) | |
|---|---|---|
| new 节点 | 每个元素一次 | 0 次 |
| delete 旧节点 | 析构时全部 delete | 0 次 |
| 内存碎片 | 大量申请释放 | 无 |
| 代码复杂度 | 简单 | 稍复杂但可控 |
一句话:节点迁移让扩容从"搬家"变成了"换门牌号"——东西还是那些东西,只是按新的规则重新组织了一下。
七、查找——三步定位,链表遍历
链地址法的查找思路极其自然:先定位桶,再遍历链表。
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) // 比较 key,不是比较哈希值!
{
return cur; // 找到了
}
cur = cur->_next;
}
return nullptr; // 遍历完都没找到
}
时间复杂度分析:
- 最好情况:桶里只有 0~1 个元素,O(1)
- 平均情况:负载因子控制在 1.0,每个桶平均 1 个元素,接近 O(1)
- 最坏情况:所有 key 哈希冲突,退化成一个长链表,O(n)
注意:比较的是
cur->_kv.first == key(key 值相等),不是比较哈希值!哈希值相等不代表 key 相等(哈希冲突就是这个意思)。博主在自己实现的时候踩了这个坑。
八、删除——标准单链表删除
链地址法的删除就是经典的单链表删除指定节点:
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)
{
// 情况1:删除的是链表头节点
_tables[hashi] = cur->_next;
}
else
{
// 情况2:删除的是中间或尾部节点
prev->_next = cur->_next;
}
delete cur;
--_n;
return true;
}
prev = cur;
cur = cur->_next;
}
return false;
}
删除链表头: 删除中间节点:
删除前: [桶] -> A -> B -> C 删除前: [桶] -> A -> B -> C
↑删除目标 ↑删除目标
删除后: [桶] -> B -> C 删除后: [桶] -> A -> C
prev=nullptr prev->_next = cur->_next
对比开放定址法:开放定址法需要一个
DELETE状态来"占坑",防止探测链断裂。链地址法直接删节点、重新链接指针即可,不需要任何标记位。这也是链地址法的一个优势。
九、析构函数——释放所有堆内存
节点都是 new 出来的,析构时必须逐一释放,否则内存泄漏:
~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;
}
}
遍历每个桶的链表,逐个 delete。指针数组 vector<Node*> 本身会自动析构,不用管。
写了析构函数,就要写拷贝构造和赋值重载,此处省略。
十、测试验证
#include "HashTable.h"
#include <iostream>
int main()
{
// 插入 10 个元素,初始 53 个桶,不超过负载因子,不触发扩容
int a2[] = { 19, 30, 5, 36, 13, 20, 21, 12, 24, 96 };
hash_bucket::HashTable<int, int> ht2;
for (auto e : a2)
{
ht2.Insert({ e, e });
}
// 验证查找
cout << ht2.Find(96) << endl; // 非空指针
cout << ht2.Find(30) << endl; // 非空指针
cout << ht2.Find(19) << endl; // 非空指针
// 验证删除
ht2.Erase(96);
ht2.Erase(19);
ht2.Erase(30);
cout << endl;
cout << ht2.Find(96) << endl; // nullptr(已删除)
cout << ht2.Find(30) << endl; // nullptr(已删除)
cout << ht2.Find(19) << endl; // nullptr(已删除)
return 0;
}
输出预期:
0000021A8B5C1E80 ← Find(96) 有地址
0000021A8B5C1990 ← Find(30) 有地址
0000021A8B5C1890 ← Find(19) 有地址
0000000000000000 ← Find(96) nullptr,删除成功
0000000000000000 ← Find(30) nullptr,删除成功
0000000000000000 ← Find(19) nullptr,删除成功
十一、开放定址法 vs 链地址法
| 维度 | 开放定址法 | 链地址法 |
|---|---|---|
| 数据结构 | 数组直接存数据 + 状态标记 | 指针数组 + 链表节点 |
| 冲突解决 | 线性探测找下一个空位 | 冲突元素挂同一个链表 |
| 删除方式 | 懒惰删除(DELETE 占坑) | 直接 delete 节点 |
| 内存布局 | 连续,缓存友好 | 节点分散在堆上 |
| 负载因子上限 | 通常 0.5~0.7 | 可以到 1.0 甚至更高 |
| 额外内存开销 | 只多一个 State 枚举 | 每个节点多一个 next 指针 |
| 扩容代价 | 搬运数据,重新哈希 | 节点迁移,只改指针 |
| 最坏情况 | 一次长探测 | 一个长链表 |
| 实际应用 | 较少见,如一些嵌入式场景 | std::unordered_map 的选择 |
C++ 标准库的
unordered_map和unordered_set用的就是链地址法。学会本文的实现,再看标准库源码就不会觉得陌生了。
十二、总结
本文从零实现了一个链地址法哈希表,核心要点:
- 节点结构:
HashNode存pair+next指针 - 桶数组:
vector<Node*>,每个元素是一个链表的头指针 - 头插法:插入 O(1),简单高效
- 节点迁移扩容:不 new 不 delete,只改指针指向,效率远高于重新插入
- 删除:标准单链表删除,
prev和cur配合操作 - 析构:遍历每个桶的链表逐一
delete,防止内存泄漏 - 负载因子 1.0:链地址法比开放定址法能容忍更高的负载
如有纰漏,敬请指正!
515



被折叠的 条评论
为什么被折叠?



