【C++第二十二章】哈希与散列

前言 🚀

setmap 解决的是有序查找 ,而 unordered_setunordered_map 解决的是快速查找 。二者都能完成插入、查找、删除,但底层思路完全不同:前者依赖平衡搜索树维护顺序,后者依赖 hash 把关键码映射到存储位置,再尽量把查找范围缩小到极小的局部区域。

这也是为什么很多场景下,unordered_* 的平均性能往往优于 set/map。因为一旦哈希分布足够均匀,查找不再需要沿树逐层比较,而是可以直接落到目标桶,再在很短的冲突链或探测区间内完成定位。

不过,哈希并不是"永远更快"的银弹。它换来了无序、依赖哈希函数质量、存在冲突、扩容成本明显、最坏情况性能退化等一系列问题。真正把哈希学明白,关键不在于会用 unordered_map,而在于想清楚三件事:为什么要映射、冲突怎么处理、工程上如何把它封装成稳定可用的容器。


一. 为什么 unordered_* 往往更快:哈希的核心思想 🧠

哈希首先是一种算法思想 ,而哈希表则是用这种思想实现出来的数据结构。它的核心目标非常直接:让关键码和存储位置建立关联关系,从而减少查找时真正需要比较的数据量。

例如,在普通顺序结构里查找一个值,可能要从头扫到尾;在平衡树里查找一个值,时间复杂度通常是 O(logN);而在理想哈希表里,目标位置可以通过哈希函数直接计算出来,平均查找复杂度接近 O(1)

1.1 set/mapunordered_set/unordered_map 的核心区别

容器 底层结构 是否有序 平均查找复杂度 典型特点
set / map 红黑树等平衡搜索树 有序 O(logN) 支持顺序遍历、范围查询
unordered_set / unordered_map 哈希表 无序 O(1) 单点查找快,但不维护顺序

因此,若业务强依赖区间查找、排序遍历、上下界查找,set/map 更合适;若更关心单点插入、查找、删除效率,unordered_* 往往更占优。

1.2 哈希快在哪里

哈希的本质不是"比较得更快",而是让大部分无关元素根本不用参与比较。一旦 key 被映射到固定桶位,真正要处理的范围就被压缩到了局部区域,性能自然会更高。

💡 避坑指南:
哈希表平均很快,不代表最坏情况也快。

一旦冲突集中,哈希表同样可能退化,甚至接近线性查找。


二. 哈希函数与哈希冲突:从直接定址到取模映射 🔍

要使用哈希表,第一步一定是先解决"如何把 key 映射成位置"的问题。这个映射规则,就是哈希函数。

2.1 直接定址法

如果 key 的范围非常集中,而且取值空间不大,那么最简单的办法就是直接把 key 当作下标使用。这种方法叫直接定址法

例如,若 key 只可能落在 0 ~ 9999,那完全可以直接开一个长度为 10000 的数组,让 table[key] 对应这个值的位置。

这种方法的优点是极快,缺点也很明显:一旦 key 分布稀疏,空间浪费会非常严重。

2.2 除留余数法

更常见的哈希方式是把 key 映射到有限范围:

cpp 复制代码
hashi = key % N;

这样做的好处是空间可控,但副作用也立刻出现:不同 key 可能算出同一个位置。这就是哈希冲突。

例如:

cpp 复制代码
99 % 10 = 9
9999 % 10 = 9

两个不同的 key 落到了同一个位置,冲突就产生了。

2.3 冲突为什么不可避免

只要"关键码空间"比"桶空间"更大,冲突在数学上就是无法彻底避免的。哈希表真正能做的,不是消灭冲突,而是:

  • 尽量让哈希分布更均匀
  • 让冲突发生后仍能高效定位
  • 控制装载程度,避免局部过度拥挤

三. 冲突怎么解决:开放定址与哈希桶 🧱

哈希冲突出现后,核心问题就变成了:同一个位置已经被占了,新的元素放哪?

主流方案通常分成两类:开放定址法开散列(哈希桶 / 拉链法)

