ARTICLE DETAIL

资讯详情

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

C++ STL中map和set的高效使用:有序容器、红黑树与工程实践

C++ STL中map和set的高效使用:有序容器、红黑树与工程实践 写map和set之前先说说我为什么觉得这两个容器被严重低估了。在C的STL里map和set是我最愿意跟新人聊的两个关联容器因为只要用对了很多原本要手写排序、查找、去重的场景几行代码就干净利落地解决了。map就好比一本自动按字母排好序的词典你只负责收录词条和查词set则像一个登记严格的名单同一个名字绝不重复而且名单始终按照你定的规则排列。这篇内容围绕map和set的日常使用展开从插入、查找、删除、遍历到自定义比较器再到关联容器底层的红黑树设计把真正的用法和常踩的坑一次讲明白。不管你是刚学C不久还是已经写了一阵子业务代码但很少系统梳理STL的人这份实操笔记都值得花十分钟过一遍。1. 先认清map和set一对有性格的有序容器1.1 从使用场景看本质字典与集合很多人刚接触STL时会把map和set跟vector、list混在一起记觉得都是存数据的东西。这个理解没有错但太笼统了。vector和list属于序列式容器强调的是元素按插入顺序排列我一个个存、一个个取而map和set属于关联式容器强调的是元素按关键字组织我按key找value或者按值判断存在与否。map最适合的比喻就是字典。你有一个单词想知道它的解释直接翻到那一页。反过来你往字典里加一个词条它会自动放到正确的位置不需要你手动排序。在代码里map的每个元素是一个pair第一个成员是key第二个成员是value例如mapstring, int就把字符串映射到整数典型场景是统计词频、配置项存取、id到对象指针的映射。set则像一张会员名单只关心这个元素在不在名单里不关心它对应什么值。比如你要维护一个已经处理过的订单号集合每次来一个新订单先查set里有没有如果有就跳过没有就插入。set里的元素本身就是key不存在单独的value。这个存在性判断的能力看起来简单实际使用时非常高频。1.2 有序性才是它们真正的王牌map和set区别于unordered_map、unordered_set最核心的一点就是有序性。底层用红黑树实现元素始终按key的大小排列。这意味着你遍历map时天然拿到一个按key升序排列的结果不需要额外调用sort。这个特性在实际业务中能省掉大量代码。举个例子你需要按时间戳输出日志统计结果如果用unordered_map就得先把所有键取出来排序再遍历但直接用maplong, int存储时间戳到次数的映射遍历一遍就是有序输出一行额外排序代码都不用写。这个优势在需要范围查询的场景更明显想找出key在某个区间内的所有元素用lower_bound加upper_bound可以精确定位底层是树结构效率是O(logN)级别的比线性扫描整个容器快得多。另外map和set都要求key是唯一的。这个唯一性是容器自动保证的插入重复key时会直接失败set或保留原有valuemap不会像vector那样存两份。如果把唯一性和有序性结合起来看map和set就是一对纪律严明的容器它们强制你按规矩来也正因为这个强制使用它们的代码往往更稳健、更可预测。2. 核心细节解析map的增删查改实操要点2.1 插入insert的多种姿势与一个隐蔽的坑map的插入方式有好几种我常见到新人混淆。先看最典型的三种std::mapstd::string, int scores; // 方式一直接用pair构造 scores.insert(std::make_pair(Alice, 90)); // 方式二C11起用花括号初始化列表 scores.insert({ Bob, 85 }); // 方式三emplace原地构造减少一次拷贝 scores.emplace(Carol, 92);三种方式都能把元素插进去但有一个共同特点如果key已经存在insert不会覆盖原有value而是插入失败。这个行为经常让人踩坑。你满心期待地往map里塞配置项结果发现后面的配置没覆盖前面的程序还看不出任何报错因为insert的返回值被忽略了。正确的判断方式是检查insert的返回值。insert返回一个pairiterator, bool其中bool表示是否实际插入成功auto ret scores.insert({ Alice, 60 }); if (!ret.second) { // key已存在可以根据需要做覆盖或忽略 std::cout Insert failed, existing value: ret.first-second std::endl; }假如你想实现有则覆盖、无则插入的效果最直接的是用operator[]scores[Alice] 95。它的语义是如果key存在直接覆盖value如果不存在就新插入一个元素。注意operator[]在使用下标访问时如果key不存在会执行插入动作value用默认值填充。这就是为什么常有人说读map也会改变map——用[]去读一个不存在的key会把一个空值插进去容易引发莫名其妙的问题后面我会专门讲。2.2 查找find、count、operator[]三兄弟的差别map的查找方式主要看三种find、count、operator[]。它们的用途完全不同。find是真正的查找姿势。返回迭代器如果找不到就返回end()auto it scores.find(Alice); if (it ! scores.end()) { std::cout it-first it-second std::endl; } else { std::cout Not found std::endl; }count的语义在map里比较尴尬。因为map的key唯一count的返回值要么是0要么是1本质上就是一个布尔判断。它的好处是代码简洁适合只关心在不在的场景if (scores.count(Alice)) { // 存在 }operator[]的问题前面提过读不存在的key会自动插入。所以如果你只想查询千万别写成if (scores[Alice] 60)因为当Alice不存在时它会先插入一个value为0的元素然后返回0。查一次数据反而改变了容器这种隐蔽的bug在线上排查时相当费神。从C11开始map提供了一个at成员函数用法和[]类似但找不到key时会抛出std::out_of_range异常适合严格模式下使用try { int v scores.at(Alice); } catch (const std::out_of_range e) { // 处理缺失情况 }2.3 删除与修改erase的操作绕不开返回值和迭代器删除map元素最简单的是按key删size_t n scores.erase(Alice); // 返回删除的元素个数要么0要么1如果想在遍历过程中删除就需要注意迭代器失效的问题。map的erase会返回被删除元素的下一个迭代器C11以后所以可以这样安全地删for (auto it scores.begin(); it ! scores.end();) { if (it-second 60) { it scores.erase(it); // erase返回下一个迭代器 } else { it; } }修改value则很直接因为map的value不是const的通过迭代器或[]都能改scores[Alice] 100; auto it scores.find(Bob); if (it ! scores.end()) it-second 88;但修改key就完全是另一回事了。你不能直接改it-first因为map的key是const的强行改会破坏红黑树的有序结构。如果真要改key正确做法是erase掉旧元素再以新key插入。这一点很多新手会反复踩坑我见过有人为了省一次插入直接const_cast掉key去改结果整棵树的顺序全乱find再也查不到元素这是典型的Undefined Behavior。注意map的key类型必须是可比较的默认使用operator。如果你用自定义结构体做key不提供比较规则编译直接报错这个坑我放在后面专门讲。3. set的使用要点比map更纯粹的集合容器3.1 插入、去重与有序输出的组合拳set的使用比map简单因为每个元素既是key又是value。它的核心能力有三个去重、排序、快速查找。std::setint numbers; numbers.insert(5); numbers.insert(3); numbers.insert(8); numbers.insert(3); // 重复插入失败 for (int n : numbers) { std::cout n ; // 输出3 5 8天然有序 }这个例子一口气展示了set的三个特点重复元素无法插入遍历结果自动升序插入和查找都是O(logN)。如果你需要处理一批数据里有哪些不重复的元素set是最省心的答案。有一个很容易被忽略的细节insert同样返回pairiterator, bool可以用于判断这个元素是不是第一次出现auto [it, inserted] numbers.insert(3); if (!inserted) { // 说明之前已经有3了 }这种用法在实时去重统计里很实用比如处理消息ID、订单号时可以顺便知道当前来的数据是不是重复的不用额外再查一次count。3.2 set的迭代器为什么偏偏是const的set有个看起来限制很大的设计它的迭代器指向的元素是只读的不能通过迭代器修改元素。用*it 10这样的写法编译都过不去。原因和map的key不可修改一样set里的元素本身参与红黑树的排序如果允许修改树的结构可能被破坏。但实际开发中经常有人需要修改set里的元素。比如set里存的是某个对象根据对象的score排序现在score变了想更新。正确的做法是先erase旧的再insert新的。如果嫌两个操作麻烦可以用C17的extract接口把节点提取出来修改完再insert回去这样省一次内存分配std::setItem items; // ... auto node items.extract(old_key); // 提取节点不删除内存 node.value().updateScore(100); // 修改内容 items.insert(std::move(node)); // 重新插入这个方法在实际工程里未必高频使用但知道它能帮你理解set为什么设计成只读迭代器也能在性能敏感场景下少做一次不必要的分配。3.3 集合运算交集、并集、差集的经典操作set这个名字本身就暗示了集合运算。STL在algorithm里提供了std::set_intersection、std::set_union、std::set_difference它们要求输入容器必须有序set天然满足这个条件。std::setint a {1, 2, 3, 4}; std::setint b {3, 4, 5, 6}; std::setint result; // 交集a和b都有的元素 std::set_intersection(a.begin(), a.end(), b.begin(), b.end(), std::inserter(result, result.begin())); // result {3, 4}注意输出要用std::inserter或std::back_inserter这类插入迭代器不能直接传result.begin()因为集合运算不会预先知道要插入多少元素直接用普通迭代器会覆盖已有元素导致未定义行为。如果你经常处理权限标签、特征集合、已读列表这类数据这三个算法配合set能写出非常清晰的代码。4. 自定义类型与比较器让map和set听懂你的排序规则4.1 结构体做key重载operator是第一步内置类型int、string、double做map的key时一切都很自然因为库已经内置了比较规则。但一旦换成自定义结构体问题就来了。最常见的错误是试图用原始结构体直接当key然后编译报错一长串核心信息是invalid comparator或者no match for operator。解决办法很明确为结构体重载operator让它具备可比较的能力。举个例子你要按学号和学生姓名做索引struct Student { int id; std::string name; int score; bool operator(const Student other) const { return id other.id; // 按学号排序 } }; std::setStudent students; students.insert({1001, Alice, 90}); students.insert({1002, Bob, 85});这里有几个要点。第一operator必须是const成员函数因为比较操作不应该修改对象。第二比较规则必须满足严格弱排序strict weak ordering简单说就是ab和ba不能同时成立而且ab、bc推出ac。如果这个规则写错了容器内部的红黑树可能会被破坏导致find、insert行为完全不可预测。第三重载operator时最好把所有参与排序的字段都纳入比较不要只比较一个字段然后放任其他字段随意否则两个不同元素会看起来相等。4.2 仿函数与lambda不修改结构体也能指定规则有时候你没法修改结构体定义比如结构体来自第三方库或者同一个结构体需要在不同容器里按不同规则排序。这时可以用仿函数函数对象作为map/set的模板参数struct CompareByScore { bool operator()(const Student a, const Student b) const { if (a.score ! b.score) return a.score b.score; // 分数高的排前面 return a.id b.id; // 分数相同再按学号升序 } }; std::setStudent, CompareByScore studentsByScore;C11之后更简洁的方式是直接用lambda不过这里有个小坑set和map的模板参数需要一个类型而lambda是一个匿名类型对象直接写有点丑。C17开始可以用std::function包装或者从C20开始直接用lambda类型做模板参数。如果项目长期停留在C11/14我更推荐仿函数清晰且零额外开销。关于比较规则要稳定我再多说一句。仿函数内部如果依赖外部可变状态比如某个全局标志位改变排序方向就会造成同一个元素在不同时刻的排序结果不一致红黑树会在你毫无察觉的时候变得一团糟。我自己在业务里见过一次这样的bug排查到最后才发现是比较函数里用了某个可变的优先级配置。所以比较函数一定要做成纯函数同样的输入永远给出同样的输出。4.3 用一个实战案例说明任务调度器的数据结构设计拿一个真实场景串联一下上面的内容。假设你要写一个简单的任务调度器任务有优先级、创建时间、任务ID三个属性。你需要一个数据结构能够按优先级高到低取任务相同优先级时创建时间早的先执行任务ID唯一避免重复提交用一个set就能解决struct Task { int priority; long createTime; int taskId; bool operator(const Task other) const { if (priority ! other.priority) return priority other.priority; if (createTime ! other.createTime) return createTime other.createTime; return taskId other.taskId; } }; std::setTask taskQueue; // 插入任务 taskQueue.insert({1, 1000L, 1}); taskQueue.insert({1, 1000L, 2}); taskQueue.insert({0, 999L, 3}); // 取最高优先级且最早创建的任务 auto it taskQueue.begin(); if (it ! taskQueue.end()) { Task t *it; taskQueue.erase(it); // 执行任务 t... }这个设计方案的好处是每次取begin()就是当前最该执行的任务不需要排序或堆操作erase也只需要logN时间。相比手动维护vector再sort代码清晰很多。这个思路在很多语言里的优先队列都有类似实现但C的set胜在既能当队列又能当集合去重和排序一次搞定。5. multimap与multiset允许重复关键字的异类兄弟5.1 什么时候才需要用multimapmap要求key唯一但现实里经常出现一个key对应多个值的需求。比如一个班级的分数表不同学生可能有相同分数一个订单系统里同一个用户ID对应多个订单。这种场景有两个选择一个是mapkey, vectorvalue手动聚合另一个是multimapkey, value让容器自己管理重复key。multimap和map的接口有很大差异最不适应的一点是它没有operator[]也没有at。原因很容易理解一个key对应多个值你没法用multimap[key]去访问某一个具体value。插入直接使用insert同样的key可以插入多次std::multimapstd::string, int scoreMap; scoreMap.insert({ Alice, 90 }); scoreMap.insert({ Alice, 95 }); scoreMap.insert({ Bob, 85 });查找时需要注意find只会返回第一个匹配的迭代器不是唯一的那个。如果你用find再配合去遍历所有重复key很容易越界或者漏项。STL提供了equal_range一次返回一个迭代器区间正好覆盖所有相同key的元素auto range scoreMap.equal_range(Alice); for (auto it range.first; it ! range.second; it) { std::cout it-first it-second std::endl; } // 输出两行Alice 90, Alice 95multiset同理区别只是它没有value只有key本身允许重复元素插入。需要统计一组数据里每个值出现的次数时multiset其实可以直接当桶用count(key)返回这个key的出现次数。5.2 实际使用中的几个细节别被count误导对multimap来说count(key)返回的是重复key的数量这个语义和map完全不同。map的count只有0或1multimap的count可能是任意非负整数。做存在性判断时依然可以用count判断是否大于0但如果你同时需要获取这些元素一定要用equal_range避免先count再find导致两次查找。另一个细节是删除。erase(key)会删除所有等于这个key的元素返回删除个数。如果你只想删除其中某一个那就需要用迭代器定位到具体那个元素再erase(it)这样只会删除迭代器指向的那一个。很多人用multimap时下意识写m.erase(value)想删除特定值结果把所有相同key的全删了这种误操作在统计场景里会造成数据大面积丢失。注意multimap和multiset虽然允许重复key但底层依然是红黑树插入和查找复杂度仍然是O(logN)。它们不会退化成线性结构只是节点里面多了一个同一个key出现多次的叶子排列规则。6. 底层结构、性能分析与选型为什么默认用红黑树6.1 红黑树为什么是中庸的王者map和set的底层是红黑树准确地说是一棵近似平衡的二叉搜索树。它没有AVL树那么严格的平衡要求但保证了从根到叶子最长路径不超过最短路径的两倍。这意味着最坏情况下的树高大概是2 * log2(N)插入、删除、查找都能维持在O(logN)级别。我经常被问一个问题为什么不直接用数组加二分查找因为数组插入需要移动大量元素插入成本O(N)。为什么不直接用哈希表因为哈希表牺牲了有序性。红黑树最妙的地方在于插入、删除、查找稳定在logN同时能自动维持有序。这三个特性组合在一起让map/set成为一个什么都能干的通用容器。实际项目中如果你需要频繁做范围查询、顺序遍历、求前驱后继红黑树比unordered_map舒服得多。比如维护一个股票价格表需要随时知道当前价格排名这种需求用map天然满足用哈希表则要额外维护一个有序结构。6.2 map与unordered_map的选型对照哈希容器在C11标准里就是unordered_map和unordered_set。它们底层是哈希表平均查找O(1)但牺牲了有序性。选型时不能只看复杂度要结合实际场景场景推荐容器原因需要按key有序遍历map遍历天然有序需要范围查询key在区间内maplower_bound/upper_bound高效只做单点查找不关心顺序unordered_map平均O(1)更快需要统计词频unordered_map大量随机插入哈希表均摊更优key是自定义对象难以哈希map只需提供operator哈希还需设计hash函数需要稳定logN性能防止哈希冲突攻击map哈希表最坏O(N)一个实用的经验是数据量小于几千级别时map和unordered_map的差距几乎感觉不出来优先选代码清晰、行为稳定的map。当数据量上到十万、百万级别并且你的操作绝大多数是单点查找再考虑unordered_map。实际工程里我还见过一种情况一个map在某个key上频繁插入和删除导致红黑树反复调整性能比unordered_map差一些但仍然是可接受的logN级别不是灾难。所以选型不要过度优化优先保证代码可维护。6.3 节点内存开销map和set占用比你想象的多map和set的每个节点不仅存数据还有颜色标记和三个指针左孩子、右孩子、父节点所以单节点内存开销明显大于vector里的一个元素。以一个int为key的setint为例一个节点可能占用40字节左右而真正有用的数据只有4字节。如果你存储的是海量小整数set的浪费会非常大。这个内存开销在嵌入式或内存受限环境下必须纳入考虑。我之前在日志分析系统里用setlong存了百万级的时间戳内存吃了接近80MB后来换成vector加排序的方式内存降到30MB左右查询效率在离线一次性统计场景反而更快。所以map和set并不是万能药有序这个功能是要用内存换来的。你需要想清楚真的需要频繁的O(logN)插入和有序遍历吗还是说只需要离线排序一次就够了7. 常见问题与排查技巧实录7.1 自定类型比较器不严格弱排序的后遗症这是我在实际排查里见过次数最多的问题。某团队用自定义结构体做map的keyoperator只比较了一个int字段结果发现两个不同的结构体对象在map里被认为是同一个key插入操作莫名其妙失败。原因就是比较器没有区分足够多的字段导致两个本该不同的元素在排序规则下等价。解决思路写比较规则时想象一下两个对象的每一个字段都相同最后一定要有一个兜底字段保证严格区分。常见做法是最后比较id这一类唯一字段。还有一个排查技巧如果怀疑比较器写错了可以在插入后遍历容器看元素个数是否和预期一致。红黑树不会报错但元素丢失是明确信号。7.2 operator[]读不存在的key导致灵异数据我见过一个线上事故统计接口某天开始返回大量异常数据排查到最后发现代码里写的是if (config[name] enabled)这里的config是一个mapstring, string。由于某个配置项没被初始化operator[]自动插入了一个空字符串value导致map里凭空多出很多垃圾键值对。后续遍历输出配置时这些脏数据全部暴露出来。这个问题的根治方法很简单只判断存在性的场景一律用find或count绝对不要用operator[]。只有确定你要么赋值、要么覆盖时才用[]。如果你联调时发现map的元素数比预期多优先检查是不是有地方用[]读不存在的key了。7.3 erase和迭代器失效vector等序列容器在删除元素后后面的迭代器会全部失效大家多少有这个概念。map和set好一些删除某个元素后其他元素的迭代器不会失效。但你自己正在用的那个迭代器肯定失效了所以遍历中erase要特别小心。我见过一个经典错误for (auto it scores.begin(); it ! scores.end(); it) { if (it-second 60) { scores.erase(it); // bugerase之后it已经失效了但循环还在it } }这个代码在C11之前是未定义行为在C11之后依然危险因为it操作的是已经失效的迭代器。正确写法是前面提到的it scores.erase(it)或者先记录下一个迭代器再删除。写代码时一定要养成这个习惯。7.4 快速问题排查对照表现象可能原因排查方法插入元素后count仍然是0key类型缺少合适的operator或者比较器不满足严格弱排序检查自定义类型的比较规则确保字段区分完整遍历map时key顺序混乱用const_cast修改了key禁止直接改key先erase再insertmap元素数量莫名增加使用operator[]读取了不存在的key查找是否存在map[key]的读操作改为findset去重失败比较器认为两个不同元素等价补全比较字段最后加唯一id字段兜底multimap删除时误删大量数据使用erase(key)而不是erase(it)确认要删除单个元素还是全部重复key遍历中erase导致程序崩溃迭代器失效后仍继续使用使用it container.erase(it)写法插入操作成功但元素不在预期位置比较函数依赖可变外部状态确保比较函数是纯函数不读取外部可变量7.5 一个源自实践的小技巧利用lower_bound做区间统计最后分享一个我真实用到的技巧。假如有一个maptime_t, int记录了每个时间点的事件数现在要统计[t1, t2]区间内的事件总量不用遍历整个mapauto lo events.lower_bound(t1); auto hi events.upper_bound(t2); long total 0; for (auto it lo; it ! hi; it) { total it-second; }lower_bound返回第一个不小于t1的迭代器upper_bound返回第一个大于t2的迭代器两者之间正好是区间内容。这个操作背后的复杂度是O(logN K)K是区间内元素数。如果区间很小效率非常高。这个模式在时间序列统计、范围查询里非常实用也是红黑树有序性带来的独特优势unordered_map完全做不到。我个人在实际操作中的体会是map和set用起来不难但真正决定代码质量的往往是那些你以为没事其实有事的细节——比如读操作改变了容器、比较器不够严谨、遍历删除时迭代器失效。每次遇到这类问题我都会先停下来想一想容器底层是怎么组织数据的想通了排查思路就清晰了。如果你刚开始用map和set建议拿一个小项目练手把本文的插入、查找、删除、自定义比较器、multimap的equal_range都用一遍再故意制造几个bug看看现象印象会非常深。这一套容器在工程里的出镜率极高把这些基本功打扎实后面写C代码会顺畅很多。
返回列表