C++哈希表实现:开放定址法和链地址法 (哈希桶)

1. 哈希表概述

哈希表(Hash Table)是一种高效的数据结构,它通过哈希函数将关键字映射到数组中的特定位置,从而实现平均时间复杂度为 O(1) 的查找、插入和删除操作。哈希表的核心在于哈希函数 的设计和哈希冲突的解决。

2. 哈希函数:除法散列法

2.1 基本概念

哈希函数 h(key) 将关键字 key 映射到哈希表大小 M 范围内的索引:h(key) ∈ [0, M-1]。一个好的哈希函数应尽可能均匀地将关键字分布到哈希表空间中。

2.2 除法散列法(除留余数法)

除法散列法是最简单、最常用的哈希函数之一,其公式为:

复制代码
h(key) = key % M

其中 M 是哈希表的大小。

2.3 M 的选择原则

  • 避免 M 为 2 的幂 :如果 M = 2^k,那么 key % M 实际上只保留了 key 的低 k 位二进制。这会导致许多不同的 key 产生相同的哈希值,增加冲突概率。
    • 例如:M = 16 (2^4)key = 31 (00011111)key = 63 (00111111) 的低4位都是 1111,哈希值均为15。
  • 避免 M 为 10 的幂 :类似地,如果 M = 10^k,则只保留 key 的十进制低 k 位。
    • 例如:M = 100key = 112key = 12312 的哈希值都是12。
  • 推荐选择M 取一个不太接近 2 的整数次幂的质数。这有助于哈希值分布更均匀。

实践中的变通 :某些实现(如 Java 的 HashMap)仍使用 2 的幂作为 M,但通过额外的位运算(如 (key ^ (key >>> 16)))让高位也参与计算,以改善均匀性。本文遵循经典理论,选择质数作为表大小。

3. 哈希冲突与开放定址法

3.1 哈希冲突

当两个不同的关键字 key1key2 经过哈希函数计算得到相同的索引,即 h(key1) = h(key2),就发生了哈希冲突。冲突是不可避免的,因此需要设计解决冲突的方案。

3.2 开放定址法

开放定址法的核心思想:所有元素都存储在哈希表数组本身中。当发生冲突时,按照某种探测序列在表中寻找下一个空闲位置。

负载因子α = N / M,其中 N 为已存储元素个数,M 为表大小。在开放定址法中,负载因子必须小于 1(通常控制在 0.7 以下),否则可能无法找到空闲位置。

4. 线性探测

4.1 探测公式

设初始哈希位置为 hash0 = key % M。若该位置已被占用,则线性向后探测:

复制代码
hash_i = (hash0 + i) % M,   i = 1, 2, 3, ..., M-1

直到找到一个空闲(EMPTY)或已删除(DELETED)的位置。

4.2 示例

假设 M = 11,插入序列 {19, 30, 5, 36, 13, 20, 21, 12}

  • h(19)=8 → 位置8
  • h(30)=8 冲突 → 线性探测 (8+1)%11=9 → 位置9
  • h(5)=5 → 位置5
  • h(36)=3 → 位置3
  • h(13)=2 → 位置2
  • h(20)=9 冲突 → (9+1)%11=10 → 位置10
  • h(21)=10 冲突 → (10+1)%11=0 → 位置0
  • h(12)=1 → 位置1

最终存储位置:[21, 12, 13, 36, _, 5, _, _, 19, 30, 20]

4.3 线性探测的缺点:聚集

当连续位置被占用后,后续映射到该区域的关键字都会争夺后续的同一个位置,形成"聚集"现象,降低探测效率。

5. 二次探测

5.1 探测公式

二次探测通过跳跃式探测缓解聚集问题:

复制代码
hash_i = (hash0 ± i²) % M,   i = 1, 2, 3, ..., floor(M/2)

探测序列为:hash0+1², hash0-1², hash0+2², hash0-2², ...

