ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

C++ map与set用法详解:从红黑树底层到实战避坑指南

C++ map与set用法详解:从红黑树底层到实战避坑指南 1. 先弄明白map和set是什么1.1 用生活场景理解「键值对」与「集合」很多学C的朋友第一次接触map和set时最容易产生的困惑是这俩东西和数组、vector到底有什么区别用一句话说明白vector是按下标存东西的有序序列map是按名字找东西的字典set是只记名字不记东西的集合。举个生活例子。你想记全班同学的电话号码用vector可以这样写phones[0] 138xxxx问题是下标0是谁没人记得住。用map就很自然phones[张三] 138xxxx。张三就是键key电话号码就是值valuemap负责帮你把「根据键找值」这件事做到最高效。set就更简单了。你只需要记录「哪些同学今天交作业了」不需要额外存任何值set就是干这个的——只存一堆元素且元素不重复、自动排序。所以map解决的是「键值映射」问题set解决的是「唯一集合」问题。两者本质上都建立在同一套有序二叉树结构之上理解了一个另一个也就不难了。1.2 C里四类关联容器的一分钟速览C标准库里的关联容器有四大金刚它们的关系和区别特别清晰容器元素类型键是否允许重复是否有序底层实现查找复杂度std::mappairconst Key, T否是默认升序红黑树O(log n)std::setKey否是默认升序红黑树O(log n)std::multimappairconst Key, T是是默认升序红黑树O(log n)std::multisetKey是是默认升序红黑树O(log n)先记住这四张脸。map和set的键唯一multimap和multiset允许重复。它们都是有序的因为底层是红黑树。另外还有四个unordered系列unordered_map、unordered_set、unordered_multimap、unordered_multiset底层是哈希表无序平均O(1)查找这个后面单独说。注意C里有个细节经常被踩——默认排序是升序用的是std::lessKey也就是operator。如果你的类型没有提供operator把这些类型放进map或set会直接编译报错。接下来的篇幅我按「map → set → 底层原理 → 避坑 → 面试」这条线逐个拆开揉碎讲。2. std::map用法拆解从初始化到遍历的完整实战2.1 初始化与插入insert、emplace和[]三者的区别map的插入有四种常用写法很多教程会让初学者背下来但真正重要的是理解它们之间的差异。#include iostream #include map #include string int main() { std::mapstd::string, int scores; // 方式1花括号初始化列表C11 scores.insert({Alice, 90}); // 方式2make_pair scores.insert(std::make_pair(Bob, 85)); // 方式3显式pair scores.insert(std::pairstd::string, int(Charlie, 92)); // 方式4emplace推荐 scores.emplace(David, 88); // 方式5[]运算符直接赋值 scores[Eve] 95; return 0; }这五种的差别在哪insert在键已存在时不会覆盖旧值而operator[]在键已存在时会覆盖旧值。emplace则是直接在容器内部构造元素避免了临时对象的拷贝性能比insert略好。make_pair和pair的写法在C11之后其实有点冗余了直接花括号比较清爽。这里还藏着一个极其经典的坑operator[]在键不存在时会默认构造一个新元素插进去。std::mapstd::string, int m; std::cout m[hello] std::endl; // 不报错输出0但m里多了一个{hello, 0}也就是说你只是查一下结果把数据写进去了。这个问题我在实际项目里见过不止一次最典型的场景是统计词频时误用了[]统计结果莫名其妙多了一堆计数为0的键。如果想「查找不存在就不插入」得用find或contains这个下一节细说。2.2 查找与删除find、contains、erase的正确打开方式map的查找有三种常用手段按C版本不同有不同偏好。std::mapstd::string, int scores { {Alice, 90}, {Bob, 85}, {Charlie, 92} }; // 方式1findC11之前就有 auto it scores.find(Bob); if (it ! scores.end()) { std::cout Bob: it-second std::endl; } else { std::cout not found std::endl; } // 方式2countC20之前用于判断是否存在 if (scores.count(Alice)) { std::cout Alice exists std::endl; } // 方式3containsC20引入语义最清晰 if (scores.contains(Charlie)) { std::cout Charlie exists std::endl; }find返回迭代器找不到时返回end()代价是O(log n)。count在map上返回0或1因为键唯一判断存在性很好用但语义上有点绕「数一下有几个」听起来不像「查是否存在」。C20之后contains最直观项目如果允许用C20优先用contains。删除操作有按键删、按迭代器删、按区间删三种// 按键删除返回删除的数量0或1 int n scores.erase(Alice); // 按迭代器删除 auto it scores.find(Bob); if (it ! scores.end()) { scores.erase(it); } // 区间删除 auto first scores.lower_bound(B); auto last scores.upper_bound(D); scores.erase(first, last);这个lower_bound和upper_bound会在set部分详细讲先记住一个概念map按键有序所以可以用二分查找定位边界这是数组做不到的。2.3 遍历的几种写法迭代器、范围for与结构化绑定map的遍历在C11之前只能老老实实用迭代器。for (auto it scores.begin(); it ! scores.end(); it) { std::cout it-first - it-second std::endl; }it-first是键it-second是值这个写法不直观而且容易手滑把first和second搞反。C11有了范围for之后稍微好一点但仍然要写p.firstfor (const auto p : scores) { std::cout p.first - p.second std::endl; }C17引入结构化绑定这才真的舒服了for (const auto [key, value] : scores) { std::cout key - value std::endl; }这里有个性能细节值得说遍历时一定要用const auto不要用auto。map的value_type是pairconst Key, T如果你写auto p : scores拷贝整个pair对性能不友好写auto p又会不小心让外部代码能够修改value语义不严谨。const auto既能避免拷贝又能防止误改是标准做法。另外map是按键升序排列的。遍历顺序就是字典序或你自定义的比较序。如果要逆序遍历用反向迭代器for (auto it scores.rbegin(); it ! scores.rend(); it) { std::cout it-first - it-second std::endl; }平时开发中需要按序输出或按序处理键值对时map天然有序这个特性特别省事。比如统计每个字符出现次数后想按字母顺序打印map一键搞定根本不用手动排序。3. std::set用法拆解自动排序的唯一集合3.1 set的基本操作与去重场景set可以理解为「只含键的map」操作比map更简单因为不涉及value的读写。#include iostream #include set int main() { std::setint s; s.insert(3); s.insert(1); s.insert(4); s.insert(1); // 重复不会插入成功 std::cout size s.size() std::endl; // 3 for (int x : s) { // 自动升序 std::cout x ; // 1 3 4 } std::cout std::endl; s.erase(3); std::cout s.count(3) std::endl; // 0 return 0; }set最常见的应用场景是去重。比如给你一个数组想获得里面所有不重复的值set一行就能搞定std::vectorint nums {5, 3, 8, 3, 1, 5, 8, 2}; std::setint unique(nums.begin(), nums.end()); for (int x : unique) { std::cout x ; // 1 2 3 5 8已经排好序 }这里有个细节很实用set构造函数的范围版本接受两个迭代器任何容器的迭代器都能用所以vector、list、数组都可以直接转set。而且set构造完成后元素自动排序去重一步到位。如果不想排序只需要去重那应该用unordered_set后面会讲。如果还需要保留原数组的顺序那set就没法直接满足了得用辅助手段比如unordered_set判断是否出现过同时用vector保存结果。3.2 insert的返回值怎么判断元素是否已存在map和set的insert返回值设计得很有意思是一个pairiterator, bool。std::setint s; auto [it, inserted] s.insert(42); if (inserted) { std::cout 42 inserted std::endl; } else { std::cout 42 already exists std::endl; }it指向元素在set中的位置无论是新插入的还是已存在的inserted是bool表示是否真的插入了新元素。这个设计在去重场景里特别有用——比如刷题时你要把元素插入set同时还想知道它是不是重复的一次insert就全拿到了。有个面试题经常考set的迭代器能不能修改元素答案是不能。因为set是基于二叉搜索树的有序容器如果允许直接修改元素值会破坏树的有序性。所以set的迭代器实际上是const_iterator类型你写*it 10会直接编译报错。如果真想修改set中的某个元素正确的做法是先erase旧元素再insert新元素。这个过程是两次O(log n)操作性能开销并不大。3.3 lower_bound、upper_bound与equal_range有序容器的区间利器这是set和map非常强大的一个特性很多初学者没用过。因为底层是有序树「查找第一个不小于某个值的元素」这种需求可以高效完成。lower_bound(k)返回第一个不小于k的元素的迭代器即 k的第一个元素。upper_bound(k)返回第一个大于k的元素的迭代器即 k的第一个元素。equal_range(k)返回一个pairiterator, iterator区间[lower_bound(k), upper_bound(k))包含所有等于k的元素。std::setint s {1, 2, 4, 5, 7, 9}; auto lb s.lower_bound(3); // 指向4 auto ub s.upper_bound(3); // 指向4因为3不存在所以与lower_bound相同 auto lb2 s.lower_bound(4); // 指向4 auto ub2 s.upper_bound(4); // 指向5 auto [first, last] s.equal_range(4); // first - 4, last - 5区间包含4对于maplower_bound和upper_bound操作的键的范围返回的迭代器同样是pairconst Key, T的迭代器。这个特性的应用场景很广。比如系统里有一批订单记录按时间存放你想找出「时间在某个范围内所有订单」lower_boundupper_bound就是标准答案std::maptime_t, Order m; auto begin m.lower_bound(start_time); auto end m.upper_bound(end_time); for (auto it begin; it ! end; it) { process(it-second); }如果是multimap或multisetequal_range更是神器——因为重复元素在树中连续存放equal_range直接帮你把这个重复区间整体框出来比手动find 循环高到不知道哪里去了。这个在使用multimap存多条记录时几乎是标配操作。4. 底层原理红黑树为什么是map/set的默认选择4.1 红黑树五条性质与O(log n)的来源面试题和工程实践都会问到一个问题**map底层为什么用红黑树**这得从红黑树本身说起。红黑树是一棵自平衡二叉搜索树它给每个节点增加了一个颜色属性红色或黑色并维护以下五条性质每个节点非红即黑。根节点是黑色的。每个叶子节点NIL是黑色的。如果一个节点是红色的那么它的两个子节点都是黑色的即红色节点不能相邻。从任一节点到其每个叶子节点的所有路径上黑色节点的数目相同。这五条性质保证了一个关键结论红黑树的最长路径不会超过最短路径的两倍。最短路径全是黑节点最长路径是红黑相间黑节点数量相同所以最长也就多出一倍红色节点。这样树的高度始终维持在O(log n)量级插入、删除、查找的时间复杂度都是O(log n)。为什么能保证平衡因为一旦插入或删除破坏了上面五条性质红黑树会通过「变色」和「旋转」来自我修复把树重新弄平衡。旋转有左旋和右旋两种类似于把一个节点和它的子节点换个位置重新整理节点之间的层次关系。4.2 为什么不用AVL树、不用哈希表做默认实现红黑树不是唯一的平衡二叉搜索树AVL树比它平衡得更严格——AVL要求任何节点的左右子树高度差不超过1。那C标准库为什么不用AVL而用红黑树关键是插入和删除的性能。AVL平衡更严格意味着插入或删除后触发旋转的概率更高而且可能需要一路向上回溯调整到根节点。红黑树的约束相对宽松插入时最多两次旋转就能恢复平衡删除时最多三次旋转。对于插入删除频繁的通用容器来说红黑树的整体效率更稳定。那为什么不用哈希表做map的默认实现因为哈希表本身是无序的。C标准库中std::map明确要求键按顺序排列提供lower_bound、upper_bound、按序遍历这些能力。哈希表做不到这些所以标准库把哈希表版本单独放出来做成unordered_map让使用者自己权衡。一句话总结map用红黑树是为了换一个「有序」这两个字。如果你不需要有序直接用unordered_map性能会更好。4.3 unordered_map/unordered_set什么时候换哈希表既然哈希表查找O(1)平均比红黑树的O(log n)更快那是不是无脑用unordered系列就行当然不是取舍要看场景。先看一个简单的基准对比思路unordered_map插入、查找、删除平均O(1)但元素无序无法范围查询且最坏情况退化到O(n)哈希冲突严重时。map所有操作稳定O(log n)有序支持范围查询迭代顺序稳定。工程上的选择标准我个人的经验是业务需求推荐容器需要按键遍历且有顺序要求map需要按范围查找如时间区间map数据量极大只做随机读写unordered_map对迭代性能敏感unordered_map需要自定义比较规则map自定义比较器键是自定义类型且很难写hashmap只需operator有一个细节很容易被忽略unordered_map遍历出来的顺序是随机的跟插入顺序无关。如果哪天你在测试环境发现unordered_map打印顺序「偶尔变」这不是bug是哈希表现。有些人在这上面排查了很久才发现是容器本身无序。还有一个工程细节自定义类型放进unordered_map需要同时提供哈希函数和operator而放进map只需要operator。写起来前者麻烦很多这也是很多人「懒得折腾」继续用map的原因。性能差距在数据量几万、几十万级别时体感并不明显通常到百万级别以上才需要考虑切换到unordered系列。5. 关联容器避坑指南我实际踩过的一些坑5.1 最经典的坑operator[] 误插入元素这个坑上文提过但值得单独再说一遍因为它真的很容易出线上问题。我之前做过一个日志统计组件统计每个IP的请求次数代码长这样std::mapstd::string, int ip_count; for (const auto log : logs) { ip_count[log.ip]; }log.ip是直接从日志解析出来的字符串理论上没问题直到某天日志里出现了解析失败的空字符串统计结果里就多了一个{ , 1 }。如果只是多一行数据问题不大但如果在生产环境做权限校验场景用[]去查「某个key是否存在」就会意外把权限信息插进去后果可能是灾难性的。正确做法是区分「读」和「写」// 只读不存在就跳过 auto it m.find(key); if (it ! m.end()) { use(it-second); } // 读后写存在则更新不存在则插入 auto it m.find(key); if (it ! m.end()) { it-second new_value; } else { m.emplace(key, new_value); }C17以后也可以用insert_or_assign一步完成m.insert_or_assign(key, new_value);这个函数语义就很清晰存在就更新不存在就插入。比m[key] value安全得多因为它至少不会默认构造一个临时值再赋值回去。5.2 遍历删除时的迭代器失效问题这个坑在vector里存在在map和set里同样存在但表现有所不同。很多人写遍历删除时会写成这样// 错误示范 for (auto it m.begin(); it ! m.end(); it) { if (it-second 60) { m.erase(it); // it已经失效了但循环还在用it } }在map/set上erase(it)导致迭代器失效的标准说法是被删除元素的迭代器失效但其他元素的迭代器不受影响。然而上面的代码在erase之后仍然对失效的it执行it这在不少实现上也许看起来是在遍历后续元素但严格来说是未定义行为。C11之后标准给map/set的erase增加了一个返回值返回被删元素的下一个迭代器所以可以这样写// 正确示范C11 for (auto it m.begin(); it ! m.end();) { if (it-second 60) { it m.erase(it); // erase返回下一个迭代器 } else { it; } }如果你用的是C98/03那么只能先缓存下一个迭代器for (auto it m.begin(); it ! m.end();) { if (it-second 60) { m.erase(it); // 先用it来erase但递增操作通过临时变量完成 } else { it; } }这里的关键点在于m.erase(it)是先让it指向下一个元素然后再用旧的it去删除当前元素。旧迭代器虽然失效了但循环已经不再依赖它了。5.3 自定义类型进容器比较器缺失的编译报错map和set默认使用operator排序。当你的键是自定义类型时如果不提供operator编译会报一长串模板错误很多新手直接被吓懵。错误信息长归长核心问题就一句话编译器找不到如何比较这个类型。解决办法有两个办法一给自定义类型重载operatorstruct Student { int id; std::string name; }; bool operator(const Student a, const Student b) { return a.id b.id; } std::setStudent students; // OK办法二定义仿函数函数对象作为模板的第二个参数struct Student { int id; std::string name; }; struct StudentLess { bool operator()(const Student a, const Student b) const { return a.id b.id; } }; std::setStudent, StudentLess students; std::mapStudent, int, StudentLess scores; // OK第二种方式更灵活因为一个类可以有多种比较规则你可以设计不同的仿函数来实现不同的排序方式而不用改类型本身。注意比较器必须是严格弱序strict weak ordering即传递性、非自反性、反对称性都要满足。最常见的错误是只比较了其中一个字段结果两个不同对象被判定为「相等」导致其中一个插不进去。5.4 map和set的性能挑选不是所有场景都该用它们最后想分享一个关于「什么时候别用map」的经验。很多人一看到「根据key找value」就顺手用map但有些场景其实有更好的选择。比如键是连续的整数如ID从0到999999map的O(log n)虽然不慢但直接用vector或数组按下标访问是O(1)而且内存友好得多。有一道经典的LeetCode题「字符串中的第一个唯一字符」很多解法都用map统计字符次数。但字符集只有26个字母用一个26长的整型数组就够了何必上map这就是典型的「杀鸡用牛刀」。再比如你需要频繁按顺序遍历键值对但容器本身几乎没有插入删除操作那不如把数据放进vectorpairK,V使用时再排序。因为vector在内存中连续存储遍历时的CPU缓存命中率远高于红黑树节点分散在内存各处的map。数据量一大比如千万级别这个差距会很可观。我个人的选择原则是数据量小几千以下随便vector排序或map都行性能差别感知不到。需要频繁按key插入删除查找map/unordered_map。需要有序迭代或范围查询map。只做一次构建、之后只读vector sort binary_search性能通常更好。键是连续整数直接用数组/vector。工具没有绝对的好坏选型永远看业务场景。把每个容器的设计初衷理解了用起来才不会别扭。6. 面试高频题与综合实战演示6.1 C面试「八股」map相关的几个高频问答C面试绕不开map常被问的问题基本就这几个我按面试官的视角把答案整理成速查问题1map底层为什么是红黑树而不是AVL树或哈希表答红黑树和AVL都是平衡二叉搜索树但AVL平衡更严格插入删除时旋转次数更多性能波动大。红黑树的平衡约束宽松插入最多两次旋转、删除最多三次旋转整体读写性能更均衡。不用哈希表是因为哈希表无序无法支持有序遍历和范围查询而map的标准语义就要求有序。如果需要真正的O(1)平均查找且不需要顺序请用unordered_map。问题2operator[] 和 insert 有什么区别答operator[]在键不存在时会先默认构造一个value并插入再返回value的引用因此存在意外插入的副作用。insert在键已存在时不会覆盖原值返回pairiterator, boolbool表示是否真正插入。C17的insert_or_assign则明确「存在就更新不存在就插入」。问题3set的迭代器为什么不能修改元素答因为set底层是有序二叉搜索树元素的相对顺序决定了树的结构。如果直接修改元素值可能破坏有序性导致容器后续操作全部错误。所以set的迭代器本质上是const_iterator修改元素无法通过编译。问题4multimap 和 map 有什么区别答multimap允许键重复不支持operator[]因为同一个键可能有多个值无法确定返回哪个但支持equal_range(key)来获取所有键值为key的元素区间。内部同样是红黑树重复键的元素在树中连续存放。问题5map和unordered_map如何选择答按业务是否需要有序遍历/范围查询来决定。需要有序或范围操作选map数据量大且只做随机读写选unordered_map。另外自定义类型做键时map只需要operatorunordered_map需要hash和operator开发成本也值得考虑。6.2 一段代码串起四种容器的用法把map、set、multimap、unordered_map的核心操作塞进一个能编译运行的示例方便你整体对照#include iostream #include map #include set #include unordered_map #include vector #include string int main() { // 1. map键值对 按键排序 std::mapstd::string, int fruit_count; fruit_count.emplace(apple, 3); fruit_count[banana] 2; fruit_count[cherry] 5; fruit_count[apple] 4; // 覆盖 for (const auto [fruit, cnt] : fruit_count) { std::cout fruit - cnt \n; } // 输出按字母序apple - 4, banana - 2, cherry - 5 // 2. set去重 自动排序 std::vectorint nums {7, 3, 9, 3, 7, 4, 1, 9, 4}; std::setint s(nums.begin(), nums.end()); for (int x : s) { std::cout x ; // 1 3 4 7 9 } std::cout \n; // 3. multimap重复键 equal_range std::multimapstd::string, int scores; scores.emplace(Alice, 90); scores.emplace(Bob, 85); scores.emplace(Alice, 95); auto [begin, end] scores.equal_range(Alice); for (auto it begin; it ! end; it) { std::cout it-first - it-second \n; } // 输出两个Alice的成绩 // 4. unordered_map哈希表无序 std::unordered_mapstd::string, int hash_map; hash_map[cpp] 1; hash_map[java] 2; hash_map[python] 3; std::cout cpp count hash_map.count(cpp) \n; return 0; }这个示例我建议你亲手编译运行一遍重点观察map和multimap输出顺序的区别以及set去重后的结果。跑一遍比看十篇笔记都管用。最后分享一个我自己的体会。学习关联容器时最容易犯的错就是把语法背下来却不理解「为什么这样设计」。比如operator[]为什么不友好、insert为什么返回pair、底层为什么是红黑树——这些问题想通了写代码时会少踩很多坑。另外多读标准库源码或文档里的复杂度标注慢慢你会形成一种直觉用任何容器之前先问自己一句「这个操作是O(1)还是O(log n)我到底需不需要有序」。带着这种意识去写代码容器选型基本不会出大错。
返回列表