【C++进阶】STL容器与迭代器 - 05 map 和 set 为什么按键保持有序

博主介绍:程序喵大人

好文推荐:

【AIAgent项目】从零构建一个代码PRAgent

【C++进阶】STL容器与迭代器 - 01 STL 容器先解决元素放在哪里

【C++进阶】STL容器与迭代器 - 02 vector 为什么是一段会长大的连续数组

【C++进阶】STL容器与迭代器 - 03 string、array 和 deque 各自守住什么边界

【C++进阶】STL容器与迭代器 - 04 list 和 forward_list 用节点换稳定位置

统计一段文本的词频然后按字典序输出,这个需求用 vectorsortunique 也能做,但你得先在 vector 里收集所有单词(包括重复的),排完序再遍历统计连续相同单词的次数。两轮遍历,一趟排序,代码散在好几处。用 std::map<std::string, int>,代码变成:

cpp 复制代码
std::map<std::string, int> word_count;
for (const auto& word : words)
    ++word_count[word];   // 不存在则自动插入,值初始化为 0,然后递増
for (const auto& [word, count] : word_count)
    std::cout << word << ": " << count << '\n';

输出自动按字典序排好,不需要额外 sort。这背后是一个深刻的差异:顺序容器按"你放在第几个"来组织元素,有序关联容器按"键的大小关系"来组织元素。键不只是用来查找的标签,它就是元素在容器内部结构中的定位坐标。

键决定位置

std::map<K, V> 存储的元素类型是 std::pair<const K, V>,其中 K 是键的类型,V 是值的类型。注意键是 const K,一旦插入,键在容器中的副本不能被修改(否则会破坏有序结构)。值可以随便改。

当一个新的键值对插入 map 时,容器会比较新键和已有节点的键,沿着比较结果决定的路径找到合适的位置。这个过程和在一个二叉搜索树中搜索或插入的逻辑一致:如果新键小于当前节点键,就往左走;大于就往右走;等于就说明键已存在(对于不允许重复键的 mapset,插入失败,但 operator[] 会覆盖旧值)。

std::set<K> 的逻辑更简单,它只存键,没有值。你可以把它想象成键类型本身就是值类型的 map,或者反过来,mapset 的每个元素旁边附了一个值。

常见实现用平衡二叉树(通常是红黑树)来维护这个有序结构。红黑树通过给每个节点标记颜色(红或黑)并维护一组不变量,保证树的高度始终是 O(log n),从而保证查找、插入、删除都是对数复杂度。但标准不强制要求红黑树,任何满足有序关联容器要求的实现都可以。对于使用者来说,你只需要知道:这些操作的复杂度是对数级别,容器的迭代器是双向的(可以前进和后退),中序遍历得到的序列按键的顺序排列。

比较函数:什么算"小"

有序关联容器的默认比较函数是 std::less<K>,它通常调用 operator<。所以你插入 int 键时,从小到大排;插入 std::string 键时,按字典序排。这个默认行为对大多数情况都够用了。

但你可以在模板参数中指定自定义的比较函数:

cpp 复制代码
std::map<std::string, int, std::greater<std::string>> desc_map;
// 遍历结果按键的降序排列

比较函数必须定义严格弱序(strict weak ordering)。简单说,它要满足传递性:如果 a < bb < c,则 a < c。同时不能出现 a < bb < a 的情况,也就是不能循环比较。违反严格弱序会导致容器的内部结构被破坏,行为是未定义的,通常表现为查找不到已插入的元素,或者程序崩溃。自定义比较函数时最常见的错误是"小于等于"(<=)当作小于来用,这使得 a < a 为真(因为 a <= a),破坏了严格弱序的非自反性。

比较函数只关心键。map 的值在排序中完全不参与,两个键值对按键比较,值再大都影响不了位置。这个规则有一个重要的推论:你不能通过修改比较逻辑来对值排序,值的排序只能通过把键值对提取到 vector 中再按值 sort 来实现。

find、lower_bound 和 operator\[\] 的三个面孔

查找一个键在 mapset 中是否存在,有几种不同的方式,它们的语义差异值得分清楚。

find(key) 返回一个指向键为 key 的元素的迭代器,找不到则返回 end()。这是最纯粹的查询操作:只读不写,找不到也不会修改容器。当你只需要判断存在性或取值时,用 find

