【C++ STL篇(十四)】哈希表实现:开放定址法与链地址法


大家好,欢迎来到 huangjin007_ 的博客
个人主页:huangjin007_
🔥 文章收录专栏:零基础入门C++
总会有一些坚持
能从冰封的土地里
培育出十万朵怒放的蔷薇


C++ STL篇(十四) ------ 哈希表实现

  本篇文章将带你从零开始,吃透哈希表底层原理 。全程干货,坐稳发车~ ദ്ദി˶ー̀֊ー́ )✧

文章目录


一、哈希表到底在解决什么问题?

1.1 直接定址法:理想状态下

  假设你有一组学生的成绩,学号范围是 0~99,你想根据学号快速查到成绩。最直接的办法是开一个长度 100 的数组,学号是多少,就把成绩存到下标为多少的位置。查找时,拿着学号直接去对应下标取数据------时间复杂度 O(1),完美!

  再比如,你只想存储小写字母 'a'~'z' 的出现次数,开一个长度 26 的数组,让 'a' 对应下标 0,'b' 对应下标 1......这只需要用 ch - 'a' 算出一个相对位置,也是 O(1)。

就像这道OJ题:

传送门:字符串中的第一个唯一字符

cpp 复制代码
class Solution {
public:
    int firstUniqChar(string s) {
        int count[26]; // 数组大小为26,对应26个小写字母
        for(auto ch : s)
        {
            count[ch - 'a']++;// 将字符转换为数组索引,并增加计数
        }

        for(size_t i = 0;i<s.size();i++)
        {
            // 如果当前字符的出现次数为1,说明是第一个不重复的字符
            if(count[s[i] - 'a'] == 1)
                return i;
        }
        return -1;
    }
};

  这种方式叫直接定址法:关键字本身(或经过简单偏移)就直接作为存储位置的下标。

1.2 哈希冲突的出现

  直接定址法有个致命伤:它要求关键字的范围必须很集中、很有限。如果关键字是身份证号、手机号、或者任意字符串呢?哪怕只存几百个数据,你也不可能开一个能覆盖所有可能值的巨型数组(内存直接爆炸)!

  假设我们只有数据范围是0,9999的N个值,我们要映射到一个M个空间的数组中(一般情况下M>=N),那么就要借助 哈希函数 (hashfunction) hf,关键字 key 被放到数组的 h(key) 位置,这里要注意的是 h(key) 计算出的值必须在[0,M)之间。

  但问题又来了:既然数组大小 M 是有限的,而可能的 Key 是无限的(或非常多的),必然会出现两个不同的 Key 被映射到同一个位置 的情况,这叫哈希冲突(Hash Collision)

  举个例子:数组大小 M = 10,哈希函数是 key % 10。那么 key = 15 和 key = 25 都会映射到位置 5,它俩就冲突了。

因此,设计哈希表时我们要同时解决两个核心问题:

  1. 如何设计一个"好"的哈希函数,让冲突尽可能少;
  2. 冲突一旦发生,我们该如何处理。

二、衡量哈希表的关键指标:负载因子

  在深入具体方法之前,先介绍一个贯穿始终的概念------负载因子(Load Factor)

  假设哈希表底层数组的大小为 M,目前已经存了 N 个数据,那么:

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

负载因子的直观意义是"数组有多满"。它直接影响两个东西:

  • 负载因子越大 -> 数组越满,冲突概率越高,但空间利用率高;
  • 负载因子越小 -> 数组越空,冲突概率低,但空间利用率低,浪费内存。

  这是一个经典的 "时间换空间" 或 "空间换时间" 的权衡。在实际实现中,我们会设定一个阈值(比如 0.7 或 1.0),当负载因子超过这个值时,就对哈希表进行扩容,重新分配一个更大的数组,把所有数据重新哈希进去。


