多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

C++集合全解析:set/unordered_set选型、底层原理与实战避坑

C++集合全解析:set/unordered_set选型、底层原理与实战避坑 做C这些年我发现自己写过的代码里出现频率最高的不是那些花哨模板也不是手写内存管理而是集合相关的操作。不管是刚学编程时拿数组模拟集合还是后来用 std::set、std::unordered_set 处理数据去重再到面试和算法题里频繁出现的并集、差集、子集判断集合这个数据结构几乎贯穿了整个C学习曲线。今天这篇就好好聊聊C里的集合容器怎么选、集合运算怎么写、底层为什么快、实际开发里趟过哪些坑。文章适合刚开始学C的选手拿来做入门梳理也适合准备面试或打算法比赛的同学查漏补缺不管你处在哪个阶段理论上都能从这里抠出点有用的东西。1. 先搞清楚“集合”在C里到底指什么1.1 数学集合与C容器的映射关系集合这个概念我们高中就学过一堆互不相同的元素放在一起讲究无序性、互异性、确定性。编程语言里的集合基本保留了这三个特点只是“无序”在工程里往往被“排序”或“哈希”这两个方案重新解释了一遍。C没有单独一个叫“集合”的容器它是通过标准模板库STL里的若干容器和算法组合来体现“集合”语义的。最常见的家族成员有四个std::set底层红黑树元素有序且唯一。std::multiset元素有序但允许重复。std::unordered_set底层哈希表元素无序且唯一。std::unordered_multiset无序且允许重复。另外还有两个常被忽略但很有用的std::bitset 适合做位级集合std::vector 配合排序和去重也能模拟一部分集合功能。选容器的第一原则其实很简单需不需要按顺序遍历需不需要快速查找把这两个问题想清楚容器就定了一半。我见过不少初学者在 set 和 unordered_set 之间纠结其实你只要问自己一个问题如果后续要输出结果并且是按大小顺序输出那就用 set如果只是判断“在不在”“重复没重复”完全不在乎顺序那就上 unordered_set。大多数算法题里输出前把数据从 unordered_set 拷到 vector 再排序反而多此一举。1.2 为什么用 std::set 而不是自己写平衡树很多初学者会问既然红黑树这么麻烦为什么不直接用数组模拟集合这个问题我当年也被问过无数遍。答案不是“数组不行”而是当你需要频繁插入、删除、查找时数组的线性扫描在大数据量下会很吃力。举个例子你要维护一个不断加入新元素并且随时判断某个数字是否已经存在的场景用数组就是每插一个元素都从头扫一遍10 万条数据进来复杂度直接爆炸。std::set 把这套逻辑封装好了插入、删除、查找平均都是 O(log n)而且天然有序遍历和求最值都方便。它的红黑树实现是经过几十年工程验证的不需要你重复造轮子。面试里要求手写红黑树那是另一回事写业务代码和算法题时直接用 STL 是最稳妥高效的选择。你可能会担心 O(log n) 和 O(1) 的差距。但红黑树的常数非常小实践中 10 万级的数据 set 和 unordered_set 的差距几乎感觉不到。只有当数据量来到百万级以上并且你的操作全是查找时哈希表的优势才会明显体现出来。所以别一上来就无脑哈希先看看数据规模和你的操作类型。1.3 “集合”在算法竞赛与等级考试里的真实含义搜索词里出现了“gesp认证c三级真题”“快速幂算法c”“单调栈算法c”这些内容其实都是算法竞赛和等级考试的常见关键词。在这些场景里集合操作往往是解题的一环而不是全部。常见用法包括用 set 去重并排序、用 unordered_set 判断存在性、用 multiset 维护滑动窗口内的有序集合、用 bitset 加速子集枚举。我建议算法初学者不要把集合当成一个孤立知识点而要理解它是一个“辅助工具”。遇到去重、查重、区间最值、集合运算这类需求时第一反应就应该是这里能不能用集合的数据结构来优化这种思维习惯比单纯背 API 重要得多。等级考试里有一个很经典的三级题思路给定两个整数集合求它们的交集并从小到大输出。很多孩子的第一反应是双重循环数据一大就超时。如果他知道 set直接遍历小集合去大集合里 find时间立刻从 O(n*m) 降到 O(n log m)。这就是集合思维在竞赛里的价值。2. 容器选型与底层原理红黑树和哈希表的抉择2.1 有序集合的底层逻辑std::set 之所以能保持元素有序是因为它的底层是红黑树。红黑树是一种自平衡二叉搜索树每次插入删除后通过旋转和变色让树的左右子树高度差保持在一个可控范围内从而保证查找、插入、删除都是 O(log n)。用生活类比来说红黑树就像一本自动按拼音排序的通讯录。你往里加一个联系人它自己会找个合适的位置插进去不用每次查完再手动整理。std::set 本质上就是这种“自动有序”的结构所以它给你带来了几个很实在的能力通过 begin() 拿到最小值通过 rbegin() 拿到最大值都是 O(1)。用 lower_bound 和 upper_bound 快速找到“第一个不小于某值”或“第一个大于某值”的位置。配合算法库做二分查找、区间遍历效率很高。这些能力在业务里很实用。比如你维护一个优惠券 ID 集合想知道当前有多少张优惠券编号比 10086 大直接用 end() 减去 upper_bound(10086) 就是了。这比把数据倒出来再排序快得多。2.2 无序集合的底层逻辑unordered_set 底层是哈希表本质是一个数组加链表或开放寻址。它通过哈希函数把元素映射到桶里理想情况下查找、插入、删除都是 O(1)。生活类比是衣柜里的每件衣服都有一个固定编号按照编号直接去对应格子找不用翻遍整个衣柜。但哈希表有个天然特性元素之间没有顺序。你无法从小到大遍历 unordered_set也没有 lower_bound它只适合“我只关心这个元素在不在”的场景。所以容器选型我坚持一条判断路径需要有序遍历、求区间、找前驱后继 → std::set。只查重、判存在、快速去重 → std::unordered_set。允许重复且需要计数 → multiset 或 unordered_map。需要大量位级状态表示 → bitset。我自己的经验是写业务代码时大部分方案用 set 就够了因为业务输出通常需要排序。写算法题时如果题目明确说“不要求输出顺序”unordered_set 可以帮你在大数据量下省掉不少时间。这两种容器不是替代关系是互补关系谁也别想完全取代谁。2.3 自定义类型放进集合的三道门槛工程上最常见的坑是把自定义结构体放进集合。这里有三道门槛跨不过去编译器就会报错第一道std::set 要求元素能比较大小。你的结构体必须重载 operator 或者提供自定义比较器。第二道std::set 要求元素是“严格弱序”的也就是说比较关系必须满足传递性否则红黑树会被破坏。第三道unordered_set 要求元素能计算哈希你需要提供 std::hash 的特化或者自定义哈希仿函数。给一个最小可用的例子#include set #include string #include unordered_set struct Person { std::string name; int age; // set 需要小于运算符 bool operator(const Person other) const { if (age ! other.age) return age other.age; return name other.name; } }; struct PersonHash { size_t operator()(const Person p) const { size_t h1 std::hashstd::string()(p.name); size_t h2 std::hashint()(p.age); return h1 ^ (h2 1); } }; int main() { std::setPerson people1; // 用 operator std::unordered_setPerson, PersonHash people2; // 用 PersonHash }如果你只是想按年龄排序但两个年龄相同的不同人也应该被区分那 operator 里就必须把 name 也加上。漏掉任何一层就可能导致 set 认为两个不同的人“相等”插入时直接把后一个丢掉。这种 bug 非常隐蔽排查起来相当费劲。3. 集合运算的标准实现并集、交集、差集、对称差与去重3.1 用 STL 算法一次性完成集合运算C 标准库在 头文件里提供了 set_union、set_intersection、set_difference、set_symmetric_difference 四个函数。前提条件是两个输入范围必须有序并且输出位置的迭代器要提前准备好。std::set 天然有序所以它是最匹配的输入容器。看一个求两个集合差集的完整例子#include algorithm #include iostream #include iterator #include set #include vector int main() { std::setint a {1, 2, 3, 4, 5}; std::setint b {4, 5, 6, 7}; std::vectorint result; // a - b只保留在 a 中且不在 b 中的元素 std::set_difference(a.begin(), a.end(), b.begin(), b.end(), std::back_inserter(result)); for (int x : result) std::cout x ; // 输出 1 2 3 return 0; }这里要注意set_difference 并不会真正“删除” b 中的元素它是把结果拷贝到输出迭代器里。如果你只是想知道某个元素在不在集合中直接用 count 或 find 更简单。真正需要求差集的场景通常是处理两个数据源的对比比如找出“数据库里有但文件里没有”的 ID 列表。其他三个函数用法完全一样只是语义不同。set_union 是并集set_intersection 是交集set_symmetric_difference 是对称差集只在其中一个集合里出现的元素。它们的时间复杂度都是 O(nm)因为输入都有序可以像归并排序一样线性扫描完成。3.2 基于链表的两个集合差集——经典面试演进搜索词里有个“基于链表的两个集合的差集”这是个非常经典的链表考题通常出现在数据结构课和面试手写题里。它的意义在于考察你能不能抛开 STL自己操作链表完成集合运算。思路整理如下两个有序链表分别用 p 和 q 指针遍历。当 p 指向的值小于 q 时p 指向的值肯定不在 q 链表中把它加入结果链表p 后移。当 p 大于 q 时说明 p 指向的值在 q 链表中不可能出现q 后移。当 p 等于 q 时说明两边都有跳过两个指针。循环直到 p 或 q 为空再把 p 中剩余元素全部加入结果。这个算法的时间复杂度是 O(mn)空间是 O(结果长度)。它其实就是 set_difference 的链表版。把这道题亲手写一遍比背十遍八股文管用得多因为你会真正理解“有序集合的线性对比”是怎么回事。我还记得第一次面试被问到这道题时脑子里全是 set_difference 的用法却忘了它背后就是这个归并对比的过程。面试官说“你既然会 STL那就用自己的话讲讲它为什么是 O(mn) 的吧。”那一刻我才意识到底层原理比 API 重要得多。3.3 multiset 与重复计数什么时候用它才是对的set 只能存不重复的元素但实际场景中我们经常需要知道某个元素出现了多少次。这时候 multiset 就上线了它的 insert 会保留重复元素count() 接口可以统计元素出现次数。不过要注意multiset 的 count 复杂度是 O(log n 出现次数)频繁统计大量重复元素时性能可能不如 unordered_map。如果只是统计次数我的第一选择其实是 unordered_map 或 map把元素当 key次数当 value插入时用 m[key]语义更清晰性能也稳定。multiset 更适合的场景是“既能保留重复元素又要求保持有序”。典型例子是求数据流的中位数用两个 multiset 一前一后维护左右半部分实现 O(log n) 插入和 O(1) 取中位数这是很经典的算法题。多提一句去重计数时用 unordered_map 还有一个好处就是你可以直接拿到每个元素的出现次数而不是先 count 一遍再遍历集合。很多新手在 multiset 上统计次数代码又绕又慢改成 map 之后又快又直观。数据结构没有绝对的好坏只有适合不适合。4. 集合在算法题与工程实战里的高频用法4.1 去重加排序set 的一体化能力先看一道简单但高频的题给一个数组去掉重复数字并且按从大到小输出。很多新手会用两层循环去重再看怎么排序绕了一大圈。其实用 std::set 可以一次性解决#include iostream #include set #include vector int main() { std::vectorint nums {5, 3, 8, 3, 1, 8, 2, 5}; std::setint s(nums.begin(), nums.end()); for (auto it s.rbegin(); it ! s.rend(); it) { std::cout *it ; // 8 5 3 2 1 } return 0; }set 内部有序就省了排序唯一性又自动去重两行代码搞定。这种“以空间换思维成本”的写法在数据量不是特别夸张时非常推荐。如果你连排序都不需要那可以直接用 unordered_set内存占用和速度都更有优势。4.2 判断质数优化与集合记忆化搜索词里提到“判断质数c优化”这是一个和集合有关的常见优化思路。经典做法是埃拉托色尼筛法先生成小于等于 n 的所有质数放进一个 unordered_set之后判断目标数是否是质数时直接查找这个集合平均 O(1)。#include unordered_set #include vector std::unordered_setint primes; void init_primes(int n) { std::vectorbool is_prime(n 1, true); is_prime[0] is_prime[1] false; for (int i 2; i * i n; i) { if (is_prime[i]) { for (int j i * i; j n; j i) is_prime[j] false; } } for (int i 2; i n; i) { if (is_prime[i]) primes.insert(i); } }这种“预计算 集合查询”的组合在竞赛里非常吃香。你算一次后面随便查。比起每次都重新判断质数效率不是一个量级。工程上同理凡是“计算结果不常变但查询频繁”的数据都适合用集合把结果缓存起来。4.3 bitset 与子集枚举位级集合的降维打击如果集合元素是有限非负整数并且数量不大比如不超过 10 万std::bitset 是比 unordered_set 更极致的选择。一个 bitset 的每一位代表一个元素是否存在占用空间极小位运算让并集、交集、差集变得异常高效。比如两个集合 a 和 b 的并集直接 a | b 就完成这和调用 set_union 写一堆迭代器相比性能完全是降维打击。当然 bitset 的使用条件比较苛刻要求元素值能映射到固定的位范围。做状态压缩 DP 和子集枚举时这一招几乎是标配。我还记得有一道题给定两个大集合求它们的交集大小。数据范围是 0 到 100000我一开始用 unordered_set 逐个比对跑了 200 毫秒。后来改成两个 bitset 求与再统计 1 的个数3 毫秒就结束了。那个速度差距让在场的同学都惊了。所以当数据范围和内存都允许时不要忘记 bitset 这个犀利的工具。5. 高频问题排查与实战避坑清单5.1 集合里改坏元素最隐蔽的迭代器失效std::set 的红黑树按元素值维护结构如果你通过非 const 引用修改了元素值等于破坏了树的有序性后续查找会得出错误结果。正确做法是先取出、从集合中删除再插入新值。unordered_set 如果修改了影响哈希值的部分也会导致元素“找不回来”。这算是集合使用中最隐蔽的坑网上搜“c八股文”也经常被问到。核心记忆点就一句话集合元素是只读的改值前先 erase再 insert。很多人写代码时图方便直接把元素从 set 里拿出来改了再放回去结果不是崩溃就是找不到数据折腾半天才发现是这里的问题。5.2 erase 的返回值版本差异C11 之前set::erase(iterator) 返回 void你需要先保存下一个迭代器再删除std::setint s {1, 2, 3, 4, 5}; for (auto it s.begin(); it ! s.end(); ) { if (*it % 2 0) { s.erase(it); // 老写法先自增再删除旧迭代器 } else { it; } }C11 之后erase 返回指向下一个元素的迭代器写法就简单了for (auto it s.begin(); it ! s.end(); ) { if (*it % 2 0) { it s.erase(it); // 新写法直接用返回值 } else { it; } }如果你还在维护老代码看到这种区别要能认出来。很多老项目的 C 标准还停留在 C98你贸然用新写法编译器直接报错。5.3 求最大值别老想手写循环搜索词里有“c# mongodb 集合 最大值”这种跨界需求。放到 C 里求 set 最大值直接用 *rbegin() 或 *prev(end())不要写循环遍历去找。rbegin 是反向迭代器返回最后一个元素O(1)。同理求最小值用 begin()。这是新手最容易写绕的地方。我还见过有人遍历整个 set用一个变量保存当前最大值循环结束后再输出。这种做法在元素多的时候非常浪费时间而且代码很长。一个反向迭代器就能搞定的事没必要自己造轮子。5.4 性能陷阱判断存在优先用 find 而不是 countset 类容器的 count 会统计所有匹配元素unordered_set 的 count 返回 0 或 1看起来用得挺顺手。但在需要判断是否存在的时候find 的效率更稳定而且可以直接拿到迭代器去做后续操作。写代码的时候培养一个习惯判断存在用 find不要用 count。有些人多写几行代码就为了证明“count 1”其实这既不清楚也不高效。用 find 的话代码意图一目了然而且如果要插入或者删除直接拿着迭代器操作就完事了。这个习惯养成之后很多容器操作的效率就自然上去了。5.5 自定义哈希函数的注意事项unordered_set 使用 std::hash 作为默认哈希但 C 标准库没有内置组合哈希的通用方法。一个常见做法是写一个辅助哈希模板把多个字段组合进去。前面 PersonHash 那个例子已经展示了基本写法。一个经常被忽略的细节是如果你的 unordered_set 里存的是指针默认哈希是地址而不是对象内容。两个内容完全相同的对象如果地址不同就会被认为是两个不同的元素。如果业务上有“按内容去重”的需求必须把对象本身存进集合而不是存指针。这个坑我在实际项目里踩过一次排查了好久才发现是地址哈希导致的“假的重复项”。6. 从集合出发怎么系统提升C容器能力6.1 三个适合入门的集合练习个人经验是掌握集合的最快路径不是通读 STL 源码而是做三类练习。第一道产生 N 个互不重复的随机数并排序输出。要求随机生成 1 到 1000 之间的 N 个不重复数字并按从小到大输出。你会发现用 set 可以一次搞定而用数组加双重循环的人多少都会绕点弯路。这个练习能帮你快速建立“去重 排序”的直觉。第二道两个有序链表求差集。分别用 STL 算法和手写链表两种方式实现体会不同数据结构的适用边界也能把 3.2 节的内容吃透。第三道判断一个数组里是否存在重复元素并且统计每个元素的出现次数。用 unordered_set 查重用 unordered_map 计数这是最常用的组合拳一定要练熟。这三道题做完你对集合的基本操作就没什么盲区了。6.2 带着集合思维去刷题与工程实践真正让我对集合理解的突飞猛进的是带着集合思维去刷题。遇到“判断数组中是否有重复元素”“求出现次数最多的前 K 个元素”“维护滑动窗口的有序集合”这类题先把集合用起来再考虑性能优化。久而久之你看到题目会自动映射到合适的数据结构这才是真正的经验积累。工程上也一样。我写业务代码时经常要对比两批 ID 的差异比如“用户已购商品”和“全部上架商品”的差集。这时候与其写一堆循环不如直接把两个 vector 装进 unordered_set再用 set_difference 或者遍历哈希表去解决。代码可读性和运行效率同时提升。最后分享一个我的习惯写代码前先花十秒钟想清楚“我要的是什么顺序要不要去重查找多还是插入多”这十秒钟往往会帮你避开后面一小时的调试时间。集合虽然只是 STL 里的一小块但把这三种问题想明白了C 容器体系的很多内容就一通百通了。
返回列表