unordered_set 与unordered_multiset?我们该如何选择
前言
平时写 C++,容器是我们最离不开的工具。vector
、list
大家都用得滚瓜烂熟了,但一旦需求变成“我要快速查找某个 key”,顺序容器立刻显得笨重。
这时候,就轮到关联容器上场了。很多人第一反应是 set
——基于红黑树,有序,查找 O(log n),挺好。但如果你压根不需要“有序”,只想要“快到飞起”的查找呢?标准库早就为我们准备好了答案:std::unordered_set
和 std::unordered_multiset
。
它们的底层是哈希表,能在大多数情况下把查找、插入、删除做到均摊 O(1)。不过别以为这是“白给”的性能,背后有不少细节和坑,一旦忽略,代码就可能掉链子:rehash 突然把迭代器全废掉、哈希函数写不好导致性能崩成 O(n)、内存吃得吓人……
今天我们就好好聊聊这俩兄弟,从哈希函数讲到负载因子,从迭代器规则到内存代价,顺便拆解一些常见的误解。读完你应该能在心里做到“知其所以快,也知其会慢”,下次选容器就不会拍脑袋了。
哈希函数,容器的灵魂
所有的性能承诺,都建立在“哈希分布足够均匀”的前提下。一个烂的哈希函数能让本该 O(1) 的查找,退化到 O(n),而且退得非常彻底。
标准库提供了 std::hash<T>
,对内建类型和 std::string
等常见类型够用了。但如果你有自定义类型,就得自己写 hash 和比较器。比如:
struct Point {int x, y;bool operator==(const Point& other) const {return x == other.x && y == other.y;}
};struct PointHash {std::size_t operator()(const Point& p) const noexcept {return std::hash<int>()(p.x) ^ (std::hash<int>()(p.y) << 1);}
};std::unordered_set<Point, PointHash> s;
这段代码里,我用了最常见的“异或+位移”方法。简单好写,但其实也有风险:如果输入数据有规律(比如所有点坐标差不多),碰撞会多得吓人。
更稳健的做法是用类似 boost::hash_combine
的公式,把不同字段“搅拌”得更均匀:
inline void hash_combine(std::size_t& seed, std::size_t value) {seed ^= value + 0x9e3779b97f4a7c15ULL + (seed<<6) + (seed>>2);
}
记住:哈希函数的好坏,往往直接决定了 unordered_set 的上限。别偷懒。
负载因子和 rehash:性能的暗礁
哈希表的性能和负载因子(load_factor)息息相关。它定义为:元素个数 / 桶数。
当负载因子小,查找快,空间浪费大。
当负载因子大,查找慢,空间利用率高。
标准库有一个 max_load_factor()
,默认大概是 1.0 左右。当实际 load_factor 超过它时,容器会触发 rehash ——重新分配更多桶,并把所有元素搬过去。
rehash 是代价极高的操作:不仅耗 CPU,还会让所有迭代器、指针、引用统统失效。
所以有个很重要的技巧:批量插入之前先 reserve。比如你要插入 10 万个元素:
std::unordered_set<int> s;
s.reserve(100000);
for (int i = 0; i < 100000; ++i) s.insert(i);
这能极大减少 rehash 次数,让插入过程平滑。
迭代器的那些坑
说到 unordered_set
的迭代器,真的得给大家提个醒:它远没有你想象的那么稳定。
rehash 一发生,所有迭代器、引用、指针全废
rehash 是什么?就是哈希表容量不够了,需要扩容,重新把所有元素分配到新的桶里。这个过程相当于给整个容器换了一个「新房子」,老的地址全都不对了。所以,只要发生 rehash,你手里所有旧的迭代器和引用就变成了“野指针”。举个例子:
std::unordered_set<int> us; auto it = us.begin(); for (int i = 0; i < 1000; i++) {us.insert(i); } // it 已经失效,继续用 it 会爆炸
很多新手(甚至老手)都在这里翻过车。你可能在 debug 时还跑得好好的,但一上线数据一大,rehash 触发了,瞬间全崩。
插入元素,如果不触发 rehash,老迭代器是安全的
这一点跟vector
不一样。vector
插入一个元素可能导致底层数组扩容,进而让迭代器全失效。但unordered_set
更友好:只要没有触发 rehash,老迭代器还是能用的。删除元素,只让被删的迭代器失效,其他都没事
这个规则很好记:你删谁,谁失效,其他不受影响。
所以结论就是:
遍历时插入,极度危险,除非你能保证不会 rehash。
如果真的要安全,建议 先 reserve 一个足够大的容量,把 rehash 的可能性压到最低。
Key 不可修改
这个规则很多人觉得“反人类”,但其实很好理解:
unordered_set
的元素就是 key。哈希表的存储依赖 key 的哈希值来确定桶的位置。
如果你直接改 key,元素可能应该去另一个桶,但哈希表根本不知道,数据结构就乱套了。
所以 C++ 标准直接禁止修改元素。你拿到的迭代器指向的是 const
元素:
std::unordered_set<int> us{1, 2, 3};
auto it = us.find(2);
// *it = 10; // 编译错误,不能修改
那怎么办?如果真的想“改”呢?
答案是:只能删了再插。
if (auto it = us.find(2); it != us.end()) {us.erase(it);us.insert(10);
}
麻烦吗?是的。但这是维护数据结构正确性的必要代价。
内存与缓存:哈希表的另一面
很多人喜欢 unordered_set
的 O(1),却忽略了一个现实问题:它很吃内存。
原因很简单:
它是 node-based 容器,每个元素单独分配一个节点。节点里除了存值,还得放指针来链接桶里的链表。
桶数组本身也要占内存,而且大部分时候还留有空位。
这就意味着:
相同数据量下,
unordered_set
的内存占用往往比set
还要大。遍历时,内存访问是跳来跳去的,缓存友好度极差。
对比一下:
vector
是连续存储,遍历时 CPU 的缓存命中率非常高,速度飞快。unordered_set
是离散节点,CPU 每次都要去不同的内存块取数据,缓存 miss 一大堆。
于是现实中常常出现这样的情况:
理论复杂度上 O(1) 的哈希表,遍历起来却比 O(log n) 的红黑树还慢。
在某些高性能场景(比如游戏循环),直接用一个排序过的
vector
+binary_search
,反而比unordered_set
更快更省内存。
这就是“算法复杂度 ≠ 实际性能”的典型案例。
和 set
的对比
一句话总结:
要有序、要范围查询,选
set
。要极致查找/插入性能,选
unordered_set
。数据量小?直接用
vector + sort + binary_search
往往更香。
展开来说:
set
底层是红黑树,有序,支持lower_bound
、upper_bound
这种范围查询,迭代顺序固定。unordered_set
底层是哈希表,无序,不支持范围查询,但平均查找插入 O(1)。如果你只关心“存不存在”,而不关心顺序,
unordered_set
更好。但要注意,unordered_set 的迭代顺序是不稳定的,不同实现、不同 rehash 时机都会让顺序发生变化。
常见的误区
有些坑,很多人都踩过:
以为迭代顺序是固定的
错。unordered_set
的迭代顺序完全依赖底层桶和链表的状态,一旦 rehash,顺序立刻乱了。以为插入不会让迭代器失效
错。插入如果触发 rehash,所有迭代器瞬间废掉。以为 O(1) 就等于快
哈希冲突严重时,桶里可能挂了一条长链表,查找复杂度退化到 O(n)。以为
unordered_multiset::count()
一定快
不对。它要扫描整个桶里所有相等的元素,复杂度 O(k),k 是等值元素的数量。
安全性问题:哈希攻击
这一点经常被忽略。
哈希表的性能依赖于“哈希分布足够均匀”。但如果有人故意制造一堆相同哈希的 key,整个哈希表就会退化成链表,性能直接崩塌。
这就是所谓的 哈希攻击。
一些现代标准库实现(比如 libstdc++、libc++)会在哈希函数里加一个随机种子,来让攻击变得困难。但如果你写的是 网络服务,对公网输入,还是要格外小心。
并发与线程安全
最后一个大坑:
unordered_set
不是线程安全的。多线程只读是安全的。
但只要有一个线程在写,另一个线程读或写,就会炸。
怎么办?
要么外部自己加锁。
要么用专门的并发容器,比如 TBB 提供的
concurrent_unordered_map
。
这也是为什么在高并发场景下,C++ 程序员很少直接用标准库的哈希表。
小结
如果把 unordered_set / unordered_multiset
总结成几条原则:
迭代器失效规则必须牢记,特别是 rehash。
key 不可修改,只能删了重插。
内存占用大,缓存友好度差,别迷信理论复杂度。
要有序用 set,要快用 unordered_set,数据少用 vector。
别依赖迭代顺序,它根本不保证稳定。
哈希函数决定上限,冲突严重时 O(1) 也没用。
多线程环境一定要加锁或换容器。
理解这些,你在选择容器时就能心里有数:何时用它快如闪电,何时换别的更合适。