3.1 开放定址法:在表内继续找空位

开放定址法也叫闭散列思路。它的逻辑是:当前位置被占用后,不额外挂链,而是在表里按某种规则继续寻找下一个可用位置。

常见方式有两种:

方式 规则 特点
线性探测 hashi + i 实现简单,但容易产生聚集
二次探测 hashi + i^2 能缓解连续聚集,但实现更复杂

3.2 负载因子为什么重要

开放定址法能否继续找到位置,和负载因子关系极大。

负载因子定义为:

α=已存元素个数表长 \alpha = \frac{\text{已存元素个数}}{\text{表长}} α=表长已存元素个数

当负载因子越来越高,可用空位越来越少,探测距离就会越来越长,插入与查找性能都会明显下降。

3.3 哈希桶 / 拉链法:把冲突元素挂到同一个桶里

开散列的思路更常见:让哈希表的每个槽位对应一个桶,发生冲突的元素不再继续探测空位,而是挂到同一个桶中
key
Hash函数
桶下标
桶内链表/结点序列

这种做法的好处是:

  • 插入逻辑简单
  • 删除更自然
  • 不需要墓碑标记
  • 扩容和重排更容易实现

3.4 链过长怎么办

若某个桶过长,查找效率会下降。工程上通常有两种思路:

  • 通过合理扩容降低平均桶长
  • 在特定实现里,对超长桶做进一步优化

有些语言实现会在链表过长后转成红黑树以降低最坏情况复杂度,但这不是 C++ unordered_map 的标准要求。在 C++ 里,更常见的实现仍是哈希桶 + 链式结构。

3.5 两种方案怎么选

方案 优点 缺点
开放定址 数组连续,缓存友好 删除复杂,负载高时性能下降明显
哈希桶 / 拉链法 插入删除自然,扩容灵活 结点分散,额外指针开销更大

在自己实现 unordered_set / unordered_map 时,哈希桶 + 拉链法通常更容易封装成稳定容器。


四. C++ 中如何封装一个可复用的哈希表 💻

真正自己实现哈希表时,难点并不只在"把元素挂进桶里",而在于如何把它做成一个既支持 unordered_set,又支持 unordered_map 的通用组件

4.1 通用设计:树靠 KeyOfT,哈希表也一样要靠 KeyOfT

底层哈希表通常不应直接写死"值就是 key",否则只能支持一种容器。更合理的做法是让哈希表模板存储 T,同时额外传入一个 KeyOfT 仿函数,用于从 T 中取出 key。

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

template<class K, class V>
struct MapKeyOfT
{
    const K& operator()(const pair<const K, V>& kv) const
    {
        return kv.first;
    }
};

这样,底层 HashTable 就能统一支持:

  • unordered_set<K>:值本身就是 key
  • unordered_map<K, V>:值是 pair<const K, V>,key 来自 first

4.2 泛型哈希函数:整型 key 最简单

若 key 本身就是整数,最直接的哈希函数通常就是把它转成无符号整数,再参与映射:

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

真正决定桶位的通常不是这里,而是外层再做一次 % bucket_count 或位运算映射。

4.3 为什么 string 不能简单按地址哈希

字符串的相等语义看的是内容,不是对象地址。若直接拿 c_str() 指针或对象地址做哈希,同样内容的字符串可能得出完全不同的结果,等价性就被破坏了。

所以 string 需要基于字符内容计算哈希值。一个很常见的写法是乘法滚动哈希,例如:

cpp 复制代码
template<>
struct HashFunc<string>
{
    size_t operator()(const string& key) const
    {
        size_t hash = 0;
        for (auto ch : key)
        {
            hash *= 31;
            hash += (unsigned char)ch;
        }
        return hash;
    }
};

这种写法的思想和 BKDR 类哈希非常接近:通过乘一个基数,再累加字符值,让不同位置的字符都参与结果,从而降低简单冲突。

4.4 为什么这里用特化,而不是随手重载

对于类模板哈希器,更自然的方式是对特殊 key 类型做模板特化,而不是在主模板里强行塞进多个重载版本。

