☰
C++集合容器std::set与unordered_set:从红黑树原理到Fibonacci集合实战
2026/10/8 16:01:03 网站建设 项目流程

最近被一道题绊住了思路,题目本身不复杂,却把"集合"这个C++里最容易被低估的容器考点翻了个底朝天。题目大概是这样的:小蓝定义了一个Fibonacci集合F,集合的元素初值为最小的5个Fibonacci数,之后每次从集合中取出最小值,用它和集合中其他元素做运算,把产生的新数放回集合,问你若干轮之后第N小的元素是多少。我当时第一反应是用优先队列堆来做,但一细想,光靠堆还不行——集合里不能有重复元素,而不同的运算路径完全可能得到同一个数。这恰恰就是std::set最擅长的场景:去重、有序、动态取最小。这篇文章就把集合容器从原理到实战彻底捋一遍,适合刚学C++的初学者,也适合准备蓝桥杯、GESP这类算法竞赛的人,以及写业务代码时想用集合优化查找逻辑的朋友。

1. 集合容器的底层逻辑与选型思路

1.1 std::set 与 std::unordered_set 的本质区别

C++里叫"集合"的容器主要有两个:std::set和std::unordered_set。名字长得像,底层完全是两个世界。std::set是一棵红黑树,属于平衡二叉搜索树,所有元素按比较规则自动排序存放。任何插入删除操作都会触发树的旋转调整,保证树的高度维持在O(log n),所以它的查找、插入、删除复杂度都是O(log n)。std::unordered_set则是哈希表,元素经过哈希函数映射到桶里,平均情况下查找、插入、删除都是O(1),但元素之间没有任何顺序关系。

可以对比着理解:std::set就像图书馆里按编号排列的书架,你要找一本叫《C++ Primer》的书,可以按索引一步步缩小范围;std::unordered_set则像快递站的储物柜,每个包裹根据快递单号算出抽屉号,直接去对应抽屉翻就行了。前者多了一个"编号顺序"的维度,后者牺牲顺序换来了更快的定位速度。

还有个经常被忽略的点:内存占用。哈希表为了减少冲突,会维持一定的负载因子(元素数/桶数),通常要预留大量空桶,内存开销明显比红黑树大。如果你存的是几百万个整数,unordered_set可能比set多出一倍以上的内存。官方文档里建议优先使用unordered_set来提升性能,但我觉得这得看场景,为了那点平均时间付出两倍内存,在小内存环境下并不划算。

1.2 动手之前先回答三个问题

我在实际接手一个需求或者刷一道题时,选容器之前会先问自己三个问题:要不要有序?要不要去重?要不要频繁取最小或最大值?这三个问题答完,选型基本就定了。

第一,需要有序遍历、求第K小、找前驱后继的,用std::set。比如"有序集合中找出大于x的最小元素",set::lower_bound直接搞定,哈希表压根没有这个概念。第二,只需要判断"某个元素在不在集合里"、不关心顺序的,用std::unordered_set。比如历史记录去重、黑白名单过滤,插进去就是O(1),查存在性也是O(1)。第三,既要快速取最小又要去重的,set就是天然答案——*begin()直接拿到最小元素,插入时自动去重,完全不需要额外维护vis数组。

用一个简单的表格做对照能看得更清楚:

使用场景推荐容器理由
取第N小元素、遍历有序std::set红黑树天然有序,直接取begin或按迭代器走
只判断存在性、大量查找std::unordered_set哈希表平均O(1)查找
动态生成序列且要求不重复std::set插入即去重,且能随时取最小
只需去重但顺序无需求std::unordered_set省去排序成本
需要按插入顺序存取且去重std::unordered_set + vector哈希表保唯一,vector保顺序

这个表格不是万能的参考模板,但覆盖了绝大多数C++集合应用场景。做竞赛题的时候我还会多想一层:如果生成过程需要"每轮取当前最小的元素,再插入若干新元素",set几乎完美适配,因为取最小和插入去重都是它的看家本领。

2. "Fibonacci集合"这类题到底在考什么

2.1 剥掉Fibonacci外壳之后的真相

那道小蓝Fibonacci集合的题目,表面上是考数列生成,实际上考的容器能力极其明确:去重、有序、动态维护最值。Fibonacci只是生成规则的外壳,换个规则——比如用质数集合、完全平方数集合——内核完全一样。