注意 :当 (hash0 - i²) % M 结果为负数时,需要加上 M 使其落在 [0, M-1] 范围内。

5.2 在线性探测基础上修改以实现二次探测

二次探测的实现只需修改探测步长计算方式,其他逻辑(如查找、删除)与线性探测完全相同。

6. C++ 实现:开放定址法哈希表

6.1 数据结构定义

cpp 复制代码
enum State
{
    EXIST,   // 该位置有元素
    EMPTY,   // 该位置为空
    DELETE   // 该位置元素已被删除
};

template<class K, class V>
struct HashData
{
    pair<K, V> _kv;// 这里存放键值对
    State _state = EMPTY; // 初始状态为空
};

6.2 哈希函数与字符串特化

哈希表的关键在于将任意类型的关键字转换为整数,以便进行取模运算。对于不同类型的关键字,我们需要提供相应的哈希函数仿函数。

cpp 复制代码
// 默认哈希函数:适用于可直接转换为 size_t 的类型(如正整数、指针等)
template<class K>
struct HashFunc
{
    size_t operator()(const K& key)
    {
        // 注意:对于有符号整数(如 int),直接转换 (size_t)key 会将负数的二进制表示
        // 解释为很大的无符号数,虽然能工作但分布可能不均匀。
        // 对于整数类型,建议使用特化版本(如下面的 HashFunc<int>)
        return (size_t)key;
    }
};

// 特化 string 类型的哈希函数
template<>
struct HashFunc<string>
{
    // BKDR 哈希算法
    size_t operator()(const string& key)
    {
        size_t hash = 0;
        for (auto ch : key)
        {
            // 乘数的作用:
            // 1. 避免相同字符不同顺序的字符串产生相同哈希值
            //    例如:"abc" 和 "cba" 如果没有乘数,hash = 'a'+'b'+'c' = 'c'+'b'+'a'
            //    加入乘数后:hash = ((0*131+'a')*131+'b')*131+'c' ≠ ((0*131+'c')*131+'b')*131+'a'
            // 2. 扩大哈希值的分布范围,减少冲突
            // 3. 常用乘数:31, 131, 1313, 5381 等质数,这些数有良好的数学特性
            hash *= 131;  // 乘数常用 31, 131, 1313 等
            hash += ch;
        }
        return hash;
    }
};

使用说明:

  1. 整数 :对于 unsigned int, size_t 等无符号类型,默认的 HashFunc 通过 (size_t)key 转换即可。
  2. 字符串:使用 BKDR 哈希算法,确保不同字符串尽可能映射到不同的哈希值。

在哈希表类中,我们通过模板参数 Hash = HashFunc<K> 来使用这些仿函数:

cpp 复制代码
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
    // ...
    Hash hash;  // 哈希函数对象
    size_t hash0 = hash(key) % _tables.size();  // 计算哈希值
    // ...
};

这样,当用户使用 HashTable<int,string> 时,会自动使用特化的 HashFunc<int>;使用 HashTable<unsigned int,string> 时,会使用默认模板;使用 HashTable<string, int> 时,会自动使用 HashFunc<string>,以此类推。

6.3 哈希表类框架

cpp 复制代码
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
private:
    vector<HashData<K, V>> _tables;  // 哈希表数组
    size_t _n = 0;  // 存储的元素个数
    
public:
    HashTable()
    {
        // 直接初始化表大小为 11(示例中使用的质数)
        _tables.resize(11);
    }
    
    // 插入、查找、删除等方法将在下面实现
};

为什么使用 HashData 而不是 pair

