【C++篇】把混沌映射成秩序:哈希表的底层哲学与实现之道

文章目录

从理论到实践:深入理解哈希表的实现原理

💬 欢迎讨论:如果你在阅读过程中有任何疑问或想要进一步探讨的内容,欢迎在评论区留言!我们一起学习、一起成长。

👍 点赞、收藏与分享:如果你觉得这篇文章对你有帮助,记得点赞、收藏并分享给更多的朋友!

🚀 逐步实现哈希表:本篇文章将带你深入理解哈希表的核心概念,从哈希函数的设计到冲突解决方案,再到完整的代码实现,确保每个步骤都详细讲解,让你真正掌握这一高效的数据结构。


一、哈希表基础概念

1.1 什么是哈希

哈希(Hash)又称散列,是一种高效的数据组织方式。从字面意思来看,"散列"表示数据以分散的方式存储。哈希的核心思想是通过哈希函数建立关键字Key与存储位置之间的映射关系,使得在查找数据时,可以直接通过计算得到数据的存储位置,从而实现快速查找。

这种映射关系的建立,使得哈希表在理想情况下能够实现O(1)时间复杂度的查找、插入和删除操作,这是其他数据结构难以达到的性能表现。

1.2 直接定址法

直接定址法是哈希方法中最简单直观的一种。当关键字的范围比较集中时,这种方法非常高效。其基本思想是:用关键字的值直接作为数组下标,或者通过简单的线性变换得到数组下标。

适用场景示例

假设我们有一组关键字都在[0, 99]之间,那么只需要开辟一个大小为100的数组,每个关键字的值就是它在数组中的存储位置。再比如,一组关键字都是[a, z]的小写字母,我们可以开辟一个大小为26的数组,用字符的ASCII码减去'a'的ASCII码作为下标。

实际应用案例

这种方法在计数排序中已经使用过。下面通过一道LeetCode题目来加深理解:

387. 字符串中的第一个唯一字符

cpp 复制代码
class Solution {
public:
    int firstUniqChar(string s) {
        // 每个字母的ASCII码 - 'a'的ASCII码作为下标映射到count数组
        // 数组中存储该字符出现的次数
        int count[26] = {0};
        
        // 统计每个字符出现的次数
        for(auto ch : s)
        {
            count[ch - 'a']++;
        }
        
        // 按顺序查找第一个只出现一次的字符
        for(size_t i = 0; i < s.size(); ++i)
        {
            if(count[s[i] - 'a'] == 1)
                return i;
        }
        
        return -1;
    }
};

这个例子充分展示了直接定址法的优势:时间复杂度为O(n),空间复杂度仅为O(26),即O(1)。

直接定址法的局限性

虽然直接定址法简单高效,但它的缺点也很明显。当关键字的范围比较分散时,会造成严重的空间浪费,甚至可能出现内存不足的情况。例如,如果我们只有10个数据,但这些数据的值分布在[0, 1000000]之间,使用直接定址法就需要开辟一个百万大小的数组,造成了极大的空间浪费。


二、哈希函数与哈希冲突

2.1 哈希冲突的产生

为了解决直接定址法的空间浪费问题,我们需要引入哈希函数。假设我们有N个数据,数据范围是[0, 9999],但我们只想用一个大小为M的数组来存储(通常M >= N),这时就需要哈希函数h(key)将关键字映射到[0, M)范围内的位置。

哈希冲突的定义

由于多个不同的关键字可能被映射到同一个位置,这种现象称为哈希冲突或哈希碰撞。理想情况下,我们希望找到一个完美的哈希函数来避免冲突,但在实际应用中,冲突是不可避免的。因此,我们需要从两个方面来解决这个问题:

  1. 设计优秀的哈希函数,尽可能减少冲突的概率
  2. 设计有效的冲突解决方案

2.2 负载因子

在讨论哈希函数之前,我们需要先理解负载因子这个重要概念。

假设哈希表中已经存储了N个元素,哈希表的大小为M,那么:

负载因子 = N M 负载因子 = \frac{N}{M} 负载因子=MN