operator 的行为就没这么单纯了。对 map 来说,如果 key 存在,返回对应值的引用;如果 key 不存在,它会插入一个键为 key、值为默认构造值的元素,然后返回这个新值的引用。这个隐式插入行为可能导致意外的后果:在一个 const map 上你无法用 operator[](因为它可能修改容器),在一段"看起来只读"的代码中可能悄悄插入了新元素。如果你想用 operator[] 的便利语法但不想要插入行为,用 map.at(key),它在键不存在时抛出 std::out_of_range 而不是插入。

lower_bound(key) 返回第一个键不小于 key 的元素的位置。upper_bound(key) 返回第一个键大于 key 的元素的位置。这两个函数联合使用可以划定一个范围:对于 map[lower_bound(k), upper_bound(k)) 包含了所有键等于 k 的元素(对于不允许重复键的 map,这个范围要么是空,要么只包含一个元素);对于允许多个等值键的 multimap,这个范围可能包含多个元素。

lower_bound 的价值在于范围查询。"找出所有键在 "apple""orange" 之间的元素"可以用 lower_bound("apple")upper_bound("orange") 之间的遍历实现,复杂度是 O(log n + 范围内元素数量),而不是遍历全部再做过滤的 O(n)。这个能力在顺序容器或无序容器中是做不到的,只有有序容器才天然支持按键的范围查询。

唯一键与重复键

std::mapstd::set 要求每个键最多出现一次。尝试插入一个已经存在的键时,insert 会返回一个 pair<iterator, bool>,其中 boolfalse 表示插入失败(键已存在),iterator 指向已存在的那个元素。operator[] 则直接覆盖旧值。

std::multimapstd::multiset 允许同一个键出现多次。它们的 insert 总是成功,返回指向新插入元素的迭代器(没有 bool 标志)。multimap 没有 operator[],因为同一个键可能有多个值,无法确定该返回哪个。查找时 find 返回第一个匹配的迭代器,但并不意味着只有一个;count(key)multimap 中可能大于 1,而在 map 中最多为 1。

multimap 的一个典型场景是字典索引:同一个单词可能出现在多个页面,一个 key 对应多个 value。std::map<std::string, std::vector<int>> 也可以做这件事,但 multimap 直接支持多个等值键,省去了你自己管理 vector 的代码。选择哪一个取决于你更频繁做什么操作,查找某个键对应的所有值(multimapequal_range 直接返回迭代器范围),还是遍历和修改某个键对应的所有值(vector 版本更方便)。

cpp 复制代码
std::multimap<std::string, int> index;
index.insert({"apple", 1});
index.insert({"apple", 5});   // 同一个键,不同值,插入成功
index.insert({"apple", 12});

auto [first, last] = index.equal_range("apple");
std::cout << "apple 出现在页面: ";
for (auto it = first; it != last; ++it)
    std::cout << it->second << ' ';  // 输出: 1 5 12

map 找键,set 看存在

选择 map 还是 set,取决于你需要存储的是键值对还是纯粹的键集合。

map 的核心语义是关联数组,把键映射到值,键到值的对应关系就是数据本身。统计词频用 map<string, int>,因为你需要记录"单词→次数"的映射。做配置管理用 map<string, string>,因为你需要通过配置项名字找到对应的值。

set 的核心语义是集合,存不存在这个元素是唯一关心的信息。白名单用 set<string>(一个名字在不在白名单里),已访问 URL 去重用 set<string>(URL 见没见过),这些场景没有"值",键本身就是全部信息。

set 还有一个容易被忽视的用法:去重排序。把一批数据插入 set,遍历得到的就是去重后的有序结果,一步完成"去重 + 排序"。注意这个做法的效率取决于数据量:插入 N 个元素到 set 是 O(N log N),和把元素放进 vectorsort + unique 理论上同阶,但 set 的节点分配和树结构调整有更大的常数开销。数据量很大时,vector + sort + unique 通常比直接用 set 快,又一次,缓存在连续内存上的优势压倒了理论复杂度上的常数差异。

为什么不是哈希:有序的价值