三、哈希函数的设计

  一个好的哈希函数应该能让关键字均匀地散列到各个位置,尽量避免"扎堆"。这里我们介绍几种经典的哈希函数设计方法。

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

  这是最常用、最直观的方法:用关键字除以哈希表大小 M,取余数作为下标

h ( k e y ) = k e y % M h(key)=key\%M h(key)=key%M

  这个方法简单高效,但有一个关键细节:M 的取值要讲究 。如果你选择 M = 2^X(比如 16、32、64......),那么 key % M 实际上就相当于只保留了 key 二进制形式的低 X 位。那些高位不同、低位相同的 key 就会全部冲突。(二进制规则: key % 2ⁿ = 只保留二进制最后n位)

  例如 M = 16 时,63(二进制 00111111)和 31(二进制 00011111)的低 4 位都是 1111,都映射到位置 15,尽管它们看起来毫不相关。同理,M 取 10^X(比如 100、1000)也会只保留十进制末尾几位,同样容易产生规律性冲突。

  所以经典的数据结构教材通常建议 M 选一个不太接近 2 的整数次幂的质数(素数),这样可以让 key 的所有位都参与到取模运算中,分布更均匀。

延伸思考:Java 的 HashMap 为什么用 2 的幂?

  Java的HashMap采用除法散列法时就是2的整数次幂做哈希表的大小M,这样玩的话,就不用取模,而可以直接位运算,相对而言位运算比模更高效一些。

  但是他不是单纯的去取模,比如M是2^16次方,本质是取后16位,那么用key'=key>>16,然后把key和key'异或 的结果作为哈希值。

  也就是说我们映射出的值还是在[0,M)范围内,但是尽量让key所有的位都参与计算,这样映射出的哈希值更均匀一些即可。

3.2 乘法散列法(了解即可)

乘法散列法不要求 M 是质数。它的思路分两步:

  1. 用 key 乘上一个常数 A(0 < A < 1),然后取出乘积的小数部分;
  2. 用 M 乘以这个小数部分,再向下取整,得到最终位置。

h ( k e y ) = f l o o r ( M × ( ( A × k e y ) % 1.0 ) ) h(key) = \mathrm{floor}\big(M \times ((A \times key)\% 1.0)\big) h(key)=floor(M×((A×key)%1.0))

其中floor表示对表达式进行向下取整,A∈(0,1)。

Knuth 建议 A 取黄金分割比 ( 5 − 1 ) / 2 ≈ 0.618 (\sqrt{5}-1)/2 \approx 0.618 (5 −1)/2≈0.618。因为最后一步是乘 M,所以 key 的微小变化也能被放大到整个表的不同位置。

乘法散列法对哈希表大小 M M M 无取值约束,

设: M = 1024 , k e y = 1234 , A = 0.6180339887 M=1024,\ \mathit{key}=1234,\ A=0.6180339887 M=1024, key=1234, A=0.6180339887

  1. 计算 A × k e y A \times \mathit{key} A×key:
    A × k e y = 0.6180339887 × 1234 = 762.6539420558 A \times key = 0.6180339887 \times 1234 = 762.6539420558 A×key=0.6180339887×1234=762.6539420558
  2. 取小数部分 ( A × k e y ) % 1.0 = 0.6539420558 (A\times key)\% 1.0 = 0.6539420558 (A×key)%1.0=0.6539420558
  3. 带入哈希公式:
    M × ( ( A × k e y ) % 1.0 ) = 1024 × 0.6539420558 = 669.6366651392 \begin{align} M \times ((A\times key)\% 1.0) &= 1024 \times 0.6539420558 \\ &= 669.6366651392 \end{align} M×((A×key)%1.0)=1024×0.6539420558=669.6366651392
  4. 向下取整得到哈希下标:
    h ( k e y ) = f l o o r ( 669.6366651392 ) = 669 h(key)=\mathrm{floor}(669.6366651392)=669 h(key)=floor(669.6366651392)=669