在开放定址法哈希表的实现中,我们使用 HashData<K, V> 结构体而不是简单的 pair<K, V>,主要原因如下:

  1. 状态管理需求:开放定址法需要区分三种状态:

    • EXIST:该位置有有效元素
    • EMPTY:该位置为空,可以插入新元素
    • DELETE:该位置元素已被删除,但探测时不能停止(需要继续查找)

    如果只使用 pair,无法表示这些状态信息。

  2. 删除操作的实现:在开放定址法中,删除元素不能直接清空位置,否则会破坏探测链。例如:

    • 元素 A 和 B 哈希冲突,A 在位置 i,B 在位置 i+1
    • 如果删除 A 后直接清空位置 i,查找 B 时会误以为位置 i 为空而停止,导致找不到 B
    • 使用 DELETE 状态标记已删除位置,查找时继续探测
  3. 内存效率与清晰性

    • HashData 将键值对和状态封装在一起,逻辑清晰
    • 状态信息与数据紧密关联,避免使用额外的状态数组
    • 代码可读性更好,明确表达了每个位置的状态
  4. 扩展性 :如果需要添加其他元数据(如版本号、时间戳等),可以在 HashData 中轻松扩展。

相比之下,链地址法(哈希桶)通常直接使用 std::pair 或节点结构,因为每个桶是链表,删除操作直接移除节点即可,不需要状态标记。

6.4 插入操作(支持线性探测与二次探测)

cpp 复制代码
bool Insert(const pair<K, V>& kv)
{
    // 1. 如果已存在,返回false
    if (Find(kv.first))
        return false;
    
    // 2. 负载因子超过0.7时扩容
    if (_n * 10 / _tables.size() >= 7)
    {
        HashTable<K, V, Hash> newHT;
        newHT._tables.resize(_stl_next_prime(_tables.size()));
        
        // 将旧表元素重新插入新表
        for (size_t i = 0; i < _tables.size(); i++)
        {
            if (_tables[i]._state == EXIST)
            {
                newHT.Insert(_tables[i]._kv);
            }
        }
        _tables.swap(newHT._tables);
    }
    
    Hash hash;
    size_t hash0 = hash(kv.first) % _tables.size();
    size_t hashi = hash0;
    size_t i = 1;
    
    // 3. 探测空闲位置
    while (_tables[hashi]._state == EXIST)
    {
        // 线性探测
        hashi = (hash0 + i) % _tables.size();
        
        // 若要改为二次探测,只需修改为:
        // hashi = (hash0 + i * i) % _tables.size(); // 二次探测公式
        
        ++i;
        
        // 理论上最多探测 M-1 次,因为负载因子<1,一定能找到空位
        if (i > _tables.size())
            return false; // 实际不会走到这里
    }
    
    // 4. 插入元素
    _tables[hashi]._kv = kv;
    _tables[hashi]._state = EXIST;
    ++_n;
    return true;
}

6.5 查找操作

cpp 复制代码
HashData<K, V>* Find(const K& key)
{
    HashFunc hash;
    size_t hash0 = hash(key) % _tables.size();
    size_t hashi = hash0;
    size_t i = 1;
    
    // 遇到 EMPTY 才停止(DELETE 状态继续探测)
    while (_tables[hashi]._state != EMPTY)
    {
        if (_tables[hashi]._state == EXIST && 
            _tables[hashi]._kv.first == key)
        {
            return &_tables[hashi];
        }
        
        // 使用与插入相同的探测方式
        hashi = (hash0 + i) % _tables.size();
        ++i;
        
        // 防止死循环
        if (hashi == hash0)  // 回到起始位置,说明已遍历整个表
            break;
    }
    
    return nullptr;
}

6.6 删除操作

cpp 复制代码
bool Erase(const K& key)
{
    HashData<K, V>* ret = Find(key);
    if (ret == nullptr)
    {
        return false;
    }
    else
    {
        // 标记为 DELETE,而不是真正删除
        ret->_state = DELETE;
        --_n;
        return true;
    }
}

9.1 链地址法(哈希桶)与开放定址法对比

链地址法(Separate Chaining,又称哈希桶)是另一种重要的哈希冲突解决方法,与本文实现的开放定址法有显著区别。

核心思想对比
  • 开放定址法:所有元素都存储在哈希表数组本身中,冲突时通过探测序列在数组中寻找下一个空闲位置。
  • 链地址法:哈希表的每个位置(桶)是一个链表头节点,冲突元素直接链接到对应桶的链表中。