负载因子反映了哈希表的填充程度,它直接影响哈希表的性能:

  • 负载因子越大:哈希冲突的概率越高,但空间利用率也越高
  • 负载因子越小:哈希冲突的概率越低,但空间利用率也越低

在实际应用中,需要在冲突概率和空间利用率之间寻找平衡点。

2.3 关键字类型转换

哈希函数通常需要对整数进行取模运算来得到数组下标。如果关键字不是整数类型(如字符串、日期等),我们需要先将其转换为整数。

转换的关键要求是:尽可能让不同的关键字转换出不同的整数值,并且让关键字的每个部分都参与到计算中。具体的转换方法我们会在后面的代码实现中详细展示。


三、常见的哈希函数

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

除法散列法是最常用的哈希函数设计方法,其思想非常简单:用关键字除以哈希表大小M,取余数作为哈希值。

数学表达式

h ( k e y ) = k e y   m o d   M h(key) = key \bmod M h(key)=keymodM

M值的选择技巧

在使用除法散列法时,M的选择至关重要。我们应该避免以下情况:

  1. 避免2的幂次:如果M = 2^X,那么取模运算本质上是保留key的后X位二进制数。这会导致后X位相同的不同数值产生相同的哈希值。

    例如:key1 = 63(二进制:00111111),key2 = 31(二进制:00011111),如果M = 16 = 2^4,两者的后4位都是1111,哈希值都是15。

  2. 避免10的幂次:如果M = 10^X,则保留的是十进制的后X位,同样会导致冲突。

    例如:key1 = 112,key2 = 12312,如果M = 100 = 10^2,两者的后两位都是12,哈希值相同。

推荐做法

建议M取一个不太接近2的整数次幂的质数(素数)。质数的特性使得取模后的结果分布更加均匀。

实践中的变通方法

值得注意的是,Java的HashMap采用了不同的策略。它使用2的整数次幂作为哈希表大小,但不是简单地取模,而是采用位运算来提高效率。具体做法是:先将key右移16位得到key',然后将key和key'进行异或运算,让key的所有位都参与哈希值的计算,从而使哈希值分布更加均匀。

这个例子告诉我们,在实际工程中要灵活运用理论知识,抓住问题的本质,而不是生搬硬套。

3.2 乘法散列法

乘法散列法采用了不同的思路,它对哈希表大小M没有特殊要求。

算法步骤

  1. 用关键字K乘以常数A(0 < A < 1)
  2. 提取K×A的小数部分
  3. 用M乘以这个小数部分
  4. 对结果向下取整

数学表达式

h ( k e y ) = ⌊ M × ( ( A × k e y )   m o d   1.0 ) ⌋ h(key) = \lfloor M \times ((A \times key) \bmod 1.0) \rfloor h(key)=⌊M×((A×key)mod1.0)⌋

其中,floor表示向下取整,A ∈ (0, 1)。

A值的选择

计算机科学家Knuth建议A取黄金分割点:

A = 5 − 1 2 = 0.6180339887... A = \frac{\sqrt{5} - 1}{2} = 0.6180339887... A=25 −1=0.6180339887...

计算示例

假设M = 1024,key = 1234,A = 0.6180339887:

  1. A × key = 0.6180339887 × 1234 = 762.6539420558
  2. 取小数部分:0.6539420558
  3. M × 0.6539420558 = 1024 × 0.6539420558 = 669.6366651392
  4. 向下取整:h(1234) = 669

3.3 全域散列法

全域散列法是一种更加高级的方法,它的设计初衷是为了防止恶意攻击。

问题背景

如果哈希函数是公开且确定的,攻击者可能会构造出一组特殊的数据,使得所有数据都映射到同一个位置,从而使哈希表退化为链表,查找效率降低到O(n)。

解决思路

全域散列法通过给哈希函数增加随机性来解决这个问题。它不是使用单一的哈希函数,而是从一族哈希函数中随机选择一个使用。

数学表达式

h a b ( k e y ) = ( ( a × k e y + b )   m o d   P )   m o d   M h_{ab}(key) = ((a \times key + b) \bmod P) \bmod M hab(key)=((a×key+b)modP)modM