原因很简单:当 K = string 时,HashFunc<string> 就应该有一套明确且唯一的字符串哈希逻辑。若在主模板里混入多个重载,实例化到 string 时,接口语义会变得混乱,维护成本也更高。

4.5 扩容时不要"重新构造结点",直接挪链更合理

哈希表扩容时,最笨的做法是把旧表中的元素重新拷贝一遍,再在新表里重新插入。这种方法逻辑能跑通,但代价偏大:

  • 会多一次对象构造
  • 旧结点还要析构
  • 大对象移动成本明显

若底层采用哈希桶链表结构,更高效的方式通常是:直接把旧结点重新挂到新桶里。也就是说,重算桶号,但尽量复用原有结点本身。

4.6 迭代器为什么不能只存 Node*

红黑树迭代器通常存一个 Node* 就够了,因为通过父子关系就能做中序前驱后继移动;但哈希表迭代器不同,它不仅要知道当前结点是谁,还得知道:

  • 当前属于哪张哈希表
  • 当前在哪个桶
  • 当前桶走完后,下一桶该从哪里继续

因此哈希表迭代器更常见的成员会包含:

cpp 复制代码
Node* _node;
HashTable* _pht;
size_t _hashi;

4.7 const_iterator 的一个典型坑:权限放大

如果 const_iterator 仍然持有普通的 HashTable*,那就可能在 const 场景里绕过限制,导致"本应只读,却能间接修改容器"的权限放大问题。

更稳妥的做法通常是:

  • 单独设计 iterator / const_iterator
  • 或者用模板参数把 RefPtr、表指针类型一起区分开

4.8 unordered_* 的遍历顺序为什么不要想当然

unordered_set / unordered_map 的顺序本来就是未指定的。不同标准库实现、不同扩容阶段、不同结点组织方式,都可能让遍历结果不同。

有些实现会用额外链指针让遍历体验更稳定,有些实现则直接按桶和链表顺序走。只要容器语义正确,就不应依赖它的"看起来像插入顺序"这种现象。

4.9 "素数扩容一定更好"并不是铁律

哈希表扩容时,桶数选素数是一种常见经验,目的是减少某些取模分布下的周期性冲突。但这不是绝对真理。

现代实现里也有大量方案会直接使用 2 的幂次容量,再配合高质量哈希函数和位运算优化。是否选择素数,更像是实现策略,而不是一条放之四海皆准的规则。


五. 位图:当 key 是整数时,比哈希更省空间 ⚠️

若 key 本身是整数,而且问题只关心"在不在""出现过没有",那很多场景里其实根本不需要哈希表,位图会更直接、更省空间。

5.1 位图的核心思想

把一个整数是否存在,映射成某一位是 1 还是 0。这样一个 key 只需要占 1 bit,空间效率极高。

例如,对 x 的映射:

  • 第几个整型块:i = x / 32
  • 第几位:j = x % 32

设置该位:

cpp 复制代码
void set(size_t x)
{
    size_t i = x / 32;
    size_t j = x % 32;
    _bits[i] |= (1u << j);
}

5.2 为什么位图特别适合海量整数判重

若要判断一个 unsigned int 是否出现过,理论上值域是 2^32。如果用位图表示,总共只需要:

232 bits=229 bytes=512MB 2^{32} \text{ bits} = 2^{29} \text{ bytes} = 512\text{MB} 232 bits=229 bytes=512MB

这个空间虽然不小,但比把 40 亿个整数完整存下来小得多,也比排序加查找更适合某些判存在题。

5.3 左移和右移到底在干什么

位图操作经常和位运算一起出现。这里要分清一个基础概念:

  • <<:从低位向高位移动
  • >>:从高位向低位移动

这说的是二进制位的位置变化方向,和机器是大端还是小端没有关系。大小端影响的是字节在内存中的布局,不改变移位运算的逻辑语义。

5.4 两个位图如何表达更多状态

若一个位不够表示状态,就可以用两个位图叠加。例如用两位表示一个整数出现次数状态:

