1. 先弄明白map和set是什么
1.1 用生活场景理解「键值对」与「集合」
很多学C++的朋友第一次接触map和set时,最容易产生的困惑是:这俩东西和数组、vector到底有什么区别?用一句话说明白:vector是按下标存东西的有序序列,map是按名字找东西的字典,set是只记名字不记东西的集合。
举个生活例子。你想记全班同学的电话号码,用vector可以这样写:phones[0] = "138xxxx",问题是下标0是谁?没人记得住。用map就很自然:phones["张三"] = "138xxxx"。张三就是键(key),电话号码就是值(value),map负责帮你把「根据键找值」这件事做到最高效。
set就更简单了。你只需要记录「哪些同学今天交作业了」,不需要额外存任何值,set就是干这个的——只存一堆元素,且元素不重复、自动排序。
所以map解决的是「键值映射」问题,set解决的是「唯一集合」问题。两者本质上都建立在同一套有序二叉树结构之上,理解了一个,另一个也就不难了。
1.2 C++里四类关联容器的一分钟速览
C++标准库里的关联容器有四大金刚,它们的关系和区别特别清晰:
| 容器 | 元素类型 | 键是否允许重复 | 是否有序 | 底层实现 | 查找复杂度 |
|---|---|---|---|---|---|
std::map | pair<const Key, T> | 否 | 是(默认升序) | 红黑树 | O(log n) |
std::set | Key | 否 | 是(默认升序) | 红黑树 | O(log n) |
std::multimap | pair<const Key, T> | 是 | 是(默认升序) | 红黑树 | O(log n) |
std::multiset | Key | 是 | 是(默认升序) | 红黑树 | O(log n) |
先记住这四张脸。map和set的键唯一,multimap和multiset允许重复。它们都是有序的,因为底层是红黑树。另外还有四个unordered系列(unordered_map、unordered_set、unordered_multimap、unordered_multiset),底层是哈希表,无序,平均O(1)查找,这个后面单独说。
注意:C++里有个细节经常被踩——默认排序是升序,用的是
std::less<Key>,也就是operator<。如果你的类型没有提供operator<,把这些类型放进map或set会直接编译报错。
接下来的篇幅,我按「map → set → 底层原理 → 避坑 → 面试」这条线,逐个拆开揉碎讲。
2. std::map用法拆解:从初始化到遍历的完整实战
2.1 初始化与插入:insert、emplace和[]三者的区别
map的插入有四种常用写法,很多教程会让初学者背下来,但真正重要的是理解它们之间的差异。
#include <iostream> #include <map> #include <string> int main() { std::map<std::string, int> scores; // 方式1:花括号初始化列表(C++11) scores.insert({"Alice", 90}); // 方式2:make_pair scores.insert(std::make_pair("Bob", 85)); // 方式3:显式pair scores.insert(std::pair<std::string, int>("Charlie", 92)); // 方式4:emplace(推荐) scores.emplace("David", 88); // 方式5:[]运算符直接赋值 scores["Eve"] = 95; return 0; }这五种的差别在哪?insert在键已存在时不会覆盖旧值,而operator[]在键已存在时会覆盖旧值。emplace则是直接在容器内部构造元素,避免了临时对象的拷贝,性能比insert略好。make_pair和pair的写法在C++11之后其实有点冗余了,直接花括号比较清爽。
这里还藏着一个极其经典的坑:operator[]在键不存在时,会默认构造一个新元素插进去。
std::map<std::string, int> m; std::cout << m["hello"] << std::endl; // 不报错,输出0,但m里多了一个{"hello", 0}也就是说,你只是查一下,结果把数据写进去了。这个问题我在实际项目里见过不止一次,最典型的场景是统计词频时误用了[],统计结果莫名其妙多了一堆计数为0的键。如果想「查找,不存在就不插入」,得用find或contains,这个下一节细说。
2.2 查找与删除:find、contains、erase的正确打开方式
map的查找有三种常用手段,按C++版本不同有不同偏好。
std::map<std::string, int> scores = { {"Alice", 90}, {"Bob", 85}, {"Charlie", 92} }; // 方式1:find(C++11之前就有) auto it = scores.find("Bob"); if (it != scores.end()) { std::cout << "Bob: " << it->second << std::endl; } else { std::cout << "not found" << std::endl; } // 方式2:count(C++20之前用于判断是否存在) if (scores.count("Alice")) { std::cout << "Alice exists" << std::endl; } // 方式3:contains(C++20引入,语义最清晰) if (scores.contains("Charlie")) { std::cout << "Charlie exists" << std::endl; }find返回迭代器,找不到时返回end(),代价是O(log n)。count在map上返回0或1,因为键唯一,判断存在性很好用,但语义上有点绕(「数一下有几个」听起来不像「查是否存在」)。C++20之后contains最直观,项目如果允许用C++20,优先用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的遍历在C++11之前只能老老实实用迭代器。
for (auto it = scores.begin(); it != scores.end(); ++it) { std::cout << it->first << " -> " << it->second << std::endl; }it->first是键,it->second是值,这个写法不直观,而且容易手滑把first和second搞反。C++11有了范围for之后,稍微好一点但仍然要写p.first:
for (const auto& p : scores) { std::cout << p.first << " -> " << p.second << std::endl; }C++17引入结构化绑定,这才真的舒服了:
for (const auto& [key, value] : scores) { std::cout << key << " -> " << value << std::endl; }这里有个性能细节值得说:遍历时一定要用const auto&,不要用auto。map的value_type是pair<const 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::set<int> 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::vector<int> nums = {5, 3, 8, 3, 1, 5, 8, 2}; std::set<int> 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返回值设计得很有意思,是一个pair<iterator, bool>。
std::set<int> 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):返回一个pair<iterator, iterator>,区间[lower_bound(k), upper_bound(k))包含所有等于k的元素。
std::set<int> 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对于map,lower_bound和upper_bound操作的键的范围,返回的迭代器同样是pair<const Key, T>的迭代器。
这个特性的应用场景很广。比如系统里有一批订单记录按时间存放,你想找出「时间在某个范围内所有订单」,lower_bound+upper_bound就是标准答案:
std::map<time_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或multiset,equal_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(自定义比较器) |
| 键是自定义类型且很难写hash | map(只需operator<) |
有一个细节很容易被忽略:unordered_map遍历出来的顺序是随机的,跟插入顺序无关。如果哪天你在测试环境发现unordered_map打印顺序「偶尔变」,这不是bug,是哈希表现。有些人在这上面排查了很久才发现是容器本身无序。
还有一个工程细节:自定义类型放进unordered_map需要同时提供哈希函数和operator==,而放进map只需要operator<。写起来前者麻烦很多,这也是很多人「懒得折腾」继续用map的原因。性能差距在数据量几万、几十万级别时体感并不明显,通常到百万级别以上才需要考虑切换到unordered系列。
5. 关联容器避坑指南:我实际踩过的一些坑
5.1 最经典的坑:operator[] 误插入元素
这个坑上文提过,但值得单独再说一遍,因为它真的很容易出线上问题。
我之前做过一个日志统计组件,统计每个IP的请求次数,代码长这样:
std::map<std::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); }C++17以后也可以用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,这在不少实现上也许看起来是在遍历后续元素,但严格来说是未定义行为。C++11之后,标准给map/set的erase增加了一个返回值:返回被删元素的下一个迭代器,所以可以这样写:
// 正确示范(C++11) for (auto it = m.begin(); it != m.end();) { if (it->second < 60) { it = m.erase(it); // erase返回下一个迭代器 } else { ++it; } }如果你用的是C++98/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<,编译会报一长串模板错误,很多新手直接被吓懵。
错误信息长归长,核心问题就一句话:编译器找不到如何比较这个类型。解决办法有两个:
办法一:给自定义类型重载operator<:
struct Student { int id; std::string name; }; bool operator<(const Student& a, const Student& b) { return a.id < b.id; } std::set<Student> 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::set<Student, StudentLess> students; std::map<Student, int, StudentLess> scores; // OK第二种方式更灵活,因为一个类可以有多种比较规则,你可以设计不同的仿函数来实现不同的排序方式,而不用改类型本身。注意:比较器必须是严格弱序(strict weak ordering),即传递性、非自反性、反对称性都要满足。最常见的错误是只比较了其中一个字段,结果两个不同对象被判定为「相等」,导致其中一个插不进去。
5.4 map和set的性能挑选:不是所有场景都该用它们
最后想分享一个关于「什么时候别用map」的经验。
很多人一看到「根据key找value」就顺手用map,但有些场景其实有更好的选择。比如键是连续的整数(如ID从0到999999),map的O(log n)虽然不慢,但直接用vector(或数组)按下标访问是O(1),而且内存友好得多。
有一道经典的LeetCode题「字符串中的第一个唯一字符」,很多解法都用map统计字符次数。但字符集只有26个字母,用一个26长的整型数组就够了,何必上map?这就是典型的「杀鸡用牛刀」。
再比如,你需要频繁按顺序遍历键值对,但容器本身几乎没有插入删除操作,那不如把数据放进vector<pair<K,V>>,使用时再排序。因为vector在内存中连续存储,遍历时的CPU缓存命中率远高于红黑树节点分散在内存各处的map。数据量一大(比如千万级别),这个差距会很可观。
我个人的选择原则是:
- 数据量小(几千以下):随便,vector排序或map都行,性能差别感知不到。
- 需要频繁按key插入删除查找:map/unordered_map。
- 需要有序迭代或范围查询:map。
- 只做一次构建、之后只读:vector + sort + binary_search,性能通常更好。
- 键是连续整数:直接用数组/vector。
工具没有绝对的好坏,选型永远看业务场景。把每个容器的设计初衷理解了,用起来才不会别扭。
6. 面试高频题与综合实战演示
6.1 C++面试「八股」:map相关的几个高频问答
C++面试绕不开map,常被问的问题基本就这几个,我按面试官的视角把答案整理成速查:
问题1:map底层为什么是红黑树,而不是AVL树或哈希表?
答:红黑树和AVL都是平衡二叉搜索树,但AVL平衡更严格,插入删除时旋转次数更多,性能波动大。红黑树的平衡约束宽松,插入最多两次旋转、删除最多三次旋转,整体读写性能更均衡。不用哈希表是因为哈希表无序,无法支持有序遍历和范围查询,而map的标准语义就要求有序。如果需要真正的O(1)平均查找且不需要顺序,请用unordered_map。
问题2:operator[] 和 insert 有什么区别?
答:operator[]在键不存在时会先默认构造一个value并插入,再返回value的引用,因此存在意外插入的副作用。insert在键已存在时不会覆盖原值,返回pair<iterator, bool>,bool表示是否真正插入。C++17的insert_or_assign则明确「存在就更新,不存在就插入」。
问题3:set的迭代器为什么不能修改元素?
答:因为set底层是有序二叉搜索树,元素的相对顺序决定了树的结构。如果直接修改元素值,可能破坏有序性,导致容器后续操作全部错误。所以set的迭代器本质上是const_iterator,修改元素无法通过编译。
问题4:multimap 和 map 有什么区别?
答:multimap允许键重复,不支持operator[](因为同一个键可能有多个值,无法确定返回哪个),但支持equal_range(key)来获取所有键值为key的元素区间。内部同样是红黑树,重复键的元素在树中连续存放。
问题5:map和unordered_map如何选择?
答:按业务是否需要有序遍历/范围查询来决定。需要有序或范围操作选map;数据量大且只做随机读写选unordered_map。另外自定义类型做键时,map只需要operator<,unordered_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::map<std::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::vector<int> nums = {7, 3, 9, 3, 7, 4, 1, 9, 4}; std::set<int> 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::multimap<std::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_map<std::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)?我到底需不需要有序?」。带着这种意识去写代码,容器选型基本不会出大错。