如果你的需求只是"按键快速查找",那 unordered_map 平均 O(1) 的查找复杂度看起来比 map 的 O(log n) 更有吸引力。选择 map 而不是 unordered_map 的理由几乎总是围绕顺序:

  • 你需要按键有序遍历,比如按字典序输出报告,按时间戳顺序处理事件,按 ID 顺序列出记录。map 遍历天然有序,unordered_map 遍历出来是什么顺序完全不可预期。
  • 你需要范围查询,"键在 A 到 M 之间的所有元素"。maplower_bound/upper_bound 在 O(log n) 内找到范围边界,然后范围内的遍历是 O(k)。unordered_map 做不了范围查询,你必须遍历整个容器,对每个键逐个判断。
  • 你需要稳定的比较语义。map 的比较函数是你显式指定的(或默认的 std::less),行为确定。unordered_map 的哈希函数和相等判断虽然也可以自定义,但哈希冲突后的行为和桶分布在不同运行中可能不同(哈希种子随机化),导致性能表现不那么确定。

但有序也有它的代价。对数复杂度的常数因子通常大于哈希的常数因子(尤其是键类型简单、哈希函数质量高时),树节点比哈希表元素多存储了两个子节点指针和颜色信息(或平衡因子),空间占用更大。理解和接受这个取舍是工程判断的一部分:需要顺序时 map,不需要时 unordered_map

迭代器的双向性

mapset 的迭代器是双向迭代器,你可以 ++ 向前,也可以 -- 向后。这种能力来自树结构的对称性:每个节点既有左子(小于当前键的节点),也有右子(大于当前键的节点),还有父节点指针,双向迭代器可以沿着任意方向移动。

这意味着你可以从 map.end() 开始 -- 向前遍历,得到键的降序序列。要在 unordered_map 上做同样的事,你必须把所有元素拷贝到 vectorsort。双向遍历还使得算法如 std::reverse_copy 可以在有序关联容器上直接使用,而不需要先把元素倒排一遍。

但双向≠随机访问。map 不能做 m.begin() + 5 这种事,要做到"第 5 个元素",你必须从 begin() 开始逐个递增 5 次。std::advance(it, 5) 在你看来是一行调用,在 map 上它是 5 次 ++it 的循环,每次都是沿着树结构找下一个位置。这个 O(n) 的"随机访问"提醒你:有序关联容器的一切能力都围绕键来设计,"位置"是键的排序结果,不是可直接计算偏移的第一类概念。

cpp 复制代码
#include <iostream>
#include <map>
#include <string>

int main() {
    std::map<std::string, int> scores;

    // 插入
    scores["Bob"]   = 85;
    scores["Alice"] = 92;
    scores["Tom"]   = 78;

    // 遍历,自动按名字字典序
    for (const auto& [name, score] : scores)
        std::cout << name << ": " << score << '\n';
    // Alice: 92, Bob: 85, Tom: 78

    // find 不插入
    auto it = scores.find("Alice");
    if (it != scores.end())
        std::cout << "找到 Alice: " << it->second << '\n';

    // lower_bound 找范围
    auto lb = scores.lower_bound("B");
    std::cout << "名字 >= \"B\" 的第一条: " << lb->first << '\n';

    // operator[] 插入默认值
    std::cout << "Eve 的分数: " << scores["Eve"] << '\n';  // 打印 0 且插入了 Eve
    std::cout << "现在 map 有 " << scores.size() << " 条记录\n"; // 4 条
}

注意代码最后一行之后的 size() 是 4,scores["Eve"] 看似只是一个读操作,实际上因为 Eve 不存在,它插入了一个默认分数 0。这是 map::operator[] 最经典的陷阱:看起来是查询,其实是"查不到就插入"。避免它的方式是:查询用 findat,只有确定要在键不存在时插入才用 operator[]

有序是能力,不只是附带属性

回到开头的词频统计,map 真正提供的能力超出了"存键值对",因为 unordered_map 也能存键值对。map 提供的是存储过程即排序的能力:你每插入一个元素,容器就在为最终的有序输出做布局。当遍历开始时,结果已经按你的顺序规则排好了,不需要额外的整理步骤。map 的有序性是它的设计目标,键的组织方式就是为了这个目标服务的。

就像 vector 用连续内存服务随机访问,list 用分散节点服务位置稳定性,map 用按键构建的树结构服务有序访问和范围查询。每个容器的底层结构都不是偶然的,它们是在不同维度上对"数据被怎样使用"的回答。

map 的比较函数是这个结构的核心输入。默认情况下,std::less<Key> 决定键的升序关系;如果你传入自定义比较函数,树的形状、键是否被认为相等、遍历顺序都会跟着改变。比较函数必须满足严格弱序,否则容器会进入逻辑混乱状态:有些键可能找不到,有些插入可能被误判为重复,遍历顺序也不再可信。很多 map 的诡异 bug,表面看像容器问题,根源其实是比较函数没有稳定、一致地回答"谁排在谁前面"。

