资讯动态

C++ set与map原理剖析:从红黑树到实战应用

发布时间:2026/9/9 20:54:54 来源:尧图企业网站定制
写这篇东西的起因很简单最近在带几个实习生做项目发现不少人在处理去重排序和键值关联这类需求时第一反应还是双层for循环配手写结构体或者用vector硬存再sort。明明C的标准库就摆着set和map这两件利器却被很多人当成面试题里的抽象概念而不是日常开发中真正能救命的工具。所以我决定把这两个容器从底层原理到使用细节完整梳理一遍希望能让读者少走些弯路。1. 为什么是set和map从使用场景到底层契约先聊一个最实际的问题什么情况下你会需要set和map我总结了四个最常见的需求去重给一个数组要输出不重复的元素或者统计一共有多少种不同的元素。排序去重之后还要保持有序方便后续按顺序处理。快速查找判断一个元素是否存在且集合数据在持续变化。键值关联通过一个键比如学号、单词找到对应的值比如学生姓名、出现次数。用普通数组做这些事不是不行但代价很大。最直观的做法是O(n^2)的遍历比较几万个数据时还能忍几十万上百万时性能就不行了。set和map的核心价值在于它们把插入、删除、查找三项操作的时间复杂度全部压到了O(log n)。这是个什么概念假设有一百万个数据O(log n)大约只需要20次比较操作。如果用数组暴力查找平均要五十万次。能做到这一点靠的是底层的红黑树。1.1 红黑树到底解决了什么问题红黑树是一种自平衡的二叉搜索树。二叉搜索树的特性是每个节点的左子树所有值都小于该节点右子树所有值都大于该节点。理论上只要树保持平衡查找、插入、删除都是O(log n)。问题在于保持平衡这四个字。如果数据是顺序插入的二叉搜索树会退化成一条链表查找复杂度直接变成O(n)。为了不让它退化就需要在每次插入和删除后调整树的结构让它尽量左右均衡。红黑树用颜色标记每个节点红色或黑色通过一套规则根节点是黑的、红节点的子节点必须是黑的、从任一节点到其每个叶子的所有路径都包含相同数目的黑节点等来约束树的形态。这套规则保证了任何一条路径的长度不会超过另一条路径长度的两倍所以树不会太歪斜。你可能会问为什么不用AVL树AVL树要求左右子树高度差不超过1平衡性比红黑树更严格查找更快但插入和删除时需要更多的旋转操作来维持平衡。红黑树是近似平衡牺牲了一点查找效率换来了更少的旋转调整开销。对于set和map这种频繁插入删除的应用场景红黑树是更均衡的选型。1.2 set和map的共性与差异从使用者的角度set和map的底层都是红黑树只是一者只存键一者存键值对。所以它们的很多接口几乎一模一样insert、erase、find、count、lower_bound、upper_bound都长得差不多。但有一个关键差异容易被忽略set的迭代器指向的元素是常量不可修改。而map的迭代器指向的pair中first键也是常量second值可以修改。setint s {3, 1, 2}; auto it s.begin(); // *it 10; // 错误不能修改set中的元素 mapstring, int wordCount; wordCount[hello] 1; auto mit wordCount.begin(); // mit-first world; // 错误键不可修改 mit-second 10; // OK值可以改这背后的逻辑很清晰红黑树的有序性依赖键的值。一旦允许修改键树的结构可能就乱了。STL直接在编译期禁止了这种操作省得你踩出隐蔽的运行时bug。2. set的完整用法从初始化到迭代器陷阱2.1 初始化与插入的几种姿势set的初始化方式很多最常用的有那么几种// 方式一默认构造然后逐个插入 setint s1; s1.insert(3); s1.insert(1); s1.insert(2); // 方式二初始化列表 setint s2 {3, 1, 2, 2, 1}; // 实际上只有 {1, 2, 3} // 方式三从其他容器构造 vectorint v {5, 2, 8, 2, 1}; setint s3(v.begin(), v.end()); // 方式四拷贝构造 setint s4 s2;这里有个小技巧从vector构造set天然完成了去重加排序。很多面试题问如何给数组去重最简单的答案就是一行setint s(v.begin(), v.end())然后vectorint result(s.begin(), s.end())。insert函数有个很容易被忽略的返回值它是pairiterator, bool第一个是插入位置的迭代器第二个是插入是否成功。当插入的元素已经存在时bool是false迭代器指向已有的那个元素。setint s {1, 2, 3}; auto [it, inserted] s.insert(2); cout inserted endl; // 0插入失败因为2已存在 cout *it endl; // 2在C17里可以配合结构化绑定直接拿到这个pair写起来很舒服。2.2 删除erase的三个重载版本erase在C里分三个形态每个形态的返回值还不一样这是个经典坑点。setint s {1, 2, 3, 4, 5}; // 形态一按值删除返回删除的元素个数0或1 size_t n s.erase(3); // n 1 // 形态二按迭代器删除C11之前返回voidC11之后返回下一个元素的迭代器 auto it s.find(4); auto nextIt s.erase(it); // C11之后 nextIt指向5 // 形态三按范围删除 [first, last) auto first s.lower_bound(2); auto last s.upper_bound(4); s.erase(first, last);写完形态一和形态二正好可以引出遍历时删除的经典安全写法后文专门讲。这里先记住一个原则尽量不要在循环里直接erase迭代器后再用该迭代器那是未定义行为。2.3 查找find、count、lower_bound、upper_boundset的查找接口是高频使用的几个函数的语义要区分清楚find(k)返回指向k的迭代器找不到则返回end()。count(k)返回k的个数对于set来说只有0或1。很多人喜欢用count(k) 0来判断元素是否存在这没问题但语义上find更直观。lower_bound(k)返回第一个不小于k的元素的迭代器。upper_bound(k)返回第一个大于k的元素的迭代器。equal_range(k)返回一个pair同时包含lower_bound和upper_bound的结果。除了判断存在性lower_bound和upper_bound更大的用处是范围查询。比如找出所有在[20, 40]区间内的元素setint s {10, 20, 30, 40, 50}; auto l s.lower_bound(20); // 指向20 auto r s.upper_bound(40); // 指向50 for (auto it l; it ! r; it) { cout *it ; // 输出 20 30 40 }这个模式和map结合时更强大因为可以为键划定范围做类似数据库区间扫描的事情。2.4 自定义排序与multiset默认情况下set按升序排列但有时候我需要降序或者按自定义规则排序比如按字符串长度排。// 方式一使用函数对象 struct StringLengthLess { bool operator()(const string a, const string b) const { return a.size() b.size(); } }; setstring, StringLengthLess s1 {apple, kiwi, banana, fig}; // 方式二使用lambdaC11之后 auto comp [](int a, int b) { return a b; }; setint, decltype(comp) s2(comp); s2.insert({3, 1, 4, 1, 5}); // 遍历结果是 5 4 3 1 1注意1会去重 // 方式三使用标准库提供的greater setint, std::greaterint s3 {3, 1, 4, 1, 5}; // 降序关于自定义比较器有一条重要的坑比较器必须满足严格的弱序关系也就是comp(a, a)必须返回false。如果比较器把相等元素也返回trueset的行为会变得诡异甚至可能崩溃。比如按字符串长度比较时两个长度相同但内容不同的字符串会被认为是等价的从而只保留其中一个——这往往不是你想要的效果。multiset是允许重复元素的set底层是同样的红黑树但插入时不去重。需要注意用erase(k)删除multiset中的一个值时会把所有等价元素都删光。如果只想删一个必须先find得到迭代器再erase。3. map的完整用法键值对的增删查改与中括号陷阱3.1 初始化与三种插入方式的区别map的初始化比set多了一层大括号包pair的灵活性mapstring, int m1; m1[apple] 3; mapstring, int m2 { {apple, 3}, {banana, 2}, {cherry, 5} }; mapstring, int m3 m2;插入键值对时有三种常见写法虽然看着差不多底层行为却有很大差异mapstring, int m; // 方式一operator[] m[apple] 3; // 如果apple不存在先插入一个{apple, 0}然后再赋值为3 // 如果已存在直接修改value // 方式二insert auto res m.insert({banana, 2}); // 如果键已存在插入失败返回的pair中bool为false // 注意insert不会覆盖已有值 // 方式三emplace m.emplace(cherry, 5); // 直接在容器内构造节点避免构造pair的拷贝这里要特别提一个使用户容易掉坑的地方mapstring, int m; m[apple] 1;这个操作如果键不存在也会插入一个值为0的默认节点。这在做词频统计时很顺手但如果只是想检查某个键是否存在千万别用if (m[key] someValue)这种写法它会意外地插入默认值。正确的检查姿势是find或count。3.2 operator[]的机制和insert的对比operator[]好用但坑也最多。我列个对照表来展示它和insert、find的区别操作键不存在时键存在时推荐场景m[k] v插入{k, v}修改value为v需要覆盖式写入m.insert({k, v})插入{k, v}什么都不做只在键不存在时写入m.emplace(k, v)插入什么都不做同上但效率略高m.find(k)返回end()返回迭代器只读查询m.count(k)返回0返回1只判断是否存在multimap中返回个数operator[]的原理会让人疑惑一阵子它用了default constructible的要求。当键不存在时它会用键构造一个pair然后value用默认构造函数生成再返回value的引用。这意味着map的value类型必须能默认构造。如果你的value类型没有默认构造函数比如某个自定义class只写了带参构造那m[k]的写法根本编译不过必须改用insert或emplace。至于为什么会有两条插入路径纯粹是历史原因加语义分工insert族保留不覆盖语义operator[]是查找或创建语义。3.3 查找与遍历的细节map查找最稳的方式是find返回的迭代器可以直接解引用访问pair的first和secondmapstring, int m {{a, 1}, {b, 2}}; auto it m.find(a); if (it ! m.end()) { cout it-first - it-second endl; } else { cout not found endl; }遍历有几种写法C11之后推荐基于范围的for循环配合结构化绑定会更清爽// C11 for (const auto kv : m) { cout kv.first : kv.second endl; } // C17 结构化绑定 for (const auto [key, value] : m) { cout key : value endl; }注意遍历时map是按key升序来的不是插入顺序。这一点特别容易让刚接触map的人困惑尤其是用惯了Python dict保持插入顺序的同学。如果你需要保持插入顺序得自己再加一个vector存键或者改用类似boost::multi_index的容器。3.4 multimap和map的差异multimap允许键重复。因为键可以重复所以operator[]不能用了insert也会返回指向新插入元素的迭代器。multimap有一个很好用的特性同键值的元素会连续存储配合equal_range可以一次性取回某一个键对应的所有值multimapstring, int mm; mm.insert({apple, 1}); mm.insert({apple, 3}); mm.insert({banana, 2}); auto range mm.equal_range(apple); for (auto it range.first; it ! range.second; it) { cout it-first : it-second endl; } // 输出 // apple: 1 // apple: 3这个模式在处理一个键对应多个值的场景中很好用但注意它和map底层同样是红黑树所以同一个键的多个值之间依然是有序排列的。当然实际开发中我很少直接用multimap因为用mapK, vectorV往往更直观但理解multimap仍然对理解红黑树的有序性有帮助。4. 三个高频实战场景去重、统计与邻接表4.1 去重排序一行代码搞定最常见的需求之一是给定一个无序数组输出不重复且排序后的元素。用set做这个几乎是作弊级别的简单vectorint raw {5, 2, 8, 2, 5, 1, 9, 8}; setint uniqueSorted(raw.begin(), raw.end()); for (int x : uniqueSorted) { cout x ; } // 输出1 2 5 8 9在算法题中这个技巧大量用于离散化。比如坐标压缩coordinate compression的预处理阶段正是靠set去重排序再把排序结果映射到递增编号。这套操作在树状数组、线段树的实现中是家常便饭。4.2 词频统计与计数器用map做词频统计operator[]的自动补默认值机制反而成了便利vectorstring words {apple, banana, apple, cherry, apple, banana}; mapstring, int freq; for (const string w : words) { freq[w]; } for (const auto [word, count] : freq) { cout word : count endl; } // 输出 // apple: 3 // banana: 2 // cherry: 1注意这里用operator[]时每次会先在map中查找键然后递增。问题来了freq[w]实际上做了两次查找操作一次找位置一次递增后写回其实不是operator[]返回引用后递增只查了一次。那如果用insert需要怎么处理我一般会这样写mapstring, int freq; for (const string w : words) { auto it freq.find(w); if (it ! freq.end()) { it-second; } else { freq.emplace(w, 1); } }这段逻辑虽然比 operator[] 啰嗦但可以明确区分键存在和键不存在两种路径在某些性能敏感的场合find emplace 的方式更可控。实战中我发现operator[] 的可读性更好多数场景下就选 operator[]。4.3 图的邻接表表示与拓扑排序辅助说到图算法邻接表是存储稀疏图的主流方式。map配合vector可以让邻接表自带有序性mapint, vectorint graph; graph[1].push_back(2); graph[1].push_back(3); graph[2].push_back(4); for (const auto [node, neighbors] : graph) { cout node : ; for (int v : neighbors) { cout v ; } cout endl; }使用map当邻接表的好处是遍历节点时天然有序在做一些需要按编号顺序处理节点的算法比如某些拓扑排序变体时省去了单独排序的步骤。缺点是节点数量很大但数据稀疏时红黑树的节点开销和log n查找成本会比vector数组略高。所以选型时先预估数据规模。5. 选型省思set/map与unordered系列、vector的取舍很多人有一个误区既然红黑树这么好那所有场景都用set/map不就行了其实选型要看具体需求。容器有序性插入/删除/查找复杂度内存开销适用场景set / map有序O(log n)高每个节点带颜色、父子指针需要有序遍历、范围查询、求前驱后继unordered_set / unordered_map无序平均O(1)最坏O(n)中哈希桶只做快速的插入和查找不要求顺序vector sort binary_search只读排序建好之后查找O(log n)插入O(n)低数据建好后基本不再变动vector 暴力无序查找O(n)低数据量极小几十个且频率不高关键决策点就一句话是否需要有序性如果需要有序遍历、范围查询、或者需要快速获取“比当前键小的最大键”那就用set/map。如果只关心“存不存在”或者“根据键找值”而且键是整数或短字符串这种容易哈希的类型优先用unordered系列。还要留意数据量级。当数据量只有几十上百时vector暴力往往比set更快因为连续的缓存访问比红黑树那种指针跳跃的访问高效得多。STL容器在算法竞赛中经常“被膨胀”但工程实际中要综合考虑数据规模、访问模式和缓存效应。我在做实时日志分析时就有个深刻体会日志里要按时间戳范围查记录用map存储很简单因为lower_bound(start)和upper_bound(end)能直接圈出区间但要统计某个用户Id出现次数完全不需要有序性用unordered_map能快一个数量级。6. 我在使用set和map时踩过的五个坑最后这部分我用心总结下都是实际写过不少代码之后才彻底想明白的细节。6.1 遍历时删除迭代器erase返回值的版本差异这是个高频翻车点。想在遍历set时删除部分元素C11之前的经典写法是setint s {1, 2, 3, 4, 5}; for (auto it s.begin(); it ! s.end(); ) { if (*it % 2 0) { s.erase(it); // 先保存迭代器副本再删除原来的 } else { it; } }为什么erase(it)能行因为递增操作发生在erase之前所以迭代器可以先指向下一个位置再删除当前元素。C11之后erase返回下一个元素的迭代器写法更直观for (auto it s.begin(); it ! s.end(); ) { if (*it % 2 0) { it s.erase(it); } else { it; } }注意map同样适用这个规则。但千万不要写成it s.erase(it)这种试图“顺便跳过”的逻辑里面的求值顺序会让你摸不着头脑。6.2 operator[] 在只读场景下意外插入元素这是我之前在一个性能优化任务里踩过的坑。代码大概长这样mapstring, int scoreMap {{Alice, 90}, {Bob, 85}}; if (scoreMap[Charlie] 100) { // 处理逻辑 }然后我排查了半天发现scoreMap莫名多了一个{Charlie, 0}。原因是operator[]在键不存在时插入了默认值。解决办法是改成find或countauto it scoreMap.find(Charlie); if (it ! scoreMap.end() it-second 100) { // ... }这不算bug但确实是个隐蔽的副作用。特别在const环境下map没有非const的operator[]重载所以如果你用const map查询还要小心operator[]会编译错误。用find才是普适方案。6.3 自定义类型没有比较器的编译报错如果想把自定义struct放进set或map的键必须提供比较方式否则编译器报错能绕晕你。struct Student { string name; int score; }; // 错误没有定义 operator setStudent students; // 编译失败解决方案有两种给结构体重载operator或者显示传入仿函数/lambda。重载一个默认的排序规则struct Student { string name; int score; bool operator(const Student other) const { if (name ! other.name) return name other.name; return score other.score; } };注意比较器必须严格弱序尤其要注意不要只比较其中一个字段。如果你只比score两个学生分数相同时set会认为它们是“等价”的导致其中一个被丢弃这是非常隐蔽的数据丢失。6.4 用auto遍历map时不小心拷贝了pair每当我看到有人写for (auto kv : m)就觉得浪费资源。这里的auto kv会拷贝整个pair如果value是个巨大的vector或string代价不低。稳妥的选择是for (const auto kv : m) { ... } // 只读 for (auto [key, value] : m) { value * 2; } // 允许修改值同样道理insert时如果已经有一个pair对象用m.insert(pair)也可能会拷贝。如果只是临时构造m.emplace(k, v)或m.insert({k, v})都行但要注意insert({k, v})的类型推导有时会产生额外的move性能要求高时优先写emplace。6.5 误以为set和map能替代所有有序数据结构最后说个认知层面的坑。set和map确实强大但红黑树的树节点在内存中不是连续的遍历时cache miss较多。在一些需要频繁范围遍历的场景vector存着排好序的数据再配合二分查找性能会好很多。所以我不建议把set/map当“万能容器”用。它们最合适的场景是数据频繁增删 需要保持有序 需要范围查询。三者中命中至少两条时红黑树的价值才能体现出来。顺带提一个小技巧如果需要同时维护“当前集合最大的元素”*s.rbegin()和*s.rbegin()在set和map上都能用红黑树的最右节点就是最大值。这比用max_element快很多因为后者是线性扫描。我在做区间调度算法时就是靠setpairint, int存区间、rbegin()取最大结束时间这种操作把复杂度从暴力O(n^2)降到了O(n log n)那一版的改动直接让线上服务的耗时从秒级降到几十毫秒。7. 后记再聊一点实践经验结合这几年的项目经验我最后说几个个人体会。第一set和map的接口设计非常成熟但正因为接口多才特别容易在细节上翻车。如果你刚接触不要急着背那些返回值细节而是先写几个能跑通的小例子把insert返回的pair、find和end比较、lower_bound的范围查询手感养出来。写多了自然就记住了。第二在刷算法题或者做项目的时候可以多想一步“这个容器我要的是有序性还是纯查找速度”每次选型都这么过一遍脑子比背“map底层是红黑树”这张说明书重要得多。第三当心性能测试的误导。set在小数据集上可能比unordered_set还慢但大数据量下log n和1的差距会被cache效应稀释。工程中永远以实测为准但前提是代码逻辑正确、无隐藏拷贝。第四如果你在写代码时遇到奇怪的编译器报错尤其是关于operator的先检查是否自定义类型没有提供合法的比较器或者比较器不满足严格弱序。这个报错信息往往很晦涩但归根结底就那么几个原因。最后分享一个我自己一直在用的小技巧当你需要按键范围统计区间内元素的个数时别用循环遍历后count累加用distance(s.lower_bound(L), s.upper_bound(R))一行搞定。虽然distance内部还是O(n)的迭代器移动但在set这种非随机访问迭代器上它和手动循环等价但代码简洁得多。如果你需要更快的区间计数就该考虑树状数组或平衡树扩展了。set和map这两个容器说到底是红黑树这个数据结构的两个门面。理解它们不只是学会调用几个函数更是理解“有序关联容器”这个设计思路。跟着这篇博文把代码跑一遍再对照自己的项目想一想哪里能用到比单纯看十遍概念有用得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价