3.3 全域散列法(了解即可)

  如果存在一个恶意攻击者,他知道你用的哈希函数是什么,他就可以故意构造一批 key,让它们全部映射到同一个位置,把你的哈希表性能直接拉到最差(O(n) 查找)。这叫"哈希洪水攻击"。

  全域散列法通过在运行时随机选择哈希函数来对抗这种攻击。其典型形式是:

h a b ( k e y ) = ( ( a × k e y + b ) % P ) % M h_{ab}(key) = ((a \times key + b) \%P) \% M hab(key)=((a×key+b)%P)%M

  其中 P 是一个比所有 key 都大的质数,a 从 1, P-1 中随机选一个任意整数,b 从 0, P-1 中随机选一个任意整数。这样哈希函数就有了随机性,攻击者无法事先确定用哪个函数,也就无法轻易构造冲突数据。

  需要注意,一旦哈希表初始化时选定了某个 a 和 b,后续的操作都得一直用它,不能每次查找时重新随机选,否则自己都找不着之前存的数据了。

h a b ( k e y ) = ( ( a × k e y + b ) % P ) % M h_{ab}(key) = ((a \times key + b)\%P)\%M hab(key)=((a×key+b)%P)%M

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

则 h 34 ( 8 ) = ( ( 3 × 8 + 4 ) % 17 ) % 6 = 5 h_{34}(8) = ((3 \times 8 + 4)\%17)\%6 = 5 h34(8)=((3×8+4)%17)%6=5

3.4 其他方法

  上面的几种方法是《算法导论》书籍中讲解的方法。

  《殷人昆 数据结构:用面向对象方法与C++语言描述(第二版)》和《数据结构(C语言版).严蔚敏_吴伟民》等教材型书籍上面还给出了平方取中法、折叠法、随机数法、数学分析法等,这些方法相对更适用于一些局限的特定场景,有兴趣可以去看看这些书籍。


四、处理哈希冲突的两大流派

  有了哈希函数,冲突还是不可避免。如何解决冲突,决定了哈希表的组织形态。主流方法分为两类:

  • 开放定址法(Open Addressing) :冲突时,顺着某种探测序列在数组里找到下一个空位存进去。所有数据都老老实实待在数组里。
  • 链地址法(Separate Chaining):数组里不直接存数据,而是存一个链表的头指针。冲突的数据就挂在这个链表上。也叫 "拉链法" 或 "哈希桶" 。

下面我们结合代码,深入剖析这两种实现。


五、开放定址法(线性探测)实现详解

  开放定址法的核心思想是:冲突了,就顺着一条预先定好的"探测路径"找空位。常见的探测路径有三种:

1. 线性探测

  最简单的方法:发生冲突后,依次往后找,一个一个地探测,直到找到空位。如果走到表尾,就绕回表头继续。公式为:

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

其中 hash0 = key % M 是初始位置。

例子 :M=11,插入 {19, 30, 5, 36, 13, 20, 21, 12}

  • 19 % 11 = 8,位置 8 空,直接放入。
  • 30 % 11 = 8,冲突,i=1,探测 9,空,放入。
  • 5 % 11 = 5,放入。
  • 36 % 11 = 3,放入。
  • 13 % 11 = 2,放入。
  • 20 % 11 = 9,探测 9 已占,i=1 探测 10,空,放入。
  • 21 % 11 = 10,冲突,i=1 探测 0,空,放入。
  • 12 % 11 = 1,放入。

  线性探测实现简单,但有一个明显问题:堆积(群集)。一旦某个连续区间被占满,后续映射到这个区间的任何 Key 都会去争抢区间之后的同一个空位,导致冲突进一步加剧。