这也是 set 容器特别适合表达"按规则去重"的原因。普通的 set<int> 依赖整数大小关系去重;一个 set<std::string, CaseInsensitiveLess> 可以按照不区分大小写的规则去重;一个 set<Task, ByDeadline> 可以按照截止时间组织任务。但你必须承认这种比较规则会定义元素身份:在 set 看来,两个元素只要在比较函数下互不小于对方,就被视为等价。换句话说,set 的"重复"由排序规则决定,和 operator== 可以不是同一套规则。

map 的节点稳定性也很有工程价值。插入新键不会让已有元素的引用和迭代器失效,删除一个节点只影响被删的那个节点。这一点和 vector 形成鲜明对比。比如你在一个调度器里保存任务对象,并且外部模块长期持有某个任务的迭代器或引用,map 可以在持续插入新任务时保持旧位置有效。当然,被删除的节点仍然会失效,稳定性不是免死金牌,它只是把影响范围缩小到被操作的节点。

map 的代价也要直说。每个节点通常要保存键、值、颜色或平衡信息、父子指针,内存开销明显高于连续数组。树形访问还会带来指针跳转,缓存局部性较差。对几十个元素的小集合来说,排序后的 vector 加二分查找经常比 map 更快;对需要频繁批量遍历的场景,map 也可能因为节点分散而输给连续容器。map 的优势集中在持续维护有序状态、频繁范围查询、插入删除同时保持迭代器稳定这些需求上。

因此,看到"我要一个字典"时不要立刻写 map。先问输出顺序是否有业务意义,是否需要 lower_boundupper_bound 这样的区间查询,是否需要稳定迭代器,键的比较是否天然可靠。如果答案都是肯定,map 很合适;如果只是按键查找且顺序无意义,哈希表可能更直接;如果数据量小且更新少,排序 vector 也可能更简单。map 是一棵始终维护秩序的树,这个秩序有价值时它才值得付出节点和对数查找成本。

使用 map 时还要把键的不可变性当成规则。map 元素的类型是 pair<const Key, T>,键部分是 const,因为键一旦改变,节点在树中的位置就可能错误。想修改键,正确做法通常是删除旧键再插入新键,或者在 C++17 之后用 extract 拿出 node handle 修改键后重新插入。标准库把键设成 const 是在保护树结构的排序不变量。只要树里的节点位置由键决定,键就不能在原地随便变。

自定义比较函数也要和业务身份绑定清楚。假设你用 CaseInsensitiveLessset<std::string>,那么 "abc""ABC" 在这个集合里等价,第二个插入可能失败;这不是 set 丢数据,它执行的正是你定义的身份规则:"忽略大小写后相同"。如果业务上还需要保留原始拼写,就要把原始字符串放到值里,或者改用 map<NormalizedKey, OriginalData>。比较函数决定容器视角下的唯一性,不能写完后再按另一个身份规则解释结果。

范围查询是 map 最容易被低估的能力。比如你有一批按时间戳组织的事件,想找某天 00:00 到 24:00 之间的记录,lower_bound(start)lower_bound(end) 能直接定位范围边界,然后只遍历范围内元素。哈希表只能扫全表过滤,排序 vector 也能做二分,但频繁插入时维护排序会付出搬动成本。只要"有序范围"是日常操作,map 的对数查找和稳定顺序就有明确价值。

测试 map 代码时,既要测查找,也要测顺序。许多 bug 不表现为找不到 key,而表现为比较函数把两个业务上不同的 key 判成等价,导致插入数量比预期少;或者比较函数不稳定,导致遍历顺序在不同数据集下看似随机。一个可靠的测试应该覆盖重复键、边界键、范围查询的首尾、比较函数认为等价但 operator== 不相等的样例。map 的正确性建立在比较规则上,测试也必须围绕比较规则展开。

从工程维护角度看,map 适合表达"顺序本身是业务规则"的场景。配置项按名字输出、定时任务按执行时间排列、价格档按档位区间查找、符号表按名称稳定遍历,这些需求中顺序是数据结构持续维护的核心状态。这个状态有价值,map 的成本就合理;这个状态没价值,map 就只是用树绕远路做查找。

