ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

C++ std::set实战:红黑树有序容器的高效使用与避坑指南

C++ std::set实战:红黑树有序容器的高效使用与避坑指南

年初帮一个刚入职的同事review代码,他处理一批订单号去重时用了一个vector加嵌套循环,三百万条数据在低端机器上跑了四秒多。我让他换成了std::set,一行改动,耗时降到几十毫秒。他很惊讶,问我set容器到底做了什么。其实这个场景背后,藏着一整组关于set容器的核心特性:有序性、唯一性、红黑树底层结构、迭代器稳定性、以及那些容易出问题的自定义类型比较和修改键值难题。

这篇文章不是来讲教科书目录式的内容,而是想把我在实际项目里对std::set的使用经验、踩过的坑、面试中被追问过的点,一次性完整地拆给你看。适合正在学C++的初学者,也适合写了好几年C++但一直在“会用不等于用对”边缘挣扎的兄弟。核心围绕set容器,但也会和unordered_set、vector手动去重做横向对比,方便你在真实工程里做选型。

1. set容器被误用最多的三个场景:去重、排序与集合运算

1.1 去重只是起点,有序性才是set真正值钱的地方

很多人认识set就是从“去重”开始的:插入重复元素时,set会自动忽略。这个功能在面试里几乎必问,但是实际工程里,单纯为去重而用set的情况其实并不多,因为unordered_set去重更快,连排序都省了。那set的不可替代性在哪里?核心是“去重的同时保持有序”。

我举个很典型的场景:一个实时订单系统,需要持续维护当前在售的SKU列表,要求列表里没有重复、同时按照价格从低到高展示。如果每次新到一批数据就用vector去重再sort,数据量小还好,数据量大就是灾难。改用set后,每来一条数据就insert一次,内部的红黑树会在O(log n)时间内找到插入位置,既保证了唯一性又保证了有序性,完全省掉“去重+排序”两步操作。哪怕是需要间隙性输出,直接遍历set就已经是排好序的。

我建议你在设计接口时,如果业务同时要求“唯一”和“有序”,第一反应不要去想sort+unique,而是想想set能不能直接顶上。很多同事去重时用vector,排序时又用vector,维护两套结构,其实一个set就能把这件事吃下来。

再补充一点,std::set默认按升序排列,但是可以通过模板第二参数指定比较方式,比如std::set<T, std::greater<T>>就是降序。更关键的是,这个有序性不只是“遍历有序”,还包括范围查找:lower_bound、upper_bound、equal_range,这些操作在红黑树的天然支撑下都是O(log n)。这些接口只在有序容器里才有意义,unordered_set完全提供不了。

1.2 集合运算:set的隐藏技能

第一次看到std::set_intersection、std::set_union、std::set_difference这几个算法时,我愣了一下:原来标准库早就帮你封装好了集合运算。它们的使用前提就是“两个容器都已经有序”。如果用的是vector,你得先sort;如果用的是set,你什么都不用做,直接传迭代器就行。

举个例子,两个集合A和B,要找出A里有但B里没有的元素:

std::set<int> a{1, 2, 3, 4, 5}; std::set<int> b{3, 4, 5, 6, 7}; std::vector<int> result; std::set_difference(a.begin(), a.end(), b.begin(), b.end(), std::back_inserter(result)); // result: 1, 2

这里有个实际的坑:set_difference的返回迭代器指向输出容器的末尾,用back_inserter没问题,但如果你传的是result.begin()并且result没提前分配空间,会越界。另一个坑是,这几个算法要求输入区间必须是“已经排好序的”,如果源容器是unordered_set,千万别直接拿来用,否则结果是未定义行为。

除此之外,还有std::set_symmetric_difference(对称差集),以及调用次数不那么多的std::includes判断子集关系。这些算法在处理权限集合、标签集合、配置过滤器的时候非常实用。我用set做集合运算的最大体会是:它把“数学里的集合”直接映射到了代码里,你几乎不需要关心怎么排序,set已经天然满足算法的前提。

1.3 动态有序缓冲区:set比“每次sort”更合适的场景

还有一种常见需求:数据是一个一个流式到达,每次到达后我希望取当前数据里的中位数、top K或者某个区间。如果每次都把数据塞进vector再sort,时间复杂度就成了O(n^2 log n)级别的,显然不划算。set在这种情况下可以充当一个动态有序缓冲区。