其中:

  • P是一个足够大的质数
  • a是从[1, P-1]中随机选择的整数
  • b是从[0, P-1]中随机选择的整数

这样可以构成P×(P-1)个不同的哈希函数。

计算示例

假设P = 17,M = 6,a = 3,b = 4:

h 34 ( 8 ) = ( ( 3 × 8 + 4 )   m o d   17 )   m o d   6 = 28   m o d   17   m o d   6 = 11   m o d   6 = 5 h_{34}(8) = ((3 \times 8 + 4) \bmod 17) \bmod 6 = 28 \bmod 17 \bmod 6 = 11 \bmod 6 = 5 h34(8)=((3×8+4)mod17)mod6=28mod17mod6=11mod6=5

使用注意事项

在初始化哈希表时随机选择一个哈希函数,之后的所有操作都使用这个固定的函数。不能每次操作都随机选择,否则插入和查找使用的可能是不同的函数,导致无法找到已插入的数据。

3.4 其他哈希方法

除了上述方法,在一些数据结构教材中还介绍了平方取中法、折叠法、随机数法、数学分析法等。这些方法通常适用于特定的场景,在实际应用中相对较少使用。感兴趣的同学可以参考《算法导论》、《数据结构(C语言版)》等书籍深入学习。


四、哈希冲突的解决方法

4.1 开放定址法概述

在开放定址法中,所有的元素都存储在哈希表内部。当一个关键字key通过哈希函数计算出的位置已经被占用(发生冲突)时,按照某种规则在哈希表中寻找下一个空闲位置进行存储。

由于所有元素都存储在哈希表内,开放定址法的负载因子必须小于1。根据寻找下一个空闲位置的规则不同,开放定址法可以分为三种:线性探测、二次探测和双重散列。

4.2 线性探测

线性探测是开放定址法中最简单的一种方法。

探测规则

从发生冲突的位置开始,依次向后查找,直到找到一个空闲位置。如果查找到哈希表末尾,则回绕到哈希表开头继续查找。

数学表达式

假设h(key) = hash₀ = key % M,当hash₀位置发生冲突时:

h a s h i = ( h a s h 0 + i )   m o d   M , i ∈ 1 , 2 , 3 , . . . , M − 1 hash_i = (hash_0 + i) \bmod M, \quad i \in {1, 2, 3, ..., M-1} hashi=(hash0+i)modM,i∈1,2,3,...,M−1

由于负载因子小于1,最多探测M-1次,一定能找到一个空闲位置。

插入示例

将数据{19, 30, 5, 36, 13, 20, 21, 12}插入到大小M=11的哈希表中:

  • h(19) = 8,直接插入位置8
  • h(30) = 8,位置8已占用,探测到位置9插入
  • h(5) = 5,直接插入位置5
  • h(36) = 3,直接插入位置3
  • h(13) = 2,直接插入位置2
  • h(20) = 9,位置9已占用,探测到位置10插入
  • h(21) = 10,位置10已占用,探测到位置0插入
  • h(12) = 1,直接插入位置1

线性探测的问题

线性探测存在一个明显的问题,称为群集(堆积)现象。当某个位置连续发生冲突时,会形成一个连续占用的区域。后续映射到这个区域附近的值都会争夺相邻的空闲位置,使得这个区域越来越大,进一步加剧冲突。

4.3 二次探测

二次探测通过改进探测方式来缓解线性探测的群集问题。

探测规则

从发生冲突的位置开始,按照二次方的增量进行左右跳跃式探测。

数学表达式

h a s h i = ( h a s h 0 ± i 2 )   m o d   M , i ∈ 1 , 2 , 3 , . . . , ⌊ M ⌋ hash_i = (hash_0 \pm i^2) \bmod M, \quad i \in {1, 2, 3, ..., \lfloor\sqrt{M}\rfloor} hashi=(hash0±i2)modM,i∈1,2,3,...,⌊M ⌋

实现细节

当计算hash_i = (hash₀ - i²) % M时,如果结果为负数,需要加上M使其落在有效范围内。

插入示例

