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

发布时间:2026/10/12 19:19:12
C++ set与multiset全解:红黑树底层、核心API与性能避坑 1. 从场景说起什么时候你会需要set和multiset在C里关联容器这块儿set和multiset总被人当成“排序去重”的标配工具。但说实话你要是只知道sortunique可能根本意识不到这俩容器真正的价值。我先说个实际场景你感受一下。之前做一个模拟项目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)。这个没什么好讲的直接插入一个元素返回pairiterator, boolset或iteratormultiset。set返回的pair里second表示是否插入成功——如果你插入的值已经存在second就是false迭代器指向已存在的那个元素。这个返回值很多人不接但它是判断“去重是否生效”的关键。第二种insert(iterator_hint, value)。这个带“提示位置”的版本是set族高效插入的核心。你给一个迭代器作为提示提示这个新元素应该插在什么位置附近。如果提示给得准插入是均摊O(1)的否则退化成O(log n)。怎么给提示往set里插入一段有序序列的时候用上一次插入的返回值当提示就是最优操作。std::setint s; std::setint::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)。C11开始支持直接用花括号列表插入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。在C11之前返回voidC11之后才改成返回size_t但至今还有老代码在用旧接口要注意兼容问题。第二种erase(iterator)。删除迭代器指向的那个元素。注意set的erase(iterator)返回voidC11之前C11之后返回下一个元素的迭代器。但multiset和set行为一致。这里有个千古大坑——在遍历中删除元素迭代器失效。如果你在一个循环里用it遍历并删除元素经典写法是for (auto it s.begin(); it ! s.end(); ) { if (need_erase(*it)) { it s.erase(it); // 返回下一个迭代器不用手动 } else { it; } }这里erase返回的迭代器就是“被删除元素的下一个元素”。如果你在C11之前不能用这个返回值就得用另一个技巧先取下一个迭代器再删当前的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。通俗讲就是三个性质不可比自反对于任何xx x必须是false。非对称如果x y为true那么y x必须为false。传递性如果x y为true且y z为true那么x z必须为true。第三条最难满足也是各种奇奇怪怪bug的根源。很多人定义operator的时候只想着“能排序就行”结果写出来的比较器不满足传递性set的底层红黑树就会出现“同一个元素插入两次能成功”“查找明明存在却返回end()”“遍历顺序混乱”这类魔幻问题。举个例子。你要存一个三维坐标点先存一个比较器比较x坐标x相同就比yy相同就比z。这是满足严格弱序的。但如果为了图省事比较器写成“距离原点的距离小于”那就完蛋了——两个不同点可能有相同距离distance(a, b) false且distance(b, a) false但它们的中间值、传递性完全失控。你插入了点(1, 0, 0)和点(0, 1, 0)理论上它们“相等”因为距离都是1但它们的实际坐标完全不同后续查找、删除的时候你就永远不知道碰到的到底是谁。还有一个常见错误是只在operator里比较了部分字段。比如一个Person类有人id、姓名、年龄你只按年龄排序。两个不同的人年龄相同operator就返回falseset判定它们“相等”于是只保留一个。这种“隐性去重”问题非常难排查你表面上感觉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::setPoint, PointLess points;Lambda的做法C11起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::setPoint, 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字节加上数据。如果你的元素本身是int4字节一个节点的内存开销大约是数据内存 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引用复制-修改-删旧-插入的流程老代码在C11标准下编译告警erase(key)返回值从void变为size_t升级为统一使用返回值或者忽略但保证逻辑依赖正确multiset元素顺序不是插入顺序比较器没包含区分相同元素的因子增加唯一排队编号进比较器排查这一类问题的通用思路是先怀疑比较器再怀疑迭代器然后才是业务逻辑。set和multiset本身实现成熟95%以上的“诡异行为”都是使用方定义错了比较器或误用了迭代器。你把这两个方向查清楚问题基本就水落石出了。最后再说一个调试技巧给自定义类型打印的时候把operator的判定结果也打印出来自己手动验证几个关键组合有没有传递性。我之前排查过一个极其诡异的bugset里明明只有3个元素find却永远返回end最终就是靠这样逐步检查才发现比较器有一个分支写错了导致排序关系是循环的。这类问题一旦定位修复就是一行代码的事难就难在“相信根因在比较器”这个判断上。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询