2. 二次探测

  从发生冲突的位置开始,依次左右按二次方跳跃式探测,直到寻找到下一个没有存储数据的位置为止,如果往右走到哈希表尾,则回绕到哈希表头的位置;如果往左走到哈希表头,则回绕到哈希表尾的位置;

  • h ( k e y ) = h a s h 0 = k e y % M h(key)=hash0=key \% M h(key)=hash0=key%M, h a s h 0 hash0 hash0位置冲突了,则二次探测公式为:
    h c ( k e y , i ) = h a s h i = ( h a s h 0 ± i 2 ) % M , i ∈ { 1 , 2 , 3 , . . . , M 2 } hc(key,i)=hash_i=(hash0 \pm i^2)\%M,\quad i\in\left\{1,2,3,...,\frac{M}{2}\right\} hc(key,i)=hashi=(hash0±i2)%M,i∈{1,2,3,...,2M}
  • 二次探测当 h a s h i = ( h a s h 0 − i 2 ) % M hashi=(hash0 - i^2)\%M hashi=(hash0−i2)%M 时,当 h a s h i < 0 hashi<0 hashi<0时,需要 h a s h i + = M hashi += M hashi+=M

例子 :M=11,插入 {19, 30, 52, 63, 11, 22}。19、30、52、63 的 hash0 都是 8,产生连续冲突,通过平方步长可以更快找到空位。

3. 双重散列 (了解即可)

  第一个哈希函数算出的值发生冲突,使用第二个哈希函数算出一个跟key相关的偏移量值,不断往后探测,直到寻找到下一个没有存储数据的位置为止。

  • h 1 ( k e y ) = h a s h 0 = k e y % M h_1(key)=hash0=key \% M h1(key)=hash0=key%M, h a s h 0 hash0 hash0位置冲突了,则双重探测公式为:

    h c ( k e y , i ) = h a s h i = ( h a s h 0 + i × h 2 ( k e y ) ) % M , i = { 1 , 2 , 3 , . . . , M } hc(key,i)=hash_i=\big(hash0 + i \times h_2(key)\big)\%M,\quad i=\{1,2,3,...,M\} hc(key,i)=hashi=(hash0+i×h2(key))%M,i={1,2,3,...,M}

  • 要求 h 2 ( k e y ) < M h_2(key) < M h2(key)<M 且 h 2 ( k e y ) h_2(key) h2(key) 和 M M M 互为质数,两种常用取值:

    1. M M M 为2的整数幂: h 2 ( k e y ) h_2(key) h2(key) 在 0 , M − 1 0,M-1 0,M−1 中任选一个奇数;
    2. M M M 为质数: h 2 ( k e y ) = k e y % ( M − 1 ) + 1 h_2(key)=key \% (M-1)+1 h2(key)=key%(M−1)+1。
  • 保证 h 2 ( k e y ) h_2(key) h2(key) 与 M M M互质是因为根据固定的偏移量所寻址的所有位置将形成一个群,若最大公约数 p = gcd ⁡ ( M , h 2 ( k e y ) ) > 1 p = \gcd(M,h_2(key))>1 p=gcd(M,h2(key))>1,那么所能寻址的位置的个数为 M P < M \displaystyle\frac{M}{P}<M PM<M,使得对于一个关键字来说无法充分利用整个散列表。举例来说,若初始探查位置为1,偏移量为3,整个散列表大小为12,那么所能寻址的位置为 { 1 , 4 , 7 , 10 } \{1,4,7,10\} {1,4,7,10},寻址个数为 12 gcd ⁡ ( 12 , 3 ) = 4 \displaystyle\frac{12}{\gcd(12,3)} = 4 gcd(12,3)12=4

  • 下面演示 { 19 , 30 , 52 , 74 } \{19,30,52,74\} {19,30,52,74} 等这一组值映射到 M = 11 M=11 M=11的表中,设 h 2 ( k e y ) = k e y % 10 + 1 h_2(key) = key\%10 + 1 h2(key)=key%10+1