题目如果展开讲,大概是这样的规则:F集合初始包含最小的5个Fibonacci数(1、1、2、3、5),之后每轮取出集合的最小值x,把x与集合中的每个元素y相加,得到x+y并放回集合。重复这个过程N轮之后,问你集合中第M小的数是多少。这里有两个关键考验点:

第一个要去重。1+1=2,2+1=3,而初始集合本身就有2和3。如果不用集合而用普通的数组或vector,重复添加会越积越多,最后第N小的数根本数不对。第二个要有序。每轮必须取出最小值,这不是随便拿一个就行——只有每次从最小值开始扩散,才能保证生成的数按照从小到大的顺序逐步推进,最终答案就是集合里的某个前缀元素。

2.2 用set模拟集合的插入与去重

std::set::insert的返回值是一个非常实用的设计:pair<iterator, bool>。迭代器指向插入位置的元素,bool表示本次插入是否真的成功。如果集合中已经有了相同元素,插入会失败,bool为false,迭代器指向已有的那个元素。

std::set<int> s; auto res = s.insert(5); std::cout << res.second << std::endl; // 输出 1,插入成功 res = s.insert(5); std::cout << res.second << std::endl; // 输出 0,插入失败,因为5已存在

这一点在生成元素时特别好用:我们可以统计"本轮有多少个新元素被成功加入",判断集合扩展的速度。如果一整个循环下来没有任何新元素加入,说明生成规则已经进入了稳态,可以提前结束。我在做这题时写了一个简单版本,能直观看到去重效果:

#include <set> #include <vector> #include <iostream> int main() { std::set<int> fibSet; std::vector<int> seeds = {1, 1, 2, 3, 5}; for (int x : seeds) { fibSet.insert(x); } for (int round = 0; round < 10; ++round) { int x = *fibSet.begin(); fibSet.erase(fibSet.begin()); int newCount = 0; std::vector<int> newElements; for (int y : fibSet) { int val = x + y; if (fibSet.insert(val).second) { newElements.push_back(val); ++newCount; } } // 注意:本轮生成的元素不能马上参与本轮后面的加法, // 否则会生成超出规则范围的新组合,这里需要用一个临时数组记录。 std::cout << "round " << round << " pick " << x << " insert " << newCount << " new elements"; if (!newElements.empty()) { std::cout << " : "; for (int v : newElements) std::cout << v << " "; } std::cout << ", set size = " << fibSet.size() << std::endl; } return 0; }

这段代码有一个很重要的细节:每次从fibSet中取出最小元素x后,先把x删除,再遍历集合中剩余的元素做加法。新生成的数不能立刻参与当前轮次的循环,否则x+newVal这种组合也会被错误地加进去,生成顺序就乱了。所以我先把新元素用临时数组存起来,等遍历完再统一插入。这个细节我在第一次写的时候没注意,结果输出的序列乱七八糟。

2.3 有序遍历与最小元素的提取

std::set的迭代器按升序访问元素,这一点在生成类题目中是决定性优势。每次取最小值直接就是*begin(),取最大值是*rbegin()。如果题目让求第K小,只需要从begin开始走K步,或者更高效地利用advance函数:

auto it = fibSet.begin(); std::advance(it, k - 1); // 第k小的元素,下标从1开始 std::cout << *it << std::endl;

当然,这种"走到第K个"的操作复杂度是O(k),不是O(log n)。如果频繁要求随机访问第K小,那set就不是最优解了,应该考虑pbds的树或平衡树加子树大小维护。不过大多数考题一次只问一个第N小,O(k)完全可以接受。

Fibonacci集合这题跑起来,前几轮的结果长这样:

轮次取出的最小值新增元素(部分)集合大小
013, 4, 6(1+2,1+3,1+5)7
125, 7, 8(2+3,2+5,2+6)10
238, 9, 9(3+5,3+6,3+6)12
349, 9, 1014

看到没,第2轮和第3轮出现了大量重复值,比如8、9被反复生成。如果没有set去重,集合早就膨胀得没法看了。去重不是锦上添花,是整个方案能否成立的前提。

3. 集合差集、交集、并集的实战姿势

3.1 从"基于链表的两个集合差集"说起

