C++ 哈希表深度解析:删除为什么必须打标记,负载因子为什么卡 0.7

文章目录

  • [C++ 哈希表深度解析:删除为什么必须打标记,负载因子为什么卡 0.7](#C++ 哈希表深度解析:删除为什么必须打标记,负载因子为什么卡 0.7)
    • [一、哈希的本质:用 key 算位置,冲突是常态](#一、哈希的本质:用 key 算位置,冲突是常态)
      • [1.1 直接定址法:key 范围集中时最香](#1.1 直接定址法:key 范围集中时最香)
      • [1.2 哈希冲突:映射到 [0, M) 就躲不开](#1.2 哈希冲突:映射到 [0, M) 就躲不开)
      • [1.3 负载因子:哈希表的"拥挤度"](#1.3 负载因子:哈希表的"拥挤度")
    • [二、哈希函数:把 key 均匀撒开](#二、哈希函数:把 key 均匀撒开)
      • [2.1 除法散列法:key % M,M 的取值是学问](#2.1 除法散列法:key % M,M 的取值是学问)
      • [2.2 乘法散列法和全域散列法(了解)](#2.2 乘法散列法和全域散列法(了解))
      • [2.3 key 不是整形怎么办](#2.3 key 不是整形怎么办)
    • 三、开放定址法:所有数据都放在表里
      • [3.1 三种探测规则](#3.1 三种探测规则)
      • [3.2 数字实例:同一组 key,三种探测怎么落座](#3.2 数字实例:同一组 key,三种探测怎么落座)
      • [3.3 删除为什么必须打标记](#3.3 删除为什么必须打标记)
      • [3.4 开放定址法完整实现](#3.4 开放定址法完整实现)
    • 四、链地址法:冲突的数据挂成链表
      • [4.1 思路:表里存指针,冲突挂链](#4.1 思路:表里存指针,冲突挂链)
      • [4.2 完整实现](#4.2 完整实现)
      • [4.3 极端场景:某个桶特别长怎么办](#4.3 极端场景:某个桶特别长怎么办)
    • 五、两种实现怎么选:数学说了算
      • [5.1 探测次数的定量分析](#5.1 探测次数的定量分析)
      • [5.2 全维度对比:决策总表](#5.2 全维度对比:决策总表)
      • [5.3 STL 的选择:从源码看佐证](#5.3 STL 的选择:从源码看佐证)
    • 六、实战
    • 七、总结

C++ 哈希表深度解析:删除为什么必须打标记,负载因子为什么卡 0.7

unordered_map 的查找是 O(1),这件事人人都知道。面试往下问一层就露馅:删除为什么不能直接把位置清空?负载因子为什么开放定址法卡在 0.7,链地址法却放到 1? 这两个问题答不上来,说明哈希表只学了个接口。

哈希表的难点从来不是查找------查找就是一次取模加若干次比较。难的是冲突发生之后怎么处理。处理冲突有两条技术路线:开放定址法和链地址法,两条路的删除策略完全不同,性能天花板也差得极远。从 key % M 出发,把两条路的原理、坑、完整实现一路拆到底。


一、哈希的本质:用 key 算位置,冲突是常态

1.1 直接定址法:key 范围集中时最香

哈希(hash)又称散列,本质一句话:通过哈希函数把 key 和存储位置建立映射。存的时候用 key 算出位置放进去,查的时候用同一个 key 算出同一个位置取出来。

最朴素的映射是直接定址法------key 的值本身就是下标:

  • key 都在 [0, 99]:开一个 100 元素的数组,key 直接当下标;
  • key 都是小写字母:开一个 26 元素的数组,key - 'a' 当下标。

LeetCode 387「字符串中的第一个唯一字符」就是这个套路:

cpp 复制代码
int firstUniqChar(string s)
{
    int count[26] = {0};       // 字母的 ascii - 'a' 直接当下标:key 范围集中,无需处理冲突
    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;
}

1.2 哈希冲突:映射到 [0, M) 就躲不开

直接定址法的死穴是 key 一分散就废:key 范围 [0, 9999] 但实际只有 N 个值,直接定址要开一万个空间,大部分空着,浪费甚至不够用。

于是引入哈希函数 hf:把 key 映射到 [0, M) 范围(M 是表大小,一般 M ≥ N)。问题随之而来------两个不同的 key 可能算出同一个位置,这就是哈希冲突(碰撞)。

key 的取值空间往往远大于 M,任何确定的散列函数都必然存在大量碰撞的 key 对。冲突不是函数没写好,是数学上绕不开的。所以哈希表要同时解决两件事:设计好函数减少冲突 + 设计好方案处理冲突。前者是第二节,后者是第三、四节的主角。

1.3 负载因子:哈希表的"拥挤度"

表里存了 N 个值、表大小是 M,那么:

负载因子 = N / M

它衡量表挤不挤,背后是空间和时间的权衡:

负载因子 冲突概率 空间利用率
越大 越高 越高
越小 越低 越低

这个比值直接决定两种实现的天花板:开放定址法要求它必须小于 1(实际约 0.7),链地址法可以大于 1(实际约 1)。为什么差这么多?第五节用探测次数公式算给你看。


二、哈希函数:把 key 均匀撒开

设计目标是让 N 个 key 等概率、均匀地散列到 M 个空间。实际做不到完美,但方向是明确的。

2.1 除法散列法:key % M,M 的取值是学问

最常用的方法是除留余数法:h(key) = key % M。

第一个关键细节:M 不要取 2 的幂、10 的幂。 M = 2^X 时,key % 2^X 本质就是保留 key 二进制的后 X 位------后 X 位相同的 key,哈希值全一样。

{63, 31} 两个看起来毫无关联的值,M = 16(2^4)时:63 的二进制后 4 位是 1111,31 的后 4 位也是 1111,双双算出 15,撞了。M = 100(10^2)时更直白:{112, 12312} 保留十进制后两位,都是 12。

所以除法散列法建议 M 取一个不太接近 2 的幂的质数。 质数没有"规整"的因子,key 里按二进制位、按十进制位对齐的规律全部被打乱,分布才均匀。

实践里抓本质、不死读书。Java 的 HashMap 就用 2 的幂做 M,图的是位运算比取模快。但它不是单纯取低位,而是先 key' = key >> 16,再用 key ^ key' 作为哈希值------让 key 的所有位都参与计算,弥补"只取低位"的缺陷。两条路目标一致:均匀。

2.2 乘法散列法和全域散列法(了解)

乘法散列法对 M 没要求,两步:key 乘常数 A(0 < A < 1)取小数部分,再用 M 乘这个小数向下取整:

cpp 复制代码
h(key) = floor(M × ((A × key) % 1.0))

关键是 A 的取值,Knuth 认为黄金分割点 A = (√5 - 1) / 2 ≈ 0.618 效果好。

全域散列法防的是另一类攻击:散列函数公开且确定,恶意对手可以故意构造一批全撞同一个位置的数据,让哈希表退化成 O(N)。解法是给函数加随机性------构造一个函数族,初始化时随机选一个,之后固定用它:

cpp 复制代码
h(key) = ((a × key + b) % P) % M

P 取足够大的质数,a 随机取 [1, P-1],b 随机取 [0, P-1]。"初始化选一次、之后固定"是铁律------插入用一个函数、查找换一个,插进去的 key 就永远找不到了。

2.3 key 不是整形怎么办

key 是 string、Date 这类类型时不能取模,需要给哈希表加一个仿函数,把 key 转成可取模的整形。要求是:key 的每个部分都参与计算,不同 key 转出的整数尽量不同。

string 做 key 极常见,特化一个。直观想法是把字符 ascii 相加------不行,"abcd" 和 "bcad" 转出来一模一样,字符的位置丢了。用 BKDR 哈希的思路:用上次的结果乘以一个质数(31、131 常见)再加新字符,位置信息就参与了计算:

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

// string 特化:BKDR 哈希
template<>
struct HashFunc<string>
{
    size_t operator()(const string& key)
    {
        size_t hash = 0;
        for (auto e : key)
        {
            hash *= 131;       // 乘质数让字符位置参与:交换字符顺序,结果就不同
            hash += e;
        }
        return hash;
    }
};

三、开放定址法:所有数据都放在表里

3.1 三种探测规则

开放定址法的思想:所有元素都放在哈希表内。key 算出的位置冲突了,就按某种规则探测下一个空位。

线性探测:从冲突位置依次向后探测,走到表尾回绕到表头。因为负载因子小于 1,表里必然存在空位,最多探测 M-1 次一定能找到。

cpp 复制代码
hc(key, i) = (hash0 + i) % M,i = {1, 2, 3, ..., M-1}

二次探测:从冲突位置开始,按二次方左右跳跃探测,往右到头回绕、往左到头也回绕。

cpp 复制代码
hc(key, i) = (hash0 ± i²) % M

二次探测的探测序列(hash0 ± i²)最多覆盖约一半的表位------要保证一定能探到空位,负载因子得压到 0.5 以下,这是它在实践中更少被用的原因之一。

双重散列:第一个函数冲突了,用第二个函数算一个和 key 相关的偏移量,步长随 key 变化。

cpp 复制代码
hc(key, i) = (hash0 + i × h2(key)) % M

双重散列要求 h2(key) 与 M 互质,数学原因很硬:若 p = gcd(M, h2(key)) > 1,探测序列只能寻址 M/p 个位置,大半个表摸不到。举例:初始位置 1、偏移量 3、M = 12,只能落在 {1, 4, 7, 10}------因为 gcd(12, 3) = 3,12/3 = 4,12 个位置只有 4 个能被访问。保证互质有两种简单取值:M 是 2 的幂时,h2 任取一个奇数;M 是质数时,h2(key) = key % (M-1) + 1。

三种探测规则的决策总表:

探测方式 步长 堆积问题 实现难度 实践采用
线性探测 +1 严重:连续占用区只增不减 最简单 教学/简单场景
二次探测 ±i² 较轻:步长递增,跳过占用区 中(负载因子要压到 0.5) 较少
双重散列 i × h2(key) 最轻:步长随 key 变化 中(需保证与 M 互质) 较少

开放定址法整体在实践里就不如链地址法用得多(数据全挤在一个表里互相影响),所以线性探测足够,本文实现也选它。

3.2 数字实例:同一组 key,三种探测怎么落座

光看公式没感觉,走三遍真实数据。

线性探测 :{19, 30, 5, 36, 13, 20, 21, 12},M = 11。

key hash0 探测过程 落点
19 8 直接放 8
30 8 8 冲突 → 探测 9 9
5 5 直接放 5
36 3 直接放 3
13 2 直接放 2
20 9 9 冲突 → 探测 10 10
21 10 10 冲突 → 回绕到 0 0
12 1 直接放 1

插完之后表的状态:[21, 12, 13, 36, 空, 5, 空, 空, 19, 30, 20]。

注意 8、9、10 三个位置连续占用------这就是堆积。往后任何 hash0 落进 8、9、10 的 key,探测时都会扎进这个连续区并把它继续拉长。集群像滚雪球,越滚越大。二次探测就是来治这个的。

二次探测 :{19, 30, 52, 63, 11, 22},M = 11。这组数据故意让前四个全撞 8(19%11 = 30%11 = 52%11 = 63%11 = 8):

key hash0 探测过程 落点
19 8 直接放 8
30 8 i=1:8+1 = 9 9
52 8 i=1:9 已占;i=2:8+4 = 12,12%11 = 1 1
63 8 i=1:9 已占;i=2:1 已占;i=3:8+9 = 17,17%11 = 6 6
11 0 直接放 0
22 0 i=1:1 已占;i=2:0+4 = 4 4

四个全撞 8 的 key 被撒到 8、9、1、6 四个方向。同样的数据用线性探测,会把 8、9、10 连成一片------步长递增跳过了已占用区,这就是二次探测缓解堆积的原理。

双重散列 :{19, 30, 52, 74},M = 11,设 h2(key) = key % 10 + 1。前三个照样全撞 8:

key h2 探测过程 落点
19 10 直接放 8
30 1 i=1:8+1×1 = 9 9
52 3 i=1:9 已占;i=2:8+2×3 = 14,14%11 = 3 3
74 5 i=1:9 已占;i=2:3 已占;i=3:8+3×5 = 23,23%11 = 1 1

步长随 key 变化(10、1、3、5),冲突的 key 各走各的路,分布最均匀。

3.3 删除为什么必须打标记

开放定址法删除不能把位置清空,这是它和链地址法最本质的差异,也是面试高频题。用一个场景推演一遍。

h(19) = 8,h(30) = 8,19 先占到位置 8,30 冲突后线性探测到 9。又来一个 20,h(20) = 9,9 被 30 占着,继续探测到 10。此时表里:

复制代码
位置:   8     9     10
数据:   19    30    20

删除 30。如果直接把位置 9 清空成 EMPTY,会发生什么?查找 20 时:从 hash0 = 9 出发,一看位置是空的,停,返回"不存在"。但 20 明明在 10 待着。

根因:开放定址法里,一个 key 的实际位置可能离它的 hash0 很远,中间隔着的是因为冲突被挤到同一条路上的 key。清掉其中一个,就把后来者的查找路径斩断了。

解法是给每个位置加状态标记:

cpp 复制代码
enum State
{
    EXIST,   // 有数据
    EMPTY,   // 从来没有任何 key 来过
    DELETE   // 来过,但走了
};

删除时不清数据,只把状态改成 DELETE。查找的循环条件因此必须是遇到 EMPTY 才停:

cpp 复制代码
while (_tables[hashi]._state != EMPTY)   // DELETE 不能停:后面可能还挂着被挤走的 key
{
    if (_tables[hashi]._state == EXIST && _tables[hashi]._kv.first == key)
        return &_tables[hashi];
    hashi = (hash0 + i) % _tables.size();
    ++i;
}

写成 == EXIST 就前功尽弃------遇到 DELETE 也停,等于变相清空。

EMPTY 和 DELETE 的本质区别:EMPTY 表示从没有任何 key 的探测路径经过这里,所以再往后不可能有 key;DELETE 表示曾经有过。查找只信 EMPTY,不信 DELETE。

Insert 遇到 DELETE 可以直接占用(循环条件写 == EXIST,遇到 DELETE 停)。这不破坏正确性:新 key 占了这个坑,查找它的路径走到这里命中;查找别的 key 走到这里,状态是 EXIST 且值不等,继续往后------和占用一个全新空位没有任何区别。

3.4 开放定址法完整实现

负载因子控制在 0.7,超过就扩容;扩容后表大小必须还是质数,用 SGI-STL 那张近似 2 倍关系的质数表:

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:
        // SGI-STL 同款:28 个近似 2 倍关系的质数,扩容后桶数从表里取
        inline unsigned long __stl_next_prime(unsigned long n)
        {
            static const unsigned long prime_list[28] =
            {
                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
            };
            for (unsigned long p : prime_list)
                if (p >= n) return p;
            return prime_list[27];
        }

        HashTable()
        {
            _tables.resize(__stl_next_prime(0));   // 初始 53 个位置
        }

        bool Insert(const pair<K, V>& kv)
        {
            if (Find(kv.first)) return false;      // 查重:开放定址法不支持重复 key

            // 负载因子 > 0.7 就扩容。必须交叉相乘:
            // 直接写 _n / _tables.size() >= 0.7 是错的------两边都是整形,
            // 整数除法在 _n < size 时恒为 0,条件永远不成立,表永远不扩容
            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);
                // 扩容后负载因子直接腰斩(0.7 → 0.35),不会连续触发下一次扩容
            }

            Hash hash;
            // 必须在扩容之后计算:表大小变了,取模的分母变了,扩容前算的 hash0 全部作废
            size_t hash0 = hash(kv.first) % _tables.size();
            size_t hashi = hash0;
            size_t i = 1;
            while (_tables[hashi]._state == EXIST)   // 遇到 DELETE 直接占用
            {
                hashi = (hash0 + i) % _tables.size(); // 线性探测
                // 二次探测就变成 +- i*i
                ++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)   // 只有 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;
            ret->_state = DELETE;    // 假删除:只改状态,数据留着挡住查找路径
            --_n;
            return true;
        }

    private:
        vector<HashData<K, V>> _tables;
        size_t _n = 0;
    };
}

三个容易写错的点,全是"为什么"级别:

  1. 负载因子的整数陷阱 。_n * 10 / _tables.size() >= 7 是 _n / size >= 0.7 的交叉相乘形式。两边都是 size_t,直接和 0.7 比较,整数除法在 _n < size 时恒等于 0,0 >= 0.7 永远不成立------表永远不扩容,冲突淹到天上。
  2. hash0 必须在扩容之后算。取模的分母变了,扩容前算的 hash0 在新表里全是错的。代码里扩容块放在计算 hash0 之前,顺序不能倒。
  3. Find 的循环条件是 != EMPTY,Insert 的是 == EXIST。两个条件故意不对称:查找要穿过 DELETE 继续走,插入要停在 DELETE 占坑。写成一样就错。

四、链地址法:冲突的数据挂成链表

4.1 思路:表里存指针,冲突挂链

开放定址法的先天问题是所有数据挤在一个表里,删一个都要打标记、互相影响。链地址法(拉链法、哈希桶)换思路:哈希表里不再存数据,存指针。没有 key 映射到的位置,指针为空;多个 key 映射到同一个位置,挂成链表吊在桶下。

链地址法的负载因子没有小于 1 的限制------冲突再多不过是链表长一点。STL 的 unordered_xxx 把最大负载因子控制在 1,超过就扩容,本文实现同样。

数字实例:{19, 30, 5, 36, 13, 20, 21, 12, 24, 96},M = 11。注意 96 % 11 = 8,和 19、30 同桶:

桶号 链表(头 → 尾)
1 12
2 24 → 13
3 36
5 5
8 96 → 30 → 19
9 20
10 21

8 号桶挂了 3 个节点,查找成本就是一次取模加三次比较------冲突的代价被关在单个桶里,不波及其他桶。这是两种方法气质的分水岭。

4.2 完整实现

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()
        {
            // __stl_next_prime 与开放定址版相同(28 个质数的表),此处从略
            _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;   // 先存 next 再 delete,否则解引用已释放的内存
                    delete cur;
                    cur = next;
                }
                _tables[i] = nullptr;
            }
        }

        bool Insert(const pair<K, V>& kv)
        {
            Hash hs;

            // 负载因子 == 1 就扩容。扩容必须赶在算桶号之前:
            // swap 之后表大小变了,之前算的 hashi 在新表里就是错的桶
            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();
                        // 直接搬旧节点到新桶:改 _next 指向,每个节点 O(1)
                        // 新建节点再插入的写法要拷贝构造加析构,纯浪费
                        cur->_next = newtables[hashi];
                        newtables[hashi] = cur;
                        cur = next;
                    }
                    _tables[i] = nullptr;
                }
                _tables.swap(newtables);
            }

            size_t hashi = hs(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];   // 头插 O(1)
            _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. 删除是真删除。链地址法里 key 的桶号只由 hash0 决定,删掉节点不影响任何其他 key 的查找路径------不存在"斩断路径"的问题,自然不需要 DELETE 标记。
  2. 扩容搬节点而不是建节点 。旧链表的节点直接改 _next 指向挂到新桶,每个节点 O(1)。若走"新建节点 + 插入"的老路,每个节点要拷贝构造再析构,纯浪费。
  3. 桶号必须在扩容之后算 。这是手写链地址法最高频的 bug:hashi 在扩容前算好,swap 之后表大小变了,节点进错桶,随后 Find 必失败。小数据测不出来------压根不触发扩容;数据一多,第一次扩容就挂。开放定址版把取模放在扩容之后,链地址版必须一样。

4.3 极端场景:某个桶特别长怎么办

偶然情况下某个桶很长(不是被攻击),查找退化成遍历长链表。Java 8 的 HashMap 的做法是桶长度超过阈值(8)就把链表转成红黑树,查找回到 O(log 桶长)。正常情况下不断扩容,单桶过长的概率很低,本文实现不搞这么复杂,思路了解即可。真被恶意构造的数据针对,上全域散列法,让攻击者摸不着规律。


五、两种实现怎么选:数学说了算

5.1 探测次数的定量分析

两种方法的负载因子阈值(0.7 vs 1)不是拍脑袋定的,是探测次数公式算出来的。均匀散列模型下的结果(Knuth,《计算机程序设计艺术》卷三):

负载因子 α 线性探测·成功 线性探测·失败 链地址·成功 链地址·失败
0.5 1.50 2.5 1.25 0.5
0.7 2.17 6.06 1.35 0.7
0.8 3.00 13.0 1.40 0.8
0.9 5.50 50.5 1.45 0.9

公式本身:

  • 线性探测:成功约 ½(1 + 1/(1-α)),失败约 ½(1 + 1/(1-α)²)
  • 链地址:成功约 1 + α/2,失败约 α

盯住线性探测的失败列:α 从 0.7 到 0.9,探测次数从 6 飙到 50------分母 (1-α)² 在 α 逼近 1 时二次爆炸。0.7 正是失败次数还没失控、空间浪费(三成)还能接受的拐点。再往上,每次扩容间隔缩短的速度,赶不上探测次数暴涨的速度。

链地址那一列平得像直线:失败次数就是 α 本身,线性增长。α = 1 时失败也只要 1 次------这就是链地址法敢把负载因子放到 1 的底气。

一句话概括这张表的形状:线性探测那两列是二次曲线,α 越接近 1 越陡;链地址那两列近似直线。开放定址法的性能对负载因子平方级敏感,链地址法线性敏感------两种实现负载因子阈值差这么多的全部原因就在这。

5.2 全维度对比:决策总表

维度 开放定址法 链地址法(哈希桶)
数据位置 全在表里 表存指针,数据在堆上节点
负载因子 必须 < 1(实际 0.7) 可 > 1(实际 1)
冲突处理 线性/二次/双重探测 桶内挂链表
删除 假删除(打 DELETE 标记) 真删除(摘节点)
缓存友好性 好(数据连续存数组) 差(节点散落堆上)
最坏情况 堆积 → 探测链很长 单桶过长 → 退化成链表
扩容代价 全量重新散列 搬节点(改指向)
性能对负载因子敏感度 平方级(见 5.1 表) 线性
工程采用 较少 STL unordered_xxx 用这个

工程一边倒选链地址法:不存在数据互相挤占、删除干净利索、负载因子上限宽松、扩容只改指向。开放定址法胜在缓存友好,适合规模可预估、冲突少的场景。

5.3 STL 的选择:从源码看佐证

SGI-STL 的 hashtable 就是链地址法的工业级形态,把 4.2 的实现换成标准库包装:

  • 桶数组 :_M_buckets,行为等同 vector<Node*>,桶数永远取自那张 28 个质数的表;
  • 节点 :_Hashtable_node 只带一个 _M_next,单链表------所以 unordered 容器的迭代器是单向的,没有 --;
  • 扩容策略:元素数超过桶数(负载因子 > 1)就扩容,新桶数取质数表中的下一个;
  • 字符串特化 :hash<string> 同样是乘积累加的思路,和 2.3 的 BKDR 同源。

结构决定行为:unordered_map 遍历无序(位置由哈希值定)、迭代器单向(节点只有 _M_next)、rehash 之后迭代器失效(桶数组整块换掉)------这些接口层面的"反直觉",底层一看就明白。在这套结构上怎么包出 map 和 set 两个容器,是泛型封装层面的问题,用的还是 KeyOfT 仿函数那套手法。


六、实战

直接定址法:LeetCode 387「字符串中的第一个唯一字符」(代码见 1.1)。key 的范围(26 个字母)比数据量还集中,直接定址就是最优解,上哈希表反而重。

自己实现的哈希表 :词频统计,topK 的前置步骤。用 4.2 的 hash_bucket::HashTable<string, int>:

cpp 复制代码
hash_bucket::HashTable<string, int> ht;
for (auto& word : words)                 // words 是切分好的单词序列
{
    if (ht.Find(word))
        ht.Find(word)->_kv.second++;     // 已存在,计数加一
    else
        ht.Insert(make_pair(word, 1));   // 不存在,插入计数 1
}

Find 返回的是节点指针,直接改 _kv.second 就是改值------均摊 O(1) 完成"字符串到次数"的映射。同样的需求用 map 也能做,但那是 O(logN),数据量上来就是数量级差距。


七、总结

问题 一句话答案
哈希的本质 key 经哈希函数算出存储位置,冲突不可避免
负载因子 N/M;空间与时间的权衡,直接决定两种实现的阈值
M 为什么取质数 打乱 key 按位、按十进制位对齐的规律,分布才均匀
线性探测的问题 堆积:连续占用区只增不减,新 key 扎堆涌入
删除为什么打标记 清空会斩断被挤走的 key 的查找路径;只有 EMPTY 能终止查找
负载因子 0.7 vs 1 线性探测对 α 平方级敏感,链地址线性敏感(见 5.1 表)
为什么工程选链地址法 不互相挤占、删除干净、扩容改指向、上限宽松
string 怎么做 key BKDR:乘 131 累加,让字符位置参与计算

全文压成五条:

  1. 哈希表的两大件:好哈希函数减少冲突 (M 取质数、key 每个部分都参与计算)加冲突解决方案(开放定址 / 链地址)。
  2. 开放定址法的灵魂是状态标记:EMPTY 才能终止查找,DELETE 只能路过 。Find 的循环写成 == EXIST 是最常见的错。
  3. 链地址法把冲突关在单个桶里,删除真删、扩容搬节点,是工程和 STL 的共同选择。
  4. 阈值不是玄学:探测次数公式算出来,0.7 是开放定址的拐点,1 是链地址的舒适区。
  5. 原理和两种实现吃透之后,"怎么封装成 unordered_map/set"是再往上的一层------KeyOfT 仿函数、迭代器跨桶、operator\[\],解决的问题和本文不同,但地基都在这两套实现上。
相关推荐
m0_639906942 小时前
HashMap:哈希扰动、扩容源码、红黑树转换、并发问题
java·算法·哈希算法
miller-tsunami2 小时前
STL简介&string相关知识点
开发语言·c++·string
JasmineX-12 小时前
数据结构(笔记)——顺序栈
c语言·数据结构·笔记
坤坤子吖3 小时前
Linux 线程同步与互斥——互斥锁
linux·服务器·c++·笔记·学习
TDTC_XQ3 小时前
Linux网络编程accept的讲解
linux·服务器·c++
半摆烂日常3 小时前
手机表单的左滑操作:触摸事件的处理细节
大数据·开发语言·人工智能·智能手机·https·c#
星夜离尘563 小时前
MATLAB滑动RMS如何接报警:窗口未满和缺样不能当成正常
开发语言·matlab
开开心心就好3 小时前
白天晚上自动调蓝光,护眼工具亮度可调
服务器·开发语言·算法·pdf·ocr·intellij-idea·idea
张张123y3 小时前
后端第十一课:RAG 落地实战(从 0 到 1 接上私有知识库,并设计一个好用的 RAG)
开发语言·人工智能·python·机器学习·fastapi·rag