状态 含义
00 出现 0
01 出现 1
10 出现 2
11 出现 3 次及以上

这样就能解决"找只出现一次的数""找出现次数不超过两次的数"等变种问题。

5.5 交集问题也能这么做

若两个文件里都是整数集合,可以分别映射到位图中。某个数在两个位图中都为 1,那它就是交集元素。

💡 避坑指南:

位图非常省空间,但前提是 key 必须能自然映射到较小整数范围

一旦 key 是字符串、对象、超大稀疏整数,位图就不再合适。


六. 布隆过滤器:允许误判时的超高性价比方案 🗺️

位图适合整数,而字符串等一般对象没有天然下标。这时如果仍然只关心"可能在不在",而且能接受少量误判,就可以用布隆过滤器

6.1 布隆过滤器解决了什么问题

对于海量字符串、昵称、URL、ID 等对象,如果直接存哈希表:

  • 空间不低
  • 还要保存完整元素
  • 长字符串比较本身也有成本

布隆过滤器则只保留若干位图信息,不保存原始元素,从而显著降低空间消耗。

6.2 它的核心机制是什么

插入一个元素时,不是只映射一个位置,而是通过多个哈希函数映射到多个 bit 位,把这些位置都置 1

查询时:

  • 只要有任意一位是 0,则一定不存在
  • 若所有位都是 1,则说明可能存在

这就是它最经典的性质:

"判断不在是准确的,判断在是不准确的。"

6.3 为什么映射多个位置能降低误判

如果只映射一个位置,冲突很容易让不同元素撞在同一位上;而映射多个位置后,另一个元素要恰好把同一组位置都撞满,概率会低得多。

6.4 误判率和参数的关系

设:

  • 位数组长度为 m
  • 插入元素个数为 n
  • 哈希函数个数为 k

则布隆过滤器误判率近似为:

p≈(1−e−kn/m)k p \approx \left(1 - e^{-kn/m}\right)^k p≈(1−e−kn/m)k

这意味着:

  • m 越大,误判率通常越低
  • k 太少不够分散
  • k 太多会把位图过快打满

参数设计本质上是在空间、误判率、计算成本之间做平衡。

6.5 典型使用场景

布隆过滤器特别适合这类场景:

  • 用户名是否可能已注册
  • URL 是否可能已抓取
  • 黑名单 / 白名单的快速前置过滤
  • 缓存穿透防护
  • 分布式系统中的快速存在性预判

6.6 "可能在"之后为什么常常还要查数据库

因为布隆过滤器允许误判,所以工程上常见流程是:

  1. 先查布隆过滤器
  2. 若判定"不在",直接返回不存在
  3. 若判定"在",再去数据库或哈希表做一次精确查询

这样既降低了大量无效查询,又把误判影响控制在最后一道精确检查上。

6.7 为什么一般不支持删除

因为一个 bit 位可能同时被多个元素共享。若把某个元素对应的位直接清零,其他本来存在的元素也可能被误伤。

若确实要支持删除,通常需要改成计数型布隆过滤器 ,让每个位不再只存 0/1,而是存计数值。但这样会带来额外空间成本。

💡 避坑指南:
布隆过滤器不是精确集合。

它适合做"前置过滤""快速排除",不适合做需要严格准确结果的最终判定。


七. 海量数据题怎么落地:哈希切分 + 局部统计 🧩

哈希最有价值的地方之一,是把原本完全装不下的一大堆数据,切成很多能单独处理的小块。

7.1 两个超大文件求交集

若两个大文件都装不进内存,直接整体建 set 或整体哈希都不现实。这时最自然的办法就是哈希切分

核心步骤如下:
文件 A 中的 query
Hash(query) mod N
写入 A0 ~ A(N-1)
文件 B 中的 query
Hash(query) mod N
写入 B0 ~ B(N-1)
分别处理 Ai 与 Bi
在内存中求局部交集
汇总最终结果

这套方法成立的关键在于:

相同的 key,一定会被同一个哈希函数切到同一编号的小文件里。