比如维护一个滑动窗口,不断有元素进入和离开窗口,但始终需要按key顺序访问窗口内容。用set删除旧元素、插入新元素,每次操作都是O(log n),而当需要访问第k小元素时,虽然set不支持随机访问(std::advance是O(k)),但经常可以配合维护两个set分别存前半和后半,来高效求中位数。这个“双set求中位数”的技巧在实际业务里出现过,面试里也常考。

这里非常值得区分一下:set和priority_queue不一样。priority_queue只保证堆顶有最大或最小,不支持任意删除、也不支持迭代遍历,更不能说按顺序访问全套元素。当你不仅要最大最小,还要“随时能按顺序完整遍历”时,set的平衡树结构就体现优势了。

2. map与set的底层是同一棵树:红黑树特性和迭代器稳定性

2.1 红黑树是set的幕后功臣

C++标准没有强制要求到底用哪种平衡二叉树实现std::set,但几乎所有主流标准库实现都用了红黑树(Red-Black Tree)。为什么选红黑树而不是AVL树?一个重要原因是红黑树的平衡条件更宽松,插入删除时需要做的旋转次数更少,整体性能更均衡,而AVL树平衡更严格,查询更快但是插入和删除的代价更高。在实际工程中,set的插入删除和查找是混合出现的,所以关注插入删除性能的红黑树最终赢了。

红黑树的核心约束有五个,我背得很熟:每个节点非红即黑;根节点是黑色;所有叶子节点(空节点/NIL)都是黑色;红色节点的子节点必须为黑色(也就是不能有连续的红色);从任一节点到其每个叶子的所有路径包含相同数目的黑色节点。正是这些约束保证了从根到叶子的最长路径不会超过最短路径的两倍,从而保证最坏情况下搜索复杂度是O(log n)。

在实际使用时,你不需要手写红黑树,但理解这个底层很有用——它能解释另一个重要特性:为什么set的插入、删除、查找都是对数复杂度,而不是常数也不是线性。很多用户拿set和vector对比时觉得set慢,其实只有当数据量极小时vector的线性查找才更快,一旦数据量上千,树结构的好处立刻体现。

2.2 迭代器稳定性:set在并发修改中最让我安心的特性

所谓迭代器稳定性,是指当容器插入或删除操作发生之后,指向其他元素的迭代器、引用和指针是否依然有效。std::set的答案是:有效。因为set的每个元素都是独立的堆节点,删除某个节点只影响被删除元素自己的迭代器,并不会移动任意其他节点,这和vector完全不同——vector插入元素导致扩容后,所有迭代器都失效。

这一点在工程中非常重要。比如我用std::set保存了若干待处理任务的引用(或索引),在处理某个任务时可能删除其他任务,此时我只需要保证被删除元素的相关游标不继续使用即可,其余游标都能安全访问。如果换成vector,一次删除可能会导致后续迭代器全部挂在野指针上,除非每次重头再找。

当然,也不是说set的迭代器就完全无懈可击。这里有一个经常被忽略的点:删除某个元素之后,指向这个元素的迭代器会失效,这是必然的。所以标准的删除循环要非常小心:

