哈希表: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;
};

存在两个性能问题:

  1. 每插入一个元素,通常都要单独分配一个 Node。如果插入 100 万个元素,就可能产生 100 万次节点分配操作。当然,实际内存分配器可能使用内存池等机制优化,但仍然存在大量独立节点。
  2. 问题二: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 等。

优点

相较于拉链法基于链表实现哈希表,开放寻址法实现的哈希表是数组形式的。基于数组,它天然具有以下优点:

  1. CPU Cache 友好,查找性能通常更高:拉链法哈希表节点地址可能不连续,CPU 需要频繁进行指针追踪(Pointer Chasing),开放寻址法将元素存储在连续数组中,后续探测可以直接访问缓存中的数据,减少访存延迟。

  2. 减少内存开销:传统拉链法通常每个节点都需要一个额外的 next 指针,开放寻址法则不需要。不过开放寻址法必须预留空槽位(负载因子肯定不能为 1),此外开放寻址法可能还需要保存状态标记、控制字节等元数据,因此不能简单认为它的实际内存一定比拉链法少。

  3. 减少动态内存分配次数:传统拉链法通常需要单独为每个节点分配内存,而开放寻址法通常先分配一整块数组,后续插入时,直接使用数组中的空槽,不需要为每个元素单独分配节点内存。因此能够减少:

    • 动态内存分配次数;
    • 分配器管理开销;
    • 内存碎片;
    • 分散内存访问。

    当然,开放寻址法在扩容时仍然需要重新分配数组。

缺点

当然,开放寻址法也有缺点:

  1. 对负载因子更加敏感:开放寻址法必须依靠空槽位完成插入。因此,当负载因子越来越趋向 1 时,空槽位越来越少,发生冲突后需要探测的位置可能越来越多。对于简单的线性探测,这种情况尤其明显。因此很多开放寻址哈希表会设置负载因子阈值,超过阈值后进行扩容。

  2. 容易产生聚集现象(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 通过调整元素位置,使探测距离更加均衡。

    所以,聚集问题的严重程度与具体探测策略密切相关。

  3. 删除元素比较复杂:拉链法删除元素只需要修改节点指针,开放寻址法则不能简单地将任意被删除的槽位恢复成普通空槽,例如:假设使用线性探测,当前哈希表:

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),通过移动后续元素来保持查找正确性。

  1. 扩容成本较高:开放寻址法的扩容通常需要重新放置元素,因为桶数量变化后 \(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();

节点式拉链法的删除逻辑通常更直接。

开放寻址法则需要额外处理墓碑、后移删除或者其他探测状态维护问题。

不过,实际性能仍取决于实现,不意味着频繁删除时拉链法一定更快。

posted @ 2026-11-02 17:22  光風霽月  阅读(7)  评论(0)    收藏  举报