C++数据结构进阶:哈希表实现

前言

在上一篇文章中,我们已经系统地介绍了哈希表的基本原理,包括散列函数的设计、哈希冲突的产生原因以及负载因子的影响。通过这些内容可以看到,哈希表之所以能够在平均情况下实现 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++ 底层设计的核心思想:并不存在绝对最优的实现方案,关键在于在内存连续性、扩容成本与复杂度等多重因素之间做出合理权衡

当真正掌握开放定址法与链地址法这两种实现范式后,开发者也将不再局限于简单的容器使用,而是能够从更底层的视角理解数据结构的运行机制,洞察每一次访问与操作背后的内存组织与性能

相关推荐
光电笑映2 小时前
高阶数据结构之红黑树详解
数据结构
li星野2 小时前
[特殊字符] 模拟试卷一:C++核心与系统基础(90分钟)答案版
开发语言·c++·算法
呆瑜nuage2 小时前
【复习系列】高频C/C++库函数手写实现指南与自定义类型的理解指南
c语言·c++·面试
二进制星轨2 小时前
leecode-283-移动零-算法题解
算法
li星野2 小时前
C++面试真题分享20260320
java·c++·面试
Irissgwe2 小时前
c++特殊类设计
java·开发语言·c++
老鼠只爱大米2 小时前
LeetCode经典算法面试题 #215:数组中的第K个最大元素(快速选择、堆排序、计数排序等多种实现方案详解)
算法·leetcode·堆排序·快速选择·topk·数组中的第k个最大元素
2301_816651222 小时前
C++中的享元模式变体
开发语言·c++·算法
逆境不可逃2 小时前
LeetCode 热题 100 之 35. 搜索插入位置 74. 搜索二维矩阵 34. 在排序数组中查找元素的第一个和最后一个位置
数据结构·算法·leetcode