for (auto it = s.begin(); it != s.end(); ) { if (should_delete(*it)) { it = s.erase(it); // C++11 返回下一个迭代器 } else { ++it; } }

这个写法我踩过好多次:旧编译器可能erase只返回void,你还需要手动it = s.erase(it++),而现在C++11之后erase(iterator)返回下一个有效迭代器,这种写法更清晰,也更不容易出错。

2.3 节点内存开销:set“吃内存”的真相

set的每个元素不是连续存储在数组里的,而是单独分配为一个红黑树节点。一个节点通常会包含:左孩子指针、右孩子指针、父指针、颜色标记,以及实际存储的元素。在64位系统上,光这三个指针+颜色就可能占用32字节或更多,再加上元素本身的对齐和填充。相比vector元素本身只占存储元素的那点空间,set翻三到四倍很正常。

所以如果你有一个超大的数据量且内存很紧,用set前要三思。我有一次给一个数据处理程序优化内存,发现set占了将近600MB,换成排好序的vector后直接降到180MB。这种场景下,我会改用一次性排序后去重的方案,或者干脆用flat_set这类外部实现的扁平容器。标准的std::set虽然好用,但它绝对不是“最节省空间”的选择。

这也能解释为什么std::set的元素类型必须是可拷贝或可移动的——因为每个插入都会在堆上构造一个节点对象,如果自定义类型内部有大量资源,那拷贝开销不能忽略。下一节我会专门讲怎么避免无意义的拷贝。

3. set的插入删除与查找:如何省掉那层隐式拷贝

3.1 insert的返回值:不只是告诉你成没成功

std::set::insert的返回值是std::pair<iterator, bool>,其中iterator指向插入的元素,或者已存在的重复元素;bool表示插入是否真的发生了。很多初学者只喜欢用bool,忽略了那个迭代器。但工程里面,这个返回值经常可以被复用:比如插入后想原地修改一个map的value,或者想统计重复元素的出现次数。

我在优化一个计数逻辑时,就用这个返回值避免了一次重复查找:

std::map<std::string, size_t> count; for (auto& s : words) { auto [it, inserted] = count.emplace(s, 0); if (!inserted) { ++it->second; } }

这里的emplace和insert类似,而emplace更推荐的地方在于:它可以直接在节点内部构造元素,省掉一次临时对象的移动或拷贝。比如你插入的是一个std::string,s.insert(anotherString)会先从anotherString拷贝构造出一个临时值,再移动到set节点中;而s.emplace(anotherString)参数转发后,理论上可以避免多余的移动构造。

不过注意一点,emplace并不保证一定不拷贝。如果传入的参数是左值且没有重载原地构造逻辑,可能还是照拷。别太迷信emplace,一定要针对自己的类型用性能工具测一下。

3.2 自定义类型的插入:减少拷贝的三个实用手段

第一个手段是尽量使用emplace,让set直接在节点内存里构造对象。第二个手段是为自定义类型提供高效的移动构造函数,并把大对象内部的数据用unique_ptr或者vector管理,让移动成本远低于拷贝。第三个手段是如果插入的对象确实需要长期生存,并且创建的时候产生的数据无法原地构造,那就得考虑存储轻量级句柄(比如ID),而不是直接把整个大对象塞进set。

这里有一个许多教程很少提的细节:set在插入已经存在的元素时,依然会尝试构造一个临时节点,然后被红黑树发现重复后释放掉。也就是说,如果你插入一个拷贝成本极高的对象,即使重复插入key相同的内容,也会白白付出高额构造和析构代价。解决办法是,先调用set.find(key)判断是否存在,如果不存在再emplace,这样重复插入场景下可以完全避免临时构造的开销。当然,这样在多线程环境下会有竞态窗口,但在单线程数据处理中非常实用。

3.3 erase的返回值:C++11前后差别很大

在C++11之前,erase(iterator)返回void,这会让你在循环中删除元素时必须用it = s.erase(it++)这种传统技巧,既丑又容易写错。C++11之后,标准库对顺序容器和关联容器都改了,erase返回下一个迭代器,循环就变得清爽:

auto it = s.begin(); while (it != s.end()) { if (cond(*it)) { it = s.erase(it); } else { ++it; } }

erase(key)版本会返回删除的元素个数(0或1),因为set里key唯一,所以返回size_t。这个返回值在检查元素是否存在且被删除时非常直接:如果你以为删除了一条,实际返回0,说明你连key都拼错了。

另外,删除元素也有陷阱:如果set里存的是裸指针,你仅仅是erase掉指针没有delete,那就泄漏了。如果set存的是unique_ptr,删除节点时智能指针会自动释放对象,所以推荐用智能指针保存堆对象。下面我会单独讲指针set的坑。

3.4 查找:set::find比std::find快多少?

很多刚接触set的人会在set上调用std::find(s.begin(), s.end(), value),这样也能工作,但它是顺序遍历,时间复杂度O(n),完全浪费了红黑树的对数能力。正确做法是s.find(value),O(log n)。这个差异在十万级元素上可能只是数十毫秒和数毫秒的差别,但如果操作频繁,放大到线上就是几秒和几毫秒的差距。

set::lower_bound、upper_bound、equal_range同样值得掌握。当你想找“比某个key大一点的最小元素”时,lower_bound就是神器。这个操作在vector上除非预排序否则无法高效完成,因此set在这个维度上优势明显。

4. set的坑:自定义类型比较、指针集合与修改键值难题

4.1 自定义类型必须满足严格弱序

这是set使用中翻车率最高的一类问题。std::set默认用std::less<Key>,也就是对元素执行operator<。这个比较操作必须满足“严格弱序”(strict weak ordering)三个条件:不可比等于等价(如果a<b为false且b<a为false,那么a和b等价);传递性;以及比较结果在插入后不改变。

举一个常见反例。一个商品类,你只根据价格做排序:

struct Product { std::string name; double price; // 错误:只按price比较 bool operator<(const Product& other) const { return price < other.price; } };

这样两个不同名字但价格相同的商品会被set判定为“等价”,后插入的那个会被丢弃。更严重的是,如果需求允许价格变动(比如price被外部修改),那就会直接破坏红黑树的不变量,后续查找、删除行为全部不可预期。

也不能写“怪异比较器”,比如“a<b如果a和b的某字段相等则返回false”,这看起来没问题,但是如果你在多个线程里共享同一个set并同时修改对象内部用于比较的字段,就会导致树结构腐坏。基本原则是:key字段一旦插入set,就不应该被修改,所有可变字段要么不作为比较依据,要么封装成不可变值。

4.2 指针集合的三大坑

第一种坑:默认按指针地址排序。如果set里存的是Foo*,你明明想按Foo的某个属性排序,但因为默认比较的是指针本身,所以顺序完全不是你想要的。解决办法是提供自定义比较器,比如struct FooPtrLess { bool operator()(const Foo* a, const Foo* b) const { return a->id < b->id; } };,然后把std::set<Foo*, FooPtrLess>作为类型。

第二种坑:内存管理。set的析构只会释放节点自身,不会delete裸指针指向的对象。你删除元素也不管对象释放,所以裸指针set几乎必然造成泄漏。解决方案:用std::unique_ptr或std::shared_ptr存进set,或者用一个专门的RAII容器统一持有所有权。

第三种坑:查找时临时指针的构造。如果你用std::set<Object*, comp>,并且comp需要解引用比较对象内部字段,那么set.find(key)传入的临时指针必须真的指向一个构造完整且字段有效的对象,否则比较器会访问未定义内存。这种情况下我优先使用异质查找接口(C++14的透明比较器),避免临时构造对象。

4.3 修改“键值”前必须erase

set的迭代器返回的是const T&,这是刻意设计的:因为红黑树的有序性完全建立在元素的排序key不变这一前提上,如果允许通过迭代器直接修改key,树结构会瞬间失序,那么所有查找、上下界、集合运算全部失效。所以标准库把*it设计成了const引用,不允许你直接改。

但是实际业务中常常需要“以某个字段为key,其他字段为value”这样的结构。这里两个选择:一是改用std::map<Key, Value>,key保持不可修改,而value可以通过迭代器修改,因为value不参与排序;二是真要用set,就得按老办法:取出对象、erase、修改对象(或构造新对象)、再insert。千万别想着用const_cast绕过const,相信我,你这是给自己埋雷——即使真改成功了,红黑树的排序key已经变了,插入位置和查找逻辑全部对不上。

我在写一个“优先级任务调度器”时,任务处理完需要更新优先级,当时偷懒用了const_cast直接改迭代器引用的key,后面连续半小时排查不出来为什么find偶尔找不到明明存在的元素。最后老实改成erase后重新emplace,一行代码的事,问题立刻消失。所以,正确心态是:set中的key是不可变“身份证”,要改身份证就得换张新的,而不是在树上偷偷涂改。

4.4 比较器抛出异常与未定义行为

很多人的自定义比较器不会考虑异常安全。如果operator<或者仿函数执行过程中抛出了异常,红黑树内部状态会处于不一致状态,标准库对此并没有强保证。最容易出问题的就是在比较器里访问网络或依赖全局可变状态。真实代码里可能是在比较器里打日志导致文件写入失败抛异常,然后整个程序崩溃都查不出原因。

我的建议是:比较器里只做纯计算,不要访问外部资源;如果确实需要线程安全或外部条件,提前在外面算好key再进比较器。这个习惯能避免相当一部分“偶发性crash”。另外,还要警惕“非对称”的比较器:a < b和b < a结果不一致(两个都返回true或两个都返回false),红黑树旋转时会无限挣扎直到栈溢出。这种错误很隐蔽,只能靠充分的单元测试去覆盖。

5. 实测对比:set与unordered_set、vector手动去重的性能与取舍

5.1 一个可控实验:一百万个随机整数去重

为了帮团队定规范,我在本地跑过一组简单但可信的数据:随机生成100万个取值在0到499999范围内的整数,然后分别使用vector + sort + unique、std::set、std::unordered_set进行去重,测量耗时和内存(机器为i5-12400,内存16G,MSVC x64 Release)。以下是大致数据:

方案耗时(毫秒)内存占用(近似)结果是否有序
vector + sort + unique1104MB左右(额外无)有序
std::set420高于vector 3-4倍有序
std::unordered_set130高于vector 2-3倍无序

注意这个数据里vector+sort+unique居然比set快不少,因为vector一次性拷贝到连续内存,cache友好度极高,sort又经过精心优化。而set每一次插入都走堆节点分配,内存不连续,大量随机插入导致延迟。这个结果让我明确了一件事:静态数据去重时,vector+sort+unique永远是第一选择;set适合的是“去重+动态维护有序序列”的场景,不是为了快而选它。

unordered_set在去重这件事上的速度追平了vector方案,但是结果无序,且需要提供hash。如果后续需要排序,还要额外的sort开销。哈希函数质量差时还会触发严重的rehash,导致尖峰。

5.2 为什么说set的“快”是分场景的

常听人说“set查找快”,这个说法在纯内存静态数据面前不一定成立。原因是set节点离散在堆上,遍历时cache miss严重;vector连续存储,数据量不大时cache把连续区间全部预取,哪怕O(n)也经常比树O(log n)更舒适。我建议这样表述:set的优势在于“单次查找、插入、删除的复杂度稳定为O(log n)”,并且支持范围查找和上下界追溯。当操作分布均匀且数据量极大时,set的稳定性能扛住worst-case;但是数据量只有几千个元素且操作频率低时,vector可能反而更快。

所以在项目里定技术选型时,不要一上来就迷信“平衡树更快”。先问:是一次性构建还是动态维护?是否需要有序?是否支持高效按范围查询?需要什么样的迭代器稳定性?答案组合出来后,选择自然浮现。

5.3 工程选型实践建议

我总结了自己在团队里常用的选型表,也会写进代码评审规范里:

需求特征推荐容器理由
一次性给出数据,去重并排序输出vector + sort + unique连续内存、cache命中率最高
持续流式插入,需要随时有序遍历std::set保持O(log n)插入和有序
只需要去重,不关心顺序std::unordered_set均摊O(1)查找/插入,最快
需要按键范围查询std::set / std::maplower_bound/upper_bound支持范围
海量只读数据,内存敏感sorted_vector / flat_set(Boost及C++23 flat_set等价方案)连续存储,内存占用小,还支持二分查找
key不可变、value可变std::map不破坏排序,value可直接修改
优先级动态变化std::set协同remove+insert改写键值需要erase+insert

额外提一句,std::set也没有operator[],因为它没有“值”的概念,只有一个键。如果你访问某个不存在的key,不像map那样可以用operator[]插入默认值,你只能用find主动判断。这是set和map的另一个明显差异。

5.4 unordered_set的哈希函数陷阱

如果你的key是自定义类型,用unordered_set就得提供std::hash<T>的专门化。很多初学者直接用std::hash<std::string>没问题,但自定义struct如果直接hash<T>{}(t)会编译错误。正确的做法是组合成员哈希,比如:

struct Hash { std::size_t operator()(const Point& p) const { size_t h1 = std::hash<int>{}(p.x); size_t h2 = std::hash<int>{}(p.y); return h1 ^ (h2 << 1); } };

要注意的是,哈希函数必须和相等判断一致:两个相等的元素必须有相同的哈希值,否则查找会失败。而set只需要比较器一致,实现更简单一些。这也是我有时候宁愿用set做比较复杂key的原因——少写一个哈希函数,少一份出错机会。

6. 面试与工程中关于set的高频追问和我的应对经验

6.1 最常见的六连问,每问都要能拆开讲

作为面试官,我在C++方向考察候选人时,经常会顺着set往下问,因为这能快速判断对方是背了八股还是真的理解容器模型。常见问题包括:

一,set和unordered_set底层的区别。标准答案是红黑树vs哈希表,时间复杂度O(log n) vs 平均O(1),内存模型节点树vs桶数组。但更深入的可以谈:set支持有序遍历、范围查找;unordered_set不支持,但哈希遍历时顺序完全随机。二,为什么set的迭代器返回const元素?前面已经解释过,这是由有序性保证决定的,不应该通过迭代器修改key。三,set支持随机访问吗?不支持,set迭代器是双向迭代器,无法跳跃,例如要访问第k个元素只能std::next(begin, k),复杂度O(k),不是O(1)。四,set用什么比较器?默认std::less<Key>,使用operator<实现严格弱序。五,set的insert返回值是什么?pair<iterator,bool>。六,set和map谁更常用?工程上map经常更常用,因为业务数据往往需要key-value而非仅key,但它们底层一致。

6.2 如何把面试答案讲出经验感

我面试见过两类候选人:一类把文档背得滚瓜烂熟,但当我问“如果你的set中存的是10万个自定义对象,写操作非常频繁,如何设计比较器来减少开销?”就卡壳了。另一类能结合业务说:比较器不用参与所有字段,只取一个稳定的ID字段,并且确保该字段在对象生命周期内不变。我明显更欣赏后者。

因此,如果你在准备面试,建议自己动手写一个小程序:创建10万随机字符串,分别用set和vector来维护有序集合,打点耗时代码,感受实际差异。面试过程中提到“我实测过,vector+sort在静态场景下可能比set快3倍”,会比单纯背“平衡树O(log n)”加分很多。这也是一个资深工程师应该有的敏感度。

6.3 我自己踩过的坑,以及现在如何避坑

第一坑:把set当“可随时修改的普通容器”。我在一个缓存层用set保存对象的索引字段,结果发现对象在别处被改了字段,然后我调用find怎么也找不到,网上查半天才意识到是修改破坏了树序。从那以后,我坚持在结构体设计时,把所有参与set排序的成员标记为const,或至少禁止外部直接set。

第二坑:忘记清空指针set。当时写完代码跑了两个月内存持续上涨,用性能分析器一查,全是set节点和裸指针对象的堆分配。后来我把裸指针改为std::shared_ptr,并给set加上自定义删除器,问题解决。原则:谁持有生命周期,谁负责释放。

第三坑:在多线程中共享set但没有锁。set本身不是线程安全的,两个线程同时插入会导致红黑树节点指针错乱,程序偶尔崩溃。现在我的习惯是:尽量使用线程本地set,需要全局共享时,用std::mutex保护所有操作,或换成std::shared_mutex允许并发读。

第四坑:忽略自定义节点的内存碎片。set在高频插入删除时,会产生大量节点申请释放,内存碎片率上升。如果程序长期运行且内存要求严格,我会考虑对象池优化节点分配,或改用unordered_set加链表快照的方案,看具体需求平衡。

6.4 代码评审中常见的set相关反模式

我平时做code review时,看到set相关代码会重点看这几个点:

  • 是否在热路径上使用了std::find(vec.begin(), vec.end(), key),而其实只需要vec被排序+lower_bound或使用set?
  • 是否在set中保存了裸指针,却没有任何一条路径负责delete?
  • 是否用set做去重,却从未利用有序性,这时候换成unordered_set会不会更好?
  • 是否用const_cast修改set元素?一旦看到这种写法,我会直接要求重写。
  • 是否在for循环里不断调用container.erase(it++)这种旧写法?现在都推荐it = container.erase(it)。

每次评审这些点,我都能帮同事提前躲开好多线上事故。set本身不复杂,但“用对场景+用对方法”才是进阶的关键。

我个人在实际工作中,现在已经形成了一套固定的“set三问”:第一问,我需不需要保持元素有序?不需要就考虑哈希;第二问,数据是一次性构建还是持续动态变化?一次性构建优先vector+sort+unique,动态变化再考虑set;第三问,我是否能保证参与排序的键值永不变?如果能,才放心写下std::set。这套三问问完后,选型基本不会走偏。

最后分享一个小技巧:如果你要维护一个有序集合,但是又希望减少动态分配带来的开销,可以预先用vector收集数据,sort+unique之后,再用std::set<Key, Compare, Allocator>配合自定义池化分配器,把节点内存统一管理。这样你可以既享受红黑树的动态特性,又回避堆分配瓶颈。我自己在好几个高吞吐服务里都这么干,效果非常稳定。

RELATED READING

延伸阅读

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