一、set 与 unordered_set、map 与 unordered_map 对比
set/map底层是红黑树(平衡二叉搜索树) ;unordered_set/unordered_map底层是哈希表(哈希桶)。
1. 模板参数差异
// unordered_set
template<class Key,
class Hash = hash<Key>, // 哈希函数,把key转为整型哈希值
class Pred = equal_to<Key>, // 相等比较函数
class Alloc = allocator<Key>>
class unordered_set;
// set
template<class Key,
class Compare = less<Key>, // 小于比较函数,用于红黑树排序
class Alloc = allocator<Key>>
class set;
// unordered_map
template<class Key, class T,
class Hash = hash<Key>,
class Pred = equal_to<Key>,
class Alloc = allocator<pair<const Key, T>>>
class unordered_map;
// map
template<class Key, class T,
class Compare = less<Key>,
class Alloc = allocator<pair<const Key, T>>>
class map;
|-----------------------------|-----|------------------------|--------|------------|
| 容器 | 底层 | key 要求 | 迭代器遍历 | 增删查平均效率 |
| set/map | 红黑树 | 支持less小于比较 | 有序 | O(logN |
| unordered_set/unordered_map | 哈希桶 | 支持哈希转换 + equal_to 相等比较 | 无序 | O(1 |
unordered 系列名字含义:无序,遍历顺序不按照 key 大小排序。 最坏情况哈希冲突严重时,性能退化到O(N。
2. 使用上核心区别
- key 的要求
-
set:key 要能做小于比较,底层红黑树依靠比较完成查找插入。
-
unordered_set:key 两件事:①可以算出哈希值;②可以判断两个 key 是否相等。
- 迭代器
-
set 迭代器双向迭代器,遍历结果有序;
-
unordered_set 迭代器单向迭代器,遍历是无序。
- 性能:绝大多数场景 unordered 增删查更快;但是无序;set 可以得到有序结果。
二、哈希表基础概念
哈希表核心思想:通过哈希函数,直接把 key 映射成存储位置,实现接近 O (1) 查找。
1. 直接定址法
key 范围很集中,直接拿 key 作为数组下标。 例:key 范围
[0,99],直接开 100 大小数组,arr[key]存放数据。
h(key) = key
优点:无冲突,速度极快; 缺点:key 范围分散的时候,数组空间爆炸,极度浪费内存,只适合范围集中的整型。
2. 除留余数法(最常用哈希函数)
哈希表数组大小 M,
h(key)= key % M,取余数作为数组下标。
h(key) = key % M;
-
M 是哈希表桶的个数。
-
问题:不同 key 算出来的 h (key) 可能相同 → 哈希冲突。
例如 M=16,
63%16 =15,31%16=15,两个不同 key 映射同一个下标,发生冲突。
经验:M 尽量取质数,不要取 2 的整数次幂,可以减少冲突。 工程上 Java HashMap 会用 2 的整数次幂,不用取模,改用位运算提高效率,属于工程优化。
3. 哈希冲突
不同 key 经过哈希函数,得到相同存储下标,叫做哈希冲突(哈希碰撞) 。 理想哈希函数完全无冲突,但现实无法做到,只能尽量降低冲突概率,冲突不可避免,需要专门方案解决冲突。
方案 1:开放定址法(线性探测)
发生冲突,向后找下一个空位置存放。 公式:
最多探测 M‑1 次,一定能找到空位。
举例:M=11,插入序列{19,30,5,36,13,20,21,12}
-
h(19)=19%11=
-
h(30)=30%11= → 冲突,向后探测 i=1,位置 9
-
h(5)=
-
h(36)=36%11=
-
h(13)=13%11=
-
h(20)=20%11= →冲突,向后探测 i=1,位置 10
-
h(21)=21%11=1 →冲突,向后探测 i=1,位置 0
-
h(12)=12%11=
数组分布:
|----|----|----|----|----|---|---|---|---|----|----|----|
| 下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 数据 | 21 | 12 | 13 | 36 | | 5 | | | 19 | 30 | 20 |
开放定址法缺陷:
-
删除不能直接置空!直接置空会打断探测链,
find找不到后面元素。只能做标记删除。 -
冲突堆积,聚集效应,冲突越多,探测越长,性能急剧下降。
-
C++ 标准库
unordered_set/unordered_map没有使用开放定址,使用链地址法。
方案 2:链地址法(哈希桶,STL unordered 底层实现)
每个数组下标存一个链表,哈希值相同的 key 挂在同一个链表下面。 数组的每个元素叫桶(bucket)。
-
数组下标:哈希值取模得到桶号;
-
同一个桶内所有元素形成链表;
-
查询:先算哈希值定位桶,再遍历桶内链表比对 key 是否相等。
当桶内链表过长,查找退化成 O (N),于是引入负载因子 。 负载因子\\lambda = \\frac{元素个数}{桶的数量 负载因子越大,冲突概率越高,链表越长。 STL unordered 容器默认负载因子阈值为 1,当 λ>1,就会扩容:开辟更大的桶数组,把所有元素重新哈希,挂到新桶。
扩容不是简单拷贝,所有 key 要重新计算哈希映射新桶下标,叫rehash。
三、unordered_set /unordered_map 工作流程
-
插入: ①调用 hash 函数计算 key 哈希值; ②哈希值对桶数量取模,得到桶编号; ③遍历该桶链表,如果 key 已经存在,set/map 不重复插入; ④key 不存在,头插 / 尾插挂入该桶链表; ⑤检查负载因子,超过阈值触发 rehash 扩容。
-
查找: ①算出 key 哈希值,取模定位桶; ②遍历桶内链表,equal_to 判断 key 相等;找到返回迭代器,找不到返回 end ()。
-
删除: ①定位桶; ②遍历链表找到对应节点; ③链表删除节点; ④不需要 rehash(不会缩容)。
为什么 unordered 需要 hash+equal_to 两套函数?
-
hash:快速定位到属于哪一个桶;哈希值相同不代表 key 相等(冲突)。
-
equal_to:桶内链表遍历的时候,判断两个 key真正相等。
两个 key 相等,则哈希值必须相等;哈希值相等,key 不一定相等。
set/map 不需要 hash,依靠 less 比较,一边比较一边查找。
自定义类型使用 unordered_set
如果是自定义类,必须提供两个东西:
-
哈希函数
hash<T>,把对象转为 size_t 哈希值; -
equal_to<T>,实现 == 运算符。
struct Person
{
int id;
string name;
bool operator==(const Person& p) const
{
return id == p.id && name == p.name;
}
};
// 自定义哈希函数
namespace std
{
template<>
struct hash<Person>
{
size_t operator()(const Person& p) const
{
size_t h1 = hash<int>{}(p.id);
size_t h2 = hash<string>{}(p.name);
return h1 ^ (h2 << 1);
}
};
}
四、set/map vs unordered_set/unordered_map 怎么选
-
需要有序输出 key:选
set/map,红黑树O(logN; -
只需要增删查,不在乎顺序,追求速度:选
unordered_xxx平均O(1; -
注意 unordered 的最坏情况:哈希冲突严重,性能退化 O (N);
-
unordered 迭代器单向,不支持
--反向;set 双向迭代器支持++--。
五、面试高频总结
-
unordered 底层:哈希桶(链地址法),数组 + 链表;set 底层红黑树。
-
哈希冲突:不同 key 哈希映射同一位置;解决:开放定址、链地址法;STL 用链地址法。
-
负载因子:元素数量 / 桶数量;超过阈值触发 rehash,重新分配桶数组,全部元素重新哈希。
-
unordered 模板参数:hash 哈希函数、equal_to 相等比较;set 是 less 小于比较。
-
开放定址法删除不能直接置空,需要标记删除,STL 没有采用。
-
为什么不全部用 unordered?无序、最坏性能退化、自定义类型需要提供哈希函数。