将数据{19, 30, 52, 63, 11, 22}插入到大小M=11的哈希表中:

  • h(19) = 8,直接插入位置8
  • h(30) = 8,冲突,+1²探测到位置9插入
  • h(52) = 8,冲突,+1²到9占用,-1²到7插入
  • h(63) = 8,冲突,经过多次探测插入
  • h(11) = 0,直接插入位置0
  • h(22) = 0,冲突,+1²探测到位置1插入

二次探测相比线性探测能够更好地分散冲突的元素,减少群集现象。

4.4 双重散列

双重散列使用两个哈希函数,进一步改进冲突解决的效率。

探测规则

第一个哈希函数h₁(key)计算初始位置,如果发生冲突,使用第二个哈希函数h₂(key)计算探测的步长。

数学表达式

h 1 ( k e y ) = h a s h 0 = k e y   m o d   M h_1(key) = hash_0 = key \bmod M h1(key)=hash0=keymodM

h a s h i = ( h a s h 0 + i × h 2 ( k e y ) )   m o d   M , i ∈ 1 , 2 , 3 , . . . , M hash_i = (hash_0 + i \times h_2(key)) \bmod M, \quad i \in {1, 2, 3, ..., M} hashi=(hash0+i×h2(key))modM,i∈1,2,3,...,M

h₂(key)的要求

  1. h₂(key) < M
  2. h₂(key)与M互质(最大公约数为1)

这样可以保证能够遍历到哈希表的所有位置。如果h₂(key)与M的最大公约数为p > 1,那么只能访问到M/p个位置,无法充分利用整个哈希表。

h₂(key)的取值方法

  • 当M为2的整数幂时:h₂(key)从[0, M-1]中任选一个奇数
  • 当M为质数时:h₂(key) = key % (M-1) + 1

插入示例

将数据{19, 30, 52, 74}插入到大小M=11的哈希表中,设h₂(key) = key % 10 + 1:

  • h₁(19) = 8,h₂(19) = 10,直接插入位置8
  • h₁(30) = 8,冲突,h₂(30) = 1,探测位置(8+1)%11=9插入
  • h₁(52) = 8,冲突,h₂(52) = 3,探测位置(8+3)%11=0插入,位置0占用,继续探测(8+2×3)%11=3插入
  • h₁(74) = 8,冲突,h₂(74) = 5,依次探测找到空闲位置插入

五、开放定址法的代码实现

5.1 实现思路说明

在实际应用中,开放定址法的性能不如后面要讲的链地址法,因为开放定址法中的所有元素都存储在哈希表内部,冲突的元素之间会相互影响。因此,我们选择最简单的线性探测方式来实现开放定址法,重点是理解其原理。

5.2 数据结构设计

状态枚举

在开放定址法中,每个位置需要标记三种状态:

cpp 复制代码
enum State
{
    EXIST,   // 位置已存储数据
    EMPTY,   // 位置为空
    DELETE   // 位置的数据已删除
};

为什么需要DELETE状态?考虑下面的场景:

假设我们依次插入了19、30、20三个值,它们的哈希值分别映射到位置8、8、9。由于30与19冲突,30被存储在位置9;20本应存储在位置9,但位置9已被30占用,因此20被存储在位置10。

如果我们删除30时直接将位置9标记为EMPTY,那么查找20时会在位置9停止(因为遇到了EMPTY),导致无法找到位置10的20。但如果我们将位置9标记为DELETE,查找20时会跳过DELETE状态继续查找,就能正确找到20。


节点结构

cpp 复制代码
template<class K, class V>
struct HashData
{
    pair<K, V> _kv;        // 存储键值对
    State _state = EMPTY;  // 状态标识,默认为空
};

哈希表结构

cpp 复制代码
template<class K, class V>
class HashTable
{
private:
    vector<HashData<K, V>> _tables;  // 哈希表数组
    size_t _n = 0;                    // 已存储的元素个数
};

5.3 扩容机制

我们将负载因子控制在0.7,即当已存储元素个数达到表大小的70%时进行扩容。

质数表的使用