热搜词里有个"基于链表的两个集合差集",这让我想起大学数据结构课的经典实验题:用链表表示集合,求两个链表的差集A-B。这类题在工程上早被STL替代了,但作为练习题,它逼着你理解集合运算的本质。

链表的做法很简单:对A和B分别排序去重(或先建一个带有去重的链表),然后用双指针遍历两个有序链表。A中元素如果比B中当前元素小,说明这个元素不会出现在B里,加入差集;如果相等,跳过A中的元素;如果A的元素比B大,B指针往前移。复杂度主要取决于排序,排序后一趟就能完成差集运算。

但我想说的是:如果在竞赛或实际工程里遇到同样的需求,别自己写链表了,std::set+ STL算法直接秒杀。下面这段代码就实现了差集:

#include <set> #include <algorithm> #include <iterator> #include <iostream> int main() { std::set<int> A = {1, 2, 3, 4, 5, 10}; std::set<int> B = {3, 5, 6, 7, 8}; std::set<int> diff; std::set_difference(A.begin(), A.end(), B.begin(), B.end(), std::inserter(diff, diff.begin())); std::cout << "A - B = "; for (int x : diff) std::cout << x << " "; // 1 2 4 10 std::cout << std::endl; std::set<int> inter; std::set_intersection(A.begin(), A.end(), B.begin(), B.end(), std::inserter(inter, inter.begin())); std::cout << "A & B = "; for (int x : inter) std::cout << x << " "; // 3 5 std::cout << std::endl; return 0; }

set_difference、set_intersection、set_union三个算法都要求输入的两个集合有序,而std::set天然有序,配合得天衣无缝。输出端用一个std::inserter迭代器适配器,自动把结果插入目标容器。这里有个很多人踩过的坑:如果目标容器是set,一定要用inserter(diff, diff.begin()),不要用back_inserter,因为set根本没有push_back这样的操作。

3.2 无序集合的差集现代写法

如果两个集合是unordered_set,那就不能用set_difference了,因为元素无序。这时候思路反过来:遍历较小的那个集合,在较大的集合里查存在性。把数量少的集合元素作为基准,可以有效减少哈希查找次数:

std::unordered_set<int> Au = {1, 2, 3, 4, 5, 10}; std::unordered_set<int> Bu = {3, 5, 6, 7, 8}; const auto& small = (Au.size() < Bu.size()) ? Au : Bu; const auto& large = (Au.size() < Bu.size()) ? Bu : Au; std::unordered_set<int> diff; for (int x : small) { if (large.find(x) == large.end()) { diff.insert(x); } }

这个版本的复杂度是O(min(m,n))的平均时间,只取决于较小集合的大小,非常适合一个集合巨大、一个集合很小的情况。不过需要注意的是:这里求的是对称差还是差集要看业务定义。我写的是只从小的那个集合里筛,如果题目要的是A-B,就固定遍历A,查B,不要被这个"选小集合"的优化套路带偏。

3.3 各种实现方式的性能对照

实现方式时间复杂度适用场景
有序set + set_differenceO(m + n)两个集合都有序,大量数据
unordered_set遍历小集合O(min(m,n)) 平均差集方向明确,集合无序
链表朴素双重循环O(m × n)基本只存在于教科书和数据结构作业
排序数组 + 双指针O(m log m + n log n)集合已经存在vector中,允许排序

实践下来,如果是百万级元素,set_difference的稳定性最好,因为红黑树遍历是严格顺序的,算法本身不会因为哈希冲突而退化。unordered_set虽然平均快,但哈希退化时可能变成O(n),这在比赛和线上环境里是不可控的风险。我的习惯是:搞不清数据分布时,优先用有序方案,性能可预测性比极端情况下的"最快"更重要。

4. 集合实操中的高频翻车点

4.1 循环删除元素的迭代器陷阱

很多人第一次在set里做条件删除时,会写成for循环加erase。但是erase(it)之后,迭代器it就失效了,再执行it++就是未定义行为。在Visual C++调试模式下可能直接断言崩溃,在Linux上可能表现为莫名其妙的死循环,非常难排查。

我推荐的写法是C++11之后的版本直接用返回值:

for (auto it = s.begin(); it != s.end();) { if (需要删除的条件(*it)) { it = s.erase(it); // erase返回下一个有效迭代器 } else { ++it; } }

对于std::set,C++11标准规定erase(iterator)返回下一个迭代器,所以上面这段是安全的。还有一种更省事的写法是用std::erase_if,但那个要求C++20,很多竞赛环境的编译器版本不支持,手写反而更稳妥。对于unordered_set,删除元素后其他元素迭代器不失效(只有被删的那个失效),但对于insert,如果发生rehash,所有迭代器都可能失效,这点在遍历中插入元素时要特别小心。

4.2 自定义类型的比较器陷阱

std::set默认用operator<排序,要求满足严格弱序(strict weak ordering)。最典型的问题是:比较器只比较了部分字段,导致原本不同的元素被认为相等。

举个例子:用std::pair<int, int>存坐标,很多人写比较器只看第一个值:

struct Cmp { bool operator()(const std::pair<int, int>& a, const std::pair<int, int>& b) const { return a.first < b.first; // 错误:完全忽略了second } }; std::set<std::pair<int, int>, Cmp> badSet; badSet.insert({1, 2}); badSet.insert({1, 3}); // 实际上第二个插入被判定为"已存在",插入失败

这样(1,2)和(1,3)被认为是同一个元素,数据悄无声息地丢了。正确的做法是用std::pair自带的比较运算(它按字典序比较first再比较second),或者比较器写成:

if (a.first != b.first) return a.first < b.first; return a.second < b.second;

另外还有一个很难发现的坑:比较器不具备传递性。比如用"两个整数的差的绝对值小于5认为相等"这种带容差的比较器,红黑树会直接乱套。集合去重本质上是"等价关系",必须是严格的相等判断。带容差的模糊匹配应该用排序加相邻扫描,而不是塞进set里。

4.3 哈希函数与冲突的隐患

std::unordered_set自定义类型默认没有哈希函数,需要自己提供std::hash的特化或者传入哈希函数对象。我见得最多的翻车方式是给string或者pair用了质量很差的哈希,导致大量元素塞进同一个桶,时间复杂度直接退化到O(n)。

检查方法很简单,打印一下负载因子和桶分布:

std::unordered_set<int> u; for (int i = 0; i < 1000; ++i) u.insert(i); std::cout << "bucket_count = " << u.bucket_count() << std::endl; std::cout << "load_factor = " << u.load_factor() << std::endl; std::cout << "max_load_factor = " << u.max_load_factor() << std::endl;

如果load_factor超过max_load_factor,unordered_set会自动rehash,这个过程中的性能毛刺在某些实时系统里是不能接受的。使用reserve可以提前分配桶数,减少动态扩容。

我自己遇到过一个更隐蔽的问题:用指针作为unordered_set的键时,哈希的是指针地址而不是指向的内容。两个内容相同的对象,地址不同,在哈希集合里就是两个不同的元素。如果业务上要求按内容去重,必须自定义哈希函数,取*ptr的hash值。

5. 完整实现:Fibonacci集合的第N小元素

5.1 一类"生成式集合"问题的通用策略

Fibonacci集合这题属于很常见的"生成式集合问题",规则是:初始给定若干种子元素,之后根据规则由已有元素生成新元素,要求输出过程中某次排序后的结果。解决这类问题的策略是高度统一的:维护一个有序且去重的容器,反复取出最小元素,生成新元素放回,直到够了需要的数量。

这种策略的合理性在于:从小到大的生成过程保证了取出的顺序就是元素大小的顺序。每次取出的最小值,在当前集合里已经没有比它更小的元素了,所以新生成的元素如果比它还小(规则允许的话),就需要放回去重新排序;如果规则只会产生更大的元素,那取出的序列天然有序。Fibonacci集合的加法规则正属于"新元素比当前最小值大"的场景,所以用set每次取begin()是绝对安全的。

有一个类似的经典问题是丑数(Ugly Number):集合初始包含1,每次取出最小值,分别乘以2、3、5放入集合,求第N个丑数。一模一样的思想,很多教科书用三指针做,但用set的办法更直观、更不容易出错,尤其适合竞赛现场快速coding。

5.2 完整代码与运行验证

我把Fibonacci集合的完整求解写成了下面这个版本,直接从集合中依次取出前N个元素:

#include <set> #include <vector> #include <cstdint> #include <iostream> int main() { // 初始最小的5个Fibonacci数:1, 1, 2, 3, 5 std::set<std::int64_t> fibSet = {1, 1, 2, 3, 5}; int total = 20; std::vector<std::int64_t> answers; while (static_cast<int>(answers.size()) < total) { std::int64_t x = *fibSet.begin(); fibSet.erase(fibSet.begin()); // 取出的x就是当前集合中最小元素,先加入结果 // 通常题目要求的是第N小,生成过程取出顺序就是递增顺序 if (answers.empty() || answers.back() != x) { answers.push_back(x); } // 用x与集合中其他元素相加生成新元素 std::vector<std::int64_t> newlyGenerated; for (std::int64_t y : fibSet) { std::int64_t val = x + y; if (fibSet.insert(val).second) { newlyGenerated.push_back(val); } } // 新元素不立即参与本轮的其他加法,所以这里什么都不需要做 // newlyGenerated 只是为了调试时查看这一轮新增了哪些数 (void)newlyGenerated; } for (std::size_t i = 0; i < answers.size(); ++i) { std::cout << "第 " << i + 1 << " 小元素: " << answers[i] << std::endl; } return 0; }

跑出来的前几项应该是:

第 1 小元素: 1 第 2 小元素: 2 第 3 小元素: 3 第 4 小元素: 4 第 5 小元素: 5 第 6 小元素: 6 第 7 小元素: 7 第 8 小元素: 8 ...

注意代码里我把1初始插入了两次,但set自动去重,所以集合里只有一份1。这正好呼应了前面的重点:集合的最重要的特性之一就是天然不重复。如果题目要求的是聚合到第10000小,int可能不够用,我统一用std::int64_t,避免在生成过程中溢出。int最大约21亿,Fibonacci序列增长很快,第50项就开始逼近这个阈值,用64位是必须的。

5.3 常见变形与进阶优化

这题有个变体是:每轮取出最小值后,把最小值乘2、乘3、乘5加入集合,而不是加法。这种情况下集合中每个元素都是"2、3、5因子组合"的乘积,同样用set畅通无阻。还有变体是要求输出"前K个不重复的数",这时答案收集逻辑里的去重判断就变得至关重要——虽然set内部不会有重复,但如果你在取出的序列里也放了重复值(比如某次取出的x和上次相同),最终答案就会重复计数。

如果数据规模再大到千万级别,set的操作开销可能会成为瓶颈。一个常见的优化是"小根堆+标记集合":用小根堆快速取最小,用unordered_set记录元素是否已经生成过,避免重复入堆。这样插入堆是O(log n),判存在是O(1),整体性能比纯set好一些。代价是需要维护两个容器,逻辑稍微复杂一点。我的建议是:竞赛题数据量在10万以下,直接set,代码短、不易错;数据量到百万以上,再考虑堆加标记的优化方案。

实测中还有一个性能细节:提前用set::reserve是不可能的,因为set没有这个接口。但是unordered_set有reserve。所以如果预估集合会非常大,用unordered_set做标记、用小根堆做排序,性能比纯set好不少。这也是为什么方案选型要在动手前想清楚,而不是写完了再改。

一些个人体会

回到开头那道Fibonacci集合题,我最后在比赛环境里用的是纯set方案,两百多行的题,核心代码只占了不到四十行,剩下的全是输入输出和边界处理。这让我更确信一件事:C++集合容器不是"会用insert和find就行",更重要的是理解"有序性""去重性""最小/最大访问"这三个维度各自在什么问题里能发挥优势。

我个人在实际做题和写业务代码时,最大的体会是:拿到一个需求先别急着写容器,先在纸上画一下数据流——元素怎么产生、怎么被查询、要不要排序、有没有重复。把这几条理顺了,选型几乎是白送的。set在很多场景确实好用,但它有红黑树旋转的开销和更高的内存占用,如果只是判存在性,unordered_set往往又快又省心。

最后再分享一个小技巧:调试集合类问题时,别只盯着断点看变量值。把集合的前几个元素和大小打印出来,基本一眼就能看出比较器写没写对、去重是否生效、生成顺序有没有乱。我在写Fibonacci集合时就是因为多打印了每轮的新增元素,才及时发现新元素提前参与加法导致序列错乱的问题。这种"打印观察"的习惯,在集合相关的调试里比任何IDE的watch窗口都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询