优缺点对比
特性 开放定址法 链地址法(哈希桶)
存储方式 所有元素存储在数组中 数组+链表(或红黑树)
负载因子限制 必须小于1(通常≤0.75) 可以大于1,理论上无上限
删除操作 需要标记DELETE状态,不能直接置空 直接删除链表节点即可
内存占用 固定大小的数组,空间利用率低 动态链表,空间利用率高
缓存友好性 好(连续内存访问) 较差(链表节点分散)
实现复杂度 相对简单 需要链表管理,稍复杂
聚集问题 存在聚集现象(线性/二次) 无聚集问题
扩容时机 负载因子>0.75时需扩容 链表长度较长时(如>8)才需树化或扩容
最坏情况 所有元素冲突时退化为O(n)查找 所有元素哈希到同一桶时退化为O(n)查找
适用场景
  • 开放定址法适用

    • 内存空间有限,希望连续存储
    • 数据量相对固定,负载因子可预测
    • 对缓存性能要求高
    • 实现简单,适合教学和基础场景
  • 链地址法适用

    • 数据量动态变化大
    • 负载因子可能很高
    • 需要频繁插入删除
    • 实际生产环境更常用(如Java HashMap、C++ unordered_map)
链地址法简单结构定义示例
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)
    {}
};

// 使用 using 定义 Node 别名
template<class K, class V>
using Node = HashNode<K, V>;

// 链地址法哈希表框架
template<class K, class V, class Hash = HashFunc<K>>
class HashTableChaining
{
private:
    vector<Node*> _tables;  // 指针数组,每个位置是链表头
    size_t _n = 0;  // 元素个数
    
    // 获取下一个质数(与开放定址法相同)
    inline unsigned long __stl_next_prime(unsigned long n)
    {
        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
        };
        const unsigned long* first = __stl_prime_list;
        const unsigned long* last = __stl_prime_list + __stl_num_primes;
        const unsigned long* pos = lower_bound(first, last, n);
        return pos == last ? *(last - 1) : *pos;
    }
    
public:
    // 构造函数
    HashTableChaining()
  :_tables(11)
  ,_n(0)
  {}
    
    // 析构函数:释放所有链表节点
    ~HashTableChaining()
    {
        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;
        }
    }
    
    // 插入操作
    bool Insert(const pair<K, V>& kv)
    {
    // 定义仿函数类对象,相当于函数对象
        Hash hash;
        size_t hashi = hash(kv.first) % _tables.size();
        
        // 负载因子达到1时扩容
        if (_n == _tables.size())
        {
            // 创建新表,大小为下一个质数
            vector<Node*> newTables(__stl_next_prime(_tables.size()), nullptr);
            
            // 重新哈希所有元素
            for (size_t i = 0; i < _tables.size(); i++)
            {
                Node* cur = _tables[i];
                while (cur)
                {
                    Node* next = cur->_next;
                    
                    // 计算在新表中的位置
                    size_t newHashi = hash(cur->_kv.first) % newTables.size();
                    
                    // 头插到新表
                    cur->_next = newTables[newHashi];
                    newTables[newHashi] = cur;
                    
                    cur = next;
                }
                _tables[i] = nullptr;
            }
            
            // 交换新旧表
            _tables.swap(newTables);
            
            // 重新计算哈希值
            hashi = hash(kv.first) % _tables.size();
        }
        
        // 查找是否已存在相同key
        Node* cur = _tables[hashi];
        while (cur)
        {
            if (cur->_kv.first == kv.first)
                return false;  // 已存在,插入失败
            cur = cur->_next;
        }
        
        // 头插法插入新节点
        Node* newNode = new Node(kv);
        newNode->_next = _tables[hashi];
        _tables[hashi] = newNode;
        ++_n;
        
        return true;
    }
    
    // 查找操作
    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)
                return cur;  // 找到节点
            cur = cur->_next;
        }
        
        return nullptr;  // 未找到
    }
    
    // 删除操作
    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)
                {
                    // 删除的是头节点
                    _tables[hashi] = cur->_next;
                }
                else
                {
                    // 删除的是中间或尾节点
                    prev->_next = cur->_next;
                }
                
                delete cur;
                --_n;
                return true;
            }
            
            prev = cur;
            cur = cur->_next;
        }
        
        return false;  // 未找到要删除的节点
    }
    
    // 获取元素个数
    size_t Size() const
    {
        return _n;
    }
    
    // 获取桶的数量
    size_t BucketCount() const
    {
        return _tables.size();
    }
    
    // 获取最大桶长度(用于性能分析)
    size_t MaxBucketLength() const
    {
        size_t maxLen = 0;
        for (size_t i = 0; i < _tables.size(); i++)
        {
            size_t len = 0;
            Node* cur = _tables[i];
            while (cur)
            {
                ++len;
                cur = cur->_next;
            }
            if (len > maxLen)
                maxLen = len;
        }
        return maxLen;
    }
};
```## 10. 完整代码示例

```cpp
#include <iostream>
#include <vector>
#include <string>
#include <algorithm>

