ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++ set与multiset全解:红黑树底层、核心API与性能避坑

C++ set与multiset全解:红黑树底层、核心API与性能避坑

1. 从场景说起:什么时候你会需要set和multiset

在C++里,关联容器这块儿,set和multiset总被人当成“排序+去重”的标配工具。但说实话,你要是只知道sort+unique,可能根本意识不到这俩容器真正的价值。我先说个实际场景,你感受一下。

之前做一个模拟项目X,需要维护一个不断进出场的用户ID列表,要求随时知道当前的最小ID和最大ID,还要支持快速判断某个ID是否存在。如果用vector,插一个、删一个都得搬数据,O(n)操作,几千个还好,数据到了十万级就卡得没法用。用map维护又觉得浪费,毕竟我不需要键值对,我只需要那堆ID本身。这时候set就是最自然的答案:元素自动排序,插入删除查找都是O(log n),头尾元素直接拿,不用自己写任何平衡逻辑。

multiset就更有意思了。它和set几乎一样,唯一的区别是允许重复元素。什么时候需要存重复?比如你统计一批分数,想知道某个分数段的人数,或者做多路归并的时候需要保留相同优先级的多个任务,multiset就是专门干这个的。它不帮你压缩重复,而是把每一个重复元素都当成独立个体管理,各自占据一个节点。

再说一个我常跟开发者强调的点:set和multiset的底层是红黑树,这意味着它们在插入之后,元素之间的相对顺序是稳定有规律的,迭代器也不会因为其他元素的插入删除而失效(除了被删掉的那个)。这一点在写长时间运行的算法时很关键,比如你一边遍历一边插入新元素,vector必然出事,但set能扛住。

所以这篇文章我想把set和multiset从底层结构到API细节、从性能分析到避坑实操,整个过一遍。适合什么人看?刚学完STL基础想知道这俩容器到底怎么用的初学者;写过点代码但总在自定义比较器、迭代器这类细节上摔跤的进阶开发者;还有想搞明白什么时候该选set、什么时候该选unordered_set的架构选型派。内容会比较硬核,但我尽量用实际例子讲清楚,你也能直接抄去用。

2. 底层结构决定了它的性格

2.1 红黑树不是玄学,是平衡的艺术

set和multiset底层是红黑树,这个大家都知道。但很多人不知道的是,为什么偏偏是红黑树,而不是AVL树、跳表或者B+树。这不是历史包袱,是STL设计者在“操作效率”和“实现成本”之间做的平衡。

红黑树的平衡条件比AVL树宽松——它不要求左右子树高度差不超过1,只要求从根到叶子的最长路径不超过最短路径的两倍。这样带来的好处是:插入、删除时需要的旋转次数更少,平均性能更好。AVL树查得快,但插入删除频繁时重平衡的成本高;红黑树牺牲一点最坏情况下的查找性能(从严格的log n变成大约2log n),换来了更平稳的插入删除表现。对set这种增删改查都频繁的容器来说,红黑树确实是综合最优解。

这个底层逻辑会直接反映在你的使用体验上。set的插入是O(log n),查找是O(log n),删除也是O(log n),而且这三个操作的常数都不大。相比之下,unordered_set的查找在平均情况下是O(1),但最坏情况(哈希冲突严重时)会退化到O(n)。做实时系统、做对时间确定性有要求的模块,set这种有上界保证的结构反而更让人放心。

2.2 树形结构带来的三个特性

理解了红黑树,你就能推导出set和multiset的所有行为特性。我总结成三条,你记好。

第一,元素天然有序。红黑树是二叉搜索树,中序遍历就是从小到大。所以set和multiset天然支持“找出小于x的最大元素”这种操作,不需要排序、不需要额外索引。这在处理区间查询、滑动窗口、任务调度这些场景时是降维打击。

第二,迭代器不会意外失效。map、set、multiset的插入删除只影响被操作的节点,其他节点的内存地址不变,迭代器自然也不会失效。这一点跟vector“插入可能导致所有迭代器失效”完全不同。你可以在遍历set的同时,安全地插入新元素;也可以把指向某个元素的迭代器存起来,后续操作中继续使用。