5.1 状态标记:为什么不能随便删除?

  开放定址法有一个容易踩的坑:删除一个元素后,不能简单地把位置清空

  看这个例子M=11,插入 {19, 30, 5, 36, 13, 20, 21, 12}

  插入 19(位置 8),插入 30(位置 8 冲突,线性探测到 9),插入 20(位置 9 冲突,探测到 10)。此时数组:

  现在我们删除 30。如果直接把位置 9 清空,再去查找 20 时:计算 hash(20)=9,发现位置 9 空了,按探测逻辑会认为"遇到空位,查找失败"。但实际上 20 在位置 10 存着呢!

解决办法是给数组的每个位置增加一个状态标记

  • EXIST:该位置存有数据
  • EMPTY:该位置从未存过数据
  • DELETE:该位置曾经有数据,但已被删除

  查找时,遇到 EMPTY 才停止,遇到 DELETE 继续往后探测。删除时,找到目标后直接把状态改为 DELETE 即可。

5.2 整体代码结构

我们先看开放定址法的完整代码,然后逐块剖析:

cpp 复制代码
namespace open_address
{
    // 状态枚举
    enum State { EXIST, EMPTY, DELETE };

    // 哈希表每个槽位存储的数据
    template<class K, class V>
    struct HashData
    {
        pair<K, V> _kv;
        State _state = EMPTY;
    };

    // 默认哈希函数(将 key 转为 size_t)
    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& s) 
	   {
	        size_t hash = 0;
	        for (auto ch : s) {
	            hash += ch;
	            hash *= 131;   // 乘一个质数让分布更均匀
	        }
	        return hash;
	    }
   };

    // 质数表(用于扩容时选取新大小)
    inline unsigned long __stl_next_prime(unsigned long n)
    {
        static const unsigned long 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
        };
        auto first = begin(primes);
        auto last = end(primes);
        auto pos = lower_bound(first, last, n);
        return pos == last ? *(last - 1) : *pos;
    }

    template<class K, class V, class Hash = HashFunc<K>>
    class HashTable
    {
    public:
        HashTable()
            :_tables(__stl_next_prime(0))
            ,_n(0)
        {}

        bool Insert(const pair<K, V>& kv);
        HashData<K, V>* Find(const K& key);
        bool Erase(const K& key);

    private:
        vector<HashData<K, V>> _tables;
        size_t _n = 0;  // 已存储的数据个数
    };
}

5.3 默认哈希函数与 string 特化

  Key 可能是 intstring 甚至自定义结构体,它们不能直接用 % 运算。我们需要一个仿函数(函数对象)把 Key 转为 size_t

默认模版直接强转:

cpp 复制代码
template<class K>
struct HashFunc 
{
    size_t operator()(const K& key) 
    {
        return (size_t)key;
    }
};

  对于 string,我们提供一个模版特化。一个好的字符串哈希函数应该让每个字符都参与运算,这里采用经典的哈希算法:

cpp 复制代码
template<>
struct HashFunc<string> 
{
    size_t operator()(const string& s) 
    {
        size_t hash = 0;
        for (auto ch : s) 
        {
            hash += ch;
            hash *= 131;   // 乘一个质数让分布更均匀
        }
        return hash;
    }
};

  如果用户自定义类型作为 Key,只需自己写一个仿函数, 通过模版参数传入即可。

  我们用 C++ 的模板特化 语法 template<> struct HashFunc<string>,专门为 string 定制了这个版本。当用户声明 HashTable<string, int> 时,编译器会自动选用这个特化版本。

5.4 质数表与扩容大小选择

  前面说过,哈希表容量取质数有助于减少冲突。但扩容往往是按"约两倍"增长,两倍不一定是质数。这里我们借鉴了 STL 的 SGI 版本中的做法:预先算好一个将近两倍增长的质数表 ,每次扩容时用 lower_bound 找到第一个大于等于目标值的质数。

