当前位置: 首页 > news >正文

unordered_set 与unordered_multiset?我们该如何选择

前言

平时写 C++,容器是我们最离不开的工具。vectorlist 大家都用得滚瓜烂熟了,但一旦需求变成“我要快速查找某个 key”,顺序容器立刻显得笨重。

这时候,就轮到关联容器上场了。很多人第一反应是 set ——基于红黑树,有序,查找 O(log n),挺好。但如果你压根不需要“有序”,只想要“快到飞起”的查找呢?标准库早就为我们准备好了答案:std::unordered_setstd::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 的迭代器,真的得给大家提个醒:它远没有你想象的那么稳定

  1. 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 触发了,瞬间全崩。

  2. 插入元素,如果不触发 rehash,老迭代器是安全的
    这一点跟 vector 不一样。vector 插入一个元素可能导致底层数组扩容,进而让迭代器全失效。但 unordered_set 更友好:只要没有触发 rehash,老迭代器还是能用的。

  3. 删除元素,只让被删的迭代器失效,其他都没事
    这个规则很好记:你删谁,谁失效,其他不受影响。

所以结论就是:

  • 遍历时插入,极度危险,除非你能保证不会 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),却忽略了一个现实问题:它很吃内存

原因很简单:

  1. 它是 node-based 容器,每个元素单独分配一个节点。节点里除了存值,还得放指针来链接桶里的链表。

  2. 桶数组本身也要占内存,而且大部分时候还留有空位。

这就意味着:

  • 相同数据量下,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_boundupper_bound 这种范围查询,迭代顺序固定。

  • unordered_set 底层是哈希表,无序,不支持范围查询,但平均查找插入 O(1)。

  • 如果你只关心“存不存在”,而不关心顺序,unordered_set 更好。

  • 但要注意,unordered_set 的迭代顺序是不稳定的,不同实现、不同 rehash 时机都会让顺序发生变化。


常见的误区

有些坑,很多人都踩过:

  1. 以为迭代顺序是固定的
    错。unordered_set 的迭代顺序完全依赖底层桶和链表的状态,一旦 rehash,顺序立刻乱了。

  2. 以为插入不会让迭代器失效
    错。插入如果触发 rehash,所有迭代器瞬间废掉。

  3. 以为 O(1) 就等于快
    哈希冲突严重时,桶里可能挂了一条长链表,查找复杂度退化到 O(n)。

  4. 以为 unordered_multiset::count() 一定快
    不对。它要扫描整个桶里所有相等的元素,复杂度 O(k),k 是等值元素的数量。


安全性问题:哈希攻击

这一点经常被忽略。

哈希表的性能依赖于“哈希分布足够均匀”。但如果有人故意制造一堆相同哈希的 key,整个哈希表就会退化成链表,性能直接崩塌。

这就是所谓的 哈希攻击

一些现代标准库实现(比如 libstdc++、libc++)会在哈希函数里加一个随机种子,来让攻击变得困难。但如果你写的是 网络服务,对公网输入,还是要格外小心。


并发与线程安全

最后一个大坑:

  • unordered_set 不是线程安全的

  • 多线程只读是安全的。

  • 但只要有一个线程在写,另一个线程读或写,就会炸。

怎么办?

  • 要么外部自己加锁。

  • 要么用专门的并发容器,比如 TBB 提供的 concurrent_unordered_map

这也是为什么在高并发场景下,C++ 程序员很少直接用标准库的哈希表。


小结

如果把 unordered_set / unordered_multiset 总结成几条原则:

  1. 迭代器失效规则必须牢记,特别是 rehash。

  2. key 不可修改,只能删了重插。

  3. 内存占用大,缓存友好度差,别迷信理论复杂度。

  4. 要有序用 set,要快用 unordered_set,数据少用 vector

  5. 别依赖迭代顺序,它根本不保证稳定。

  6. 哈希函数决定上限,冲突严重时 O(1) 也没用

  7. 多线程环境一定要加锁或换容器

理解这些,你在选择容器时就能心里有数:何时用它快如闪电,何时换别的更合适。

http://www.dtcms.com/a/434794.html

相关文章:

  • 网站建设与维护是什么可以在线观看的免费资源
  • Windows系统下的Git安装(2025年6月更新)
  • Protobuf知识总结
  • 网站的关键词在哪里设置南阳微网站建设
  • GPT‑5 都更新了些什么?
  • 晋中路桥建设集团网站tuzicms做企业手机网站如何
  • 2013 年真题配套词汇单词笔记(考研真相)
  • 女生做网站编辑公司网站开发多少钱
  • 记录GoLang创建文件并写入文件的中文乱码错误!
  • ISO 27001 信息安全管理体系 (ISMS) 建设与运营
  • TCP 的韧性:端网关系对传输协议的影响
  • 怎么创网站赚钱网站美工工作流程
  • malloc:arena
  • 第12课:构建对话记忆:打造多轮对话RAG机器人
  • 大良营销网站建设如何模板网站没有源代码
  • 归并排序的递归和非递归实现
  • 天津建设网站个人主页网页设计模板免费
  • 整体设计 逻辑系统程序 之8 三种逻辑表述形式、形式化体系构建及关联规则(正则 / 三区逻辑)
  • 京东Java后台开发面试题及参考答案(上)
  • 婚纱摄影网站帮忙建设公司网站
  • 载具系统介绍
  • 理解采样操作的不可微性及重参数化技巧
  • 做网站 视频外链做网站的做网站麻烦吗
  • TOGAF之架构标准规范-需求管理
  • 临沂 企业网站建设seo双标题软件
  • 公司为什么做网站支付宝小程序
  • Linux中读写自旋锁rwlock的实现
  • 前端-JS基础-day5
  • 字体版权登记网站WordPress网站结构优化
  • [特殊字符]【保姆级教程】GLM-4.6 接入 Claude Code:200K 长上下文 + Agentic Coding,开发者福音!编程能力大幅提升!