第三,内存是节点式的,不是连续式的。每个元素单独分配一个节点,节点之间通过指针连接。这意味着它没有vector那样的“容量”概念,不会因为扩容导致数据搬移,也不会因为预分配不足导致内存浪费。代价是每个节点都要额外存三个指针(左孩子、右孩子、父节点)加上颜色位,内存占用比vector高不少。

这三条特性互相咬合,决定了set/multiset的适用范围。你不可能既要求元素内存连续、可以随机访问,又要求插入删除不影响其他元素,这两个需求本质上是冲突的。vector和set就是两种不同哲学的选择,没有谁代替谁,只有谁更适合什么场景。

2.3 和哈希容器的一次正面对比

既然说到选型,我把set和unordered_set的对比拉出来看。这俩名字长得像,但性格完全是两回事。

对比维度setunordered_set
底层结构红黑树哈希表(桶+链表/开放寻址)
元素顺序有序(按比较器)无序(哈希决定位置)
查找复杂度O(log n)平均O(1),最坏O(n)
插入复杂度O(log n)平均O(1),最坏O(n)
迭代稳定性插入不影响已有迭代器扩容时可能全部失效
自定义类型要求需要定义比较器(operator<)需要定义哈希函数和相等判断
内存布局节点式分散桶数组+节点链
典型场景需要有序性、区间查询、稳定迭代纯查询、去重、高频查找

一个很典型的选型建议:如果你只是想知道“这个元素在不在集合里”,集合本身十万个元素、查询极其频繁、内存足够,unordered_set确实更快。但如果你需要“找出所有大于x的元素”“找到最接近x的值”“按下标范围遍历”,那set就是唯一合理的选择。unordered_set能做存在性判断,但做不了范围查询,因为元素之间没有序关系。

我自己的习惯是“默认用set,除非性能分析明确告诉我unordered_set是瓶颈的解决方案”。理由很简单:set的可预测性和稳定性省心,而那点性能差距在绝大多数业务场景中根本感知不到。只有当profiler显示哈希查询确实是热点、数据规模在百万级以上、而且不需要有序遍历,我才换unordered_set。

3. 核心API细节与使用要点

3.1 插入操作:insert的四种姿势

set和multiset的insert看似简单,实际上有四种不同的调用方式,各有各的适用场景,写错了要么性能受损,要么代码啰嗦。我逐个说。

第一种,最普通的insert(value)。这个没什么好讲的,直接插入一个元素,返回pair<iterator, bool>(set)或iterator(multiset)。set返回的pair里,second表示是否插入成功——如果你插入的值已经存在,second就是false,迭代器指向已存在的那个元素。这个返回值很多人不接,但它是判断“去重是否生效”的关键。

第二种,insert(iterator_hint, value)。这个带“提示位置”的版本,是set族高效插入的核心。你给一个迭代器作为提示,提示这个新元素应该插在什么位置附近。如果提示给得准,插入是均摊O(1)的,否则退化成O(log n)。怎么给提示?往set里插入一段有序序列的时候,用上一次插入的返回值当提示,就是最优操作。

