
如果你写过一段时间C大概率和我一样早期碰到“统计一串数据里每个数字出现几次”“根据某个ID快速找到对应的用户信息”这类需求第一反应是开数组、开vector硬扫。数据量小还好一旦数据量上来或者数据类型根本没法当下标用比如字符串、结构体这套思路立马崩。今天这篇笔记我就把 set、map 这两个最常用的关联容器连同它们各自带重复语义的版本 multiset、multimap一次性讲透。内容包括底层原理、常用API、实战场景以及一些我在实际项目中踩过的坑。不管你是在准备面试还是想把手头的代码写得更优雅这篇应该都能帮上忙。1. set和map在C容器体系中的定位1.1 关联容器到底解决了什么问题C的STL容器粗略分两类序列容器和关联容器。vector、deque、list属于前者它们强调“元素按什么顺序排列、怎么插入删除”。而 set、map 这类关联容器核心卖点不是存储而是检索。你可以把关联容器的价值理解成“一个自带快速查找功能的抽屉柜”。数组也能查找但你要知道下标链表也能查找但只能线性扫。而 set/map 你只需要告诉它“有没有这个值”“这个键对应什么内容”它内部能在对数时间内回答你。这里的底层支撑就是红黑树它让所有操作稳定在 O(log n)。另一个容易忽略的点是set 和 map 存储元素时天然有序。这一点在做范围查询、找最大最小、按顺序遍历时非常方便。比如你要“找出所有分数在[60, 90)之间的学生”用 map 按分数做键再配合 lower_bound/upper_bound几行代码就搞定了用数组模拟反而麻烦。1.2 set与map的核心差异两者的区别严格说只有一条set 存储的是“键本身”map 存储的是“键值对”。set 就像体检时用的名单你只关心“张三有没有来”map 则像通讯录你不仅关心“有没有这个人”还要拿他的电话、地址。在C语法层面set 的元素类型是 Tmap 的元素类型是 pairconst Key, T。这个 const 很关键等下讲修改和迭代器失效时你会感受到它的分量。从使用场景看set 一般用来做去重、集合运算、存在性判断map 用来做键值映射、缓存、计数、字典。两者内部都靠红黑树所以时间复杂度、迭代器稳定性这些性质高度相似学会了 setmap 就是换了个壳。还有一点需要注意C11 以后有了 unordered_set 和 unordered_map很多新手容易搞混。简单记——需要有序遍历、范围查询、稳定的迭代器就选 set/map只追求单点查找速度、对顺序完全不关心unordered 系列更快。后面我专门有一节对比它们这儿先不展开。2. 底层机制红黑树与有序性从哪来2.1 红黑树靠什么保证查询效率红黑树不是C作者的发明它是一类自平衡二叉搜索树。二叉搜索树的理想状态是“每次比较砍掉一半”但普通的搜索树如果插入顺序不幸比如已经有序会退化成一个链表查找直接变成O(n)。红黑树通过给节点涂色配合旋转操作保证任何一条路径的长度差不会超过另一条两倍这样整棵树始终维持在近似平衡的状态。可能你觉得“保持平衡”是数学上的事跟你写代码没关系。实际上它直接决定了你的程序上限100万量级的数据红黑树查找只需约20次比较而链表遍历平均要50万次。这就是“对数时间”的真实含义。C里的 std::set、std::map、std::multiset、std::multimap 四个容器底层都是一棵红黑树区别只在于节点结构不同。另外红黑树是节点式存储每个元素在堆上独立分配。这意味着插入和删除不需要搬动已有元素指向元素的指针、迭代器不会因为其他元素的插入删除而失效。这是它比 vector 更“抗造”的地方也是很多实时系统选它的理由。2.2 比较器有序性的核心规则红黑树必须能判断“谁大谁小”这个判断规则由比较器提供。默认情况下用的是 std::less 也就是调用 operator。std::setint s1; // 默认升序 std::setint, std::greaterint s2; // 降序自定义类型就不一样了。如果你直接往 set 里塞一个没定义 operator 的结构体编译都过不了struct Student { int id; std::string name; }; // 未定义 operatorstd::setStudent 编译报错正确做法有两种一是给类型定义 operator二是给容器传入自定义仿函数或lambda。前者更通用后者更灵活。我个人的习惯是如果这个类型本质上就有一种“自然顺序”比如按id排就定义 operator如果有多种排序需求有时按名字、有时按年龄就用自定义比较器别把规则焊死在类型里。自定义比较器还有一个隐藏要求必须满足“严格弱序”。简单说就是三个性质——反自反a a 恒为false、反对称ab 则 ba 为false、传递性ab且bc 则 ac。很多人写的比较器漏掉第一条导致结果飘忽不定之后会有专门一节讲这个坑。3. set与multiset的完整实操3.1 set的基础操作与遍历#include set #include iostream int main() { std::setint st; st.insert(5); st.insert(3); st.insert(8); st.insert(3); // 重复插入set会忽略 std::cout size st.size() \n; // 3 for (int x : st) { std::cout x ; // 3 5 8自动升序 } }这段代码里有一个特别容易踩的直觉误区你以为 set 的“忽略重复”是先把重复值存下来再过滤其实不是。set 在插入时会先用红黑树查找一遍如果发现已有相等元素直接返回连节点都不建。所以在循环里调用 insert 也是安全的不会越插越大。set 的 insert 返回值是 pairiterator, bool。iterator 指向现有元素bool 表示是否真的插进去了。这个返回值在需要“把数据同时塞进多个集合且想知道哪个集合先拥有它”的场景非常好用。而 find、erase、count 这些操作也都是先走一遍树的搜索路径所以它们的时间复杂度全是 O(log n)。3.2 边界查找find、count、lower_bound与upper_boundset 里最常用的是 find 和 count但它们的语义其实有差异。count 在 set 里只会返回 0 或 1因为不重复而在 multiset 里会返回实际个数。find 则是返回迭代器判断是否存在应该用st.find(x) ! st.end()。真正强大的是 lower_bound 和 upper_bound它们是做区间查询的核心工具std::setint st {1, 3, 5, 7, 9}; // 第一个 5 的元素 auto it1 st.lower_bound(5); // 指向 5 // 第一个 5 的元素 auto it2 st.upper_bound(5); // 指向 7 // 搜索区间 [5, 8) 的所有元素 for (auto it st.lower_bound(5); it ! st.upper_bound(8); it) { std::cout *it ; // 5 7 }注意 lower_bound 的语义是“不小于第一个参数”upper_bound 是“严格大于”这个细节很多人会搞反。做闭区间查询时上界记得用 upper_bound(r) 而不是 lower_bound(r)否则会把右边界多包含进来。如果业务里经常要做“查年龄在20到30之间的用户”这种事这套组合就是你的常备武器。equel_range 在 set 这里的返回值等价于 pair(lower_bound(x), upper_bound(x))但它在 multiset 里才能真正体现价值。等下我会详细说。3.3 multiset允许重复后的搜索逻辑multiset 和 set 只差一个词允许重复。但这一个差别牵动了很多行为insert 永远成功返回值退化为迭代器不告诉你是否插入count(x) 返回 x 出现的次数这个调用要遍历整棵树去统计复杂度 O(logn k)。如果重复特别多别用它做“存在性判断”用 find 更快erase(x) 会删除所有等于 x 的元素而不是只删一个。想只删一个要用st.erase(st.find(x))std::multisetint ms; ms.insert(1); ms.insert(2); ms.insert(2); ms.insert(3); ms.erase(2); // 现在只剩 1, 3 ms.insert(2); ms.insert(2); ms.erase(ms.find(2)); // 只删一个2剩下一个2这个“erase(值) 删全部”的行为坑过很多同事。我的建议是如果容器里可能有重复且你想精确删除某一个永远用迭代器版本的 erase。对应的还有一件事证明删除后迭代器不会失效因为红黑树节点释放是即时的。multiset 用得最爽的场景是处理“滑动窗口内的中位数”这类问题。你把窗口里的数扔进 multiset用 advance 找到中间位置的迭代器每次窗口滑动只需一次插入、一次删除、一次 advance复杂度比每次排序低得多。C 里没有内置的“有序多重集合 高效按排名取元素”multiset 已经是最接近需求的容器配合 advance 勉强能用。3.4 应用案例去重与频次统计去重是 set 最直白的应用。我写过一段数据清洗代码从日志文件里解析出大量用户ID要去重后统计留存。直接塞进 setstd::setstd::string unique_users; std::string id; while (getline(log_file, id)) { unique_users.insert(id); } std::cout unique users: unique_users.size() \n;这段代码的好处不止是去重还顺手排了序。后续如果要按字典序输出给别的系统set 直接输出就行。频次统计则适合用 map等一下我会讲但你也可以用 multiset count 来做。数据量小的时候几行代码就能输出频率。不过要注意如果有100万条日志、涉及10万个不同ID你用 multiset 做统计每个 count 都会从树根重新走一遍整体复杂度 O(nlogn * k)效率并不好。这种时候应该选 map 单次遍历累加复杂度只有 O(nlogn)。选择容器不只是“能用”还要考虑操作频率。4. map与multimap的完整实操4.1 map的键值对操作与insert细节map 就是 set 的“加值版”每个节点存储一个 pairconst Key, T。它最经典的用法是统计词频#include map #include string #include iostream std::mapstd::string, int word_count; word_count[hello]; word_count[world]; for (const auto [word, cnt] : word_count) { std::cout word : cnt \n; }这里藏着一个非常常见的坑word_count[hello]如果键不存在map 会先插入一个默认构造的值这里是 0然后再返回引用让你修改。这意味着“查一下某个键存不存在”如果写成if (mp[key])会在 map 里留下一个垃圾键。尤其是后续遍历时发现多了很多莫名其妙的空元素多半就是这个原因。正确判断键是否存在应该用 find 或者 C20 的 containsif (mp.contains(key)) { // 存在 } if (mp.find(key) ! mp.end()) { // 存在 }insert 和 operator[] 的行为也有区别。insert 在键已存在时不会覆盖旧值而 operator[] 会覆盖因为它是先拿到引用再覆盖。如果你想把一个键值对塞进去、又不想动已经存在的旧数据用 insert如果明确要“存最新的”用 operator[] 或者 C17 的 insert_or_assign。这个细节我写缓存时踩过两次后来干脆固定成一条铁律读操作绝不碰 operator[]写操作先想清楚“覆盖还是保留”。4.2 map的遍历、查找与删除map 的迭代器解引用出来是 pairconst Key, T尽量用结构化绑定去拆别老写it-firstit-second代码可读性差一截for (auto it mp.begin(); it ! mp.end();) { if (it-second 0) { it mp.erase(it); // C11以后返回下一个迭代器 } else { it; } }这里特别强调 erase 的返回值。老版本CC03的 erase 返回 void删除后必须手动保存下一个迭代器C11 开始返回下一个有效迭代器循环删除时直接用返回值更新即可。这是个容易导致未定义行为的地方迭代器失效不单单是 vector 的专利map 虽然稳但你如果边遍历边删不接收返回值照样崩。至于删除单个键值对erase(key)按键删最快复杂度 O(log n)。如果想删“一组范围内的数据”就配合 lower_bound/upper_bound 先拿到区间再擦// 删除所有年龄在 [20, 35) 的记录 mp.erase(mp.lower_bound(20), mp.upper_bound(35));4.3 multimap一对多映射的真面目multimap 解决的问题是“一个键对应多个值”。比如一个班级里同一个老师带多个学生、一份订单对应多个商品。C 里没有一套“键-值集合”的专用容器multimap 是最接近的替代品。multimap 不允许使用 operator[]因为“键重复时到底返回哪个值”没有意义。所有插入都用 insert同一键可以不断插入新值且这些值不是键之间不要求唯一。查找一个键对应的全部值标准做法是 equal_range#include map std::multimapstd::string, std::string courses; courses.insert({math, Alice}); courses.insert({math, Bob}); courses.insert({cs, Carol}); auto range courses.equal_range(math); for (auto it range.first; it ! range.second; it) { std::cout it-second ; // Alice Bob }equal_range 返回 pairlower_bound(key), upper_bound(key)也就是“所有键等于 key 的区间”。你完全可以替代手写 lower_bound 再循环一个函数拿全。实际开发中如果发现自己在用 multimap 且经常要遍历某个键下的所有值我建议评估一下换成mapKey, vectorValue是否更顺手。后者在存数据的时候把同一个键的值收拢进数组访问时一次性取出缓存友好度更高。multimap 的优势在于“新值进来不需要手动维护数组”但如果你做的是“把一批数据攒好再统一处理”vector 版往往更快更好调试。这类容器选择没有绝对答案全看操作重心在哪。4.4 扩展到 mapstring, vector 之类组合容器单独用 map 还体现不出它的强大组合使用才是工程常态。最典型的是“按类别分组”std::mapstd::string, std::vectorint scores_by_class; scores_by_class[math].push_back(90); scores_by_class[math].push_back(88); scores_by_class[cs].push_back(95);这里 operator[] 的作用是“不存在就给我建一个空的 vector然后返回引用”。这种需求下 operator[] 的不存在即插入的行为反而是优点因为你需要的就是一个可修改的容器。要注意内存分配每个 vector 都是独立堆内存数据拆得越碎、缓存越不友好。所以我一般建议如果确定每个键下的数据都会很多就先 reserve否则频繁 reallocate 会有额外开销。另外当键是字符串时operator[] 每次查找都会做一次字符串比较红黑树路径上的比较次数是 O(logn)字符串本身比较又不是 O(1)。所以用 string 做键时要注意性能。曾经我处理一份上百万行文本文件键是URL字符串程序跑了十几秒。后来搞清楚瓶颈就出在 string 比较上换成数字ID做键并配合一个 ID-URL 的辅助数组速度立刻上来了。这不是 map 的错是键的选择问题。能用整数头、哈希值做键的别拿长字符串硬扛。5. 常见问题与排查教训5.1 迭代器失效规则与“边遍历边删除”很多初学者把容器迭代器失效想得太可怕以为凡是关联容器都安全。准确的说法是set/map/multimap 的插入不会使任何迭代器失效擦除只会使“指向被擦除元素”的那个迭代器失效其他迭代器安然无恙。这比 vector/deque 温柔多了但如果你硬要拿着失效迭代器继续用一样会未定义行为。最容易出问题的场景还是边遍历边删。C11 之后写法已经很简单for (auto it st.begin(); it ! st.end();) { if (need_delete(*it)) { it st.erase(it); } else { it; } }如果在 erase 后忘了接收返回值代码也不会立刻崩因为红黑树可能只是释放了节点并调整父子指针你的旧迭代器还残留着地址。但接下来 it 就会访问一块已经被释放的内存轻则数据错乱重则段错误。这类 bug 在 debug 版可能完全正常一到 release 就抽风特别难查。我后来养成的习惯是涉及“遍历时删除”的循环一律先写迭代器版本不写范围for。范围for 看着简洁但内部隐藏了迭代器你没法在循环里安全删除当前元素。5.2 自定义类型的比较器与严格弱序陷阱给自定义结构体写 operator最大的陷阱就是“只比较了部分成员”。比如struct Student { int id; std::string name; }; bool operator(const Student a, const Student b) { if (a.id ! b.id) return a.id b.id; return a.name b.name; // 这是对的 }如果你偷懒只写return a.id b.id;那么两个 id 相同但 name 不同的对象会被 set 认为是“相等”的后插入的直接被丢弃。这类问题在数据量小、数据人造的时候根本测不出来上了生产才发现用户莫名其妙“消失”。我的建议是operator 要比较的成员必须和“你认为什么才算同一个对象”的语义完全一致。如果 id 已经能唯一标识一个用户那 comparator 只比 id 反而没错错的是你没想清楚业务的等值定义是什么。比较器的另一条铁律是严格弱序。常见错误是手滑写成return a b;这直接违反反自反性。红黑树内部判断“a和b是否相等”用的是!(ab) !(ba)如果 ab 可以同时成立且 ba 也成立因为 在相等时返回 true整个树的唯一性判断就乱套了。这种 bug 很难复现因为触发条件高度依赖插入顺序。写 lambda 做比较器时同样要注意传递性。我自己调试过一个诡异问题一个按距离排序的优先队列出队顺序不稳定后来发现 lambda 里用了浮点数距离的差值比较return da - db 1e-6;这在数学上不构成传递关系。所以凡是 comparator 里出现“近似比较”“容差”“epsilon”这类词都要拉响警报红黑树需要的是全序关系。5.3 什么时候不该用 set/map与unordered系列和vector的取舍不是所有去重和查找都得用 set/map。做一次冷静判断能省下很多不必要的性能损耗。场景推荐容器原因数据量小几百个且只做一次查找vector std::find线性扫描的常数极小logn 的优势体现不出来需要有序遍历、范围查询、找最大最小set / map红黑树天然有序接口直接只需要单点插入和查找、完全不管顺序unordered_set / unordered_map哈希表平均 O(1)但注意退化风险需要数据按插入顺序保存又要按键快速查组合方案vector unordered_mapKey, int既保留顺序又获得快速索引内存极度紧张vector 二分查找节点式容器的每元素内存开销大最后那种“vector 二分”我特别推荐给嵌入式或高性能场景。vector 是连续内存cache 命中率高配合 std::sort std::lower_bound性能和 set 在同一数量级甚至更好内存开销却小得多。代价是插入时要把元素挪来挪去。所以如果数据是“一次性建好、之后只读”vectorsort二分几乎永远优于 set。这个结论反过来也成立如果数据频繁插入删除vector 的搬移成本会吃掉二分查找的优势此时红黑树容器更合适。6. 实操心得我习惯的几招讲完理论分享几个我平时写代码时固定会遵守的习惯算是收尾。第一招凡是写“判断键是否存在”的代码一律不用 operator[]而是 find 或 contains。这样可以杜绝“无意识插入”对 map 造成的污染尤其是遍历前先做一些存在性检查的场合能减少很多后期排查噪音。第二招能用 emplace 就别用 insert。C11 引入的 emplace 直接在节点里构造元素省去一次临时对象的构造和拷贝。对比较贵的类型比如字符串、结构体收益肉眼可见。但对 int 这种平凡类型两者差异微乎其微别为了炫技做无用优化。std::mapint, std::string mp; mp.emplace(1, one);第三招调试 STL 容器时别只盯着逻辑先看迭代器。如果我写的代码涉及“遍历时删除”“在函数里传容器引用”这类场景第一反应是检查有没有迭代器被拷贝保存。红黑树容器的迭代器虽然稳定但你不可能永远记住哪个迭代器对应哪个节点最好的办法是不要保存长生命周期迭代器用的时候从 begin/end 现取。第四招如果一个项目里 set/map 和 unordered 系列同时出现尽量用 using 起好别名并且把“有序/无序”写在类型名里。比如using UserIndex std::mapint, User;和using UserHashIndex std::unordered_mapint, User;。这样后续维护时读代码的人一眼就知道这个容器是否保证顺序不用去翻定义。这算是一个很小但非常提升可读性的工程习惯。set、map、multiset、multimap 这组容器表面看只是 STL 的几页文档实际用起来牵扯到红黑树平衡逻辑、比较器设计、迭代器生命周期、缓存利用等一堆工程问题。把它们彻底弄明白后面再接触 unordered 家族、甚至其他语言的 TreeMap、TreeSet都会顺畅很多。希望这篇笔记能帮你少走一些弯路也欢迎你自己上手多跑几段代码毕竟这些感觉是“跑”出来的不是“看”出来的。