哈希表:STL Hashtable 实现原理、展开链表、开放寻址法
STL hashtable
现代 STL 中的 _Hashtable 作为 unordered_map 和 unordered_set 的底层实现,其设计更为复杂且高效。
类定义(简化):
template<typename _Key, typename _Value, typename _Alloc,
typename _ExtractKey, typename _Equal,
typename _H1, typename _H2, typename _Hash,
typename _RehashPolicy, typename _Traits>
class _Hashtable
: public __detail::_Hashtable_base<...>,
public __detail::_Map_base<...>,
public __detail::_Insert<...>,
public __detail::_Rehash_base<...>,
public __detail::_Equality<...>,
private __detail::_Hashtable_alloc<...>
{
// ...
};
模板参数含义
_Key:键值类型_Value:实值类型(如pair<const Key, T>)_Alloc:分配器_ExtractKey:从 value 中提取 key 的方法(如_Select1st)_Equal:键相等判断函数_H1:主哈希函数(如hash<Key>)_H2:范围映射函数(如_Mod_range_hashing)_Hash:组合函数,_Hash(k, N) = _H2(_H1(k), N)_RehashPolicy:rehash 策略(如_Prime_rehash_policy)_Traits:特性萃取(如是否缓存 hash code)
类定义
template<typename _Key, typename _Tp,
typename _Hash = hash<_Key>,
typename _Pred = equal_to<_Key>,
typename _Alloc = allocator<std::pair<const _Key, _Tp>>>
class unordered_map
{
typedef __umap_hashtable<_Key, _Tp, _Hash, _Pred, _Alloc> _Hashtable;
_Hashtable _M_h;
};
// __umap_hashtable如下
template<bool _Cache>
using __umap_traits = __detail::_Hashtable_traits<_Cache, false, true>;
template<typename _Key,
typename _Tp,
typename _Hash = hash<_Key>,
typename _Pred = std::equal_to<_Key>,
typename _Alloc = std::allocator<std::pair<const _Key, _Tp> >,
typename _Tr = __umap_traits<__cache_default<_Key, _Hash>::value>>
using __umap_hashtable = _Hashtable<_Key, std::pair<const _Key, _Tp>, _Alloc, __detail::_Select1st,
_Pred, _Hash,
__detail::_Mod_range_hashing,
__detail::_Default_ranged_hash,
__detail::_Prime_rehash_policy, _Tr>;
可以看出,_H1 就是 _Hash = hash<_Key>,_H2 就是 __detail::_Mod_range_hashing,_Hash 就是 __detail::_Default_ranged_hash。typename _H1, typename _H2, typename _Hash 之间的关系如下:_Hash(k, N) = _H2(_H1(k), N)。
看完了这些,继续看一下 _Hashtable 类的数据成员:
template<typename _Key, typename _Value, typename _Alloc,
typename _ExtractKey, typename _Equal,
typename _H1, typename _H2, typename _Hash,
typename _RehashPolicy, typename _Traits>
class _Hashtable
{
// hash表中节点类型
using __node_type = __detail::_Hash_node<_Value, __hash_cached::value>;
// __node_base的定义如下:
using __node_base = __detail::_Hash_node_base;
// __bucket_type的定义如下,实际上就是指向__node_base的指针
using __bucket_type = __node_base*;
// buckets的定义,是一个二级指针,可以理解为类型为__node_base*的数组
__bucket_type _M_single_bucket = nullptr;
__bucket_type* _M_buckets = &_M_single_bucket;
size_type _M_bucket_count = 1;
size_type _M_element_count = 0;
__node_base _M_before_begin; // 头节点
_RehashPolicy _M_rehash_policy;
};
可以看出,_Hash_node 和 _Hash_node_base 实际上才是核心数据结构。这两个类也是息息相关的:
/*
_Hash_node_base // 仅持有 next 指针
↑
_Hash_node_value_base<_Value> // 添加数据存储能力
↑
_Hash_node<_Value, true> // 添加哈希值缓存
*/
template<typename _Value>
struct _Hash_node<_Value, true> : _Hash_node_value_base<_Value>
{
std::size_t _M_hash_code; // 缓存哈希值,避免重复计算
_Hash_node* _M_next() const noexcept {
return static_cast<_Hash_node*>(this->_M_nxt);
}
};
template<typename _Value>
struct _Hash_node_value_base : _Hash_node_base
{
typedef _Value value_type; // pair<const key,value>
__gnu_cxx::__aligned_buffer<_Value> _M_storage;
};
struct _Hash_node_base
{
_Hash_node_base* _M_nxt;
_Hash_node_base() noexcept : _M_nxt() { }
_Hash_node_base(_Hash_node_base* __next) noexcept : _M_nxt(__next) {}
};
_M_before_begin
_M_before_begin 是 GCC libstdc++ 的 Hashtable 中的一个虚拟头节点(哨兵节点,Sentinel Node)。
它的主要作用是:作为整个哈希表全局单链表的起点,方便在链表头部插入节点。
先看 _Hashtable 的部分成员:
template<typename ...>
class _Hashtable
{
using __node_base = __detail::_Hash_node_base;
__node_base _M_before_begin;
// 其他成员...
};
可以看到:
__node_base _M_before_begin;
所以 _M_before_begin 的类型实际上是:
_Hash_node_base
而你前面学习过 _Hash_node_base:
struct _Hash_node_base
{
_Hash_node_base* _M_nxt;
_Hash_node_base() noexcept
: _M_nxt()
{
}
};
因此,_M_before_begin 本质上是一个只有 next 指针的节点。
它不保存:
- key
- value
- hash_code
只保存:
_M_before_begin._M_nxt
这个指针指向全局单链表中的第一个真实数据节点。
libstdc++ 的优化:全局单链表 + 前驱指针
C++11 标准中的 unordered_map 并未规定底层哈希表的具体实现方式,但 GCC 的 libstdc++ 实现采用了一种不同于传统“动态数组 + 多个独立链表”的设计。
在传统实现中:
- 哈希表由一个动态数组(buckets)构成;
- 每个 bucket 指向一个独立的单链表(或为空);
- 遍历时需逐个检查每个 bucket 是否为空,若为空则跳过,直到找到下一个非空 bucket;
- 当负载因子较低(即空桶较多)时,这种跳转会带来显著的遍历开销,实际复杂度接近 O(n + bucket_count),而非理想的 O(n)。
为解决上述问题,GCC 的 _Hashtable 实现采用了以下策略:
- 所有元素节点被组织成 一个全局的单向链表,按插入顺序串联;
- 动态数组(buckets)中的每个元素 不再直接指向链表头,而是存储 该桶第一个节点的前驱节点指针;
- 特别地,若某桶的第一个节点是整个链表的首节点,则其 bucket 指向一个虚拟头节点
_M_before_begin; - 因此,桶 i 中的第一个元素可通过
_M_buckets[i]->_M_nxt访问。
优势
- 遍历高效:迭代器只需沿全局单链表顺序访问,无需跳过空桶,时间复杂度严格为 O(n);
- 内存紧凑:无需为每个 bucket 维护独立的链表头对象;
- 插入仍为 O(1):通过哈希定位桶后,利用前驱指针可在桶内头部快速插入。
注:此设计是 GCC libstdc++ 的实现细节,并非 C++11 标准强制要求。其他标准库(如 Clang 的 libc++ 或 MSVC STL)可能采用不同策略。
插入操作分析
同经典的 vector + 链表的 buckets 相比,STL 中 _Hashtable 类的 buckets 的实现为一个元素类型为的 _Hash_node_base* 数组。
便于大家后面理解源代码,我先将 STL 中 _Hashtable 的原理图展示给大家,实际上这个原理图也是我看了源代码之后才画出来的,我将这种实现称之为“单链”法。
可以看出,STL 中也是采用开链法解决 hash 冲突,但是并不是一个 slot 一个链表,而是整个 hash 表是一个链表,“前一个” slot 的尾节点指向“下一个”非空 slot 的头节点,slot 中存储的是头节点的 prev 节点的地址。 这里要着重强调 链表并不是按照 slot 数组数组即桶的前后顺序串联起来的,而是根据节点的插入时间串联起来的。
可能很多人看到这里就蒙了,下面我们详细说明一下。实际上想要明白这些,只需要看一下 _Hashtable 的插入节点的方法就行:
// 插入函数的底层均调用 _M_insert_bucket_begin
// __bkt 为 slot 的索引,__node 为需要插入的节点,使用 key 与 value 构造出来
// _M_before_begin 是虚拟头节点
// _M_buckets 桶数组
// bucket包含的元素规则是: 桶指向前驱
template<typename _Key, typename _Value,
typename _Alloc, typename _ExtractKey, typename _Equal,
typename _H1, typename _H2, typename _Hash, typename _RehashPolicy,
typename _Traits>
void _Hashtable<_Key, _Value, _Alloc, _ExtractKey, _Equal, _H1, _H2,
_Hash, _RehashPolicy, _Traits>::_M_insert_bucket_begin
(size_type __bkt, __node_type* __node)
{
// 如果所要插入的 slot 已经有节点,直接插入头部
if (_M_buckets[__bkt])
{
// Bucket is not empty, we just need to insert the new node
// after the bucket before begin.
__node->_M_nxt = _M_buckets[__bkt]->_M_nxt;
_M_buckets[__bkt]->_M_nxt = __node;
}
// 如果所要插入的 slot 还没插入过节点
else
{
// The bucket is empty, the new node is inserted at the
// beginning of the singly-linked list and the bucket will
// contain _M_before_begin pointer.
__node->_M_nxt = _M_before_begin._M_nxt;
_M_before_begin._M_nxt = __node;
// 如果新节点后面还有节点,那么原来第一个节点所在的桶,其桶指针必须从指向 _M_before_begin 改为指向 __node
if (__node->_M_nxt)
// We must update former begin bucket that is pointing to
// _M_before_begin.
_M_buckets[_M_bucket_index(__node->_M_next())] = __node;
// 将当前要插入的桶 __bkt 设置为指向 _M_before_begin
_M_buckets[__bkt] = &_M_before_begin;
}
}
传统拉链法
传统哈希表通常是:
buckets
|
v
+-----------+
0 | Node A |----> Node B ----> nullptr
+-----------+
1 | nullptr |
+-----------+
2 | Node C |----> Node D ----> nullptr
+-----------+
3 | Node E |----> nullptr
+-----------+
每个桶对应一条独立的链表。
例如:
buckets[0] = &NodeA;
buckets[2] = &NodeC;
buckets[3] = &NodeE;
这里 buckets[i] 保存的就是桶内第一个节点的地址。
但是 GCC libstdc++ 采用了另一种设计。
libstdc++ 的 Hashtable
它把所有节点连接成一条全局单链表。
例如:
全局单链表
_M_before_begin
|
v
Node A ----> Node B ----> Node C ----> Node D ----> nullptr
桶 2 桶 2 桶 5 桶 5
与此同时,还有一个桶数组:
_M_buckets
buckets[0] = nullptr
buckets[1] = nullptr
buckets[2] = &_M_before_begin
buckets[3] = nullptr
buckets[4] = nullptr
buckets[5] = &NodeB
注意这个设计:
桶数组保存的不是桶内第一个节点,而是桶内第一个节点的前驱节点。
例如:
_M_buckets[2] = &_M_before_begin;
因为桶 2 的第一个节点是 Node A,而 Node A 的前驱是 _M_before_begin。
同样:
_M_buckets[5] = &NodeB;
因为桶 5 的第一个节点是 Node C,而 Node C 的前驱是 Node B。
因此:
_M_buckets[i]->_M_nxt
才是真正的桶首节点。
为什么要保存前驱节点?
因为 Hashtable 使用的是单链表。
例如:
A ---> B ---> C
现在要在 B 前面插入 X:
A ---> X ---> B ---> C
必须修改:
A->next = X;
所以需要知道 B 的前驱 A。
如果桶数组直接保存 B,就无法通过 B 找到 A。
而如果保存 B 的前驱:
buckets[i] = &A;
那么直接执行:
X->next = A->next;
A->next = X;
就能在 O(1) 时间内插入 X。
这就是 libstdc++ 采用前驱指针设计的重要原因。
函数参数解析
先看函数声明:
template<typename _Key, typename _Value,
typename _Alloc, typename _ExtractKey, typename _Equal,
typename _H1, typename _H2, typename _Hash,
typename _RehashPolicy, typename _Traits>
void _Hashtable<_Key, _Value, _Alloc, _ExtractKey,
_Equal, _H1, _H2, _Hash, _RehashPolicy,
_Traits>::_M_insert_bucket_begin
(size_type __bkt, __node_type* __node)
这里有两个函数参数:
size_type __bkt
和:
__node_type* __node
参数一:__bkt
表示新节点应该插入的桶索引。
例如:
__bkt = 5;
说明新节点属于桶 5。
它通常由哈希值和桶数量计算得到。
在你提供的这套实现中,可以简化理解为:
bucket_index = hash(key) % bucket_count;
注意,这个函数本身并不负责计算新节点的桶索引,而是直接接收外部计算好的 __bkt。
参数二:__node
表示准备插入的节点。
例如:
__node_type* __node;
假设需要插入:
map.insert({10, 100});
那么上层逻辑会先创建一个节点,内部存储:
std::pair<const int, int>(10, 100)
然后将节点指针传递给:
_M_insert_bucket_begin(__bkt, __node);
这个函数只负责把已经创建好的节点连接到哈希表中。
它并不负责构造 key、value,也不负责检查是否存在重复键。
第一种情况:目标桶已经有节点
先看:
if (_M_buckets[__bkt])
{
__node->_M_nxt = _M_buckets[__bkt]->_M_nxt;
_M_buckets[__bkt]->_M_nxt = __node;
}
这里的判断:
if (_M_buckets[__bkt])
表示:
如果目标桶已经有节点,那么 _M_buckets[__bkt] 就不是空指针。
注意:
_M_buckets[__bkt]
虽然表示一个非空桶,但保存的仍然是该桶首节点的前驱。
第二种情况:目标桶是空桶
这是整个函数最重要的部分。
代码如下:
else
{
__node->_M_nxt = _M_before_begin._M_nxt;
_M_before_begin._M_nxt = __node;
if (__node->_M_nxt)
_M_buckets[_M_bucket_index(__node->_M_next())] = __node;
_M_buckets[__bkt] = &_M_before_begin;
}
假设目前全局链表是:
_M_before_begin
|
v
Node C ------> Node A ------> Node B ------> nullptr
桶 2 桶 2 桶 2
桶数组:
_M_buckets[2] = &_M_before_begin;
现在需要插入:
Node D
但是 Node D 属于桶 5。
所以:
__bkt = 5;
而桶 5 目前是空的:
_M_buckets[5] = nullptr;
因此进入 else 分支。
执行
__node->_M_nxt = _M_before_begin._M_nxt;
_M_before_begin._M_nxt = __node;
此时现在全局链表变成:
_M_before_begin
|
v
Node D ------> Node C ------> Node A ------> Node B ------> nullptr
桶 5 桶 2 桶 2 桶 2
到这里,Node D 已经成功插入全局单链表。
但是!
此时桶数组出现了一个问题。
我们来看原来的:
_M_buckets[2] = &_M_before_begin;
它原本是正确的。
因为桶 2 的第一个节点是 C,而 C 的前驱是:
_M_before_begin
但是现在:
before_begin -> D -> C
桶 2 的第一个节点还是 C。
而 C 的前驱已经变成:
NodeD
因此:
_M_buckets[2] = &_M_before_begin;
已经不正确了。
现在正确的值应该是:
_M_buckets[2] = &NodeD;
这就是为什么需要执行下面的代码。
if (__node->_M_nxt) {
_M_buckets[_M_bucket_index(__node->_M_next())] = __node;
}
此时:
__node = &NodeD;
而:
NodeD._M_nxt = &NodeC;
所以:
if (NodeD._M_nxt)
条件成立。
它的意思是:
新节点后面是否还有其他节点?
在当前例子中相当于:
_M_buckets[2] = &NodeD;
此时桶数组修正完成:
全局链表:
before_begin -> D -> C -> A -> B -> nullptr
| |
桶5 桶2
桶数组:
buckets[2] = &D
最后,执行
_M_buckets[__bkt] = &_M_before_begin;
设置新桶的前驱指针。
因为新节点 Node D 被插入了整个全局链表的最前面。
它的前驱就是:
_M_before_begin
因此:
_M_buckets[5] = &_M_before_begin;
到这里,整个插入操作完成。
图示
接下来我们用图逐步解析一下这个过程:
第一步:先在 slot49 中依次插入三个元素,由于是在头部插入,因此示意图如下:
第二步:在 slot46 中插入一个元素 4,示意图如下:
2.1 先将新节点 4 指向 _M_before_begin 的 next;
2.2 再将 _M_before_begin 的 next 指向新节点 4:
2.3 根据 _M_buckets[_M_bucket_index(__node->_M_next())] = __node;,将 Slot49 修改为新节点 4 的地址:
2.4 将 Slot46 指向 _M_before_begin
第三步:在 slot3 中插入一个元素 5,示意图如下:
最后依次再插入更多的元素:
关于 _Hashtable,只要明白了这些原理,我相信每个人都能够得心应手的使用 unordered_map 和 unordered_set。
这次没有涉及到的问题就是 hash 表的 rehash 过程,有了原理的加持,这些应该都不是问题,希望大家能够自己完成这部分的剖析,一起交流~
rehash
当你插入一个数据时,系统会先通过哈希函数算出它的哈希值,然后用这个值对当前的桶总数求余:桶的索引 = 哈希值 % 桶的总数。算出来是几,它就顺理成章地被放进第几个桶里。
桶的总数并不是随便拍脑门决定的。当哈希表装满需要扩容(rehash)时,主流的 C++ 编译器(比如 GCC 的 libstdc++)是这样计算新桶数的:
- 它底层其实 提前写死了一张“质数表”(比如 11, 23, 47, 97, 193... 依次递增)。
- 每次触发自动扩容时,它会直接去这张表里,挑一个比当前桶数两倍略大的质数,作为新的桶总数。
- 为什么非得用质数? 因为在数学上,用质数去“求余(%)”,能最大程度打乱数据的规律,让元素在各个桶里分布得更均匀。如果分布均匀了,单向链表就会很短,查询速度自然就快了。
Unrolled Linked List
传统拉链法的每个 Node 只存储一个 {hash, key, value},而基于展开链表(Unrolled Linked List)思想实现的块状拉链法(Block Chaining)的每个 Node 存储一个包含 8 个 {hash, key, value} 的数组。只有当前 Node 存满后,才需要通过 next 指针连接下一个 Node。
这种设计主要是为了减少内存分配、提高 CPU Cache 利用率,以及降低指针追踪(Pointer Chasing)的开销。
传统的哈希表,比如:
template<typename K, typename V>
struct HashNode {
size_t hash; // 哈希值
K key; // 键
V value; // 值
HashNode* next;
};
存在两个性能问题:
- 每插入一个元素,通常都要单独分配一个 Node。如果插入 100 万个元素,就可能产生 100 万次节点分配操作。当然,实际内存分配器可能使用内存池等机制优化,但仍然存在大量独立节点。
- 问题二:Node 在内存中不一定连续。查找时
node = node->next;可能不断跳转到不同的内存地址,这会导致 CPU Cache 命中率下降。
块状拉链法改变了传统的哈希表节点的数据结构:
template<typename K, typename V>
struct HashNode {
struct Entry {
size_t hash;
K key;
V value;
};
Entry entries[8];
size_t size = 0; // 实际 entry 个数
HashNode* next = nullptr;
};
查找时,CPU 可以顺序访问这些元素,而不是不断追踪分散在内存中的节点指针。
1. 极高的 CPU 缓存命中率(连续内存优势) 现代 CPU 读取内存并不是一个字节一个字节读的,而是每次读取一个 缓存行(Cache Line,通常是 64 字节)。
- 传统拉链法:每个节点在内存中是随机分布的,指针跳跃(Pointer Chasing)会导致严重的 Cache Miss,CPU 经常需要被迫等待缓慢的内存加载。
- 数组拉链法:8 个
{hash, key, value}在内存中是紧凑连续的。CPU 只要读取第一个元素,顺带就把后面的几个元素全部塞进 L1 缓存了。在一个数组块内进行线性查找的速度,远比跟着指针到处跑要快得多。
2. 支持 SIMD 硬件指令的“降维打击” 为什么偏偏是 8 个?因为这个数字非常契合现代 CPU 的 SIMD(单指令多数据流) 寄存器宽度。 把 8 个 hash 值连续存放在一起,程序可以直接调用底层指令(如 SSE 或 AVX),在 一个 CPU 时钟周期内,同时将目标 Hash 值与这 8 个 Hash 值进行比对。这比写一个 for 循环一个个比对要快上好几个数量级。著名的 Google absl::flat_hash_map 就是利用类似这种 8 个控制位一组的设计来封神的。
3. 大幅降低内存开销与分配频率
- 省指针:传统链表里,每一个 KV 键值对都需要附带一个
next指针。现在是 8 个 KV 共享一个next指针,大幅节省了 64 位系统下 8 字节的指针开销。 - 省调用:系统级别的内存分配(
malloc/new)是非常昂贵的操作。按 8 个一组进行分配,直接将内存分配的频次降低到了原来的八分之一,大大减轻了内存分配器和垃圾回收(如果有)的压力,也减少了内存碎片。
4. 提前保存 Hash 值带来的“短路查找” 单独把 hash 存下来是一步好棋:
- 短路比较:很多时候
key可能是很长的字符串或复杂对象,比较起来很慢。查找时,先比对简单的整数hash,如果hash都不一样,直接跳过,只有遇到hash相等的情况才去调用昂贵的key.equals()。 - 无痛扩容:当 Hash 表元素过多需要触发扩容(Rehash)时,不需要重新调用复杂的散列函数计算每个元素的哈希值,直接读现成的即可。
开放寻址法
大部分哈希表都是基于拉链法思想实现的,它简单而高效。那基于开放寻址法实现的哈希表,又有什么应用场景呢?
首先,开放寻址法有多种实现方式,这主要是哈希函数的不同,例如最简单的线性探测,复杂一些的二次探测,双重哈希以及 Robin Hood Hashing 等。
优点
相较于拉链法基于链表实现哈希表,开放寻址法实现的哈希表是数组形式的。基于数组,它天然具有以下优点:
-
CPU Cache 友好,查找性能通常更高:拉链法哈希表节点地址可能不连续,CPU 需要频繁进行指针追踪(Pointer Chasing),开放寻址法将元素存储在连续数组中,后续探测可以直接访问缓存中的数据,减少访存延迟。
-
减少内存开销:传统拉链法通常每个节点都需要一个额外的
next指针,开放寻址法则不需要。不过开放寻址法必须预留空槽位(负载因子肯定不能为 1),此外开放寻址法可能还需要保存状态标记、控制字节等元数据,因此不能简单认为它的实际内存一定比拉链法少。 -
减少动态内存分配次数:传统拉链法通常需要单独为每个节点分配内存,而开放寻址法通常先分配一整块数组,后续插入时,直接使用数组中的空槽,不需要为每个元素单独分配节点内存。因此能够减少:
- 动态内存分配次数;
- 分配器管理开销;
- 内存碎片;
- 分散内存访问。
当然,开放寻址法在扩容时仍然需要重新分配数组。
缺点
当然,开放寻址法也有缺点:
-
对负载因子更加敏感:开放寻址法必须依靠空槽位完成插入。因此,当负载因子越来越趋向 1 时,空槽位越来越少,发生冲突后需要探测的位置可能越来越多。对于简单的线性探测,这种情况尤其明显。因此很多开放寻址哈希表会设置负载因子阈值,超过阈值后进行扩容。
-
容易产生聚集现象(Clustering):这是线性探测需要重点关注的问题。假设哈希表:
Index: 0 1 2 3 4 5 6 7当前状态:
[ ][A][B][C][ ][ ][ ][ ]假设 A、B、C 都因为冲突集中在相邻位置。
现在有一个元素 D,其哈希位置是 2。
由于:
Slot2 已占用 Slot3 已占用 Slot4 为空所以 D 被插入 Slot4。
结果:
[ ][A][B][C][D][ ][ ][ ]原本连续的三个元素变成了四个。
随着插入次数增加,连续占用区域可能不断扩大。
这就是一次聚集(Primary Clustering)。
聚集区域越长,后续元素发生冲突时可能需要检查的槽位就越多。
例如:
[X][X][X][X][X][X][ ][ ]如果一个新元素的初始位置位于连续占用区域的前部,就可能需要进行多次探测才能找到空槽。
因此,线性探测在高负载因子下性能可能明显下降。
不过,这个缺点并不是所有开放寻址算法都一样严重。
例如:
- 二次探测可以缓解一次聚集问题。
- 双重哈希通过不同的探测步长降低聚集。
- Robin Hood Hashing 通过调整元素位置,使探测距离更加均衡。
所以,聚集问题的严重程度与具体探测策略密切相关。
-
删除元素比较复杂:拉链法删除元素只需要修改节点指针,开放寻址法则不能简单地将任意被删除的槽位恢复成普通空槽,例如:假设使用线性探测,当前哈希表:
Index Value
0 empty
1 1
2 9
3 17
4 empty
5 empty
...
其中:
1 % 8 = 1;
9 % 8 = 1;
17 % 8 = 1;
现在删除元素 9。
如果直接将 Slot2 设置为空:
Index Value
0 empty
1 1
2 empty
3 17
4 empty
那么接下来查找 find(17) 会发生什么?
首先 17 % 8 = 1,从 Slot1 开始探测:
Slot1:不是 17
Slot2:空
对于普通线性探测规则,遇到从未使用过的空槽位,就意味着目标元素不存在,于是查找停止。
但是实际上:
Slot3 存储着 17
结果就产生了错误!
解决方法:墓碑标记(Tombstone)
可以为槽位维护三种状态:
enum class State {
EMPTY, // 未使用
OCCUPIED, // 存储有效元素
DELETED // 槽位中的元素已经删除,但查找不能在这里停止
};
删除 9 后:
Index State Value
0 EMPTY
1 OCCUPIED 1
2 DELETED -
3 OCCUPIED 17
4 EMPTY
现在查找 17:
Slot1:不是 17,继续
Slot2:DELETED,继续
Slot3:找到 17
这样就不会出现查找错误。
但是墓碑机制也有缺点:
如果频繁插入和删除,表中可能积累大量 DELETED 槽位,导致探测链变长。
因此,有些实现需要定期清理墓碑或重新整理哈希表。
需要补充的是,墓碑并不是唯一解决方法。某些线性探测实现可以使用后移删除(Backward Shift Deletion),通过移动后续元素来保持查找正确性。
- 扩容成本较高:开放寻址法的扩容通常需要重新放置元素,因为桶数量变化后 \(hash(key)\%oldCapacity\) 与 \(hash(key)\%newCapacity\) 得到的位置很有可能不同。相比之下,传统节点式拉链法通常可以直接重新连接已有节点,未必需要移动节点内部存储的 Key 和 Value。这也是两种实现之间的重要区别。
选型
实际开发中应该选择哪一种,拉链法还是开放寻址法?
场景一:大量查询,很少删除
例如:
std::unordered_map<int, int>
用于存储大量整数之间的映射关系。
如果只需要插入和查询,并且不要求元素地址稳定,那么可以考虑开放寻址法。
因为:
- Key 和 Value 较小;
- 连续内存有利于 CPU Cache;
- 可以减少独立节点的分配;
- 查询性能通常较好。
例如高性能哈希表库中常见的 flat_hash_map 类容器,就经常采用开放寻址及其改进策略。
场景二:需要稳定的元素地址
假设:
struct Object {
int id;
std::string name;
};
程序中其他地方需要长期持有指向元素的指针或引用。
此时节点式拉链法可能更合适。
因为 rehash 时,节点通常不需要移动。
例如 C++ 标准库的:
std::unordered_map<int, Object>
就保证 rehash 不会使已有元素的指针和引用失效。
场景三:频繁插入和删除
如果哈希表需要大量动态更新:
insert();
erase();
insert();
erase();
节点式拉链法的删除逻辑通常更直接。
开放寻址法则需要额外处理墓碑、后移删除或者其他探测状态维护问题。
不过,实际性能仍取决于实现,不意味着频繁删除时拉链法一定更快。

浙公网安备 33010602011771号