std::set<int> s; std::set<int>::iterator it = s.begin(); for (int x : sorted_values) { it = s.insert(it, x); // 提示位置插入,O(1) }

这里的逻辑是:插入元素x时,如果x恰好跟在前一个插入的元素后面,提示位置it正好是x应该插入的位置,红黑树就不用从根节点重新找了。这段代码在批量插入有序数据时性能提升非常明显,实测比无提示插入快几倍。

第三种,insert(first, last)。把一个迭代器区间的元素批量插入set。这个版本内部等价于循环调用普通的insert,但代码更简洁。注意一点:如果源区间本身是另一个set,直接构造新set会更高效——直接用std::set s2(s1.begin(), s1.end()),这其实走的是构造函数重载。

第四种,insert(initializer_list)。C++11开始支持直接用花括号列表插入:s.insert({1, 2, 3})。这个没啥性能讲究,就是写法方便。适合测试代码快速造数据。

multiset的insert除了不带bool的返回值,其他完全一样。注意multiset没有“去重”概念,所以任何insert都必然成功,返回值直接是迭代器。

3.2 查找与边界:lower_bound和upper_bound才是灵魂

很多人用set只调find,完全没发挥出它的潜力。find能做什么?判断元素是否存在,返回迭代器。但set是有序的,有序结构最值钱的能力是“边界查询”——找出某个范围的所有元素。

lower_bound(x)返回第一个不小于x的元素位置,upper_bound(x)返回第一个大于x的元素位置。这两个函数配合,就是“查找所有在[a, b]区间内的元素”的标准姿势:

auto lo = s.lower_bound(a); // 第一个 >= a 的元素 auto hi = s.upper_bound(b); // 第一个 > b 的元素 for (auto it = lo; it != hi; ++it) { // 这个区间内的所有元素都满足 a <= 值 <= b }

这个代码的含义很直白:lower_bound找到了区间的左边界,iter从那个位置开始;upper_bound找到了区间的右边界,iter走到那儿停。中间遍历的所有元素,就是在[a, b]范围内的所有元素。整个查找过程是两次O(log n),不是O(n)的扫全表。

还有一个细节:equal_range(x),它等价于lower_bound(x)和upper_bound(x)的组合,返回一个pair,同时给你左闭右开的范围。multiset用的场景更多——你想找出所有等于某个值的重复元素,equal_range就是一次性给你整个区间。

auto range = ms.equal_range(42); // 所有等于42的元素 for (auto it = range.first; it != range.second; ++it) { // 处理每个重复元素 }

另一个常见套路是找“最接近某个值”的元素。set没有lower_bound之外的现成API,但你可以这么做:用lower_bound找到第一个不小于x的元素,它就是候选1;如果它不是begin,那它的前一个元素就是候选2(最大的小于x的元素)。比较两个候选谁更接近x就行。注意处理边界:lower_bound返回end()表示没有不小于x的元素,候选2可能不存在;如果返回begin,候选1是最小元素,候选2不存在。

3.3 erase的三种形式和那个经典陷阱

erase在set/multiset里也有三种形式,每种的行为差别很大,踩坑的人不在少数。

第一种,erase(key)。传入键值,删除所有等于这个键的元素,返回值是删除的元素个数。对set来说返回值只能是0或1,对multiset来说可以大于1。在C++11之前返回void,C++11之后才改成返回size_t,但至今还有老代码在用旧接口,要注意兼容问题。

第二种,erase(iterator)。删除迭代器指向的那个元素。注意:set的erase(iterator)返回void(C++11之前),C++11之后返回下一个元素的迭代器。但multiset和set行为一致。这里有个千古大坑——在遍历中删除元素,迭代器失效。

如果你在一个循环里用it++遍历并删除元素,经典写法是:

for (auto it = s.begin(); it != s.end(); ) { if (need_erase(*it)) { it = s.erase(it); // 返回下一个迭代器,不用手动++ } else { ++it; } }

这里erase返回的迭代器就是“被删除元素的下一个元素”。如果你在C++11之前不能用这个返回值,就得用另一个技巧:先取下一个迭代器,再删当前的:

for (auto it = s.begin(); it != s.end(); ) { if (need_erase(*it)) { auto next = std::next(it); s.erase(it); it = next; } else { ++it; } }

这两种写法都是对的,核心思路一致:删除一个元素后,绝对不能继续使用指向它的迭代器,必须先拿到下一个位置。

第三种,erase(first, last)。删除一个左闭右开区间[first, last)内所有元素。这个通常跟lower_bound/upper_bound配合,做“删除所有在某个范围内的元素”的批量操作,一整个区间瞬间清掉。

还有一个容易坑到人的点:erase(key)删除multiset中所有匹配的元素,这个“所有”是真正意义上的所有。如果你只想删掉multiset中的一个重复元素,必须传迭代器,不能传键值。传键值就全删了。这是个非常隐蔽的行为差异,我在实际项目中就见过有人用erase(key)删一个重复任务,结果把同优先级的十几个任务全干掉了,排查了半小时才反应过来。

4. 自定义类型与比较器:最容易被写崩的地方

4.1 严格弱序到底是个什么东西

set和multiset的排序完全依赖比较器。默认比较器是std::less ,它调用Key类型的operator<。所以自定义类型放进set的第一种方式,就是重载operator<。但这里有个数学上的要求——严格弱序(strict weak ordering)。通俗讲,就是三个性质:

  • 不可比自反:对于任何x,x < x必须是false。
  • 非对称:如果x < y为true,那么y < x必须为false。
  • 传递性:如果x < y为true且y < z为true,那么x < z必须为true。

第三条最难满足,也是各种奇奇怪怪bug的根源。很多人定义operator<的时候只想着“能排序就行”,结果写出来的比较器不满足传递性,set的底层红黑树就会出现“同一个元素插入两次能成功”“查找明明存在却返回end()”“遍历顺序混乱”这类魔幻问题。

举个例子。你要存一个三维坐标点,先存一个比较器:比较x坐标,x相同就比y,y相同就比z。这是满足严格弱序的。但如果为了图省事,比较器写成“距离原点的距离小于”,那就完蛋了——两个不同点可能有相同距离,distance(a, b) = false且distance(b, a) = false,但它们的中间值、传递性完全失控。你插入了点(1, 0, 0)和点(0, 1, 0),理论上它们“相等”(因为距离都是1),但它们的实际坐标完全不同,后续查找、删除的时候你就永远不知道碰到的到底是谁。

还有一个常见错误是只在operator<里比较了部分字段。比如一个Person类,有人id、姓名、年龄,你只按年龄排序。两个不同的人年龄相同,operator<就返回false,set判定它们“相等”,于是只保留一个。这种“隐性去重”问题非常难排查,你表面上感觉set行为异常,实际上根因是严格弱序定义不完整。

4.2 用函数对象或Lambda做比较器

除了在类型里重载operator<,你还可以在创建set时传入独立的比较器,这是个更灵活、更解耦的做法。尤其适合“同一个类型想用多种排序方式”的场景。

函数对象的做法:

struct Point { int x, y, z; }; struct PointLess { bool operator()(const Point& a, const Point& b) const { if (a.x != b.x) return a.x < b.x; if (a.y != b.y) return a.y < b.y; return a.z < b.z; } }; std::set<Point, PointLess> points;

Lambda的做法(C++11起):

auto cmp = [](const Point& a, const Point& b) { if (a.x != b.x) return a.x < b.x; if (a.y != b.y) return a.y < b.y; return a.z < b.z; }; std::set<Point, decltype(cmp)> points(cmp);

注意lambda版本一定要传cmp给set构造函数,因为decltype(cmp)是一个空类型,默认构造出来的比较器没有任何状态,但你依然要显式构造一个实例。decltype提取出来的类型是lambda的闭包类型,每个lambda表达式生成的都是独一无二的类型,你不能用另一个写法和它一模一样的lambda去声明同一个set。

经验补充:比较器内联到set类型参数里,排序逻辑就绑死在容器定义上。如果程序里只有一种排序方式,绑死最方便;如果需要动态切换排序规则(比如按x排、按距离排),那就得用支持运行时比较器的容器,或者维护多个set。

4.3 multiset中“相等”的额外陷阱

multiset允许重复元素,但“重复”的判断标准是什么?是“元素之间互不less”——a < b为false,且b < a也为false。这跟==不同。你说两个Person年龄相同就是“相等”?不行,如果operator<只有年龄字段,那两个相同年龄但姓名不同的人在multiset看来是“相等”的,虽然它们都被保留,但查找、equal_range、count的时候结果会把它们混在一起。

这是一个特别容易让人困惑的点:multiset的“重复”不是数学上的全等值重复,而是“比较器认为等价但不区分”的重复。你可以往multiset里无限制地塞任意多个“等价元素”,它们会在一起,但你是分辨不出具体哪个的。

在实际操作中,解决这个问题的方法是让你的比较器足够“细”,把业务上需要区分的所有字段都比进去。要保留重复就明确写一个能区分它们的主键(比如自增id),不要偷懒用“部分字段比较”。我曾经在一个项目中用multiset做任务队列,任务结构体里包含一个生成序号字段,比较器先比优先级再比生成序号,这样相同优先级的多个任务在multiset里保持着“先进先出”的顺序,一切正常。后来有人优化代码把生成序号字段从比较器里删了,任务调度直接乱套——同优先级的任务被multiset认为“等价”,虽然它们还是会被保留,但顺序完全由红黑树内部旋转决定,不再是插入顺序了。

5. 性能分析与选型考量

5.1 时间复杂度的实际意义

set的所有操作都是O(log n),这个复杂度到底意味着什么?我算个数给你直观感受一下。n = 1,000,000时,log2(n)大约是20。也就是说,即使在百万级数据量的set里做一次查找,也只需要比较大约20次。这个数字非常稳定,不受数据分布影响,不存在“运气差就慢”的问题。

与之对比,vector的线性查找O(n)在百万级数据量下平均需要五十万次比较。这就是为什么“几百个元素用set没感觉,几十万元素就有天壤之别”。但你要注意,set的O(log n)是红黑树上的比较次数,每次比较涉及调用比较器。如果你的比较器本身很重(比如比较两个大字符串、两个复杂结构体),那一次比较的开销可能比整数比较大几十倍。这种情况下,set的实际表现会比理论值差。优化方向有两个:一是让比较器尽可能简单,只比较必要的字段;二是减少比较次数,比如把重的字符串比较换成轻量的id比较。

5.2 内存占用:一个节点的真实开销

红黑树节点的内存布局大约是:数据本身 + 左孩子指针 + 右孩子指针 + 父节点指针 + 颜色标记(通常是一个枚举或bool,但可能有padding对齐)。在64位平台上,三个指针就是24字节,加上数据。如果你的元素本身是int(4字节),一个节点的内存开销大约是(数据内存 + 24 + 对齐填充),少说32字节往上。简单算一下:存一百万个int的set,内存占用约32MB,而存一百万个int的vector只要4MB。差一个数量级。

这是set的“内存税”。如果你的场景是纯查找、数据量又大,unordered_set的内存占用通常更低(它用连续桶数组,虽然是节点链,但桶本身是一块连续内存,而且没有红黑树那种父子针的额外开销)。但如果你需要有序性和稳定的迭代器,这点内存代价是必须付的。

我有一次在嵌入式环境里用set存一批事件对象,内存不够了。排查发现每个节点因为对象本身大(几十字节)加上树指针的24字节,翻了一倍多。最后换成了排序数组+二分查找的方案,因为那个场景是批量加载、只读查询、不频繁插入,排序数组用二分查找也一样是O(log n),但内存连续且没有指针开销,直接砍掉一大半内存。

5.3 什么时候坚决用set,什么时候别用

我跟人聊选型,最常说的判断标准就是三句话。

第一,需要有序遍历、范围查询、或lower_bound这类边界操作,用set,别犹豫。哈希容器根本不支持这些。

第二,实时性要求高、需要时间复杂度有上界保障的场景,用set。哈希表的最坏情况是O(n),虽然平均很快,但万一碰到糟糕的哈希函数或者碰撞攻击,延迟会瞬间爆炸。有状态安全要求的模块,set的可预测性更让人放心。

第三,数据量极大、需要极致查找速度、不需要有序性,而且内存不是瓶颈,用unordered_set并做好哈希函数的调优。set的O(log n)在百万级是20次比较,在亿级是27次比较,其实在绝大多数场景下都不是性能瓶颈。瓶颈往往是内存访问的局部性——set的节点分散在堆里,CPU缓存命中率低,而unordered_set的桶数组是连续的,缓存友好度高。

最后说一个经验之谈:选型不是“哪个快就选哪个”,而是“哪个适合我的约束”。如果你有100万条记录、不频繁修改但频繁查询,vector + sort + binary_search可能才是最优解,因为它内存连续、CPU缓存友好、二分查找也是O(log n)、内存还最低。set的红黑树是动态平衡结构,它的优势在于支持频繁插入删除时依然保持有序和稳定。理解了这个,你才能在各种容器之间做正确的取舍。

6. 常见问题与排查技巧实录

6.1 迭代器和指针到底什么时候失效

这个问题我在项目答疑里遇到太多次了。vector的迭代器失效规则大家都懂,换到set就懵了。我把set的迭代器失效规则总结成几句话:迭代器、引用、指针只要指向的是容器里的某个元素,那么这个元素还在容器里,它们就永远有效。唯一会让它们失效的操作是把那个元素本身erase掉。插入任何新元素、删除其他元素,都不影响已有迭代器。

这个规则直接带来两个实用技巧。

第一,你可以在遍历set的过程中安全地插入新元素。遍历完一轮,新元素可能已经插入或被跳过,但不会导致迭代器错乱、崩溃或死循环。这对很多算法是极其友好的行为。

第二,你可以安全地保存一个set元素的迭代器,跨函数、跨作用域使用,只要你不erase它。比如你有一个长生命周期的配置表存在set里,某个模块拿到一个配置项的迭代器,随时可以去读取或修改其值。但这里有一个需要特别小心的场景——如果你把一个元素的迭代器保存起来,然后在别的地方把这个元素erase了,再使用这个迭代器就是典型的悬垂指针,行为完全未定义。解决办法是:在需要使用这个迭代器的地方,用find从容器里重新查一次,确认它还在。

6.2 想改元素怎么办?先删再插是最干净的

set和map有个共同限制:不能通过迭代器直接修改元素。对map来说不能改key,对set来说不能改值。这其实是设计使然——如果允许直接改set里的元素,你会把红黑树的有序性打破,整个容器就崩了。

有人会尝试const_cast强行改,我劝你千万别在生产代码里这么干。你无法保证这个改动不会破坏排序规则,而且标准库的某些实现甚至会把节点内存放到只读页面上保护起来,写进去直接段错误。

正确的做法是:先erase掉旧元素,再插入新元素。这个操作是O(log n) + O(log n),性能上完全能接受。有一种更微妙的情况是,你要改的元素“跟排序无关”。比如set里存的是一堆记录,排序键是id,但每条记录内部有个score字段,跟比较器无关。你只想改score。这个在set的结构上是可以的——因为你改了不影响排序——但依然不能通过set的迭代器直接改,因为迭代器返回的是const引用。

解决方式还是“copy一份,改完,erase旧的,insert新的”:

auto it = s.find(42); if (it != s.end()) { Record r = *it; // 复制出一份 r.score = 99; // 修改非排序字段 s.erase(it); // 删旧 s.insert(r); // 插新 }

这套流程看着繁琐,但安全干净。C++标准的制定者就是这么设计的,你在set里把元素当不可变对象用,任何修改都需要“取出-复制-修改-重插”四步,这样红黑树的不变量才永远成立。

6.3 几个高频踩坑点速查

我把这几年看到过的和亲身踩过的高频坑整理成一张速查表,每个都有对应的规避方法。

现象根因解决方案
插入后find不到、遍历顺序诡异比较器不满足严格弱序检查operator<的传递性,测试所有等价比对
两个不同对象只保留了一个比较器只比较了部分字段把业务上需要区分的字段全部加入比较器
erase(key)删除了多个重复元素multiset按键删除就是删全部只想删一个用erase(iterator)
遍历中删除元素后程序崩溃erase后使用了失效迭代器使用it = s.erase(it)的返回值,或不返回时先取next
定位插入性能没提升提示迭代器给的位置不准提示位置是“新元素应该插入的位置”,用之前返回的迭代器作为下一个元素的提示
想改set里的值编译报错set迭代器返回const引用复制-修改-删旧-插入的流程
老代码在C++11标准下编译告警erase(key)返回值从void变为size_t升级为统一使用返回值,或者忽略但保证逻辑依赖正确
multiset元素顺序不是插入顺序比较器没包含区分相同元素的因子增加唯一排队编号进比较器

排查这一类问题的通用思路是:先怀疑比较器,再怀疑迭代器,然后才是业务逻辑。set和multiset本身实现成熟,95%以上的“诡异行为”都是使用方定义错了比较器或误用了迭代器。你把这两个方向查清楚,问题基本就水落石出了。

最后再说一个调试技巧:给自定义类型打印的时候,把operator<的判定结果也打印出来,自己手动验证几个关键组合有没有传递性。我之前排查过一个极其诡异的bug,set里明明只有3个元素,find却永远返回end,最终就是靠这样逐步检查才发现比较器有一个分支写错了,导致排序关系是循环的。这类问题一旦定位,修复就是一行代码的事,难就难在“相信根因在比较器”这个判断上。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进