为了保持哈希表大小为质数,我们采用STL中的做法,使用一个预定义的质数表,每次扩容时从表中获取下一个合适的质数:

cpp 复制代码
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;
}

这个质数表中的每个数大约是前一个数的2倍,既保证了扩容的效率,又保持了质数的特性。

5.4 类型转换问题

当关键字Key不是整数类型时(如string、Date等),我们需要先将其转换为整数才能进行取模运算。为了实现这个功能,我们使用仿函数(函数对象)。

默认仿函数

对于可以直接转换为整数的类型,使用默认仿函数:

cpp 复制代码
template<class K>
struct HashFunc
{
    size_t operator()(const K& key)
    {
        return (size_t)key;  // 直接转换为size_t类型
    }
};

字符串特化

字符串是最常见的非整数关键字类型,我们为其提供专门的哈希函数:

cpp 复制代码
template<>
struct HashFunc<string>
{
    size_t operator()(const string& key)
    {
        size_t hash = 0;
        for (auto ch : key)
        {
            hash *= 131;  // 使用BKDR哈希算法
            hash += ch;
        }
        return hash;
    }
};

这里使用的是BKDR哈希算法,它通过将前一次的结果乘以一个质数(通常选择31、131等),然后加上当前字符的ASCII码,使得字符串的每个字符都参与到哈希值的计算中,并且不同顺序的相同字符会产生不同的哈希值。

例如,"abcd"和"bcad"虽然包含相同的字符,但由于顺序不同,计算出的哈希值也不同。

哈希表模板参数

cpp 复制代码
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
    // ...
};

这里将Hash作为模板参数,并提供了默认值。如果Key类型需要自定义哈希函数,可以自己实现一个仿函数并传入。

5.5 完整代码实现

cpp 复制代码
namespace open_address
{
    enum State
    {
        EXIST,
        EMPTY,
        DELETE
    };

    template<class K, class V>
    struct HashData
    {
        pair<K, V> _kv;
        State _state = EMPTY;
    };

    template<class K, class V, class Hash = HashFunc<K>>
    class HashTable
    {
    public:
        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;
        }

        HashTable()
        {
            _tables.resize(__stl_next_prime(0));
        }

        bool Insert(const pair<K, V>& kv)
        {
            // 检查是否已存在
            if (Find(kv.first))
                return false;

            // 负载因子大于0.7就扩容
            if (_n * 10 / _tables.size() >= 7)
            {
                // 创建新的哈希表并重新插入所有元素
                HashTable<K, V, Hash> newHT;
                newHT._tables.resize(__stl_next_prime(_tables.size() + 1));
                
                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;

            while (_tables[hashi]._state == EXIST)
            {
                hashi = (hash0 + i) % _tables.size();
                ++i;
            }

            // 插入数据
            _tables[hashi]._kv = kv;
            _tables[hashi]._state = EXIST;
            ++_n;

            return true;
        }

        HashData<K, V>* Find(const K& key)
        {
            Hash hash;
            size_t hash0 = hash(key) % _tables.size();
            size_t hashi = hash0;
            size_t i = 1;

            while (_tables[hashi]._state != EMPTY)
            {
                if (_tables[hashi]._state == EXIST &&
                    _tables[hashi]._kv.first == key)
                {
                    return &_tables[hashi];
                }

                hashi = (hash0 + i) % _tables.size();
                ++i;
            }

            return nullptr;
        }

        bool Erase(const K& key)
        {
            HashData<K, V>* ret = Find(key);
            if (ret == nullptr)
            {
                return false;
            }
            else
            {
                // 标记为已删除状态,不能直接删除
                ret->_state = DELETE;
                --_n;
                return true;
            }
        }

    private:
        vector<HashData<K, V>> _tables;  // 哈希表数组
        size_t _n = 0;                    // 表中存储的元素个数
    };
}

代码要点说明

  1. 插入操作:先检查元素是否已存在,再判断是否需要扩容,最后使用线性探测找到合适位置插入。

  2. 查找操作:使用线性探测方式查找,遇到EMPTY状态时停止,遇到DELETE状态时继续探测。

  3. 删除操作:将元素状态标记为DELETE而非直接删除,保证后续查找操作的正确性。