还有一种常见工程形态是小规模有序表。几十个元素、更新很少、查询频率不高时,排序后的 vector<pair<K, V>> 可能比 map 更快、更省内存,因为它连续存储,遍历和二分查找的缓存表现很好。map 的优势会在持续插入删除、范围查询频繁、迭代器稳定性重要时变得清晰。面对小表时不要自动写树,先问这棵树持续维护的顺序是否真的能抵消节点开销。

multimapmultiset 也值得放在同一个模型里理解。它们允许等价键重复出现,适合一对多的天然关系,比如同一时间戳下多条事件、同一分数下多个玩家、同一标签下多个对象。查询重复键时通常用 equal_range 拿到一段连续的等价范围,再在这段范围内遍历。这里的"连续"指的是遍历顺序上的连续,不是内存连续;底层仍然是树节点。这一点能帮助你把多值关联关系和普通 map<K, vector<V>> 的取舍看清楚:前者按键排序和节点插入自然,后者更适合对单个 key 的值列表做批量处理。

把这些规则压回一句工程判断:当顺序、范围和稳定节点同时有价值时,mapset 是很稳的选择。它们牺牲了连续内存和平均常数查找,换来的是持续维护的键序、清晰的比较规则和局部失效范围。只要你愿意为这三件事付费,树结构就是合适的;如果你只想查一个 key,下一章的哈希容器会给出更直接的路线。

阅读 map 代码时,也应该优先找这三件事。看到 map,先看它是否使用了有序遍历;再看是否使用了 lower_boundupper_boundequal_range;最后看是否依赖插入删除后的节点稳定性。如果三件事都没有出现,map 很可能只是一个习惯性选择。反过来,只要这些能力频繁出现,树结构的成本就有明确回报。

这一章把节点从链表推进到了树。链表按前后关系组织节点,树按键的大小关系组织节点;链表给你稳定位置,树额外给你顺序和范围。接下来无序容器会把树的顺序拿掉,用哈希桶换取平均更快的按键定位。三者都属于节点式思路,但它们回答的是完全不同的问题。

这条脉络很关键。节点本身只是存储介质,真正决定容器能力的是节点之间如何连接、如何查找、如何遍历。list 的连接表达前后顺序,map 的连接表达键序关系,后面的 unordered_map 则用桶数组把节点按哈希值分组。理解了连接方式,复杂度、顺序和失效规则就能一起推出来。

因此,mapset 的学习重点不在 API 数量,而在键序如何成为容器状态。键序一旦成为状态,插入、删除、查找、遍历、范围查询都会围绕它展开。你能接受这份状态成本,就能得到稳定顺序;你不需要这份状态,就应该把注意力转向哈希容器。

这正是有序关联容器的使用边界,边界清楚,选择就清楚。这一点会贯穿后面的所有关联容器,也会影响测试设计。

不过,如果你的需求恰好落在"按 key 快速查找,但完全不在意输出顺序"的区间,对数级别就可能不如平均常数级别有吸引力。这时就该 unordered 容器登场了。

码字不易,欢迎大家点赞,关注,评论,谢谢!

相关推荐
完美火龙篇 四月的友2 小时前
SkillOpt 架构拆解:把 Skill 文本当参数,用执行轨迹训练 Agent
开发语言·php
冻柠檬飞冰走茶2 小时前
PTA基础编程题目集 7-15 计算圆周率(C语言实现)
c语言·开发语言·数据结构·算法
前端炒粉2 小时前
简易实现ssr
开发语言·前端·javascript
库克克2 小时前
【C++】 unordered_map 与unordered_set
开发语言·c++
风曦Kisaki3 小时前
Kubernetes(K8s)笔记Day03: Pod命名空间,标签,Pod 的调度,污点与容忍度,Pod 常见状态和重启策略,Pod 生命周期
linux·运维·笔记·docker·云原生·容器·kubernetes
张忠琳3 小时前
【NVIDIA】NVIDIA k8s-device-plugin v0.19.3 资源管理器模块深度分析之三
云原生·容器·架构·kubernetes·nvidia
雪的季节3 小时前
人工智能 AI 5问
开发语言
李迟3 小时前
一种轻量级C++ CSV文件读写库的实现方案
开发语言·c++
小园子的小菜4 小时前
Java 并发编程:线程安全队列全解 —— 阻塞与非阻塞实现原理及源码深度剖析
java·开发语言