我遇到过一个非常典型的咨询:有朋友用vector存了两万条学生学号,每次判断“这个学号有没有登记过”,都只能从头到尾遍历一遍,数据量一上来程序肉眼可见地变卡,他在群里问有没有更好的做法。这个场景几乎是set和map的完美广告:把数据放进合适的容器里,收益立竿见影。
std::set和std::map是 C++ STL 里最常用的两个关联容器,前者用来维护“不重复的有序集合”,后者用来维护“键不重复的键值映射”。它们底层都是红黑树,查找、插入、删除的时间复杂度稳定在 O(log n)。同样是两万条数据,vector最坏要比较两万次才能判断一个元素是否存在,而set大约只需要 15 次左右,差距就是这么来的。很多人一开始被“红黑树”“平衡二叉树”这些术语劝退,其实从使用角度完全不用怕,你只要理解它的外部行为和几个关键语义,就能应对绝大多数业务场景。
这篇文章适合三种人:刚学完vector、string、list想进一步掌握关联容器的初学者;写代码习惯拿vector硬扛查找任务、想提升效率的开发者;以及正在准备面试、需要把set和map的细节和坑位搞清楚的求职者。我会从最基础的初始化、插入讲起,一直写到自定义比较器和实战中的迭代器失效问题,尽量做到零基础也能看懂,看完能直接抄走用。
1. 先搞清楚set和map到底替你做完了什么
1.1 查找效率:从O(n)到O(log n)的本质变化
很多人用vector打天下,是因为它写法最直白:想存什么就往push_back塞什么,想找东西就写个循环遍历。但直白不等于高效。假设你要维护一个学号集合,每次录入前检查学号是否存在:
std::vector<int> ids; bool exists = false; for (int id : ids) { if (id == target) { exists = true; break; } } if (!exists) { ids.push_back(target); }这段代码功能上完全没问题,问题在于每次查找都是 O(n)。数据量小的时候感受不到,一旦上了万级、十万级,或者查询频率很高,性能就会断崖式下跌。std::set的做法是:
std::set<int> ids; if (!ids.count(target)) { ids.insert(target); }底层红黑树让每次查找都只需比较树的高度次,也就是 O(log n)。同样一万条数据,vector最坏要比较一万次,set只需要大约 14 次比较,这个差距不是“稍微快一点”,而是量级上的碾压。
1.2 有序性带来的额外价值
set和map还有一个经常被低估的优势:元素天然有序。vector里存了一堆数,你想输出有序结果,得先sort;想找小于某个值的所有元素,得遍历筛一遍。而set内部本身就是一棵二叉搜索树,遍历输出就是升序,还能用lower_bound、upper_bound快速求范围。
举个实际例子,假设你要维护一个定时任务库,每个任务的到期时间不固定,每次要取出“最早到期的那一个”。用set存到期时间戳,begin()就是最小的那个,取出来再删掉,整个过程都是 O(log n)。如果用vector,每次都要min_element扫一遍。这就是有序结构带来的工程价值,不仅是查得快,而是很多操作都能顺带变简单。
还有一点需要提前说明:C++11 之后多了unordered_set和unordered_map,查找理论上是 O(1)。那为什么还要学set和map?因为哈希版本不保证顺序,不能做范围查询,迭代顺序也不稳定。有序和无序是两种不同的需求,不是替代关系。这篇文章聚焦set和map,后面第 5 节我会专门讲怎么选型。
2. set的基本使用:插入、查找、删除一次讲透
2.1 insert返回值与去重原理
set最核心的特性是“元素不重复”,这个特性直接体现在insert的返回值上。很多人第一次写set时没用过返回值,其实它是set里最值得关注的设计:
#include <set> #include <iostream> int main() { std::set<int> s; // insert返回一个pair<iterator, bool> auto ret1 = s.insert(5); if (ret1.second) { std::cout << "5第一次插入成功" << std::endl; } auto ret2 = s.insert(5); // 再插入相同值 if (!ret2.second) { std::cout << "5已存在,插入失败" << std::endl; } return 0; }insert返回的std::pair里,first是指向该元素的迭代器,second是bool,表示是否真正插入成功。这个设计非常实用:如果你需要知道“当前这个值是不是新来的”,可以直接看second,省掉先count再insert两步操作。比如你要统计一个序列里每个元素第一次出现的位置,用second就能轻松实现。
还有一种很常用的初始化方式是从其他容器批量插入:
std::vector<int> vec = {3, 1, 4, 1, 5, 9, 2, 6, 5}; std::set<int> s(vec.begin(), vec.end()); // s = {1, 2, 3, 4, 5, 6, 9}一行代码完成了“去重 + 排序”。这是set在工程里最高频的用法之一,很多数据处理任务就是把原始数据扔进set,瞬间得到不重复的有序列表。
2.2 find、count与erase的正确用法
查找元素时,find和count都可行,但语义侧重不同。find返回迭代器,适合“找到了还要拿元素做点什么”的场景;count返回个数,适合“只关心在不在”的判断。
std::set<int> s = {10, 20, 30, 40}; // 用find查找 auto it = s.find(20); if (it != s.end()) { std::cout << "找到了:" << *it << std::endl; } // 用count判断存在性 if (s.count(20)) { std::cout << "20在集合里" << std::endl; }这里有个小坑:set::count返回值只有 0 和 1,因为set不重复;但multiset::count返回的是实际个数,可能大于 1。如果你在multiset上用count判断存在性,语义还算准确,但如果你只是想知道“有没有”,总感觉心里别扭。我个人的习惯是:判断存在用find,统计数量才用count。
删除有三种常见姿势:
s.erase(20); // 按值删除,返回删除个数(set下是0或1) s.erase(s.begin()); // 按迭代器删除,C++11之后返回下一个迭代器 s.erase(s.begin(), s.end()); // 删除一段范围特别提醒:erase(iterator)的返回值在 C++11 前后有变化。C++11 之前返回void,之后返回迭代器。如果你在维护老代码,或者做代码审查,这个细节很容易被忽略,后面第 5 节讲循环删除时会再展开。
遍历set直接用范围for:
for (int x : s) { std::cout << x << " "; } // 输出一定是升序注意,set的元素是const的,不能通过迭代器修改。原因很朴素:如果允许随便改元素,红黑树的有序性就可能被破坏。这个约束不是限制你,而是保护你。
2.3 multiset:允许重复的有序集合
set保证唯一,multiset允许重复。两者内部都是红黑树,元素都有序,区别只在插入时是否允许相同值重复出现。
典型场景是成绩排名:并列成绩要同时存在,不能去重。又比如滑动窗口维护中位数这类问题,multiset能很方便地动态维护有序集合,配合advance找到中间位置的元素。
但multiset有个很容易踩的坑:erase(值)会删除所有等于该值的元素,而不是只删一个。我在一次代码评审里见过这样的失误——朋友想清理成绩表里某个学生的记录,结果erase(60)把所有 60 分都删了,数据直接丢了一大片。如果只想删一个,必须先find拿到迭代器,再erase(iterator):
std::multiset<int> ms = {1, 2, 2, 2, 3}; ms.erase(2); // 删除所有2,变成{1, 3} std::multiset<int> ms2 = {1, 2, 2, 2, 3}; auto it = ms2.find(2); if (it != ms2.end()) { ms2.erase(it); // 只删除一个2,变成{1, 2, 2, 3} }这个差异背后是erase(value)和erase(iterator)两种重载的不同语义,理解之后才能避免数据事故。
3. map的基本使用:键值映射的正确打开方式
3.1 初始化与三种插入方式的差别
map的每个元素是一个std::pair<const Key, Value>。如果你需要按学号查姓名、按配置项查配置值、按单词统计出现次数,map就是为此而生的。
初始化可以直接用初始化列表:
#include <map> #include <string> std::map<std::string, int> scores = { {"stu_1001", 90}, {"stu_1002", 85}, {"stu_1003", 78} };注意这里键会自动按字典序排序,后续遍历时顺序是 “stu_1001、stu_1002、stu_1003”,而不是你写的顺序。如果你需要保持插入顺序,那得换unordered_map或者其他结构,map做不到。
插入的三种常用方式:
// 方式1:insert + make_pair scores.insert(std::make_pair("stu_1004", 92)); // 方式2:emplace,直接构造,效率更高 scores.emplace("stu_1005", 88); // 方式3:operator[] scores["stu_1006"] = 95;三者的行为有差异:insert和emplace在键已存在时不会覆盖原有值;operator[]在键不存在时会“凭空造”一个键值对,再让你赋值。这个差异是map最经典的坑,下面单独说。
3.2 operator[]、find、count、at的语义差异
初学者特别容易用operator[]做“只读”操作,比如判断一个键是否存在:
if (scores["stu_1007"] > 0) { // 做一些处理 }这段代码有隐患:如果"stu_1007"这个键不存在,operator[]会先插入一个{"stu_1007", 0},然后再返回值 0。也就是说,你只是想查一下,结果map里多了一个默认键值对。在统计、计数、配置读取这些场景下,这会导致数据莫名其妙变多,排查起来还特别隐蔽。
正确做法是用find:
auto it = scores.find("stu_1007"); if (it != scores.end()) { int val = it->second; // it->first是键,it->second是值 } else { // 键不存在 }只需要判断“是否存在”,直接用count:
if (scores.count("stu_1007")) { // 键存在 }C++11 还提供了一个at()成员函数:
try { int val = scores.at("stu_1007"); // 键不存在会抛std::out_of_range } catch (const std::out_of_range& e) { // 处理异常 }at()和operator[]的区别就在这里:at不会自动插入,键不存在时抛异常。如果你的逻辑要求“只有已存在的键才能读取”,用at比operator[]安全得多,也更容易定位错误。
3.3 遍历与修改:不能只盯着值
遍历map时,每个元素是一个pair:
for (const auto& kv : scores) { std::cout << kv.first << " : " << kv.second << std::endl; }我的经验是:遍历时坚持用const auto&或auto&,不要用auto。因为pair的拷贝可能涉及std::string的深拷贝,数据量一大,拷贝开销比遍历本身的逻辑还高。这是性能优化里最容易被忽略的一环。
修改value部分很简单:
scores["stu_1001"] = 91; // 键存在时直接改值 scores.at("stu_1002") = 86; // 键不存在时抛异常但key部分是const的,无法修改。这和set元素不可修改是同一个道理:红黑树的有序性依赖键的大小关系,改键就等于破坏结构。后续第 5 节我会讲怎么正确“改键”。
3.4 multimap:一对多场景的正确姿势
multimap允许同一个键对应多个值。典型场景是一个分类下有多个条目,比如一门课程有多名学生,一个标签下有多篇文章。
multimap有个反直觉的设计:它不支持operator[]。原因很好理解——同一个键对应多个值,operator[]不知道该返回哪一个。插入可以用insert或emplace,查找最常用的接口是equal_range:
std::multimap<std::string, int> courseScores; courseScores.emplace("Math", 90); courseScores.emplace("Math", 95); courseScores.emplace("English", 88); auto range = courseScores.equal_range("Math"); for (auto it = range.first; it != range.second; ++it) { std::cout << it->first << " : " << it->second << std::endl; }equal_range返回两个迭代器,中间夹着所有键为"Math"的元素。这是multimap最核心的查找姿势,几乎找不到替代方案。如果你用find,只能拿到第一个;用count,只能知道数量;真正要遍历全部,equal_range才是正解。
4. 自定义类型与比较规则:让set和map认识你的数据结构
4.1 没有operator<会怎样
set和map默认使用std::less<Key>,也就是依赖operator<来决定元素顺序和唯一性。对于int、std::string这些类型,标准库已经帮你定义好了。但实际项目里,你的键往往是一个自定义结构体。
#include <set> struct Student { int id; int score; }; int main() { std::set<Student> s; // 编译错误:找不到Student的operator< return 0; }要让Student能放进set,有两个办法:一是给Student重载operator<,二是给set传入自定义比较器。重载是最直观的方式:
struct Student { int id; int score; bool operator<(const Student& other) const { return id < other.id; } };注意两点:第一,operator<必须是const成员函数;第二,比较逻辑一定要稳定。这里operator<决定了两件事:顺序是升序还是降序,以及“什么算同一个元素”。
4.2 严格弱序与“什么才叫重复”
很多人重载operator<时只想着“能编译”,很少想“比较规则是否合法”。标准库要求比较规则必须满足“严格弱序”,用大白话说就是:如果a < b为假且b < a也为假,那么a和b就被认为是等价的。
这带来一个非常实际的坑:如果你只按score比较,那么两个score相同但id不同的Student,在set眼里就是“同一个人”,后插入的那个会被静默丢弃。
struct Student { int id; int score; bool operator<(const Student& other) const { return score < other.score; // 只看score } }; std::set<Student> s; s.insert({1, 90}); s.insert({2, 90}); // 不会插入!因为{1,90}和{2,90}按score比较等价这通常不是你想要的。解决办法是:先比主要字段,再比次要字段打破平局:
struct Student { int id; int score; bool operator<(const Student& other) const { if (score != other.score) { return score < other.score; } return id < other.id; // 分数相同就用id打破平局 } };这样既保持了“按分数排序”的语义,又不会把不同学生误判为重复。这个“什么才算等价”的决策,必须在写比较规则之前想清楚。
4.3 用仿函数和lambda自定义比较器
有时候你不想修改原结构体定义,或者同一个结构体需要支持多种排序方式,这时可以给容器传入自定义比较器。
最稳的方式是仿函数:
struct CompareByName { bool operator()(const Student& a, const Student& b) const { return a.score > b.score; // 降序排列 } }; std::set<Student, CompareByName> students;C++11 之后也可以用 lambda,但set的模板参数需要一个类型,所以要结合decltype:
auto cmp = [](const Student& a, const Student& b) { return a.id < b.id; }; std::set<Student, decltype(cmp)> students(cmp);这里有个容易踩的编译坑:decltype(cmp)作为模板参数时,构造students时必须把cmp传进去,因为默认构造的 lambda 不可用(C++20 之前)。漏掉构造参数就会编译报错,而且报错信息还不太友好。
map的自定义比较器原理完全一样,作用于key。如果你需要按某种自定义顺序遍历map,传一个适合的比较器进去即可。
5. 实战中反复踩过的几个坑与选型建议
5.1 operator[]语义导致的额外插入问题
前面提过,operator[]在键不存在时会插入默认值。这在统计计数场景下特别容易出问题。我见过类似这样的代码:
std::map<std::string, int> wordCount; for (const auto& word : words) { if (wordCount[word] > 0) { wordCount[word]++; } else { wordCount[word] = 1; } }这段代码的问题在于:wordCount[word]这个判断本身就会写入默认值 0,如果这是一个新单词,随后你还要把它改成 1,等于每个新单词被写入了两次。虽然最终统计结果可能没错,但逻辑非常混乱。更干净的做法是:
auto ret = wordCount.insert({word, 1}); if (!ret.second) { ret.first->second++; }利用insert返回的bool判断是否新插入,一步到位,既不会额外插入,也不会重复计数。
还有一个常见场景是配置管理:读取配置时用operator[]判断某个项是否存在,结果每次运行都会往map里塞一堆默认配置项。最后遍历输出时,你会看到很多本不该出现的键。排查这类问题,第一步就是检查有没有把“查找”和“插入”混在一起。
5.2 循环删除时的迭代器失效
在set或map中边遍历边删除,是迭代器失效的高发区。看这段错误代码:
std::set<int> s = {1, 2, 3, 4, 5, 6}; for (auto it = s.begin(); it != s.end(); ++it) { if (*it % 2 == 0) { s.erase(it); // 迭代器已经失效,再++就是未定义行为 } }编译不报错,运行偶尔正常偶尔崩溃,排查起来特别难受。C++11 之前的标准写法是:
for (auto it = s.begin(); it != s.end(); ) { if (*it % 2 == 0) { s.erase(it++); // 先返回当前it,再自增,erase删除的仍然是原it } else { ++it; } }C++11 之后,erase(iterator)会返回下一个有效迭代器,所以更推荐这样写:
for (auto it = s.begin(); it != s.end(); ) { if (*it % 2 == 0) { it = s.erase(it); // 直接接收下一个迭代器 } else { ++it; } }map同理。这个细节我在代码评审里反复提醒过,因为它不是一眼能看出的逻辑 bug,而是隐性的未定义行为。
5.3 修改键值等于破坏结构:只能删旧插新
set的元素是const,map的键也是const。这是设计使然,不是限制你。红黑树的自平衡依赖节点之间的顺序关系,如果你能直接改掉某个元素的值,树的有序性瞬间崩溃,后续的插入、查找、遍历都会乱套。
如果想“修改”set中的一个元素,标准做法是删除再插入:
std::set<int> s = {1, 2, 3, 5}; s.erase(2); s.insert(100); // 想改2为100,先删再插map想改键也一样,先删除旧键值对,再插入新键值对:
std::map<int, std::string> m; m.emplace(1, "old"); m.erase(1); m.emplace(2, "new");而不能尝试通过迭代器去改first,那是编译都过不去的。
5.4 何时用set/map,何时改用unordered版本
最后说选型。我的个人习惯是:
- 键唯一,需要有序遍历或范围查询:
set/map - 键唯一,不需要有序,只追求查找速度:
unordered_set/unordered_map - 键可重复,需要有序:
multiset/multimap - 键可重复,不需要有序:
unordered_multiset/unordered_multimap
在数据量不大(几千以内)时,set/map和unordered版本性能差距不明显;数据量上了十万级,哈希版本的优势才体现出来。但要注意,unordered的迭代顺序不固定,不能依赖它做有序输出和范围遍历。
还有一个很实际的考量:自定义类型做unordered容器的键时需要定义哈希函数,比定义operator<或者比较器复杂得多。哈希函数写不好,碰撞率一高,性能反而可能不如红黑树。所以我建议默认先用有序版本,等用性能分析工具确认它成了瓶颈,再针对性换成unordered,不要一上来就凭感觉做“未经验证的优化”。
另外,如果你需要维护“最早到期”这类极值场景,map的begin()就是最值,拿取和删除都是 O(log n)。如果你用unordered_map,还得额外维护一个堆或者别的结构才能做到同样效果。选型从来不是看单个操作的速度,而是看你整体的数据结构设计。
最后再分享一个小技巧:调试set和map的内容时,经常需要把全部元素输出一遍确认逻辑。有人会写一大段迭代器循环,其实范围for就够了。如果你在排查和operator[]有关的 bug,试着把代码里的operator[]临时全换成find,重跑一遍,通常很快就能定位是不是“额外插入”引起的。这个方法我在实际项目里用过很多次,简单但非常好用。