cpp 复制代码
inline unsigned long __stl_next_prime(unsigned long n)
{
    // 28 个经过挑选的质数,大致按 2 倍递增
    static const unsigned long primes[] = { /* ... */ };
    auto first = begin(primes);
    auto last = end(primes);
    auto pos = lower_bound(first, last, n);
    return pos == last ? *(last - 1) : *pos;
}

  lower_bound(first, last, n) 是在已排序的 primes 数组中用二分查找,找到第一个 >= n 的位置。如果 n 比表里所有质数都大,就返回表里最大的那个(兜底)。

  构造函数里我们用 __stl_next_prime(0) 来获取第一个质数 53 作为初始大小,这样哈希表一出生就是 53 个槽位。

5.5 Insert:扩容与线性探测

cpp 复制代码
bool Insert(const pair<K, V>& kv)
{
    // 1. 查重
    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() + 1));

        for (auto& data : _tables)
        {
            if (data._state == EXIST)
                newht.Insert(data._kv);
        }
        _tables.swap(newht._tables);
    }

    // 3. 线性探测插入
    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;
}

逐段解读:

  • 查重 :哈希表的 key 通常是唯一的,插入前先调用 Find 确认没有重复。
  • 扩容判断_n * 10 / _tables.size() >= 7 等价于 _n / _tables.size() >= 0.7,避免了浮点数运算。超过 0.7 时就创建一个新的哈希表,新表大小从质数表中获取(_tables.size() + 1 保证新表至少比旧表大)。然后遍历旧表,把所有 EXIST 的数据重新插入到新表(注意,这里会递归调用新表的 Insert,但新表是空的,不会再触发扩容)。最后用 swap 交换新旧两个数组的指针,旧数组随着 newht 被销毁而自动释放。
  • 线性探测
    • 先计算初始哈希值 hash0 = key % M
    • 如果这个位置已经是 EXIST(说明冲突了),我们就依次探测 hash0+1, hash0+2, hash0+3 ...,每次都对 M 取模,保证下标不越界且能绕回表头。
    • _state != EXIST(即 EMPTY 或 DELETE)时,循环结束,这个位置就是我们要插入的空位。

5.6 Find 与 Erase

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

  Find 的探测逻辑与 Insert 完全一致:从初始位置出发,线性向后探测。只有当遇到 EMPTY 状态时,才能确认 key 一定不存在(因为如果有插入,它一定会在遇到 EMPTY 之前填入某个空位,而 DELETE 位并不代表查找终点)。

cpp 复制代码
bool Erase(const K& key)
{
    HashData<K, V>* ret = Find(key);
    if (ret)
    {
        ret->_state = DELETE;
        --_n;
        return true;
    }
    return false;
}

  删除操作非常简单:先找到目标,把它的状态标记为 DELETE,然后把计数减一。不会真正释放空间,也不会把那个位置变回 EMPTY,这正是前面状态标记存在的意义。


六、链地址法(哈希桶)实现详解

  开放定址法的最大问题是所有数据挤在同一个数组里,冲突会互相影响(比如线性探测的"堆积"现象)。而链地址法则彻底改变了组织方式:

  • 数组里每个位置存的不是数据,而是一个链表的头指针
  • 当一个新数据映射到某个位置时,直接把它挂到该位置的链表上。
  • 冲突再多,也只是链表变长一点,不会影响其他位置。

  因为数据可以挂链表,所以链地址法的负载因子可以大于 1 。STL 中的 unordered_map 通常把最大负载因子设为 1,超过就扩容。

6.1 整体代码结构

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;
    public:
        HashTable()
            :_tables(__stl_next_prime(0))
            ,_n(0)
        {}

        // 拷贝构造、赋值、析构后面单独讲解

        bool Insert(const pair<K, V>& kv);
        Node* Find(const K& key);
        bool Erase(const K& key);

    private:
        vector<Node*> _tables;  // 每个桶存链表头指针
        size_t _n = 0;          // 数据总数
    };
}

  这里 HashFunc 和质数表 __stl_next_prime 与开放定址法完全共用,不再重复列出。