// 状态枚举
enum State
{
    EXIST,
    EMPTY,
    DELETE
};

// 哈希表节点
template<class K, class V>
struct HashData
{
    pair<K, V> _kv;
    State _state = EMPTY;
};

// 默认哈希函数
template<class K>
struct HashFunc
{
    size_t operator()(const K& key)
    {
        return (size_t)key;
    }
};

// 字符串特化
template<>
struct HashFunc<string>
{
    size_t operator()(const string& key)
    {
        size_t hash = 0;
        for (auto ch : key)
        {
            hash *= 131;
            hash += ch;
        }
        return hash;
    }
};

// 开放定址法哈希表
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
private:
    vector<HashData<K, V>> _tables;
    size_t _n = 0;
    
    // 获取下一个质数
    inline unsigned long __stl_next_prime(unsigned long n)
    {
        static const int __stl_num_primes
        


测试用例

int main()
{
	//int a[] = { 19,30,52,63,11,22 };
	
	const char* a1[] = { "abcd", "sort", "insert" };
	HashTable<string, string> ht1;
	for (auto& e : a1)
	{
		ht1.Insert({ e, e });
	}

	cout << HashFunc<string>()("abcd") << endl;
	cout << HashFunc<string>()("bcad") << endl;
	cout << HashFunc<string>()("aadd") << endl;

	int a2[] = { -19,-30,5,36,13,20,21,12 };
	HashTable<int, int> ht2;
	for (auto e : a2)
	{
		ht2.Insert({ e, e });
	}
	return 0;
}
相关推荐
imaol11 小时前
哈希表--数据结构
数据结构·散列表
呜喵王阿尔萨斯1 小时前
C/C++ 杂记-1
c++
不会代码的小猴1 小时前
2. 了解Qt
开发语言·c++·笔记·qt·算法
兵哥工控2 小时前
mfc实现在数值范围内实时改变静态文本控件字体颜色实例
c++·mfc
程序喵大人4 小时前
【C++进阶】STL算法与函数对象 - 09 函数对象保存状态并复用规则
开发语言·c++·算法·stl·函数对象
ShineWinsu9 小时前
对于C++:auto_ptr、unique_ptr、shared_ptr的模拟实现
c++·面试·笔试·智能指针·unique_ptr·shared_ptr·auto_ptr
XLYcmy11 小时前
京东 算法实习一面 下+手撕
c++·python·llm·概率论·数据处理·训练·codebert
反转180度12 小时前
Qt 6 + C++17 实现 SFTP 批量部署:工业设备运维工具实战解析
运维·c++·qt
码匠许师傅13 小时前
【C++ 面试真题】聊聊 C++ 的多继承与虚继承
开发语言·c++·面试