六、链地址法(哈希桶)

6.1 基本思想

链地址法也称为拉链法或哈希桶,它采用了与开放定址法完全不同的思路来解决冲突问题。

在链地址法中:

  • 哈希表的每个位置不直接存储数据,而是存储一个指针
  • 当没有数据映射到某个位置时,该位置的指针为空
  • 当有数据映射到某个位置时,该数据被插入到该位置对应的链表中
  • 当多个数据映射到同一位置时,这些数据形成一个链表

这种方法将冲突的元素通过链表串联起来,避免了开放定址法中元素之间相互影响的问题。

插入示例

将数据{19, 30, 5, 36, 13, 20, 21, 12, 24, 96}插入到大小M=11的哈希表中:

  • h(19) = 8,创建节点挂在位置8
  • h(30) = 8,创建节点插入位置8的链表
  • h(5) = 5,创建节点挂在位置5
  • h(36) = 3,创建节点挂在位置3
  • h(13) = 2,创建节点挂在位置2
  • h(20) = 9,创建节点挂在位置9
  • h(21) = 10,创建节点挂在位置10
  • h(12) = 1,创建节点挂在位置1
  • h(24) = 2,创建节点插入位置2的链表
  • h(96) = 8,创建节点插入位置8的链表

最终形成的哈希表中,位置8、2分别有多个节点形成的链表。

6.2 扩容策略

与开放定址法不同,链地址法的负载因子可以大于1,因为冲突的元素存储在链表中,而不占用哈希表的其他位置。

负载因子的影响

  • 负载因子越大:空间利用率越高,但链表可能越长,查找效率降低
  • 负载因子越小:链表较短,查找效率高,但空间利用率较低

在STL的unordered容器中,通常将最大负载因子控制在1左右。当负载因子达到1时进行扩容,我们的实现也采用这个策略。

6.3 极端情况处理

问题场景

在某些极端情况下,可能出现某个桶特别长的情况。虽然通过合理的哈希函数和扩容策略可以降低这种情况的概率,但仍然需要考虑应对方案。

解决方案

  1. 使用全域散列法:通过随机选择哈希函数,避免被恶意攻击

  2. 链表转红黑树:Java 8的HashMap采用了这种策略。当某个桶的链表长度超过阈值(8)时,将链表转换为红黑树,保证查找效率从O(n)提升到O(log n)

在我们的实现中,为了保持代码简洁,不实现这些优化。但在实际工程项目中,可以根据需要添加这些优化。

6.4 数据结构设计

节点结构

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)
    {}
};

哈希表结构

cpp 复制代码
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
    typedef HashNode<K, V> Node;
    
private:
    vector<Node*> _tables;  // 指针数组,每个元素指向一个链表
    size_t _n = 0;          // 表中存储的元素总数
};

6.5 完整代码实现

cpp 复制代码
namespace hash_bucket
{
    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;
        
        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:
        HashTable()
        {
            _tables.resize(__stl_next_prime(0), nullptr);
        }

        ~HashTable()
        {
            // 释放每个桶中的所有节点
            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)
        {
            // 检查是否已存在
            if (Find(kv.first))
                return false;

            Hash hs;
            
            // 负载因子等于1时扩容
            if (_n == _tables.size())
            {
                // 创建新表
                vector<Node*> newtables(__stl_next_prime(_tables.size() + 1), nullptr);
                
                // 将旧表中的节点移动到新表
                for (size_t i = 0; i < _tables.size(); i++)
                {
                    Node* cur = _tables[i];
                    while (cur)
                    {
                        Node* next = cur->_next;
                        
                        // 重新计算在新表中的位置
                        size_t hashi = hs(cur->_kv.first) % newtables.size();
                        
                        // 头插到新表对应位置
                        cur->_next = newtables[hashi];
                        newtables[hashi] = cur;
                        
                        cur = next;
                    }
                    _tables[i] = nullptr;
                }
                
                _tables.swap(newtables);
            }

            // 计算插入位置
            size_t hashi = hs(kv.first) % _tables.size();
            
            // 头插法插入新节点
            Node* newnode = new Node(kv);
            newnode->_next = _tables[hashi];
            _tables[hashi] = newnode;
            ++_n;

            return true;
        }

