
聊聊 C 里最常用的两个关联容器 map 和 set。说实话这俩家伙在标准模板库STL里的地位就跟手机里的微信支付宝似的平时可能感觉不到多重要但真到写项目、刷算法题、处理数据的时候离开它们还真不行。这篇东西我打算换个讲法不搞那种“map 简介、set 简介”的教科书式铺陈直接按我平时写代码的顺序来先讲清楚它们到底是个啥玩意儿再用具体场景演示怎么用然后钻进底层看看它们为什么这么快最后把我踩过的坑、排查过的诡异问题一次性全抖出来。适合刚学完 STL 基础序列容器vector、list想进阶的初学者也适合用了很久但没系统捋过内部机制的老手。1. 先搞清楚 map 和 set 到底是什么1.1 一句话理解关联容器和序列容器的本质区别刚接触 C 的时候我们最先碰到的肯定是 vector、list、deque 这些序列容器。它们的特点是数据按插入顺序线性排列你要找某个元素最粗暴的做法就是从头到尾遍历一遍复杂度 O(n)。数据量小的时候无所谓一旦数据量上了十万百万级别这种找法就非常吃力了。map 和 set 属于另一类关联容器。它们的核心特点是元素之间存在某种“排序”关系并且通过键key直接定位值value。map 是键值对的集合set 是纯键的集合。你可以把 map 想象成一本通讯录你按姓名键查电话号码值不需要从第一页翻到最后一页set 则像一个只收不重复名字的签到簿名字本身就是全部信息。这两者的底层核心都是一颗红黑树Red-Black Tree属于平衡二叉搜索树的一种。也就是说所有元素在插入那一刻就按照你指定的比较规则排好了序查找、插入、删除的平均时间复杂度都是 O(log n)。这个 O(log n) 有多快数据量翻一倍查找次数只多一次。一亿个数据只需要大约 27 次比较就能定位到目标这就是树形结构的威力。1.2 map 和 set 的四种变体一张表看清区别标准库里其实有四个关联容器很多人只知道 map 和 set忽略了另外两个。我把它们放在一起对比看完你就知道什么时候该用哪个了容器元素内容键是否允许重复是否允许直接修改键底层结构mapK, V键值对 pairconst K, V键唯一否const 限定红黑树multimapK, V键值对 pairconst K, V键可重复否红黑树set单个元素 K元素唯一否红黑树multiset单个元素 K元素可重复否红黑树这里有个关键点值得注意在这四个容器里键都是 const 限定的不允许直接修改。道理很简单——红黑树的有序性是靠键维持的你二话不说把键改了树的结构就全乱了后续的查找、删除全都会出逻辑错误。想改键的正确姿势是先删除旧元素再插入新元素。1.3 红黑树通俗版你不需要背五条性质但得明白平衡的意义很多教材一上来就列红黑树的五条性质节点非红即黑、根是黑、叶是黑、红节点子必黑、任意路径黑节点数相同直接把初学者劝退了。我个人的看法是性质你可以不背但得理解红黑树存在的意义。二叉搜索树如果不加约束插入一串有序数据就会退化成一条链表查找复杂度从 O(log n) 恶化成 O(n)。红黑树的本质是通过“红黑标记 变色 旋转”这一套规则保证任何一条从根到叶子的路径长度之差不超过两倍从而让树的高度维持在 O(log n) 的量级。换句话说红黑树是一种“近似平衡”的二叉搜索树它不追求像 AVL 树那样严格的高度平衡那样旋转太频繁、写操作开销大而是在写操作的旋转次数和读操作的效率之间做了工程上的折中。set 和 map 之所以选择红黑树而不是更简单的 AVL 树正是因为实际场景里插入和删除是家常便饭红黑树的调整代价更可控。实测下来频繁插入删除的场景红黑树明显比 AVL 稳。2. set 的实操全解去重、排序、自定义类型一个都不能少2.1 创建、插入、遍历从零开始跑通一个例子先看最简单的 set 用法。set 内部默认用std::lessK也就是升序排列。看代码#include iostream #include set int main() { // 1. 默认构造升序排列 std::setint s; // 2. 插入元素 s.insert(5); s.insert(1); s.insert(3); s.insert(5); // 重复插入会被忽略 s.insert(2); // 3. 遍历 for (const auto val : s) { std::cout val ; } // 输出1 2 3 5 return 0; }注意输出结果插入顺序是 5、1、3、5、2但遍历出来是 1、2、3、5。两个细节第一set 内部直接排好序了第二第二次插入 5 没有生效因为 set 不存储重复元素。这种自动去重 自动排序的特性在解决“统计不重复元素个数”、“维护一个始终有序的集合”这类问题时简直是量身定做的工具。2.2 count、find、erase 三件套set 的查询和删除查 set 里有没有某个元素最直接的方法是count返回值要么 0 要么 1因为元素唯一std::setint s {10, 20, 30}; if (s.count(20) 0) { std::cout 存在 20 std::endl; } auto it s.find(20); if (it ! s.end()) { std::cout 找到 *it std::endl; }count和find都能判断元素是否存在但如果你不仅要判断存在还想拿到这个元素挨着的上下文信息前一个、后一个就得用find拿到迭代器再操作。删除也有两种姿势一种按值删一种按迭代器删s.erase(20); // 按值删除返回删除的元素个数 0 或 1 auto it s.find(30); if (it ! s.end()) { s.erase(it); // 按迭代器删除通常用于删除后需要返回下一个迭代器的场景 }我个人的习惯是只需要删值就用第一种简单直接如果删除的同时要记录被删元素的信息、或者删除后还要继续遍历集合就先用find拿到迭代器再调用erase(it)。这里有个 C11 之后的变化需要知道erase返回的是被删除的元素个数在 C11 之前erase的返回类型是 void老代码迁移时注意编译报错。2.3 自定义类型的 set必须学会写比较器否则编译错误哭死你std::setint之所以好用是因为 int 自带运算符。可现实是你几乎不可能每次都存 int更多时候存的是自定义结构体。这时候问题就来了红黑树要排序但你的类型没有“小于”规则编译器直接一脸懵。最省事的方式是给结构体重载运算符struct Student { std::string name; int score; // 按分数升序分数相同按姓名升序 bool operator(const Student other) const { if (score ! other.score) { return score other.score; } return name other.name; } }; std::setStudent students; students.insert({张三, 90}); students.insert({李四, 85});使用“重载”的方案代码看起来最自然而且不仅 set 能用后续如果要把 Student 放进 map 的键、priority_queue 的优先队列之类需要比较的容器规则也能自动复用。还有一种方式是声明自定义仿函数作为模板参数适合不想侵入类型定义比如第三方库的类型或者比较规则会变的场景struct ScoreCmp { bool operator()(const Student a, const Student b) const { return a.score b.score; // 降序 } }; std::setStudent, ScoreCmp studentsByScoreDesc;这里必须强调一个特别容易踩的坑写比较器的时候一定要保证它构成“严格弱序”strict weak ordering。所谓严格弱序说白了就是三条规则a a必须为 false如果a b为 true则b a必须为 false如果a b且b c则a c必须成立。很多人图省事写出 “return a.score b.score” 这种带等号的比较器结果就是插入元素时位置错乱、查找时明明存在却找不到甚至直接触发未定义行为程序跑着跑着就崩了。记住比较器里千万别写等于号。2.4 multiset允许重复元素的 set 有什么用multiset 和 set 唯一的区别就是元素可以重复。那它在实际场景里有什么用我讲一个最典型的应用——滑动窗口类的问题。比如 LeetCode 上那道“滑动窗口中位数”你需要在窗口里维护有序序列快速找中位数。如果用 vector 每次排序时间复杂度是 O(n log n) 或者 O(n) 插入用 multiset 就能在 O(log n) 内完成插入删除并且元素天然有序中位数直接用迭代器访问即可。std::multisetint ms; ms.insert(3); ms.insert(1); ms.insert(3); // 允许重复现在里面有 1, 3, 3 std::cout ms.count(3); // 输出 2注意 multiset 的erase是按值删除全部匹配元素不是只删一个ms.erase(3); // 把所有的 3 全删了如果只想删一个得配合迭代器auto it ms.find(3); if (it ! ms.end()) { ms.erase(it); }这个细节我在实际编码中被坑过一次当时只想删掉一个滑出窗口的元素结果直接调erase(3)窗口里其他几个 3 全被干掉了排查了半天才反应过来。3. map 的实操全解键值对的高效增删改查3.1 常见构造、插入方式和遍历对比 4 种插入方法的差异map 的每个元素都是pairconst Key, T操作上要比 set 多一层“值”的概念。先看最常用的插入方式#include map #include string #include iostream std::mapstd::string, int ages; // 方式一insert 传入 pair ages.insert(std::make_pair(张三, 20)); ages.insert(std::pairstd::string, int(李四, 22)); // 方式二insert 传入 value_type推荐语义更清晰 ages.insert(std::mapstd::string, int::value_type(王五, 25)); // 方式三用 operator[] 直接赋值最直观 ages[赵六] 21; // 方式四C11 的 emplace原地构造避免临时对象拷贝 ages.emplace(孙七, 23);这四种方式各有讲究重点说一下区别insert(make_pair(...))如果键已存在插入失败原值不变。operator[]如果键不存在先创建键并给值做值初始化int 就是 0string 就是空串再赋值。如果键存在直接覆盖。这个操作很方便但有隐藏开销下面会专门说。emplace直接在节点内存上构造 pair省了一次移动构造的开销性能最好但注意别把参数传错类型。遍历 map 的标准姿势如下for (const auto kv : ages) { std::cout kv.first : kv.second std::endl; }kv.first是键kv.second是值。遍历顺序是按键的升序。3.2 operator[] 的暗坑是语法糖也是容易忽略的地方map 的operator[]看起来人畜无害但它的行为在一开始接触的时候很让人迷惑std::mapstd::string, int m; std::cout m[hello] std::endl; // 输出 0看到了吗你没有插入任何东西只是访问m[hello]它就会自动创建一个键为 hello、值为 0 的元素。这是标准规定的行为operator[]等价于(*((this-insert(make_pair(key, T()))).first)).second如果键不存在它先做一次插入然后返回引用。这种“自动插入默认值”的行为在统计字符频次的场景里特别爽std::mapchar, int freq; for (char c : hello world) { freq[c]; // 第一次访问自动初始化为 0再 }但在只查询不插入的场景里就是大坑。比如std::mapstd::string, std::vectorint bigData; // ... 插入了一堆数据 if (bigData[key_404] std::vectorint{}) { // 你以为只是查询实际上已经插入了空 vector内存和位置都变了 }这就是个典型的“挂广告牌”行为明明只是路过看看结果把房给买了。判断键是否存在必须用find或count只有确定要插入或覆盖时才用operator[]。3.3 find、insert 返回值、erase 注意事项正确姿势一次讲透先看查找auto it ages.find(李四); if (it ! ages.end()) { std::cout 找到了年龄 it-second std::endl; } else { std::cout 没有这个人 std::endl; }再看insert的返回值。insert返回一个pair迭代器, bool其中bool表示是否插入成功。如果键已经存在bool为 false迭代器指向已有元素。用这个返回值就能高效实现“插入不覆盖已有则通过迭代器修改”的需求std::mapstd::string, int scores; auto ret scores.insert({张三, 100}); if (!ret.second) { ret.first-second 100; // 更新旧值 }删除方面erase(key)返回删除数量0 或 1erase(iterator)返回下一个元素的迭代器C11 之前是 void。在循环里遍历删除时务必写成for (auto it m.begin(); it ! m.end();) { if (it-second 0) { it m.erase(it); // C11 及以后erase 返回下一个迭代器 } else { it; } }这种写法比m.erase(it)好看而且统一了版本差异。注意 map 的 erase 不会像 vector 那样搬移其他元素它只调整树节点指针所以删除操作不会导致其他迭代器失效。3.4 map 的迭代器遍历和 pair 解引用理解 first/secondmap 的迭代器指向的元素类型是pairconst Key, T这意味着it-first是 const 的不能改it-second是可修改的值。这反映了一个本质设计红黑树的节点物理上存储的是整个 pair所以迭代器解引用拿到的就是 pair 的引用。很多人初学时困惑为什么不用单独的 key 和 value 两个迭代器其实正是因为这个 pair 型元素的设计让 map 在 STL 里天然适配所有需要“成对处理数据”的算法。结构体绑定structured binding是 C17 之后才有的遍历代码能清爽不少for (const auto [name, age] : ages) { std::cout name : age std::endl; }它本质上是把 pair 的 first 和 second 分别绑定到 name 和 age写着舒服性能上和手写.first/.second完全等价。4. multimap 和 map 的对比以及什么时候用 multimap4.1 multimap 的特性与 equal_range 组合拳multimap 与 map 的差别只有一个键允许重复。这个场景最常见的就是“一对多”关系表的建模。比如一个班级里同一个分数可能对应多个学生一个分类下挂多个商品。用 multimap 非常直接std::multimapint, std::string scoreToName; scoreToName.insert({90, 张三}); scoreToName.insert({90, 李四}); scoreToName.insert({85, 王五});查询键 90 对应的所有学生最优雅的方式是equal_range它返回一个 pair里面是两个迭代器标记了目标键区间的头和尾auto range scoreToName.equal_range(90); for (auto it range.first; it ! range.second; it) { std::cout it-second std::endl; // 输出张三、李四 }注意multimap 没有operator[]因为键不唯一下标方式没有意义。插入也只能用insert或emplace。4.2 用 lower_bound/upper_bound 手工划区间equal_range内部其实就是lower_bound和upper_bound的组合。lower_bound返回第一个不小于键的元素迭代器upper_bound返回第一个大于键的元素迭代器。这两个方法在 multimap 里同样适用例如想手动控制区间、或者你想查“分数大于 80 的区间”就可以用 lower_bound(80) 拿到起点用 upper_bound(90) 拿到终点。这俩函数在处理有序序列时非常重要除了 multimap它们也能用在 set 和 map 上用途都是“按范围批量处理元素”。比如批量删除一组成绩范围内的学生auto begin students.lower_bound(60); // 第一个 60 的 auto end students.upper_bound(80); // 第一个 80 的 students.erase(begin, end);这个批量删除是 O(k log n) 的k 是删除的元素个数。别写循环逐个删不仅慢而且代码啰嗦。5. 自定义类型作为 map 的键这些细节决定你的代码能不能编译过5.1 键必须可比较类内部的 operator 与外部比较器的选择用结构体当 map 的键时最常见的难题是结构体本身没有运算符。好比你说“拿这个名字当作索引”却不告诉我这些名字怎么排序map 不知道该怎么建树。方案一重载struct Key { int id; std::string name; bool operator(const Key other) const { if (id ! other.id) return id other.id; return name other.name; } };方案二写独立比较器并通过模板参数传给 mapstruct KeyCmp { bool operator()(const Key a, const Key b) const { return a.id b.id; } }; std::mapKey, int, KeyCmp m;我建议首选方案一理由很直接比较逻辑跟着类型走用起来省心。但如果是两个团队分别定义了 key 类型和比较逻辑或者同一个 key 在不同场景需要不同排序方式比如一个场景按 id 排一个场景按 name 排那必须用方案二。5.2 注意 const 正确性operator 必须加 const自定义类型放进 map/set 时成员函数operator必须要加 const 限定符这是新手最容易忽略的坑。不加 const 的后果是STL 内部通过 const 引用方式调用比较函数编译直接报错而且报错信息极其不友好一大串模板实例化错误。比如bool operator(const Key other) { // 少了 const编译报错 ... }正确的写法是bool operator(const Key other) const { // 末尾 const 不能少 ... }这个 const 的含义是“当前对象在比较过程中不被修改”正是关联容器在红黑树中反复调用比较算子时的安全保证。5.3 空结构体和过期字段的处理另外当结构体里出现不影响比较逻辑的“附加字段”时也要想清楚比较器怎么写。比如 Key 里有 id、name、priority你只想用 id 和 name 做排序那 priority 变化时 map 不会重新排序——因为红黑树的节点位置是在插入时确定的。很多人在业务逻辑里写“把 priority 改了就希望 map 自动重排”这是不可能的必须删掉原键值对再插入。6. 性能分析为什么是 O(log n)以及什么时候改用 unordered_map6.1 红黑树的查找、插入、删除的复杂度推演map 和 set 的增删查改全是 O(log n)这个是树高决定的。但光说 O(log n) 不够你还得知道常数因子。红黑树每个节点要比 vector 多存一堆指针父指针、左右子指针外加红黑标记所以内存占用高、访问时跳转多不连续内存对缓存不太友好。文件 1 万个元素时无所谓但如果是 1 亿个元素、且对查找性能要求极高map 就不一定是最优选了。6.2 哈希表版本的 map/set 该怎么选C11 引入了std::unordered_map和std::unordered_set底层是哈希表查找均摊 O(1)。它们的适用场景和 map/set 有明显的互补性特性map / setunordered_map / unordered_set底层结构红黑树哈希表查找复杂度O(log n)平均 O(1)最坏 O(n)元素顺序有序按比较器无序对键的要求必须支持 严格弱序必须支持 std::hash 内存占用较高指针颜色位较高桶数组节点适用场景需要有序遍历、范围查询、找前驱后继只需要快速查找、插入、删除不关心顺序一个非常典型的取舍是如果业务里需要“按分数范围列出所有学生”你必须用 map因为只有树形结构支持lower_bound(60)和upper_bound(80)这种范围查询如果只是“根据学号查学生姓名”用 unordered_map 明显更合适因为少一次比较链的跳转性能更稳。6.3 看一个实际选择案例大数据量的题干统计举个例子假设你要统计一篇文章的词频单词数量 10 万级。如果最后还要“按字典序输出”map 就省掉了最后再排序的步骤直接遍历就是字典序如果最后只是“根据单词查频次”那 unordered_map 更快因为查找是常数级别。我的经验是五五开的时候优先用 map因为有序性带来的调试便利打印出来肉眼可读、可二分验证比那点性能差距更值钱。7. 常见坑点与排查技巧这些都是我实际踩过的雷7.1 迭代器失效问题map 的迭代器为什么比 vector 稳vector 在插入元素导致内存重新分配时所有迭代器全部失效erase 也会让指向被删除元素后面的迭代器失效。但 map 和 set 是节点型容器每个元素在堆上独立分配插入删除只修改相关节点指针其他元素的迭代器不会失效被删除的迭代器当然无效了。这个特性在写带缓存的代码时特别有用。比如你有一个 map 存储了大对象另有一个迭代器列表记录“最近访问的几个”在 map 里删除其中一个对象时其他对象的迭代器仍然能用不用重新查找。这就是我为什么在有频繁插入删除 引用稳定需求时首选 map 而不是 vector 的原因之一。但注意不要长时间保存迭代器然后跨越整个生命周期使用。哪怕 map 的迭代器不失效指向已删除节点的迭代器还是悬空指针。标准建议是在修改操作的同一作用域内使用迭代器别把它存到很远的地方。7.2 用 count 判断存在性和 operator[] 误插入前面说过 operator[] 会自动插入默认值的坑。这里给一个铁律只读查询用 find 或 count不要用 operator[]。这句话我重复无数遍因为我自己就在这里栽过——当时的场景是配置项读取代码里写了if (config[timeout] 5)结果本来不存在的配置项被硬生生插入了一个 0后续遍历时多出一个无意义的键值对还差点把另一份逻辑带偏。有一个叫你做“配置管理”的项目最稳妥的做法是先 find 拿到迭代器再判断auto it config.find(timeout); if (it ! config.end() it-second 5) { ... }7.3 erase 的返回值在不同 C 版本之间的差异map::erase(iterator)在 C11 之前返回 voidC11 之后返回“下一个迭代器”。如果你是在老代码基础上做升级可能会遇到这种写法编译不过it m.erase(it); // C03 报错void 不能赋值给迭代器解决办法是条件编译处理或者在升级时统一改成 C11 语义。另外erase(key)返回删除的元素数量map 中是 0 或 1multimap 中可能大于 1标准保证了erase(key)不会使任何迭代器失效而erase(iterator)只会让被删元素的迭代器失效。7.4 自定义比较器不满足严格弱序导致的神秘 bug自定义类型放 set/map 时如果比较器写得不对最常见的是return a.score b.score这种。带等号的比较器会让红黑树在“相等”时出现歧义插入时判断“不比自己小”就认为比自己小出现环或者重复节点查找时可能找到了错误的“等价”元素。这种 bug 特别恶心的一点是小数据量时表现得完全正常数据量上千以后才偶发崩溃或死循环。排查方法也很直接单测覆盖插入、查找、删除三种操作用 assert 验证!cmp(a,b) !cmp(b,a)的时候两个元素在容器中被视为等价。一旦发现没有严格弱序优先检查比较器是不是把等号写进去了。7.5 从编译报错看模板实例化问题C 模板类报错能让人心态爆炸尤其是 set/map 自定义类型时。常见的一种错误就是“类里没有定义 operator”。比如这个报错信息error: no match for operator (operand types are const Student and const Student)看到这个信息第一反应是给结构体加 operator或者在 map/set 模板参数里传比较器。别去改容器头文件也别尝试用#define绕过老老实实补上比较规则或者换用 unordered_map但 unordered_map 要求的是 hash 和 equality不是 operator。开发环境如果是 vscode gcc通常把光标放在报错行VS Code 的 C/C 插件会直接跳转到调用点附近的模板源码耐心定位到struct _Rb_tree相关的实例化位置就能发现问题。7.6 map 和 set 遍历过程中删除的安全写法还是那句话遍历中删除for (auto it m.begin(); it ! m.end();) { if (条件) { it m.erase(it); } else { it; } }这个写法在 C11 之前不成立erase 返回 void老编译器上要改成m.erase(it);。这两种写法的思想不同it m.erase(it)拿返回的下一个迭代器继续走。m.erase(it)先复制当前迭代器并自增到下一个再用旧迭代器删除。第二种的好处是代码即使版本兼容性差一点但思想统一。在实际生产代码里建议先确认编译器标准再选型一般 C11 及以上我统一用前者。8. 实际项目中的典型应用场景8.1 词频统计map 的经典用例统计一段文本中每个单词出现的次数map 是最自然的工具std::mapstd::string, int wordCount; std::string word; while (cin word) { wordCount[word]; } for (const auto [word, count] : wordCount) { std::cout word : count std::endl; }注意这里用了 operator[] 的自动默认初始化特性是 map 为数不多的“用了会真香”的场景。如果不想要这个行为也可以insert一个 pair 再判断返回值。但大多数词频统计场景用 operator[] 就够了因为键不存在时恰好需要默认值 0。8.2 数据去重与有序维护set 与 multiset 的角色一个系统里往往有多个渠道推送过来的 ID 列表需要合并成一个不重复的集合。set 天然完成这个工作std::setint uniqueIds; for (const auto batch : incomingBatches) { for (int id : batch) { uniqueIds.insert(id); } }如果处理完还需要按一定的顺序统一派发set 遍历时天然有序直接省序排序。这时候如果用 unordered_set还得额外收集到 vector 再 sort多一步。8.3 缓存系统里的 LRU 辅助索引虽然真正的 LRU 缓存一般用 list unordered_map 组合但 map 作为“按时间戳索引”的辅助结构也很常见。比如一个任务调度器用 multimap时间戳, 任务ID 存待执行任务每次取最早的任务就是 begin()任务时间更新时把旧键值对删掉再插入新的时间戳。这比遍历 vector 找最早任务高效多。所以 map 家族在实现定时器、延迟队列、按分数表排行榜这类需求时几乎是首选。8.4 有序表在算法题中的应用刷算法题碰到这些场景直接想到 set/map求一组数的中位数维护两个 multiset一个大根堆语义降序 set一个小根堆语义升序 set每次平衡两边大小。区间合并、区间覆盖用 map 维护区间端点利用有序性快速查找重叠区间。贪心算法中需要维护“当前最大值/最小值”set 天然有序直接 begin()/rbegin() 取极值。这些题基本就是 STL 关联容器的标准考法也是面试官检验你“对容器选择是否敏感”的常见切入点。8.5 项目里最新的“岁月 STL”改进C17 的 insert_or_assign / try_emplaceC17 给 map 增加了两个非常实用的函数insert_or_assign和try_emplace。先看insert_or_assign它的语义是“如果键不存在就插入存在就覆盖”并且只有一个返回值std::mapstd::string, int m; m.insert_or_assign(key, 1); // 不存在插入 m.insert_or_assign(key, 2); // 存在覆盖try_emplace则跟 emplace 类似但只在键不存在时才构造元素避免因为构造临时对象导致意外插入。它返回的同样是pair迭代器, bool适合需要“不存在就插入存在就处理旧值”的复杂逻辑。这两个函数比手工用 find insert 组合更简洁还能避免二次查找建议在 C17 项目里直接使用。9. 总结一下我用 map/set 的经验与选择思路附一份快速参考用到现在我自己总结了一套选型思路先看要不要有序性再看要不要重复键最后看键的类型是否支持比较或哈希。如果完全不需要有序、只需要查得快用 unordered_map如果必须有序遍历、范围查询、找最近邻用 map重复键需求用 multimap / multiset去重同时有序用 set。再分享一个我自己的习惯写工程代码优先用 map/set因为它们的行为可预测、调试方便等性能分析工具明确告诉我热点在这里、并且哈希表能带来显著收益时再换 unordered_map。这样能避免过早优化带来的复杂度。最后一个调试小技巧在 gdb 里查看 map 内容时标准的 print 命令往往只显示一棵树的结构非常难读。我的做法是写一个简单的辅助函数template typename K, typename V void dumpMap(const std::mapK, V m) { for (const auto [k, v] : m) { std::cerr k - v std::endl; } }调试时在断点位置调用一下配合日志输出基本能解决所有“map 里面到底长了啥”的疑问。要注意的是别在生产代码里留这类调试输出只在排查问题时临时加上。map 和 set 的用法本身不复杂真正复杂的是搞清楚“底层为什么要这样设计”以及“什么场景选什么容器”。把这层逻辑想透了写代码的时候就有底气不用每回都靠试错来摸索。回头再看我自己这几年写过的 C 代码用得最多的容器就是 map其次是 set严格说树形结构带来的有序性在业务和算法两个方向上都能撑起一片天。如果你还在纠结容器怎么选把这篇提到的对比表打印出来贴在工位边上真实需求来时照着选就行。最后说一个小点可能很多人没注意到map 和 set 的 find、count、lower_bound、upper_bound 这些接口在 multimap / multiset 上的语义会略有差异。写代码前先看清容器的类型别拿 map 的习惯直接套 multimap。比如 multimap 的 count 返回的可能是个大于 1 的数我之前就用错了绕了半天才发现是查的容器类型不对。这些就是我能想到的关于 map 和 set 的全部实战心得了希望对你有实际帮助。