ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++ map与set核心用法:红黑树原理、有序容器与实战技巧

C++ map与set核心用法:红黑树原理、有序容器与实战技巧 1. 从“会写循环”到“会用容器”C选手的必修课如果你在刷题或者做项目时还在用数组加手动标记的方式去处理“去重”和“查找”那你大概率已经感受到了那种别扭数组长度要事先想好、插入要维护顺序、查找要写循环遍历、删除还要搬移数据。每次写完都觉得自己像个手动挡司机明明自动挡就在那儿摆着。map和set就是C标准库里的那台“自动挡”。它们不是炫技用的新特性而是每个C开发者——不管你是刚学完语法的新手还是写了几年业务代码的老兵——都得熟练掌握的基础容器。它们解决的是两类极其朴素的诉求把数据存起来并快速查得到map以及让数据保持唯一且有序set。这篇文章不会去逐行分析源码也不会罗列所有成员函数的签名。我尽量用实际场景来讲讲清楚它们是什么、什么时候用、怎么用才不会踩坑以及我在实际写代码过程中遇到的那些“文档里不会告诉你”的细节。看完之后你再回头写那些需要去重、排序、关联查找的题目或模块思路会清晰很多。2. 基础认知map和set到底解决了什么问题2.1 为什么不是数组也不是vector先说一个最基础的认知map和set的底层实现在绝大多数主流编译器的标准库里都是红黑树——一种自平衡的二叉搜索树。这句话听起来有点吓人但你可以先忽略红黑树的具体旋转逻辑只需要记住两个关键结论插入、删除、查找的平均时间复杂度都是 O(log n)元素在容器中是严格按照排序规则组织的。对比一下vector和数组查找一个元素要 O(n)插入到中间要搬移数据删除也是同样的麻烦。当数据量小的时候这些差异感知不明显但数据量一旦上到几万、几十万O(n) 和 O(log n) 的差别就是几毫秒和几十毫秒甚至更久的差别。更重要的一点是map和set帮你维护了“有序”这个不变量你不需要自己在每次插入后写排序逻辑。再说一个容易让人忽视的点map和set的查找是直接通过键去定位的不需要你遍历整个容器。这种“按关键字直达”的能力本质上是因为底层树结构按照键的大小关系组织了所有节点。你把数据交给它它就帮你把秩序维护好。2.2 map是键值对的“字典”set是唯一元素的“集合”map存储的是pairconst Key, T也就是一个键对应一个值。这和字典的语义完全一致你输入一个单词拿到解释你输入一个学号拿到学生信息。它的核心约束是键唯一值可以重复可以随意修改。set存储的则是单纯的Key每个值只能存在一份。它的核心约束就是唯一性。你往set里插入一个已经存在的元素不会报错只是什么都不发生——这一点非常关键很多人误以为插入重复元素会出错其实不会只是被静默忽略。可以把set理解为“去重后的有序数组”把map理解为“按键排序的键值对集合”。两者在底层是同一棵树只是set的节点只存键map的节点多存了一个值。2.3 什么时候该用它们我的判断标准很简单需要快速判断某个元素是否出现过 → set需要根据一个键快速找到对应的值 → map需要对元素去重并保持有序 → set需要对一堆键值对按键排序 → map需要统计频次 → map键是元素值是次数反过来如果不需要有序只需要快速判断存在性那unordered_set和unordered_map基于哈希表理论上更快平均 O(1)。但它们的缺点是元素无序而且哈希函数的选取对性能影响很大。是否需要有序是我在set/map和unordered版本之间做选择时最重要的判断依据。3. set的使用拆解去重、有序、集合运算3.1 基本操作插入、查找、删除set的接口非常直观先看一段最基础的代码#include iostream #include set int main() { std::setint s; // 插入 s.insert(3); s.insert(1); s.insert(4); s.insert(1); // 重复插入无效 // 查找 if (s.find(3) ! s.end()) { std::cout 3 exists std::endl; } // 删除 s.erase(1); // 遍历 for (int x : s) { std::cout x ; // 输出有序序列 } std::cout std::endl; return 0; }几个值得注意的点insert返回一个pairiterator, boolbool表示是否插入成功。如果你想知道某个元素是不是已经存在可以用这个返回值判断而不是先find再insert——后者会多一次查找开销。find返回迭代器找不到时返回end()。千万别用std::find(s.begin(), s.end(), x)这种方式那会退化成 O(n) 遍历完全丧失set的意义。erase有两种用法传值删除或传迭代器删除。传值删除会返回删除的元素个数因为set元素唯一所以返回值只可能是0或1。这里我要特别强调一点set的迭代器是const的。什么意思你不能通过迭代器修改元素的值。原因很好理解——如果允许修改就可能破坏红黑树的有序性整个结构就乱了。想改一个元素正确做法是先erase掉旧的再insert新的。3.2 自定义排序不只是默认的less默认情况下set使用std::less 排序也就是从小到大。但很多场景下默认排序并不符合需求。比如存一串IP地址你可能希望按最后一段排序存一组任务你可能希望按优先级而不是按名称排序。这时需要传入自定义比较器。有两种常见写法函数对象仿函数和lambda表达式。#include iostream #include set struct Task { std::string name; int priority; }; // 仿函数写法 struct TaskCompare { bool operator()(const Task lhs, const Task rhs) const { return lhs.priority rhs.priority; } }; int main() { std::setTask, TaskCompare tasks; tasks.insert({write code, 3}); tasks.insert({fix bug, 1}); tasks.insert({review, 2}); for (const auto t : tasks) { std::cout t.name ( t.priority ) std::endl; } // 按priority从小到大输出 return 0; }lambda写法C11之后支持auto comp [](const Task lhs, const Task rhs) { return lhs.priority rhs.priority; }; std::setTask, decltype(comp) tasks(comp);这里有个容易犯的错自定义比较器必须满足“严格弱序”规则。也就是说comp(a, b)为真时comp(b, a)必须为假不能同时为真。如果比较器写得不严谨比如只看其中一个字段而忽略另一个就可能出现两个不同的Task被认为“相等”而丢失数据。红黑树的有序性依赖于比较器的一致性这个坑一旦踩进去数据丢得莫名其妙还很难排查。3.3 集合运算交集、并集、差集set是数学上集合概念的完美映射标准库也提供了对应的算法。这里要用到algorithm头文件中的std::set_intersection、std::set_union、std::set_difference。#include iostream #include set #include algorithm #include iterator int main() { std::setint a {1, 2, 3, 4, 5}; std::setint b {3, 4, 5, 6, 7}; std::setint inter; std::set_intersection(a.begin(), a.end(), b.begin(), b.end(), std::inserter(inter, inter.begin())); std::setint uni; std::set_union(a.begin(), a.end(), b.begin(), b.end(), std::inserter(uni, uni.begin())); std::setint diff; std::set_difference(a.begin(), a.end(), b.begin(), b.end(), std::inserter(diff, diff.begin())); // inter: 3 4 5 // uni: 1 2 3 4 5 6 7 // diff: 1 2 return 0; }几个要点这些算法要求输入的两个集合都是有序的set天然满足。输出目标必须用std::inserter包装不能直接传入inter.begin()。因为set不支持随机访问插入inserter会调用insert在正确位置插入。复杂度是线性的 O(nm)不是 O(n log m)因为有序序列可以直接归并式遍历。如果你经常做集合运算这几个算法能省下大量手写循环的时间。我也见过不少人自己写双层循环求交集结果一是不优雅二是把复杂度写成了 O(n*m)数据量一大就卡死。3.4 现场实战不重叠区间的合并给一个经典面试题的set解法给定若干区间把重叠的区间合并。比如[1,3]和[2,6]合并成[1,6]。如果用vector你得先排序再遍历合并。但如果用set因为set本身有序可以简化一部分逻辑#include iostream #include set #include vector struct Interval { int start; int end; }; struct IntervalCompare { bool operator()(const Interval lhs, const Interval rhs) const { if (lhs.start ! rhs.start) return lhs.start rhs.start; return lhs.end rhs.end; } }; int main() { std::setInterval, IntervalCompare intervals; intervals.insert({1, 3}); intervals.insert({2, 6}); intervals.insert({8, 10}); intervals.insert({15, 18}); std::vectorInterval result; for (const auto interval : intervals) { if (result.empty() || interval.start result.back().end) { result.push_back(interval); } else { result.back().end std::max(result.back().end, interval.end); } } for (const auto interval : result) { std::cout [ interval.start , interval.end ] ; } std::cout std::endl; // 输出: [1,6] [8,10] [15,18] return 0; }这里的思路是set自动把区间按起点排序然后只需要一次遍历就能完成合并。如果区间数量大且动态插入频繁set的优势比“每次插入后手动排序”要明显得多。你不需要维护排序逻辑树自己做好了。4. map的使用拆解键值存储、查找与统计4.1 基本操作与operator[]的陷阱map的接口和set类似但多了一个operator[]。这个运算符是map最方便的地方也是最容易出错的地方。#include iostream #include map #include string int main() { std::mapstd::string, int age; age[Alice] 25; age[Bob] 30; // operator[] 如果键不存在会自动创建并赋默认值 std::cout age[Charlie] std::endl; // 输出 0Charlie被插入 // find不会改变map内容 auto it age.find(David); if (it age.end()) { std::cout David not found std::endl; } std::cout age.size() std::endl; // 3因为Charlie被插入了 return 0; }注意operator[]的行为如果键不存在它会插入一个键值对值用默认构造函数初始化。对于int类型默认值就是0对于string就是空字符串对于指针就是nullptr。这意味着只读查询时使用operator[]会意外改变map的内容。我见过不少线上bug就是这么产生的——明明只是查一下某个键在不在结果往map里塞了一堆空值。只查不改正确姿势是find或者C20的containsif (age.contains(Alice)) { // C20 可用 } auto it age.find(Alice); if (it ! age.end()) { // 用 it-second 取值 }find返回的迭代器指向一个pairit-first是键it-second是值。遍历map时同样如此每个元素都是pairconst Key, T键不能被修改值可以。4.2 统计频次operator[]的正确用法虽然上面说了operator[]有陷阱但它有一个经典的适用场景——频次统计。你需要“如果键不存在就插入并初始化为0然后自增”这个逻辑用operator[]一行就能搞定#include iostream #include map #include string #include vector int main() { std::vectorstd::string words {apple, banana, apple, orange, banana, apple}; std::mapstd::string, int freq; for (const auto word : words) { freq[word]; // 不存在则初始化为0再自增 } for (const auto [word, count] : freq) { std::cout word : count std::endl; } // 按字典序输出 return 0; }这段代码的意图非常清晰freq[word]如果word第一次出现先插入并初始化为0然后自增变成1如果已存在直接自增。配合map自动按键排序的特性还能顺便得到字典序的统计结果。如果你需要统计的频次很高也可以考虑unordered_map——哈希表在查找上更快。但如果是少量数据map的排序特性反而是加分项。我的经验是需要输出有序结果时用map纯粹计数且结果顺序无所谓时用unordered_map。4.3 自定义类型作为键必须提供比较规则map和set一样要求键是可比较的。内置类型int、string、double等都有默认的operator直接可用。但如果你想把自定义类型作为键比如一个struct Point就必须自己提供比较逻辑。#include iostream #include map struct Point { int x; int y; }; struct PointCompare { bool operator()(const Point lhs, const Point rhs) const { if (lhs.x ! rhs.x) return lhs.x rhs.x; return lhs.y rhs.y; } }; int main() { std::mapPoint, std::string, PointCompare points; points[{1, 2}] origin offset; points[{3, 4}] target; for (const auto [pt, name] : points) { std::cout ( pt.x , pt.y ): name std::endl; } return 0; }这个比较器的写法很讲究先比较xx相同再比较y。如果只写return lhs.x rhs.x;那么(1,2)和(1,3)会被认为是相等的两个键——因为比较器无法区分它们。这会导致数据覆盖。这种“只看部分字段”的错误很隐蔽尤其是当你处理的类型字段很多时。一个更省事的思路如果类型支持直接重载operatorstruct Point { int x; int y; bool operator(const Point other) const { if (x ! other.x) return x other.x; return y other.y; } };这样声明std::mapPoint, std::string m;时就不需要额外传比较器了。不过重载运算符意味着全局影响如果你只是这一个地方需要排序其他地方不需要用仿函数或lambda反而更可控。4.4 遍历与结构化绑定C17引入了结构化绑定遍历map的代码大幅简化std::mapstd::string, int scores; scores[math] 90; scores[english] 85; scores[cs] 95; for (const auto [subject, score] : scores) { std::cout subject : score std::endl; }注意用const auto可以避免拷贝因为pair里的值可能很大。如果想修改值就用auto。我几乎从不写for (auto it scores.begin(); it ! scores.end(); it)这种老式遍历了除非需要同时涉及迭代器操作比如遍历中删除某些元素。4.5 实战单词出现位置记录map的价值在于“按键组织信息”。举一个有说服力的例子给定一个文本文件统计每个单词出现的所有行号。#include iostream #include map #include set #include string #include sstream int main() { std::mapstd::string, std::setint wordLines; std::string line; int lineNum 1; while (std::getline(std::cin, line)) { std::istringstream iss(line); std::string word; while (iss word) { wordLines[word].insert(lineNum); } lineNum; } for (const auto [word, lines] : wordLines) { std::cout word : ; for (int line : lines) { std::cout line ; } std::cout std::endl; } return 0; }这个例子里map和set嵌套使用map的键是单词值是set 保存出现过的行号且去重。整个代码没有一行手动排序或去重逻辑全是容器自带的特性。这是mapset组合的典型用法——外层用map做键值关联内层用set做唯一性和有序性维护。5. 实操经验我踩过的坑和调试技巧5.1 注意修改键的正确姿势set和map的键都是不可变的。set的迭代器是const迭代器map的key也是const。如果你想修改键必须先删除再插入。我遇到过一个业务场景有一组任务需要按照“截止时间”排序存储在set里。任务执行过程中截止时间会变化。我第一次尝试直接通过迭代器修改任务的截止时间字段编译直接报错——因为那个字段是const的。后来改成先erase修改对象本地副本再insert。这相当于一次删除一次插入总共两次 O(log n) 操作可接受。如果你需要频繁修改键那就该考虑换数据结构了。比如用std::priority_queue配合自定义比较器或者改用multimap等。set和map的“有序性”是它们最大的卖点也是最大的约束不要试图绕过它。5.2 注意迭代器失效问题set和map的迭代器失效问题比vector温柔得多。因为底层是树结构节点的内存不因其他节点的插入删除而移动。插入不影响已有迭代器删除只会让指向被删节点的迭代器失效其他迭代器不受影响。这意味着可以安全地在遍历过程中删除符合条件的元素std::mapstd::string, int data; // ... 插入数据 for (auto it data.begin(); it ! data.end();) { if (it-second 0) { it data.erase(it); // C11 之后erase返回下一个迭代器 } else { it; } }C11之前erase返回void你得先保存下一个迭代器。现在新标准直接返回下一个有效的迭代器安全又简洁。这条规则同样适用于set。5.3 注意lower_bound / upper_bound / equal_range这三个函数是set和map里非常实用但经常被忽略的工具。它们基于有序性用二分查找定位边界复杂度 O(log n)。lower_bound(k)返回第一个键不小于k的元素迭代器upper_bound(k)返回第一个键大于k的元素迭代器equal_range(k)返回pairiterator, iterator两个迭代器之间的区间就是所有键等于k的元素对于set来说因为键唯一equal_range返回的区间长度最多是1。但对于multiset或multimap这个区间就很有用了——你可以一次性拿到所有重复键的元素。std::mapint, std::string timestamps; timestamps[100] start; timestamps[200] process; timestamps[300] end; // 找到第一个不小于150的键 auto it timestamps.lower_bound(150); if (it ! timestamps.end()) { std::cout it-first : it-second std::endl; // 200: process }这种“给定阈值找到第一个满足条件的元素”的能力在区间查询、日程安排、游戏碰撞检测等场景里非常常用。用vector的话你得先排序再自己写二分用map的话直接调用即可。5.4 注意性能陷阱与替代方案set和map虽好但不是万能的。有几个常见的性能陷阱红黑树的节点是独立分配的每个节点都有额外的指针开销左右子节点指针、父节点指针、颜色标记。一个int的set实际内存开销远大于4字节。如果数据量上百万级内存占用可能比vector大一个数量级。O(log n)在数据量小时看不出优势甚至因为常数项大反而比vector线性查找慢。数据量在几十个以内时直接vector循环可能更快。如果需要按键范围查询比如取出所有键在[a,b]之间的元素map和set是合适的因为有序。但如果只是单纯判断存在性unordered_set/unordered_map平均 O(1) 更快。我做性能测试时有个习惯先根据理论复杂度选型再拿真实数据和std::chrono测一轮。不要凭感觉认定“红黑树就是最快的”一定要拿数据说话。5.5 调试技巧如何快速查看容器内容写C最烦的一件事是调试时看不到标准库容器的内容。有几个实用的招在Visual Studio的Watch窗口里可以直接展开map和set变量查看元素——IDE内置了对STL容器的可视化支持。但如果你用gdb或纯命令行环境就得换思路。写一个简单的打印模板函数template typename T void printSet(const std::setT s) { for (const auto x : s) { std::cout x ; } std::cout std::endl; } template typename K, typename V void printMap(const std::mapK, V m) { for (const auto [k, v] : m) { std::cout [ k ] v std::endl; } }在断点处查看迭代器时直接看*it的值或者看it-first、it-second。如果你用it._Ptr去裸看内存那是我早期干过的事——不建议太痛苦。还有一个技巧利用assert。当你不确定某个键是否存在时assert(m.find(k) ! m.end())可以在调试阶段揪出逻辑错误。5.6 VS2022与g环境下容易踩的编译坑不同编译环境下map和set的使用体验有细微差别。我目前主要用VS2022和g配合CMake遇到的坑主要有这么几个首先是作用域问题。如果你声明了std::mapstd::string, int m;然后写m[key]在VS2022里没问题。但在g的某些老版本C11之前的标准里operator[]对const map是禁用的——你可能会看到一堆诡异的模板错误。现在用C17/20基本都一致了但如果你在OJ平台上混用编译器最好统一用insert或emplace以避免差异。其次是C版本问题。结构化绑定是C17才有的如果你在老的g上编译for (auto [k, v] : m)会报错。这时只能退回到for (auto kv : m)用kv.first和kv.second访问。我的建议是正式项目至少用C17最好用C20能让代码简洁很多。还有一个隐藏比较深的坑map的[]操作符在自定义类型作为键时如果比较器写得不正确可能直接崩溃而不是编不过。比如比较器出现循环依赖ab为真同时ba为真红黑树插入时可能死循环或栈溢出。这类错误没有编译期警告只能靠单元测试兜底。6. 从“会用”到“用好”组合使用的进阶思路6.1 map set嵌套前面提到的“单词行号记录”就是一个mapset嵌套的例子。这种组合的核心思路是外层map组织“主维度”内层set维护“次维度”的唯一性和有序性。另一个类似的场景系统里需要记录每个用户订阅了哪些标签。外层的键是用户ID值是std::setstd::string。一个用户可以订阅多个标签标签不能重复。用set存储标签后查询“某用户是否订阅了某标签”就是 O(log n) 的事。比起遍历一个vector快得多。嵌套容器的可读性问题也值得注意。如果类型写起来过长一定要用using别名using UserID int; using Tag std::string; using UserTags std::mapUserID, std::setTag;光是这四行声明代码的可读性就能提升一个档次。类型别名不是锦上添花是让别人包括三个月后的你能看懂你的代码。6.2 map中的值仍是map或set不仅仅是setmap的值也可以是另一个map。一个典型场景是二级索引在统计成绩时你可能需要先按班级再按学号查成绩。std::mapstd::string, std::mapstd::string, int classScores; classScores[ClassA][001] 90; classScores[ClassA][002] 85; classScores[ClassB][001] 78;这个嵌套map在逻辑上就是一个“二维表”或“矩阵”。当数据维度可控时这种写法的可读性比自定义结构体好很多。但注意嵌套层数越多查找路径越长代码越难维护。超过两层就建议考虑用结构体替代。6.3 自定义类型与multimap/multiset的取舍有时候键不是唯一的。比如一个日期可能对应多个事件。用std::multimap可以在同一个键下保存多个值但它的接口比map少了operator[]用起来没那么顺手。我的替代方案用std::mapKey, std::vectorValue插入时写m[key].push_back(value)。这样保留了operator[]的便利同时值部分用vector可以保持插入顺序。如果还需要对值部分去重再换成std::setValue。这种设计的好处是值部分用什么容器完全由业务决定自由度更高。缺点是自己要管理好值部分的数据结构。在实际项目中我绝大多数时候都是用map套vector/set很少直接用multimap——它太“小众”了可读性和灵活性都不够。6.4 别忘了emplace和try_emplaceC11引入了emplaceC17引入了try_emplace。这两个函数在构造复杂对象时能省一次拷贝/移动构造。std::mapstd::string, std::vectorint data; // 传统写法会先构造临时对象再移动/拷贝进去 data[key] std::vectorint{1, 2, 3}; // emplace直接在节点内存里构造 data.emplace(key, std::vectorint{1, 2, 3}); // try_emplace只有键不存在时才构造键存在则什么都不做 data.try_emplace(key, std::vectorint{1, 2, 3});try_emplace在客户端里做“如果不存在就插入”这个语义时比“先find再insert”更简洁也比“先operator[]再赋值”更高效——因为operator[]即使键已存在也会先构造一个空的默认值造成浪费。实际写业务时我大多数情况下用try_emplace处理“初始化一次”的语义比如缓存填充std::mapstd::string, CacheEntry cache; auto it cache.try_emplace(key, computeValue(key)); if (it.second) { // 新插入可以记录日志 log(cache miss: key); } else { // 已存在it.first指向已有元素 }6.5 一个综合性例子任务调度按优先级排队最后放一个综合点、贴近实际工程的例子一个简单的任务调度器按照优先级取出任务相同优先级按到达顺序处理。#include iostream #include map #include queue #include string struct Task { std::string description; }; int main() { // 优先级 1 最高 std::mapint, std::queueTask scheduler; // 模拟任务到达 scheduler[1].push({fix critical bug}); scheduler[3].push({write docs}); scheduler[2].push({code review}); scheduler[1].push({hotfix}); // 按优先级处理 for (auto [priority, taskQueue] : scheduler) { while (!taskQueue.empty()) { std::cout [P priority ] taskQueue.front().description std::endl; taskQueue.pop(); } } return 0; }map在这里的作用是按优先级排序从小到大每个优先级下用queue维护FIFO顺序。整体逻辑清晰没有手动排序没有复杂的自平衡操作。这就是map在实际项目中常见的用法——按某个维度分组并自动排序。7. 常见问题速查与实战建议7.1 问题速查表现象原因解决方案用operator[]查询后map无故变大operator[]在键不存在时会插入默认值只读查询改用find或containsC20set/map中元素顺序和预期不符自定义比较器未满足严格弱序确保比较器对所有不同元素能给出确定且一致的排序结果自定义类型无法作为键编译失败类型没有operator或比较器提供仿函数/lambda作为模板参数或重载operator通过迭代器修改元素值报错set的迭代器是constmap的key是const先erase旧元素再insert新元素数据量很大时内存飙升红黑树节点开销高评估改用unordered_map/unordered_set或vector手动二分查找速度比预期慢可能误用了std::find遍历set/map使用成员函数find它是二分查找 O(log n)遍历中删除元素后迭代器失效删除当前节点会使指向它的迭代器失效用it container.erase(it)获取下一个迭代器两个不同对象被认为相等数据丢失比较器只比较了一部分字段比较器需比较所有关键字段字段多时逐字段比较map嵌套层级过深代码难读类型过于复杂用using别名或拆分为独立结构体7.2 选型决策建议需要有序 唯一 快速查找 →std::set需要有序 键值关联 →std::map需要唯一键但顺序无要求 →std::unordered_set/std::unordered_map键可能重复且需要有序 →std::multiset/std::multimap或者map套vector只需要去重且之后需要遍历 → set因为去重后自动排序遍历得到的就是有序序列只需要快速判断存在性 → unordered_set查找O(1)我个人的倾向是能默认用map/set就不用unordered版本。原因是map和set的有序性在调试和日志输出时更友好——你可以直接看到一组有序的数据很容易判断逻辑对不对。unordered版本的哈希输出是无序的调试时不太直观。只有在性能测试明确证明unordered版本是瓶颈的解决方案时我才会切换。7.3 新手最容易犯的3个错误先说operator[]查询。这个错误几乎每个新手都会犯包括我当年。要不是调试时发现map的size莫名比预期大我也不会去深究原因。现在写代码只要涉及“查询是否存在”我第一反应就是find。这个思维转变很重要。其次是忘记#include map或#include set导致编译错误。这看起来好笑但在切换环境时确实会发生。VS2022的提示比较友好g的报错信息会把你指向一长串模板内部错误新手很容易懵。记住用map和set头文件分别是map和setalgorithm里的set_intersection等函数还需要algorithm。第三是把map和set当作数组来用试图用m[i]去访问“第i个元素”。map和set不支持随机访问——m[0]的意思是“键为0的值”不是“第一个元素”。如果你想取第k个元素需要遍历到第k个位置没有operator[]那种O(1)随机访问。这个逻辑如果不理清后面理解很多接口都会混乱。7.4 调试技巧打印大法我调试map和set相关逻辑时最常用的就是打印。写一个简单的模板把容器内容输出到标准流然后分分钟定位问题。template typename Container void dump(const Container c, const std::string name container) { std::cout name : ; for (const auto x : c) { std::cout x ; } std::cout std::endl; }如果是map这个模板打印的是pair默认不会输出成[key] value的格式。我一般针对map单独写一个template typename K, typename V void dumpMap(const std::mapK, V m, const std::string name map) { std::cout name : std::endl; for (const auto [k, v] : m) { std::cout [ k ] v std::endl; } }配合断点调试这套方法能覆盖绝大多数场景。剩下的问题看报错信息、查文档、再不行就在纸上画红黑树模拟一遍。早年我调试红黑树问题时真在纸上画过插入序列一步步推演旋转过程。图形化理解之后很多操作“为什么不能这样写”就顺理成章了。8. 写在最后的个人体会map和set这套东西学起来其实不难——接口就那么几个逻辑也直观。但真正“用得好”和“仅仅会用”之间的差距往往体现在对“有序性”和“唯一性”这两个特性的理解深度上。我在实际开发中的体会是map和set最适合拿来当“思维基础设施”。遇到一个问题先想清楚核心数据操作是什么是查存在是统计频次是要有序还是要分组想清楚了再决定用什么容器代码自然就简洁了。反过来如果一开始就想着“我要用map”然后硬套进去代码往往绕来绕去反而复杂。最后再分享一个小技巧如果你在写算法题或者做项目原型先用map/set快速实现一版跑通了再考虑要不要换unordered版本或者自定义数据结构。因为map和set的代码可读性最好作为第一版实现最不容易出错。性能优化永远在功能正确之后——这是我踩过无数次坑才总结出来的经验。
返回列表