6.2 Insert:头插法 + 扩容

cpp 复制代码
bool Insert(const pair<K, V>& kv)
{
    Hash hash;
    
    // 扩容:当数据个数等于表大小时(负载因子 >= 1)
    if (_n == _tables.size())
    {
        // 创建新表
        vector<Node*> newTable(__stl_next_prime(_tables.size() + 1));
        
        // 遍历旧表每个桶
        for (size_t i = 0; i < _tables.size(); i++)
        {
            Node* cur = _tables[i];
            while (cur)
            {
                Node* next = cur->_next;
                // 重新计算新位置
                size_t hashi = hash(cur->_kv.first) % newTable.size();
                // 头插到新表
                cur->_next = newTable[hashi];
                newTable[hashi] = cur;
                cur = next;
            }
            _tables[i] = nullptr;  // 旧桶置空(节点已被搬走)
        }
        _tables.swap(newTable);
    }

    // 插入新节点
    size_t hashi = hash(kv.first) % _tables.size();
    Node* newnode = new Node(kv);
    newnode->_next = _tables[hashi];  // 新节点指向当前链表头
    _tables[hashi] = newnode;          // 新节点成为新的链表头
    ++_n;

    return true;
}

关键点解析:

  1. 扩容时机 :当 _n == _tables.size() 时,负载因子达到 1。STL 默认最大负载因子是 1,我们也沿用这个设定。
  2. 迁移数据的方式 :不是像开放定址法那样重新调用 Insert(那样会反复 new 节点),而是直接把旧表的节点摘下来,重新计算哈希值,用头插法挂到新表的对应桶里。这样省去了节点的重新分配和拷贝,效率更高。
  3. 头插法:新节点总是插在链表头部,时间复杂度 O(1)。如果用尾插,还得先遍历到链表末尾,没必要。
  4. 迁移完成后,旧表的所有桶指针已经全部置空,但旧表 vector 本身会在 swap 后随着 newTable 离开作用域而被销毁,旧节点已经被"搬运"到新表,不存在内存泄漏。

6.3 Find 与 Erase

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

  查找非常简单:算出桶下标,然后在这个桶的链表里顺序遍历,找到就返回节点指针,没找到返回 nullptr

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

  删除就是单链表的节点删除:维护一个 prev 前驱指针,找到目标后,分"删除头节点"和"删除中间/尾节点"两种情况处理。删除后记得 --_n

6.4 拷贝构造、赋值与析构

  因为我们的哈希桶是自己管理内存(用 new 创建节点),所以必须处理好深拷贝内存释放,否则会出现重复释放、内存泄漏等问题。

析构函数
cpp 复制代码
~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;
    }
}

  遍历每个桶,逐个删除链表节点,最后把所有桶指针置空。

拷贝构造函数
cpp 复制代码
HashTable(const HashTable& ht)
    :_tables(ht._tables.size(), nullptr)
    ,_n(ht._n)
{
    for (size_t i = 0; i < ht._tables.size(); i++)
    {
        Node* cur = ht._tables[i];
        Node* tail = nullptr;
        while (cur)
        {
            Node* newnode = new Node(cur->_kv);
            if (tail == nullptr)
                _tables[i] = newnode;
            else
                tail->_next = newnode;
            tail = newnode;
            cur = cur->_next;
        }
    }
}

  拷贝构造要完成"深拷贝"。我们先将自己的 _tables 调整为与源表相同的大小,初始全为 nullptr。然后遍历源表每个桶,对于桶里的每个节点,用尾插法逐个复制,保持链表顺序不变。

赋值运算符(现代 C++ 写法)
cpp 复制代码
void swap(HashTable& tmp)
{
    _tables.swap(tmp._tables);
    std::swap(_n, tmp._n);
}

