前言
在上一篇文章中,我们已经系统地介绍了哈希表的基本原理,包括散列函数的设计、哈希冲突的产生原因以及负载因子的影响。通过这些内容可以看到,哈希表之所以能够在平均情况下实现 O(1) 的查找效率,核心在于将关键字通过哈希函数映射到数组下标,从而将查找问题转化为一次数组访问
然而,仅仅理解这些理论还远远不够。由于哈希冲突是不可避免的,真正决定哈希表性能的关键,并不在于如何计算位置,而在于当多个元素映射到同一位置时,我们应当如何处理
基于此,本文将进一步深入哈希表的实现细节,重点介绍两种经典的冲突解决策略------开放定址法与链地址法,并通过完整的 C++ 实现,分析其在插入、查找、删除以及扩容过程中的具体行为,从而完成从理解原理到工程实现的过渡
一. 开放定址法代码实现
在开放定址法的实现中,最精妙也最容易出错的地方在于状态管理 和线性探测的逻辑闭环
结构设计
在开放定址法中,我们不能简单地通过数据是否为零或空来判断某个位置是否可用。正如我们之前讨论的,为了不破坏查找路径,我们需要为每个桶引入状态位
cpp
// 定义桶的状态
enum State
{
EMPTY, // 有数据
EXIST, // 无数据
DELETE // 数据已被删除
}
// 存储单元
template<class K, class V>
struct HashData
{
pair<K, V> _kv;
State _state = EMPTY; // 默认为空
}
// 哈希表主体结构
tmeplate<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
HashTable(size_t size) : _n(0), _table(size)
{}
private:
vector<HashData<K, V>> _table;
size_t _n; // 有效数据个数
}
使用 DELETE 状态是为了解决删除断裂问题。查找时,遇到 EXIST 和 DELETE 都要继续往后找,只有遇到 EMPTY 才能停止
哈希仿函数
针对 Key 类型的多样性(包括 int、string 及自定义对象),我们需设计统一接口将其转换为可进行取模运算的 size_t 类型
cpp
// 默认支持能直接强转 size_t 的类型 (int, char等)
template<class K>
struct HashFunc
{
size_t operator()(const K& key) { return (size_t)key; }
}
// 特化支持 string 类型
template<>
struct HashFunc<string>
{
size_t operator()(const string& str)
{
size_t hash = 0;
for(auto& e : str)
{
hash += hash * 131 + e; // BKDR Hash 算法,减少冲突
}
return hash;
}
}
插入、查找与删除
这三个操作是联动的,核心在于探测序列
1. 查找(Find)
查找逻辑是所有操作的底层支撑。它必须遵循:遇到 EXIST 且 Key 匹配则成功,遇到 DELETE 继续,遇到 EMPTY 彻底停止
cpp
HashData<K, V>* Find(const K& key)
{
if(_table.empty()) return nullptr;
// 调用哈希函数,将 key 转化为原始下标
Hash hash;
size_t hashi = hash(key) % _table.size();
size_t start = hashi; // 记录起点,防止死循环
while(_table[hashi]._state != EMPTY)
{
// 只有状态为 EXIST 且 Key 相等才算真正找到
if(_table[hashi]._state == EXIST && _table[hashi]._kv.first == key)
return &_table[hashi];
hashi = (hashi + 1) % _table.size(); // 线性探测
if(hashi == start) break; // 找了一圈没找到
}
return nullptr;
}
2. 删除(Erase)
伪删除法:不需要真的清空内存,只需改状态
cpp
bool Erase(const K& key)
{
HashData<K, V>* ret = Find(key);
if(ret)
{
ret->_state = DELETE;
--_n;
return true;
}
return false;
}
3. 进阶优化:素数表
在前面的除法散列法中我们提到:当哈希表大小 M 为素数时,冲突的概率最小。为了实现这一点,SGI STL 并没有简单地让数组容量乘以 2,而是预先定义了一个素数表,每次扩容时都去表中查找下一个最接近的素数
cpp
// SGI STL 提供的素数表:大约以 2 倍左右增长
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
};
// 获取下一个最接近的素数
inline unsigned long __stl_next_prime(unsigned long n)
{
const unsigned long* first = __stl_prime_list;
const unsigned long* last = __stl_prime_list + __stl_num_primes;
// 使用 lower_bound 在有序数组中寻找第一个大于等于 n 的值
const unsigned long* pos = lower_bound(first, last, n);
// 如果 n 超过了最大素数,则返回最后一个(保护机制)
return pos == last ? *(last - 1) : *pos;
}
虽然查找素数表多了一点开销,但相比于减少冲突带来的查找效率提升,这笔开销简直微不足道。并且这个列表涵盖了从 53 到 42 亿(32位无符号数上限)的范围,足以应对绝大多数内存受限的场景
4. 插入与扩容
当负载因子过高时(通常设为 0.7),冲突会剧增。此时我们需要扩容。
注意:不能直接给 vector 扩容,因为数组长度变了,之前的 key % m 的映射位置全乱了
cpp
bool Insert(pair<K, V>& kv)
{
if(Find(kv.first)) return false; // 已存在则插入失效
// 1. 扩容逻辑:负载因子超过 0.7
if(_table.size() || (double)_n / _table.size() >= 0.7)
{
// 巧妙实现:构造一个新的哈希表对象,复用其 Insert 逻辑进行数据迁移
HashTable<K, V, Hash> newHT(__stl_next_prime(_table.size() + 1));
for(auto& e : _table)
{
if(e._state == EXIST)
newHT.Insert(e._kv);
}
_table.swap(newHT._table); // 将资源交换过来
}
Hash hash;
size_t hashi = kv.first % _table.size();
// 线性探测, 寻找可用的位置
// 这里只要不是 EXIST,就可以占位插入
while(_table[hashi]._state == EXIST)
{
hashi = (hashi + 1) % _table.size();
}
// 将数据插入
_table[hashi]._kv = kv;
_table[hashi]._state = EXIST;
++_n;
return true;
}
-
hash(key) % _tables.size() 体现了哈希函数的作用。通过仿函数将复杂的 Key 变成一个巨大的整数,再通过取模将其压缩到当前数组的范围内
-
hashi = (hashi + 1) % _tables.size(),这行代码就是线性探测的灵魂。它保证了当首选位置被占用时,数据能有序地寻找下一个落脚点
二. 链地址法代码实现
链地址法也就是我们常说的拉链法,则是通过在数组每个桶位挂载一个链表,将冲突的元素引流到外部存储空间。这种结构在处理大规模冲突时表现得更加优雅,也是 C++ unordered_map 以及 Java HashMap 底层选择的主流方案
结构设计
在链地址法中,数组的每个元素不再直接存储数据,而是一个指向链表节点的指针。我们将每个位置形象地称为一个"桶(Bucket)"
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)
{}
}
// 哈希表主体结构
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
typedef HashNode<K, V> Node;
public:
HashTable(size_t size = __stl_next_prime(0))
:_table(size, nullptr)
{}
private:
vector<Node>* _table; // 桶数组,存储节点指针
size_t _n = 0;
}
插入、查找与删除
这三个操作的核心逻辑高度一致:先通过哈希函数定位置,再进入链表进行线性操作
1. 查找(Find)
cpp
Node* Find(const K& key)
{
if(_table.empty()) return nullptr;
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;
}
2. 删除(Erase)
链表的删除需要注意处理头结点的特殊情况
cpp
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(prev == nullptr) _table[hashi] = cur->_next; // 头删
else prev->_next = cur->_next; // 中间或者尾删
delete cur;
--_n;
return true;
}
prev = cur;
cur = cur->_nextl;
}
return false;
}
3. 扩容与节点迁移
链地址法的负载因子通常设为 1.0。当有效数据个数等于桶的个数时,就需要扩容了
在扩容迁移数据时,我们不应该去创建新节点再删除旧节点,而是直接将旧表的节点对象直接摘下来,挂到新表的桶里。这避免了大量的 new 和 delete 开销,性能提升显著
cpp
bool Insert(const pair<K, V>& kv)
{
if(Find(kv.first)) return false;
Hash hash;
// 负载因子达到 1 时触发扩容逻辑
if(_n == _table.size())
{
size_t newSize = __stl_next_prime(_tables.size() + 1); // 使用素数表
vector<Node*> newTable(newSize, nullptr);
for(size_t i = 0; i < _table.size(); ++i)
{
Node* cur = _table[i];
while(cur)
{
Node* next = cur->_next; // 先保存下一个节点
size_t hashi = hash(kv.first) % _table.size();
// 头插到新表的桶中
cur->_next = newTable[hashi];
newTable[hashi] = cur;
cur = next;
}
_table[hashi] = nullptr; // 原桶对应位置置空
}
_table.swap(newTable);
}
// 扩容后插入新节点
size_t hashi = hash(kv.first) % _table.size()
Node* newNode = new Node(kv);
// 头插
newNode->_next = _table[hashi];
_table[hashi] = newNode;
++_n;
return true;
}
拆解头插代码
**第一步:**cur->_next = newTables[hashi]
让当前节点 cur 的 next 指针指向新桶里现有的第一个节点。这一步是为了接班。如果这个桶目前是空的(nullptr),那 cur 就指向空;如果这个桶里已经由于之前的迁移挂了几个节点,那 cur 就排在它们前面,把它们接在自己身后
**第二步:**newTables[hashi] = cur
让新表的桶指针直接指向 cur。这一步是为了上位。现在 cur 正式成为了这个桶的新头节点
如果要使用尾插的话,需要先从头节点开始,顺着 next 一个一个找下去,直到找到最后一个。如果一个桶里的冲突节点比较多,这会浪费大量的 CPU 时间
析构函数
由于链地址法涉及大量的堆内存申请,析构函数必须负责遍历每一个桶,并释放整条链表,防止内存泄漏
cpp
~HashTable()
{
for(int i = 0; i < _table.size(); ++i)
{
Node* cur = _table[i];
while(cur)
{
Node* next = cur->_next;
delete cur;
cur = next;
}
_table[i] = nullptr;
}
}
深度解析:为什么链地址法必须手动写析构,而开放定址法不需要?
在编写代码时,你可能会发现一个有趣的现象:我们在实现开放定址法时,甚至根本没有写 ~HashTable(),但程序运行得非常完美,没有内存泄漏。而在链地址法中,如果不写析构函数,内存泄漏会非常严重
1. 开放定址法
在开放定址法的结构中,我们的底层容器是 vector<HashData>。而 HashData 对象是直接存储在 vector 开辟的连续内存空间里的。当 HashTable 对象生命周期结束时,系统会自动调用 vector 的析构函数。vector 的析构函数会负责销毁自己内部所有的 HashData 对象
因为没有涉及手动的 new 操作,所有的内存申请和释放都由 vector 代劳了
2. 链地址法
在链地址法的结构中:我们的底层容器是 std::vector<Node*>。 而 vector 此时存的是指针,而不是对象本身。虽然 vector 析构时会清理掉这些指针(几个字节的地址空间),但它并不知道这些指针指向了堆上的哪块内存,更不会主动帮你去 delete 那些链表节点
在 Insert 操作中手动创建的每个 Node 都是一笔内存债务。如果在析构时没有遍历每个桶并逐个释放节点,这些节点将造成内存泄露,永远驻留在系统中
| 数据存放位置 | 谁负责释放 | 结论 | |
|---|---|---|---|
| 开放定址法 | 栈 | vector 自动处理 | 符合 Rule of Zero,无需显式析构 |
| 链地址法 | 堆 | 开发者手动处理 | 必须显式实现析构,否则内存泄漏 |
总结
至此,这篇关于哈希表手写实现的长文也告一段落。回顾整个实现过程,本质上是一场围绕空间利用、性能表现与工程健壮性展开的权衡与取舍:开放定址法通过线性探测与状态标记,在连续数组中精细运作,以获得更优的缓存局部性与访问效率;链地址法则借助链表结构与节点迁移,在更灵活的存储空间中实现冲突管理,即使在较高负载因子下仍能保持稳定表现
结合 SGI STL 中采用的素数表扩容策略,我们不仅从实践层面验证了哈希表平均 O(1) 查找效率的实现基础,也进一步体会到 C++ 底层设计的核心思想:并不存在绝对最优的实现方案,关键在于在内存连续性、扩容成本与复杂度等多重因素之间做出合理权衡
当真正掌握开放定址法与链地址法这两种实现范式后,开发者也将不再局限于简单的容器使用,而是能够从更底层的视角理解数据结构的运行机制,洞察每一次访问与操作背后的内存组织与性能