所以只需要比较 AiBi,不需要做跨文件全局比较。

7.2 如果小文件仍然太大怎么办

那就继续切。也就是说,哈希分治可以递归进行,直到单个分块能够在内存中完成精确处理。

7.3 统计海量日志中的 Top K IP

给一个超大日志文件,每行一个 IP,想找出现次数最多的 Top K,思路依然类似:

  1. Hash(ip) % N 把所有 IP 切到多个小文件
  2. 保证同一个 IP 一定落到同一个小文件
  3. 对每个小文件用 map/unordered_map 做精确计数
  4. 从每个小文件提取局部高频项
  5. 最后合并出全局 Top K

7.4 为什么"相同 IP 一定进同一小文件"这么关键

因为只有这样,局部计数才不会被分散。若同一个 IP 被拆到多个文件里,局部统计结果就不完整,全局合并也会出错。


八. 学到这里,应该建立怎样的整体框架 📌

如果把这一整章内容压缩成一条主线,可以这样理解:

  1. 哈希的目标:让 key 和存储位置建立关联
  2. 冲突不可避免:真正要解决的是冲突后的定位效率
  3. 主流方案:开放定址 or 哈希桶
  4. C++ 封装重点KeyOfT、哈希仿函数、字符串特化、扩容重排、迭代器设计
  5. 整数场景:优先考虑位图
  6. 允许误判场景:优先考虑布隆过滤器
  7. 海量数据场景:用哈希做分治切分,再做局部精确处理

也就是说,哈希从来不只是一个容器实现细节,它更是一种非常强的空间换时间 + 分治切分思想。


总结 📝

哈希真正强大的地方,不在于 unordered_map 这个类名本身,而在于它提供了一种非常通用的建模方式:把"全局查找问题"转化成"局部定位问题"。一旦 key 和位置之间建立了高质量的映射关系,查找、判重、计数、分桶、切分这些问题,都会变得更容易处理。

围绕这条主线,整章内容其实是在逐层展开:

  • 用哈希函数建立映射
  • 用冲突处理机制维持可用性
  • 用哈希桶把容器封装成 unordered_set / unordered_map
  • 用位图在整数场景下进一步压缩空间
  • 用布隆过滤器在可容忍误判时换取更高性价比
  • 用哈希切分去处理内存装不下的海量数据问题

因此,真正学会哈希之后,得到的不只是一个容器实现能力,而是一整套问题拆解思路:

能直接映射就直接映射,冲突可控就用哈希,整数范围小就上位图,需要快速排除就上布隆过滤器,数据过大就先哈希切分再局部精确处理。

这也是为什么哈希在工程实践、面试题和底层容器设计里,始终都是绕不开的一章。

相关推荐
Yzzz-F4 小时前
Problem - 2146D1 - Codeforces &&Problem - D2 - Codeforces
算法
Kk.08024 小时前
力扣 LCR 084.全排列||
算法·leetcode·职场和发展
环黄金线HHJX.4 小时前
龙虾钳足启发的AI集群语言交互新范式
开发语言·人工智能·算法·编辑器·交互
Omics Pro4 小时前
虚拟细胞:开启HIV/AIDS治疗新纪元的关键?
大数据·数据库·人工智能·深度学习·算法·机器学习·计算机视觉
旖-旎4 小时前
分治(快速选择算法)(3)
c++·算法·leetcode·排序算法·快速选择
_日拱一卒5 小时前
LeetCode:合并区间
算法·leetcode·职场和发展
xiaoye-duck5 小时前
【C++:哈希表封装】哈希表封装 myunordered_map/myunordered_set 实战:底层原理 + 完整实现
数据结构·c++·散列表
汀、人工智能5 小时前
[特殊字符] 第3课:最长连续序列
数据结构·算法·数据库架构·图论·bfs·最长连续序列
少许极端5 小时前
算法奇妙屋(四十一)-贪心算法学习之路 8
学习·算法·贪心算法
A.A呐5 小时前
【C++第二十三章】C++11
开发语言·c++