C++ 标准库提供了两大类关联式容器:一类是有序 的,比如 set、map,底层用红黑树实现;另一类是无序 的,也就是今天要聊的 unordered_set、unordered_map、unordered_multiset、unordered_multimap,它们的底层依靠哈希表。
如果你的需求只是"快速判断一个值在不在""根据键快速查到值",不在乎元素是否排序,那么无序容器通常是更高效的选择。下面我会从最基础的 unordered_set 开始,把它们的用法、和有序容器的区别,以及一些容易被忽略的细节完整串一遍。
目录
[1. unordered_set 是什么](#1. unordered_set 是什么)
[2. unordered_set 和 set 的三个主要差异](#2. unordered_set 和 set 的三个主要差异)
[2.1 对键的要求不同](#2.1 对键的要求不同)
[2.2 迭代器和遍历顺序不同](#2.2 迭代器和遍历顺序不同)
[2.3 性能差异](#2.3 性能差异)
[3. unordered_map 和 map 的差异](#3. unordered_map 和 map 的差异)
[4. 允许重复的版本:unordered_multiset 与 unordered_multimap](#4. 允许重复的版本:unordered_multiset 与 unordered_multimap)
[5. 一些和哈希实现相关的接口](#5. 一些和哈希实现相关的接口)
[6. 小结](#6. 小结)
1. unordered_set 是什么
unordered_set 是一个不重复元素集合 ,但与 set 不同,它里面的元素没有顺序。它的声明大致如下:
cpp
template <
class Key, // 元素类型
class Hash = hash<Key>, // 哈希函数
class Pred = equal_to<Key>, // 判断相等的方式
class Alloc = allocator<Key> // 空间配置器
> class unordered_set;
对大多数日常使用,后面三个模板参数都用默认值就行。它们的作用是:
-
Hash :把
Key类型的值转换成一个整数(哈希值),默认的hash<Key>支持了int、string等常见类型。如果你的自定义类型作为键,就需要自己实现哈希函数传给这个参数。 -
Pred :判断两个键是否相等,默认为
equal_to<Key>,也就是用==。这和set用<比较完全不同,后面会细说。 -
Alloc:底层内存分配方式,一般不需要动。
unordered_set 的增删查平均时间复杂度都是 O(1),最坏情况可能退化,但合理使用下很少遇到。因为它不排序,所以迭代器遍历出的元素是无序的,并且自动去重。
2. unordered_set 和 set 的三个主要差异
很多教程光说"一个有序一个无序",但其实差异不止这一点。把它们理清,你就能在合适的场景选对容器。
2.1 对键的要求不同
-
set要求元素支持小于比较 (operator<),因为它要维持二叉搜索树的有序性。 -
unordered_set要求元素支持哈希计算 (转成整数)和相等比较 (operator==)。换句话说,元素必须能算出哈希值,并且在哈希冲突时能判断两个元素是不是真的相等。
这是由底层数据结构决定的:红黑树靠比较来决定走左子树还是右子树,哈希表则靠哈希值分散位置,冲突后用相等判断来确认。
2.2 迭代器和遍历顺序不同
-
set提供的是双向迭代器 ,可以++也可以--,因为它内部是红黑树,中序遍历天然得到升序排列。 -
unordered_set提供的是单向迭代器 ,只支持++。由于底层是哈希桶,元素顺序由哈希值决定,遍历出来的顺序既不是插入顺序,也不是大小顺序,完全无序。
小结一下:set:有序 + 去重;unordered_set:无序 + 去重。
2.3 性能差异
这是最吸引人去用无序容器的一点:大多数场景下,unordered_set 的增删查比 set 更快。
-
红黑树的增删查复杂度是 O(log N)。
-
哈希表的增删查平均复杂度是 O(1)。
我们用一段比较极端的测试代码来看看差距(代码逻辑很简单,就是对两个容器分别做 100 万次插入、查找、删除,并计时):
cpp
#include <unordered_set>
#include <set>
#include <vector>
#include <iostream>
#include <ctime>
using namespace std;
int main() {
const size_t N = 1000000;
unordered_set<int> us;
set<int> s;
vector<int> v;
v.reserve(N);
srand(time(0));
// 产生一些随机但有区分度的数据
for (size_t i = 0; i < N; ++i)
v.push_back(rand() + i);
// 插入
size_t begin1 = clock();
for (auto e : v) s.insert(e);
size_t end1 = clock();
cout << "set insert: " << end1 - begin1 << endl;
size_t begin2 = clock();
us.reserve(N); // 提前预留桶空间,提升插入效率
for (auto e : v) us.insert(e);
size_t end2 = clock();
cout << "unordered_set insert: " << end2 - begin2 << endl;
// 查找
int m1 = 0, m2 = 0;
size_t begin3 = clock();
for (auto e : v) if (s.find(e) != s.end()) ++m1;
size_t end3 = clock();
cout << "set find: " << end3 - begin3 << " -> matched " << m1 << endl;
size_t begin4 = clock();
for (auto e : v) if (us.find(e) != us.end()) ++m2;
size_t end4 = clock();
cout << "unordered_set find: " << end4 - begin4 << " -> matched " << m2 << endl;
// 删除
size_t begin5 = clock();
for (auto e : v) s.erase(e);
size_t end5 = clock();
cout << "set erase: " << end5 - begin5 << endl;
size_t begin6 = clock();
for (auto e : v) us.erase(e);
size_t end6 = clock();
cout << "unordered_set erase: " << end6 - begin6 << endl;
cout << "set size: " << s.size() << endl;
cout << "unordered_set size: " << us.size() << endl;
return 0;
}
在我的运行环境里,unordered_set 在该测试中的插入、查找、删除都明显快于 set。需要注意,这里在插入前调用了 us.reserve(N),作用是提前分配哈希表的桶,避免反复扩容带来的开销,这个优化对有序容器没有,但对无序容器很受用。
小贴士:理论上如果数据分布极端、哈希函数很差,哈希表可能退化到 O(N)。但标准库自带的
hash对付普通整数和字符串绰绰有余,一般不用太担心。
3. unordered_map 和 map 的差异
unordered_map 和 unordered_set 的处境一模一样,只不过它存储的是键值对 。它和 map 的差异也完全可以套用上面那三条:
-
对键的要求 :
map需要operator<,unordered_map需要哈希和相等比较。 -
迭代器与遍历 :
map双向,遍历时按键的升序输出;unordered_map单向,遍历时无序。 -
性能 :多数情况下
unordered_map的增删查是 O(1),比map的 O(log N) 快。
接口上,unordered_map 和 map 几乎一致,常用的 insert、erase、find,以及非常好用的 operator[] 都完全支持。比如:
cpp
unordered_map<string, int> umap;
umap["apple"] = 5;
umap["banana"] = 3;
cout << umap["apple"] << endl; // 输出 5
用起来和 map 没有差别,只是迭代时无法得到排序效果而已。
4. 允许重复的版本:unordered_multiset 与 unordered_multimap
和有序容器家族一样,无序容器也有允许键值重复的版本:
-
unordered_multiset:元素可重复,接口与multiset类似。 -
unordered_multimap:键可以重复,但不支持operator[](因为同一个键可能对应多个值,这个操作就模糊了)。
当你需要保留重复元素、又想获得哈希表的高效查找时,就用这两个容器。
5. 一些和哈希实现相关的接口
unordered_set / unordered_map 还提供了一些和哈希表内部结构相关的接口,主要分两类:
-
Buckets 系列 :
bucket_count()(当前桶的数量)、bucket_size(n)(第 n 个桶中的元素个数)、bucket(key)(键落在哪个桶)等。 -
Hash policy 系列 :
load_factor()(负载因子)、max_load_factor()(最大负载因子)、rehash(n)(重设桶数量)、reserve(n)(提前预留空间以达到某个容量)。
日常写业务代码时很少需要手动操控这些接口,默认行为已经能应对绝大多数场景。等你深入学习哈希表的底层实现,再回头看它们就会一目了然:无非就是调整桶数和负载因子,平衡空间和查找效率。
6. 小结
简单总结一下选择策略:
-
如果你需要元素/键按顺序排列 ,或者需要双向迭代器 ,选择
set或map。 -
如果你追求增删查性能 ,不在意元素顺序,优先用
unordered_set/unordered_map。 -
如果键允许重复,把后缀换成
multiset/multimap或unordered_multiset/unordered_multimap。 -
记得:有序容器依赖
<,无序容器依赖哈希函数 和==。自定义类型当作键时,务必提供哈希函数和相等比较。
希望这篇文章能帮你理清无序容器的使用场景和注意事项,不至于在 set 和 unordered_set 之间犹豫不决。