HashTable& operator=(HashTable tmp)
{
    swap(tmp);
    return *this;
}

  这里采用了"拷贝并交换"惯用法。参数 tmp 是按值传递的,调用赋值运算符时会先利用拷贝构造函数生成一个临时副本 。然后我们把自己和这个临时副本的内容交换一下,临时副本离开作用域时自动析构,顺带释放了旧数据。这种写法异常安全且代码简洁。


七、测试与使用示例

7.1 自定义类型作为 key

  当 key 是我们自己定义的类型(比如日期类),不能直接取模,需要提供专门的哈希仿函数:

cpp 复制代码
struct Date
{
    int _year, _month, _day;
    Date(int year = 1, int month = 1, int day = 1)
        :_year(year), _month(month), _day(day) 
        {}
    
    bool operator==(const Date& d) const
    {
        return _year == d._year 
        && _month == d._month 
        && _day == d._day;
    }
};

struct DateHashFunc 
{
    size_t operator()(const Date& d) 
    {
        size_t hash = 0;
        hash += d._year;
        hash *= 131;
        hash += d._month;
        hash *= 131;
        hash += d._day;
        hash *= 131;
        return hash;
    }
};

int main()
{
    open_address::HashTable<Date, int, DateHashFunc> ht;
    ht.Insert({{2026, 5, 30}, 1});
    ht.Insert({{2026, 5, 31}, 1});
    return 0;
}

  使用时,只需将仿函数作为第三个模版参数传入:HashTable<Date, int, DateHashFunc> ht;。这体现了 C++ 模版的灵活性------只要提供一个能将 Key 转为整数的仿函数,任何类型都能作为哈希表的 Key。


八、两种实现方式的对比与选择

特性 开放定址法 链地址法(哈希桶)
存储方式 所有数据存储在数组内部 数组存指针,冲突数据挂在链表中
负载因子限制 必须小于 1(我们设为 0.7) 可以大于 1(我们设为 1)
删除处理 需状态标记(EXIST/EMPTY/DELETE) 直接删除节点即可
额外内存开销 无指针,但有状态字段 每个节点有 next 指针
缓存友好性 较好(连续存储) 较差(链表节点分散)
适用场景 元素较小且数量可控 元素较大、频繁增删、未知规模

实际应用中的选择:

  • 在 C++ STL 中,unordered_mapunordered_set 底层采用的是链地址法(哈希桶),使用单向链表,当桶过长时可能转为红黑树(Java 的 HashMap 在桶长度超过 8 时会转为红黑树,防止极端退化)。

结语:

  今天的内容到这里就结束了,希望你能有所收获~

干货整理到手抖,觉得有用的话,赏个三连回回血?__(:ᗤ」ㄥ)_ _

相关推荐
承渊政道1 小时前
【MySQL数据库学习】MySQL表的约束(上)
数据库·c++·学习·mysql·bash·数据库架构·数据库系统
minji...1 小时前
Linux高级IO(六)基于ET模式、单reactor反应堆的epoll版本的TCP计算服务器
linux·服务器·网络·c++·epoll·socket套接字·reactor反应堆模式
程序大视界1 小时前
【C++ 从基础到项目实战】C++(九):友元与设计模式初探——打破封装的艺术
开发语言·c++·cpp
cpp_25011 小时前
P10377 [GESP202403 六级] 好斗的牛
数据结构·c++·算法·题解·洛谷·gesp六级
邪修king1 小时前
C++ 红黑树自平衡核心:旋转变色、规则详解与 STL 选型逻辑
数据结构·c++·b树·算法
cany10009 小时前
C++ -- 可变参数模板
c++
不会C语言的男孩10 小时前
C++ Primer 第2章:变量和基本类型
开发语言·c++
云泽80812 小时前
C++ 可调用对象通关指南:深度解析 Lambda 表达式、function 包装器与 bind 绑定器
开发语言·c++·算法
Tri_Function12 小时前
简单图论大学习
c++