第一次翻开 set 源码的时候,我先去<set>头文件里找实现,结果有点意外:set 本体非常薄,真正干活的红黑树、分配器、迭代器全在底层容器里。所以严格说,读 set 源码读的不只是 set 这个外层容器,而是读一棵红黑树是怎么被封装成有序集合的。
如果你已经开始做 STL 源码阅读,或者刚把std::set用熟练了,想搞清楚里面到底发生了什么,这篇很适合你。我会从数据结构选型、节点布局、插入删除路径、迭代器行为一路讲到最后怎么用实验验证结论,不追求把每一行源码贴出来,而是把最关键的设计思路和容易踩的坑讲透。
1. 先把思路定下来:读 set 源码到底在读什么
1.1 为什么把 set 当成源码阅读的第一站
很多人一上来就啃std::vector,但 vector 的内存管理其实很琐碎,扩容、析构、搬移都是细节活。set 反而是更好的起点,因为它背后有清晰的数据结构支撑——平衡二叉搜索树,大多数实现里都是红黑树。红黑树算得上数据结构里“难度适中但非常有用”的典型:树结构简单,但平衡维护套路固定,源码里的旋转、变色逻辑一眼能认出对应哪个经典场景。
另外,set 的对外接口足够小,头文件里不会出现几百个重载函数让你劝退。常见标准库实现里,set 本体通常就是包装了一个底层有序树容器,等于把难点都集中在“树”这一层。读懂 set,后面再看 map、multimap、multiset 就是同一棵树的四种姿势。
1.2 阅读时应该盯住哪几个关键文件
阅读源码的时候,不建议只盯<set>这个头文件。确实,std::set类模板就在里面,但你会看到代码很快跳转到某个底层树容器的定义。这套底层容器是独立实现的,名字基本能看出和“树”有关,头文件命名也常带 tree 字样。
你应该重点关注这几个部分:
- 树节点定义:颜色、左右子节点、父节点、值字段。
- 树容器主体:插入、删除、查找、清空、复制控制。
- 迭代器:自增、自减、解引用、比较。
- 比较器与分配器:如何被传递并影响容器行为。
set 的外层代码反而可以放到最后读,因为你真正想弄清楚的红黑树性质、旋转、平衡修复全在底层。阅读顺序上,先看节点,再看树,最后回头看 set 的封装,思路最顺。
1.3 set 的三个设计约束
set 之所以是 set,靠三个约束撑着:元素唯一、元素有序、元素不可修改。
元素唯一依靠比较器判断等价,而不是operator==。元素有序依靠严格弱序,存储期间一直在红黑树里保持中序有序。元素不可修改体现在迭代器类型上:set 返回的是const_iterator,即使拿到非常量迭代器,解引用得到的也是常量引用,防止你通过迭代器改坏树结构。
这三个约束分别对应源码里的三类代码:插入前的查重、插入时的比较定位、迭代器定义时的 const 修饰。读源码时带着这三个约束去对应,效率会高很多。
2. 数据结构基础:set 里藏着一棵红黑树
2.1 为什么红黑树能从那么多平衡树里胜出
红黑树是近似平衡的二叉搜索树,核心性质有五条:每个节点非红即黑;根是黑的;每个叶节点(空节点)是黑的;红节点的两个子节点都是黑的;从任一节点到其每个叶子节点的所有路径都包含相同数目的黑节点。
正是最后这个“黑高一致”,保证了红黑树最长路径不会超过最短路径的两倍,所以树高是 O(log n),查找、插入、删除都能保持对数级复杂度。
那为什么不用严格平衡的 AVL 树呢?AVL 对平衡要求更苛刻,查找确实更快一点,但插入删除后为了恢复平衡,旋转次数显著更多。set 这种容器最看重的是“频繁插入删除、偶尔查找”的综合性能,红黑树的旋转次数更少,摊还下来更划算。加上红黑树删除时的修复逻辑在工程上相对可控,很多通用关联容器就都选了红黑树。
2.2 节点布局里藏着的哨兵设计
先看节点,你会发现源码里没有一个叫“空指针”的东西出现在显眼位置。大多数实现会有一个 header 节点专门做哨兵,根节点的父指针、最左节点的左指针、最右节点的右指针都会连到这个哨兵上。这个设计不是为了炫技,是为了让迭代器判断“是否走到 end()”变得特别简单:离树根最远、最右那个节点的右指针指向 header,而 header 本身又可以被当作 end 迭代器来用。
哨兵带来的好处体现在迭代器自增和自减上。传统二叉树的遍历必须判断节点是否为空,有哨兵以后,走到边界时直接通过父指针连回 header,代码里不用到处写空指针判断。你对it != s.end()这种比较习以为常,但背后的迭代器比较其实就是在比较节点地址,而 end 就是哨兵地址。
节点结构本身通常包含五类字段:颜色标记、左孩子指针、右孩子指针、父指针、值对象。有些实现为了内存紧凑,会把颜色塞进某个指针的低位,但绝大多数标准库走的是清晰路线,宁可多占一点空间,让代码可读性更高。
2.3 底层树节点为什么存的是非 const 值
这里有个有意思的细节:set 对外说“值不可修改”,但底层树节点里存的值其实不是 const。原因是同一套底层树容器还要被 map 使用,map 的节点里除了键还有映射值,修改映射值是合法操作。
所以底层节点统一按可变值设计,set 在封装时通过迭代器的 const 性质把“写操作”挡在外面。这种分层设计非常典型:底层容器追求通用和高效,外层容器负责语义约束。源码阅读时能看到这种思路,比背一个具体函数签名有价值得多。
3. set 本体与迭代器的设计
3.1 set 之所以薄,是因为它只是壳
set 类模板本身的成员函数大多只有一行:调底层树容器。构造函数负责创建空树,insert转发给底层插入函数,find转发给底层查找函数,size直接取树的大小。这层封装的意义不是额外做运算,而是限制接口、修饰返回值类型。
比如底层树的insert返回的可能是一个通用的插入结果,set 需要把它包装成pair<iterator, bool>,同时让迭代器类型变成 const 迭代器。再比如erase的返回值,底层树可能直接返回删除数量,set 要保证这个语义对外可见。
set 没有继承底层树,而是持有它。这种组合而非继承的设计,避免了把树的全部内部函数暴露给用户,也让 set/map 这些容器各自拥有独立的对外接口,互不污染。组合在源码里表现为一个成员变量,名字一般是底层树类型,构造时同步初始化比较器。
3.2 迭代器的自增和自减到底怎么走
红黑树的中序遍历就是从小到大遍历。迭代器的operator++实现逻辑固定:如果当前节点有右子树,下一个节点是右子树的最左节点;如果没有右子树,就沿着父指针向上走,走到“自己是父节点的左孩子”为止,那个父节点就是后继。
写出伪代码就是这样:
operator++() { if (node->right != 哨兵) { node = node->right; while (node->left != 哨兵) node = node->left; } else { while (node == node->parent->right) node = node->parent; node = node->parent; } }如果你熟悉二叉树遍历,这套逻辑一眼就能看懂。但源码里看起来乱,因为到处都是哨兵判断、父指针判断。真正需要记住的是,单次自增最坏是 O(log n),但整棵树遍历一轮的所有自增摊还下来是 O(n),也就是平均每次自增 O(1)。
自减是自增的镜像,不再细说。由于 set 的迭代器不是随机访问迭代器,std::next(s.begin(), 100)这种操作会走 100 次自增,别傻傻以为能 O(1) 跳过去。
3.3 迭代器失效规则值得背下来
对于 set 来说,插入元素不会让已有迭代器失效,删除元素只会让“指向被删元素”的迭代器失效,其他迭代器仍然有效。这个特性和 vector 完全不同,也是源码里树结构带来的天然优势。因为每个元素是独立节点,内存地址没有连续性,插入删除不会搬动其他节点。
代价是你不能像 vector 那样用下标访问。源码里 set 不提供operator[],底层树也没有随机访问接口。想取得第 k 小元素,只能靠迭代器一步步走过去,这些行为都在源码设计之初就定死了。
4. insert 的全流程:查找、插入、变色与旋转
4.1 从 insert 接口到底层插入函数
set.insert(x)对外是人畜无害的一行调用,内部路径却值得走一遍。常见实现的底层树里,核心插入函数叫类似_M_insert_unique的东西,含义是“插入唯一键”。它会先按照比较器在树里查找位置,如果发现等价的键已经存在,直接返回pair<迭代器, false>,不做任何修改。
如果没找到,就新建节点,把节点连接到叶子位置,然后调用平衡修复函数。这里的“查找”不是单独走一遍find再插入,而是一边下树一边记录插入位置,最后把新节点挂上去。这样省了一次完整搜索,效率更高。源码里你会看到一个庞大的循环,循环里反复比较键值,向左或向右移动,同时记录当前节点和父节点,这个循环结束的位置就是新节点的安家之处。
返回值也很讲究:pair的第一个元素指向树中已存在的键或刚插入的新节点,第二个布尔值表示是否真的插入成功。这种返回值设计让用户既能判断结果,又能拿到元素位置,不必再调用find查一次。
4.2 插入后的红黑修复套路
新节点默认涂红。为什么涂红?因为红色不会影响黑高,插入到黑色父节点下不会破坏黑高一致,只需要处理“红节点的孩子不能是红”这条规则。如果新节点的父节点是黑色,插入直接结束。只有父节点也是红色,才需要进入修正循环。
修正循环处理三个典型情况,理解了这三个情况,红黑树源码的“天书”感就消掉一大半:
- 叔节点为红色:把父节点和叔节点变黑,祖父变红,再把当前节点上移到祖父,继续循环。
- 叔节点为黑色或不存在,当前节点与父节点、祖父节点呈一条直线:对祖父做一次单旋转,交换父节点与祖父颜色。
- 叔节点为黑色或不存在,当前节点与父节点、祖父节点呈折线:先对父节点做一次旋转,变成直线,再按直线情况处理。
源码里这几个分支看起来像一团复杂的循环和旋转调用,但剥开来看,每一步都在维护两条铁律:红节点的孩子必须黑,黑高必须一致。只要旋转后把颜色调对,这两条铁律就恢复了。
4.3 hint 版本和 emplace 的优化价值
insert还有一个带位置提示的重载,参数是一个迭代器,用来告诉底层树“新元素大概在哪个位置附近”。如果提示正确,插入可以降到摊还 O(1)。这个特性在有序数据连续插入时特别有用,源码里会先检查提示位置是否合法,再决定是直接插入还是退回完整搜索。
emplace系列则是在插入时就地构造元素,避免构造临时对象再拷贝进节点。对于复杂类型,这个优化能省掉一次甚至多次构造析构。你在源码里会看到参数包展开、完美转发的代码,它们属于实现细节,但理解“为什么有这个接口”就够了——为了减少不必要的对象开销。
我实际测试过一次向空 set 连续插入大量整数,提示版本的插入比普通插入快不少,但前提是提示位置给对。如果每次提示都乱给,性能反而会退化到完整搜索,还可能造成缓存不友好。
5. erase 的路更容易翻车
5.1 删除不是把节点抠掉那么简单
erase的关键在于,红黑树删除一个节点之后仍然要保持有序和平衡。最简单的情况是被删节点有两个子节点都为空或者只有一个非空子节点,那直接用子节点替换,或者直接删掉叶子。但如果被删节点有两个非空子节点,情况就复杂了:真实删除的是它的中序后继,也就是说,先找到右子树的最左节点,把这个后继节点的值覆盖到被删节点上,然后删掉后继节点。
这样做的原因是,中序后继最多只有一个右孩子,删除它比删除一个有两个孩子的节点简单得多。源码里你能看到一段“找后继、拷贝值、重设指针”的逻辑,然后才进入平衡修复。这种替换策略避免了复杂的结构性删除,是很多平衡树实现的标准做法。
缺点也很明显:底层树节点里键值被覆盖后,指向被删节点的迭代器实际上还停留在那个节点地址上,但元素值已经不再是原来的值了。所以删除后,你原本持有的那个迭代器指针并没有物理消失,只是逻辑上已经失效。理解这一点,你就知道为什么循环里删除时必须小心。
5.2 遍历过程中该怎么安全删除
最常见的错误是边遍历边删除时直接erase(it)然后继续用it++。在旧标准或某些实现里,删除后当前迭代器已经失效,再自增就是未定义行为。正确做法是让erase返回下一个有效迭代器,赋值给当前迭代器继续循环。
如果你的环境只支持旧式接口,那就在删除前先保存下一个迭代器:
auto next_it = it; ++next_it; s.erase(it); it = next_it;红黑树删除的平衡修复比插入更麻烦,因为删除一个黑色节点会让某条路径黑高减少。源码里会出现“双黑”概念,修复循环要分兄弟节点是红还是黑、兄弟的孩子是什么颜色等多种情况。阅读建议是先跳过这些 case,只看节点摘除和值替换逻辑,等需要debug再死磕平衡修复。
5.3 删除和比较器不一致导致的诡异现象
erase(key)会根据比较器定位要删除的元素。如果比较器的行为在插入后发生改变,比如比较器内部依赖的某个状态被修改了,树结构就会和比较器不一致。这时删除一个明明存在的键,可能返回 0,甚至让整棵树中序遍历顺序错乱。
这种问题非常难排查,因为代码不报错,只是行为不符合预期。最佳实践是让比较器成为无状态的纯函数,只比较键本身,不要依赖外部变量。这一点在源码阅读时会看到,但很多时候是踩过坑才真正重视。
6. 读源码时最容易踩的坑和排查思路
6.1 比较器不是严格弱序,后果很隐蔽
“严格弱序”听起来是数学概念,落到代码里就几个要求:比较结果是可传递的,对任何值a,comp(a,a)必须为 false。很多人自定义比较器时,为了省事写了类似return a > b,这个通常没问题。问题大多出在把“相等”也返回 true,或者比较器内部用了浮点数NaN,导致比较结果自相矛盾。
一旦严格弱序被破坏,红黑树可能退化,find找不到已经存在的元素,insert可能插入重复值。源码不会替你检查比较器正确性,这是典型的用户责任。排查时先用一个小数据集合测试比较器,把所有元素两两比较一遍,看有没有违反传递性的情况。
6.2 迭代器 increment 崩溃的常见原因
operator++方法本身逻辑稳定,崩溃多半是迭代器已经失效。最常见的是删除当前迭代器后继续自增,还有一个是把 end 迭代器拿来自增,这也是未定义行为。调试时建议在循环里加断言,确保当前迭代器不等于 end 再继续。
另外,不同容器实例之间不要互相比较迭代器。比如一个set<int>的迭代器去和另一个set<int>的迭代器做相等判断,虽然实现里可能只是比较地址,但标准规定这是未定义行为。源码能编译通过,但行为不可靠。
6.3 内存开销和性能预期
set 的单个元素内存开销比 vector 大不少,因为每个节点要存颜色、三个指针、值对象,还有分配器可能带来的额外空间。以 64 位机器为例,三个指针就是 24 字节,加上颜色、节点大小等对齐信息,一个整数的实际节点可能超过 48 字节。
这意味着大量小元素时,set 不仅更慢,还更费内存。实践上,如果只需要有序访问、不要求插入删除时保持引用稳定性,优先考虑std::vector配合排序,性能通常更好。只有需要插入删除频繁且要求迭代器不失效时,set 才真正划算。
6.4 检查红黑树性质的调试技巧
怀疑树结构坏了的时候,可以写一个递归函数,验证每个节点到叶子的黑高是否一致、红节点子节点是否都是黑的、中序遍历是否有序。这些不是标准库提供的功能,但非常适合自制 debug 工具。读源码时,把这几条性质背熟,然后用打印代码观察,会非常直观。
我自己常用的一种技巧是给底层树节点加调试计数器,统计旋转次数、修复循环次数。插入大量随机数据时,统计出来的旋转次数能帮助你验证复杂度分析是否合理。
7. 边读边试:几个能验证源码结论的实验
7.1 实验一:观察插入后中序遍历顺序
写一个std::set<int>,乱序插入一批数,然后用迭代器从头到尾打印,你会发现输出总是升序。这说明树的插入过程确实按比较器做了排序。即使你对红黑树的旋转不熟,这个实验也能证明底层有序性。
接着可以连续插入同一批数,用insert返回值打点,观察哪些插入失败。你会发现第二次插入相同值返回的bool是 false,迭代器指向第一次插入的元素。这个结果直接对应源码里的去重逻辑。
7.2 实验二:验证迭代器自增路径
自己实现一个简单二叉搜索树,打出中序序列和中序遍历的递归访问顺序,然后对比迭代器自增序列。你会发现迭代器的自增并不是每次都从根重新找,而是沿着右子树往下,或者沿着父指针回溯。这个实验能帮你把源码里的循环和树结构对上号。
更进阶一点,可以在自定义树节点里记录每个节点被访问的次数,用量化数据证明“整轮遍历是摊还 O(1)”这个结论,而单次自增偶尔会跳得很远。
7.3 实验三:使用自定义比较器看等价判断
定义一个比较器,对两个整数按绝对值排序,然后插入1和-1。由于比较器认为二者等价(绝对值相同,既不小于也不大于),set 只保留第一个,第二个插入失败。这个实验能直观展示 set 的“去重”实际上依赖比较器,而不是operator==。
把比较器设计成“相等也返回 true”,你会看到一些诡异现象,甚至可能让树的查找路径失效。这个坑建议在实验环境里故意踩一次,比看一万行源码都有用。
7.4 实验四:打开调试器看旋转过程
给红黑树实现加个开关,每次插入打印出当前树结构和旋转类型,观察连续插入1, 2, 3, 4, 5时,树会怎么调整。你会看到类似“插入 2 后无需旋转”“插入 3 后左旋”“插入 4 后变色”这样的记录。
这类打印不需要改标准库,自己写一棵最小红黑树就行。关键是亲自看到旋转的方向和颜色翻转的时机,之后读标准库源码里那些复杂循环,会有“原来这里就是那条规则”的豁然感。
8. 读完 set,下一步应该看什么
8.1 map、multiset、multimap 是同源变体
set 底层是红黑树,map 底层也是同一棵红黑树,区别只在节点存储的值类型。map 节点存的是键值对,底层树的查找比较只使用键部分,set 则整个节点都是键。multiset 去掉了唯一性约束,底层插入函数从insert_unique换成insert_equal,允许等价键重复存在。
建议读完 set 后,对照着打开这几个容器的源码,找出它们分别重用了底层树的哪些接口。你会明白标准库不是为每个容器单独写一套数据结构的,而是同一套红黑树容器被套了四层壳。这种复用思路对理解库设计非常有帮助。
8.2 对比 unordered_set 的结构差异
unordered_set底层是哈希表,和红黑树在内存布局、迭代器稳定性、性能特征上完全不同。set 提供有序迭代,unordered_set不保证顺序,但平均查找更快。源码里哈希表会有一串哈希桶、链表节点、rehash 逻辑,读起来是另一种体验。
把这两个容器对照着读,你会理解“有序性”和“哈希性能”是两种很难兼得的设计目标。标准库同时提供二者,恰恰说明没有银弹,只有不同场景下的取舍。
8.3 红黑树之外,还可以看什么
读完红黑树后,可以试试手写一棵 B 树或跳表,再回头对比 STL 的树结构。B 树更适合磁盘和数据库场景,因为节点更大、IO 次数更少;跳表实现简单,适合并发场景。这些都不是标准库 set 的实现选择,但能帮你建立更宽的数据结构视野,反过来更理解标准库选型时在权衡什么。
9. 读源码过程中的一点个人体会
几次读源码下来,我最大的心得是:不要从构造函数开始逐行读。构造函数会牵扯到内存分配器的零散细节,一开始就捧着读,很容易失去兴趣。先从插入和迭代器这两个最核心的行为入手,把数据结构和对外语义对上号,再回头补构造、析构、复制控制,整个过程会顺畅很多。
另一个体会是善用最小复现。遇到源码里看不懂的分支,就写成十几行的小实验,改一两个条件,观察输出变化。红黑树的旋转和变色,光看代码很难在脑子里跑起来,但加上打印和调试器单步,一下就清楚了。
最后,不要为了读源码而读源码。STL 源码阅读真正有价值的地方,是让你理解容器背后的工程权衡:内存、时间、迭代器稳定性、接口一致性。这些东西不会直接写成注释,但它们藏在每一个函数签名和分支判断里。你带着问题去读,读完顺手写个小总结,下一次用到 set 时,很多看似玄学的性能问题就都有了解释。