        Node* Find(const K& key)
        {
            Hash hs;
            size_t hashi = hs(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 hs;
            size_t hashi = hs(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;
        }

    private:
        vector<Node*> _tables;  // 指针数组
        size_t _n = 0;          // 元素总数
    };
}

代码要点说明

  1. 构造函数:初始化哈希表为一个质数大小的指针数组,所有指针初始化为nullptr

  2. 析构函数:必须实现深度释放,遍历每个桶,释放链表中的所有节点

  3. 插入操作

    • 先检查元素是否已存在
    • 判断是否需要扩容(负载因子达到1)
    • 使用头插法将新节点插入对应位置的链表
  4. 扩容操作

    • 创建新的更大的哈希表
    • 将旧表中的节点移动(而非复制)到新表
    • 这种移动方式避免了重新创建节点,提高了效率
  5. 查找操作:计算哈希值后,遍历对应位置的链表查找目标元素

  6. 删除操作:找到目标节点后,调整链表指针并释放节点内存


七、两种方法的对比与选择

7.1 性能对比

开放定址法

优点:

  • 数据存储紧凑,缓存友好
  • 不需要额外的指针空间

缺点:

  • 负载因子必须小于1,空间利用率受限
  • 冲突元素相互影响,容易产生群集现象
  • 删除操作较复杂,需要标记状态

链地址法

优点:

  • 负载因子可以大于1,空间利用率更高
  • 冲突元素互不影响,性能更稳定
  • 删除操作简单直接
  • 易于实现和维护

缺点:

  • 需要额外的指针空间
  • 内存不连续,缓存性能略差

7.2 实际应用

在工程实践中,链地址法因其更好的综合性能被广泛采用。例如:

  • C++ STL的unordered_map和unordered_set使用链地址法
  • Java的HashMap使用链地址法(并在链表过长时转为红黑树)
  • Python的dict也采用类似的思想

开放定址法主要在一些对内存布局有特殊要求的场景中使用。


八、总结与展望

哈希表作为一种高效的数据结构,在现代软件开发中扮演着重要角色。通过合理设计哈希函数和选择适当的冲突解决策略,我们可以实现接近O(1)时间复杂度的数据操作。本文从基础概念出发,详细讲解了哈希表的各种实现方法,特别是开放定址法和链地址法两种主流方案。在实际应用中,我们需要根据具体场景选择合适的实现方式,平衡时间效率和空间开销。掌握哈希表的原理和实现,不仅能帮助我们更好地使用标准库中的容器,也为深入理解数据结构和算法打下坚实基础。

以上就是关于哈希表实现的全部内容,各位读者如有疑问欢迎在评论区指正,或者私信交流。您的支持是我持续创作的最大动力!❤️

相关推荐
肆悟先生2 小时前
3.14 函数的参数传递
c++
Yeats_Liao2 小时前
MindSpore开发之路(四):核心数据结构Tensor
数据结构·人工智能·机器学习
wunianor2 小时前
[高并发服务器]DEBUG日志
linux·运维·服务器·c++
天赐学c语言3 小时前
12.19 - 买卖股票的最佳时机 && const的作用
c++·算法·leecode
菜鸟233号3 小时前
力扣78 子集 java实现
java·数据结构·算法·leetcode
Han.miracle3 小时前
数据结构与算法--008四数之和 与经典子数组 / 子串问题解析
数据结构·算法
AI科技星3 小时前
圆柱螺旋运动方程的一步步求导与实验数据验证
开发语言·数据结构·经验分享·线性代数·算法·数学建模
月明长歌3 小时前
【码道初阶】【Leetcode94&144&145】二叉树的前中后序遍历(非递归版):显式调用栈的优雅实现
java·数据结构·windows·算法·leetcode·二叉树
DanyHope4 小时前
《LeetCode 49. 字母异位词分组:哈希表 + 排序 全解析》
算法·leetcode·哈希算法·散列表