关联式容器中的哈希表
在 C++ STL 中,unordered_map 和 unordered_set 是典型的基于哈希表实现的关联式容器。它们的用法与 map、set 类似,但底层结构不同。简单来说,红黑树实现的 map/set 是有序的,而哈希表实现的 unordered 系列是无序的。
主要区别在于:
- 迭代器类型:
unordered系列通常只支持单向迭代器(Forward Iterator),不支持反向迭代器(如rbegin/rend)。 - 遍历顺序:数据不会按大小排序,而是根据哈希值分布。
它们同样支持去重功能,也有对应的 multi 版本(如 unordered_multimap)。下面我们通过代码来直观感受一下差异。
哈希基础概念
什么是哈希?回顾一下搜索算法的发展:暴力查找效率太低;有序数组的二分查找虽然快,但增删操作涉及大量数据移动;平衡搜索树解决了部分问题,但仍有开销。哈希(散列)的本质是在存储的值与存储位置之间建立映射关系。
比如计数排序中,最小值 15 映射到索引 0,最大值 30 映射到索引 15,这就是直接定址法。它适用于数据范围集中的场景。如果数据分散,直接定址会浪费大量空间。此时常用除留余数法:index = key % table_size。
但这会引发哈希冲突(Collision),即不同的键映射到同一个位置。解决冲突主要有两种策略:闭散列(开放定址法)和拉链法(哈希桶)。
闭散列——开放定址法
当发生冲突时,闭散列会在表中寻找下一个可用位置。常见方法包括线性探测和二次探测。
以线性探测为例,如果计算出的位置被占用,就向后找下一个空位。这里有个细节:如果表满了怎么办?实际上我们不应该等到完全填满才扩容,而是通过负载因子(Load Factor)来控制。负载因子 = 已存元素个数 / 表长。通常控制在 0.7 左右,超过则扩容。
删除操作在闭散列中比较特殊。不能简单抹成空,否则会影响后续查找(因为查找遇到空位就会停止)。我们需要引入状态标记:EXIST(存在)、EMPTY(空)、DELETE(已删除)。查找时跳过 DELETE 状态,直到遇到 EMPTY 或目标值。
拉链法——哈希桶
另一种思路是避免互相干扰。将数组的每个元素设为指针,指向一个链表。冲突的元素挂在同一个链表中。这种方式对扩容更友好,且不需要处理复杂的探测逻辑。
实现哈希桶时,核心结构是一个指针数组。插入时计算哈希值,头插到对应链表。扩容时,需要重新计算所有元素的哈希位置并迁移到新表。注意,迁移时不能直接拷贝节点,因为新表的哈希函数模数变了,必须重新计算位置后插入。
仿函数与模板特化
哈希函数要求输入能转换为整型以便取模。对于内置类型(如 int),直接转换即可。但对于 string 等复杂类型,需要自定义哈希函数。我们可以使用仿函数(Functor)来实现多态。
默认情况下,DefaultHashFunc 将 key 转为 size_t。针对 string,可以特化该模板,采用多项式滚动哈希(如乘以 131 累加字符),以减少冲突概率。
迭代器封装
为了让哈希表符合 STL 标准,需要封装迭代器。迭代器需要知道当前节点指针以及所属的哈希表对象(用于查找下一个非空桶)。
begin():找到第一个非空桶的第一个节点。end():返回空指针作为结束标志。operator++():如果当前链表有下一个节点,直接走;否则在哈希表中向后查找下一个非空桶。
此外,还需要区分普通迭代器和常量迭代器,确保 const 对象不能被修改。这通常通过模板参数控制返回值类型来实现。
封装 unordered_map 与 unordered_set
最后,基于哈希表封装标准的容器接口。
unordered_set:内部存储 ,利用哈希表去重。


