
翻开《C Primer》第5版的目录第十一章的标题是“关联容器”。很多人读到这里之前都很顺畅到了这一章突然开始卡壳。原因很直接前面花了两章建立起来的“顺序容器 泛型算法”心智模型在这里第一次遇到根本性的转向。顺序容器解决的是“按位置存取”的问题关联容器解决的是“按关键字查找”的问题两种思路的代码风格、性能预期和常用 API 完全不同。这篇文章就是我啃完第十一章之后的沉淀笔记把容器选型、底层原理、自定义键、常见练习题易错点都串起来讲一遍希望能帮你省点绕路的时间。1. 为什么第十一章值得单独啃关联容器的定位与前置准备1.1 这一章在全书中的位置在整本书的编排里第十一章承担了一个“承上启下”的角色。承上是因为它建立在前面几章打下的基础上你会用到第九章的 vector、string也会用到第十章讲过的泛型算法思想启下是因为后面第十二章讲动态内存、第十三章讲拷贝控制时很多示例代码都会用 map、set 来组织数据。换句话说如果在这里把关联容器的使用习惯建立起来后面几章读起来会顺畅很多。从知识体系来看这一章真正要解决的核心问题是当需求不再是“按位置取数据”而是“按某个键直接找数据”时标准库提供了什么工具。这个需求有多常见你可以回想平时写程序时的场景统计一段文本里每个单词出现的次数、维护一份学号到成绩的映射、给数据做去重、实现一个简单的词典、在缓存里按 ID 查询对象。这些需求在现实项目里远比“按索引遍历数组”更频繁所以标准库才专门把这一族容器单独拉出来讲。1.2 学习第十一章前需要准备哪些前置知识我的建议是不要在刚学完第三章就急着跳到这一章。关联容器虽然 API 看起来简单但它的使用牵扯到不少前几章的概念引用和 const 限定、拷贝初始化、迭代器、可调用对象。如果这些概念是模糊的你在写自定义比较器的时候会很难受。具体来说最好先掌握三件事。第一迭代器的基本操作和解引用方式因为关联容器迭代器的行为和顺序容器有差异第二模板和函数对象的基本概念这一章的练习里经常需要自己写一个谓词你至少要知道 lambda 表达式怎么定义、怎么传给容器当作比较规则第三pair 的构造和访问方式因为 map 的元素类型就是 pairconst Key, T。把这三块补齐了再来读这一章效果会好很多。顺便提一句如果你想在这一章保持手感最有效的做法不是先把整章读完再做题而是每读一个小节就立刻写一段十行以内的小程序验证。关联容器是实践性非常强的主题光看语法和原理不亲手敲一敲 insert、find、erase很多细节根本记不住。2. 关联容器全家福八种容器一次分清2.1 按两个维度划分标准库的关联容器一共有八种乍看很多但它们的划分方式非常规整。你只需要抓住两个维度第一个维度是“有序还是无序”第二个维度是“键是否允许重复”。先看有序这一族。底层是红黑树元素按照关键字的大小自动排序。map 是键值对集合键唯一set 是键集合只存键不存值multimap 和 multiset 是允许键重复的版本。再看无序这一族。底层是哈希表元素按照哈希函数的计算结果散列存放不保证顺序。unordered_map 是键唯一、无序的键值对集合unordered_set 只做去重unordered_multimap 和 unordered_multiset 允许重复键。这样划分下来整章的知识脉络就清晰了。不要逐个容器孤立地背而是先记住“树结构/哈希表”和“单键/多键”这两个维度再往里面填细节。2.2 从需求场景反推容器选型我见过很多人选择容器时几乎不做思考一律用 map导致代码又慢又别扭。其实根据场景选型并不难。如果需要按一定顺序输出结果比如按字典序打印单词统计结果就应该选 map 或 set如果只关心“这个键存不存在”“能不能快速取到值”不关心遍历顺序就应该选 unordered_map 或 unordered_set。如果一个键需要挂多个值multi 版本是你要的选项。举个例子一个班级里学生可能参加多个社团一个社团对应多个学生这种一对多关系用 multimap 就很自然。而如果你的键本身就唯一又希望查找速度尽量稳定那就是 map 或 unordered_map 的主场。我整理了一张快速参考表方便对照需求推荐容器键唯一需要按键排序遍历map只做去重需要排序输出set一个键多个值需要有序multimap / multiset键唯一只求快速查找不关心顺序unordered_map / unordered_set一对多只求快速查找unordered_multimap / unordered_multiset注意这张表只是入门阶段的选型指导。实战中还要结合元素数量、查找频率、是否频繁插入删除来做基准测试后面第五部分我会专门讲无序容器的代价。3. map 与 set 的核心操作插入、访问、删除的差异3.1 声明、初始化与元素类型声明 map 时模板参数有两个键类型和值类型。比如mapstring, size_t words;就是经典的单词计数声明。set 只需要一个键类型setstring exclude;表示一个字符串集合。初始化可以用列表初始化也可以从另一个容器的迭代器区间构造。这里有一个非常值得注意的细节map 的元素类型是pairconst string, size_t键类型带 const。这意味着你不能修改一个已插入元素的键因为一旦键变了整个有序性就被破坏了。你只能删除旧元素再插入新元素。这个设计是合理的但第一次接触时确实容易踩坑尤其是你想用一个引用去绑定 map 的某个键时会发现怎么都编译不过。3.2 insert 与 operator[]一对需要分清的好兄弟map 的插入有几种方式多数初学者都会在 insert 的返回值上困惑。insert 返回一个 pairfirst 是指向成功插入元素或已存在键对位元素的迭代器second 是一个 bool表示是否真的插入了新元素。如果是重复键second 为 false并且迭代器指向已经存在的那个元素。operator[] 的语义则完全不同。当键不存在时它会做一次值初始化然后把这个新元素插入 map最后返回被插入元素值的引用。也就是说words[hello]这句代码在键不存在时会先把 hello 插入再用默认值 0 初始化计数值最后加一。这种“不存在就插入”的行为写计数代码时非常方便但也非常危险。如果只是想查一下键在不在千万不要用 operator[]否则你会意外地向容器里塞入一堆默认元素。正确做法是用 find 或 at。at 操作是 C11 引入的它和 operator[] 唯一的区别是当键不存在时at 会抛出 out_of_range 异常而不是插入。所以在某些场景下用 at 能更安全地表达“我一定要取到这个值”的意图。如果你不想让程序因找不到键而中断就要提前用 find 判断。3.3 emplace 系列与结构化绑定insert 一个 pair 时需要先构造 pair再把它拷贝或移动到容器里。如果希望直接在容器内部构造元素可以用 emplace 或 emplace_hint把构造参数直接传进去。举个例子words.emplace(apple, 1)比words.insert(make_pair(apple, 1))少一次额外拷贝性能上更友好。不过我要提醒一句emplace 虽然效率高但如果不小心把参数写错了产生的编译错误会非常难看。我的习惯是普通场景下先用 insert make_pair 把逻辑跑通性能有明确瓶颈时再换成 emplace。在 C17 之后遍历 map 还可以用结构化绑定。for (const auto [key, value] : words)直接拿到键和值的引用比过去的 first、second 可读性强很多。这个习惯建议早点养成后面的练习题和实际项目里都很常用。4. 有序容器的底层逻辑红黑树带来的三个保证4.1 为什么有序容器的操作复杂度都是 O(log n)有序关联容器底层是红黑树。红黑树是一种自平衡二叉搜索树它保证树的高度是对数级的所以树上的查找、插入、删除都是 O(log n)。这个复杂度意味着即使容器里有 100 万个元素每次查找也只需要大约 20 次比较性能非常稳定。很多人会问为什么不直接用二分查找加动态数组因为数组的插入和删除需要搬移元素是 O(n) 的树结构在插入和删除时只需要改动局部指针关系所以能同时把查找、插入、删除压到 O(log n)。理解了这个背景你就明白有序容器的真正价值不只是“能排序”而是“在维持排序状态的同时还能高效增删改”。4.2 引用与迭代器的稳定性与 vector 这种顺序容器不同有序关联容器的插入和删除操作不会让其他元素在内存中移动。只要不删除某个特定元素指向它的迭代器、引用和地址就一直有效。这一点在复杂数据结构互相引用时非常重要。我第一次理解这个特性时想到的类比是图书馆里的书架。vector 像一排紧密排列的书柜往中间插一本书后面所有书的位置都得挪红黑树像按字母索引的卡片目录每张卡片放在独立的小格子里插入一张新卡片不会动其他卡片的位置。这个特性在第十六章学习模板容器适配时也会经常用到。4.3 严格弱序有序容器的“游戏规则”这章的练习题会反复要求你给自定义类型写比较器。这里的关键概念是严格弱序。它要求你的比较函数满足几个性质对于任意键 aa a必须为 false对于 a 和 b 不相等的情况a b和b a只能有一个为真如果a b且b c那么a c。如果不满足这些性质红黑树的排序逻辑就会乱套可能会把相等的键当成不同键或者把不同键当成相等键最终导致数据重复或者查找失败。string、int 等内置类型天然满足严格弱序所以不用操心。但自定义类型时遗漏任何一个条件都会埋下隐患。我在第六部分会给出一个完整可运行的实例帮你把这个规则落到代码层面。5. unordered 容器用哈希换常数时间但不是免费午餐5.1 平均 O(1) 背后的代价无序容器底层是哈希表。理想情况下每次查找、插入、删除都是平均 O(1)看起来全面碾压红黑树。但代价是什么一是容器不再有序你拿不到排好序的遍历结果二是哈希计算本身有开销如果键是复杂的字符串类型计算哈希不一定比几十次整数比较便宜三是哈希表需要预留桶空间内存占用通常比树容器高四是哈希冲突一旦严重最坏情况下性能会退化到 O(n)。所以选择 unordered 容器时我的建议是先问自己一个问题我真的不需要按顺序遍历吗如果答案是否定的那至少在一个性能敏感的程序里很可能应该用有序容器。很多工程里的默认选择直接上 unordered_map其实只是“觉得”它快根本没有基准测试支撑。5.2 管理桶reserve、rehash 与负载因子哈希表由桶数组构成每个桶里往往挂着冲突元素链表。负载因子是元素个数除以桶数量默认阈值为 1.0。当负载因子超过阈值时容器会 rehash也就是重新分配桶数组并把所有元素重新散列到新桶中。如果频繁插入大量元素反复 rehash 是一件很昂贵的操作。解决方法是预分配。在你大致知道元素数量时调用 reserve(n) 提前把桶数量准备好或者直接 rehash(n) 指定期望的桶数量。我踩过的一个典型性能坑是往 unordered_map 里插入了几百万个键值对每插入约 100 万个就触发一次整体 rehash整个过程慢到以为死循环。后来加了一个 reserve 预分配耗时立刻降了一个数量级。这是一个非常实际的优化手段练习题里可能不要求但项目里非常有用。5.3 无序容器对键类型的要求使用 unordered_map 时键类型必须支持两件事一个是相等判断 operator用于哈希冲突时判断两个键是否真的相同另一个是能算出哈希值。对于内置类型和 string标准库已经提供了 std::hash 的特化直接可以用。但自定义类型需要自己做两件事重载 operator以及提供 hash 特化或自定义哈希函数对象。我在下一部分会给出这种自定义键的完整方案这里先记住一个结论如果你不想写哈希函数那就别用 unordered 容器老老实实用 map用严格弱序换现成的排序支持和更简单的代码。6. 把自定义类型放进关联容器从比较器到哈希函数6.1 为有序容器定义严格弱序比较器假设我们有一个表示坐标点的结构体 Point包含 x 和 y 两个整数。想把它放进 set 方法是给 Point 重载 operatorstruct Point { int x; int y; }; bool operator(const Point a, const Point b) { if (a.x ! b.x) return a.x b.x; return a.y b.y; }这样写的逻辑是先按 x 比较x 相同时再按 y 比较。这产生了一个全序任何两个不同的点都能明确比较出大小而且不会互相矛盾。注意绝对不能只写return a.x b.x因为这样两个 x 相同但 y 不同的点会被认为“彼此都不小于对方”满足严格弱序的等价条件set 就会把其中一个当成重复元素丢掉。如果不想写全局 operator也可以用函数对象作为模板的第二个参数struct PointLess { bool operator()(const Point a, const Point b) const { if (a.x ! b.x) return a.x b.x; return a.y b.y; } }; setPoint, PointLess points;这个方案的好处是不污染全局命名空间在容器定义处就能看到排序规则。我在实际项目里更喜欢这个写法因为所有的比较逻辑都集中在一个地方代码评审的时候也更容易检查。6.2 为无序容器实现 operator 与哈希如果想把 Point 放进 unordered_set情况会稍微复杂一些。首先需要定义相等语义bool operator(const Point a, const Point b) { return a.x b.x a.y b.y; }然后就是哈希函数。最通用的办法是定义一个函数对象把两个整数哈希组合起来struct PointHash { size_t operator()(const Point p) const { return std::hashint()(p.x) ^ (std::hashint()(p.y) 1); } }; unordered_setPoint, PointHash points;这里的组合逻辑是初级写法工程上更严谨的做法可以借鉴字符串哈希的思路把 x 和 y 的哈希值错位混合尽量减少碰撞。核心原则是尽量让每个不同的点产生分布均匀的哈希值同一等价类中的元素哈希值必须完全相同。还有一点需要特别注意unordered_set 在判断两个元素是否相等时先比较哈希值当哈希值相同时再用 operator 精确判断。如果你的 operator 认为 a 和 b 相等但它们的哈希值不同容器就会认为这是两个不同的元素进而出现数据重复。所以 operator 和哈希函数必须严格一致。6.3 完整示例从声明到增删查为了让上面的内容落地我给出一个可以直接编译运行的例子#include iostream #include set #include unordered_set struct Point { int x; int y; }; // 用于有序容器 bool operator(const Point a, const Point b) { if (a.x ! b.x) return a.x b.x; return a.y b.y; } // 用于无序容器 bool operator(const Point a, const Point b) { return a.x b.x a.y b.y; } struct PointHash { size_t operator()(const Point p) const { return std::hashint()(p.x) ^ (std::hashint()(p.y) 1); } }; int main() { setPoint sorted {{1, 2}, {0, 3}, {1, 1}}; for (const auto p : sorted) std::cout p.x , p.y \n; unordered_setPoint, PointHash hashed {{1, 2}, {0, 3}, {1, 1}}; std::cout hashed.count(Point{1, 2}) \n; return 0; }这个例子看起来小但它把两种容器的关键需求都覆盖了。我建议你把代码敲下来试着去掉 operator 或者 operator看看会得到什么样的编译错误。亲手读一遍编译信息比看十遍书都记得牢。7. 关联容器与算法和迭代器的协作边界7.1 用关联容器自己的成员函数而不是泛型算法标准库的 std::find 算法会从头到尾线性遍历复杂度 O(n)。而关联容器自己有成员函数 find利用底层树或哈希表结构查找复杂度只有 O(log n) 或平均 O(1)。所以当你要在 map 里找键时一定写words.find(hello)不要写std::find(words.begin(), words.end(), ...)。同理许多泛型算法在关联容器上根本不适用。比如 std::sort 要求随机访问迭代器而 map、set 的迭代器是双向迭代器直接编译不过。即使有些算法能编译也往往不是好主意。在第十章学习时习惯把顺序容器和泛型算法配对使用到了第十一章必须切换思维先用成员函数解决问题再考虑算法。7.2 erase 的返回值与循环删除关联容器的 erase 有两种常见形式按键删除和按迭代器删除。C11 之后按迭代器删除会返回指向下一个元素的迭代器这非常适合在循环中删除元素。下面两种写法都是安全的// 写法一利用返回值拿到下一个迭代器 for (auto it words.begin(); it ! words.end();) { if (需要删除) it words.erase(it); else it; } // 写法二先递增再删除当前元素 for (auto it words.begin(); it ! words.end();) { if (需要删除) words.erase(it); else it; }注意如果容器是 map 类型erase(key)的返回值是删除的元素个数而不是迭代器。我在写代码时经常犯的错误是以为 erase 一律返回迭代器然后试图把返回值赋值给迭代器变量结果编译报错。记住按键删除返回 size_type按迭代器删除返回迭代器。7.3 lower_bound、upper_bound 与 equal_range 的实际价值这三个函数是关联容器独有的“范围查询”接口专门用来处理“找所有满足某个范围的键”这类需求。lower_bound 返回第一个不小于给定键的迭代器upper_bound 返回第一个大于给定键的迭代器equal_range 直接返回这一对迭代器组成的 pair。对于 map经常用 equal_range 来遍历某个范围的键。对于 multimapequal_range 更是“取出某个键对应的所有值”的标准手段因为 multimap 没有 operator[]也无法直接下标访问只能用 equal_range 拿到迭代器区间。举个例子学生可能参加多个社团你想查某个学号的所有社团记录就可以写auto range records.equal_range(2024001); for (auto it range.first; it ! range.second; it) { std::cout it-second \n; }这里提到一个关键点multimap 没有 operator[]。原因很简单operator[] 的语义是“不存在就插入一个默认值”但对一个键可能对应多个值的容器来说“默认值”这个概念本身就不明确所以标准库干脆禁掉了这个操作。如果你想在 multimap 中向某个键添加值只能显式 insert。8. 第十一章练习题的易错点与我的排查建议8.1 单词计数与去重的两种常见错误练习题里最经典的程序是单词计数。用 mapstring, size_t 统计词频时一个常见错误是if (words.find(word) words.end()) { words[word] 1; } else { words[word]; }这段代码逻辑没错但完全可以简写成words[word]。因为 operator[] 在键不存在时会自动做值初始化然后加一即可。很多初学者为了“避免插入默认值”写了两遍查找反而把代码搞复杂。真正需要小心的是“只查询不插入”的场景这时候才应该用 find 或 count而不是 operator[]。去重题则容易在 set 和 unordered_set 之间犯迷糊。如果题目要求输出顺序无关用 unordered_set 更快如果要求按字典序输出必须用 set。我见过有人用 vector 加 std::unique但那需要先排序代码复杂度和性能都不如直接用 set。8.2 多重容器没有 operator[]multimap 和 multiset 缺少 operator[] 这一点让很多初学者困惑。原因在上一节已经说过键值配对关系的语义不适用于“一个键多个值”的情况。如果你试着写multimapstring, int m; m[a] 1;编译器会直接报错。想要往 multimap 里添加键值对只能写成m.insert({a, 1});。我对这个特性的理解是标准库把容易产生歧义的接口藏起来逼着你使用更明确的 insert 和 equal_range。虽然刚开始不习惯但长期来看这种限制能让代码的意图更显式避免你在多值语义下写出错误的赋值逻辑。8.3 建议的练习路径与验证方法这一章的习题难度梯度其实很明显。我建议按这样的顺序走先做最基本的键值对统计和集合操作确认 insert、find、erase 这些 API 都能熟练使用然后做自定义类型的练习题把比较器和哈希函数完整写一遍这是本章最容易丢分的部分最后再看单词转换这类综合题把多个容器组合起来使用。验证自己的答案是否正确时不要只看输出结果还要做两件事。第一故意制造边界条件比如插入空字符串、插入重复键观察容器的行为第二把容器的遍历结果打印出来重点看顺序是否符合预期。这两步能让你在练习阶段就发现很多原书没明说、但实际开发中经常碰到的细节。最后再分享一个小技巧。如果你在写练习题的编译阶段卡住了优先去看编译器的第一条错误信息关于 const 限定、pair 类型的错误通常在第一行就有线索。不要试图一次性吞下整屏报错。关联容器相关的编译错误往往是类型问题只要你记住 map 的键类型是 const 的、元素类型是 pair大部分错误都能快速定位。这一章确实需要下功夫但按这个顺序刷下来第十一章的习题基本不会成为你的拦路虎。