关联容器
掌握map/set/unordered_map/unordered_set等八种关联容器——理解有序比较与无序哈希契约,知道何时选有序、何时选无序,能用map做键值映射、用set做去重与存在判断
学习目标
- 能实现用 map 完成键值映射——通过 key 查找 value、安全地区分
find()与会插入默认值的operator[],并用 C++11 迭代器访问first/second - 能比较并选择合适的关联容器——需要按键排序或范围查询选
map/set,哈希与相等判断便宜且无需顺序时评估无序容器,需要同一 key 多个值选multimap - 能回答:一个程序用
map<string,int>统计单词出现次数,写出++word_count[word]后,word_count["apple"]返回什么?用map和unordered_map分别实现有什么区别?
机制总览
关联容器:机制路径
- 1
直觉:为什么位置编号不够用?
你学过了顺序容器——用 vector 按位置 v[3] 访问元素。但想想这些场景:查电话号码——你输入名字 "Alice",想知道她的号码——你不可能说「我要第 5 个元素」——你不知道 Alice 在第几个位置。你需要的是按「名字」直接找到「号码」——这就是键值对的核心:通过 key 找到 value。
- 2
种容器,两大阵营
在 C++ 里, 关联容器(associative container) 把你的元素按 key 组织——key 就是你的搜索字——通过它定位数据。
- 3
官方 Chapter 11 的键契约与操作边界
原书的核心不是记住八个名字,而是理解有序容器如何判断键等价、无序容器如何配对哈希与相等关系,以及 insert 、 find 、下标和删除分别会不会修改容器。
章级决策实验
关联容器:机制与证据
切换《关联容器》的三个关键教学阶段,先解释机制,再用运行与失败证据验证结论。
选择推理阶段
当前阶段 · 直觉:为什么位置编号不够用?
你学过了顺序容器——用 vector 按位置 v[3] 访问元素。但想想这些场景:查电话号码——你输入名字 "Alice",想知道她的号码——你不可能说「我要第 5 个元素」——你不知道 Alice 在第几个位置。你需要的是按「名字」直接找到「号码」——这就是键值对的核心:通过 key 找到 value。
可核验证据
保留编译诊断,运行本节最小示例,并用边界断言、对象计数或 sanitizer 复核「直觉:为什么位置编号不够用?」的契约。
学完《关联容器》后,应能从输入和前置条件推导状态变化,并用可重复的构建、运行或边界测试证明结果。
失效—证据矩阵
关联容器:失效与核验
直觉:为什么位置编号不够用?
典型失效
若把「直觉:为什么位置编号不够用?」当作孤立语法点,忽略类型约束、对象生命周期或库契约,代码即使通过编译也可能破坏不变量。
核验证据
保留编译诊断,运行本节最小示例,并用边界断言、对象计数或 sanitizer 复核「直觉:为什么位置编号不够用?」的契约。
种容器,两大阵营
典型失效
若把「种容器,两大阵营」当作孤立语法点,忽略类型约束、对象生命周期或库契约,代码即使通过编译也可能破坏不变量。
核验证据
保留编译诊断,运行本节最小示例,并用边界断言、对象计数或 sanitizer 复核「种容器,两大阵营」的契约。
官方 Chapter 11 的键契约与操作边界
典型失效
若把「官方 Chapter 11 的键契约与操作边界」当作孤立语法点,忽略类型约束、对象生命周期或库契约,代码即使通过编译也可能破坏不变量。
核验证据
保留编译诊断,运行本节最小示例,并用边界断言、对象计数或 sanitizer 复核「官方 Chapter 11 的键契约与操作边界」的契约。
直觉:为什么位置编号不够用?
你学过了顺序容器——用 vector 按位置 v[3] 访问元素。但想想这些场景:查电话号码——你输入名字 "Alice",想知道她的号码——你不可能说「我要第 5 个元素」——你不知道 Alice 在第几个位置。你需要的是按「名字」直接找到「号码」——这就是键值对的核心:通过 key 找到 value。
想象工厂里有一本分类索引目录(map)——条目始终按规则排序,既能单点查找,也能从某个边界开始连续浏览。还有一种快速检索终端(unordered_map)——它先用哈希把类别分到桶里,平均查找为常数时间,但不提供排序与范围语义。选择取决于操作分布、键成本和顺序需求,不是简单的“一个慢、一个快”。
没有关联容器的世界会怎样? 你只能用两个 vector 并行维护——一个存名字、一个存号码——每次查名字都得遍历整个数组(O(n))——一万条数据就要走一万步。删除 Alice 的条目后两个 vector 要同步移动——复杂、易出错、慢。这一章教你「以 key 为核心」的数据组织方式——查找、插入、删除都是 O(log n) 甚至 O(1)。
八种容器,两大阵营
在 C++ 里,↡按键组织元素而不是按位置索引的容器。四种有序容器由比较器维护顺序,四种无序容器由哈希与键相等关系组织桶;map 系存键值对,set 系只存键。 把你的元素按 key 组织——key 就是你的搜索字——通过它定位数据。
上图展示了两大阵营的底层结构差异:
- 有序容器(map/set/multimap/multiset) 通常以 ↡常见的自平衡二叉搜索树实现,通过颜色与旋转限制树高。标准库常用它满足有序关联容器的对数复杂度,但 C++ 标准不指定具体树结构。实现——元素按比较器定义的顺序遍历,查找、插入、删除具有对数复杂度。这里的树图是常见实现模型,不是标准强制的对象布局。
- 无序容器(unordered_map/set/multimap/multiset) 使用 ↡通过哈希函数把键分配到桶,并用键相等关系区分桶内元素的数据结构。平均操作为常数复杂度,最坏可线性退化;桶内冲突表示方式由实现决定。语义——查找、插入平均 O(1),最坏 O(n)。遍历顺序未指定,插入或 rehash 后可能改变,不能作为业务契约。
重要的区别——有序容器名只有核心词(map/set),无序容器名字前面加 unordered_ 前缀。
官方 Chapter 11 的键契约与操作边界
原书的核心不是记住八个名字,而是理解有序容器如何判断键等价、无序容器如何配对哈希与相等关系,以及 insert、find、下标和删除分别会不会修改容器。
有序键:比较器定义等价关系
有序关联容器默认使用 std::less<Key>。两个键是否“相同”并不直接由 operator== 决定,而是看比较器:若 comp(a,b) 与 comp(b,a) 都为 false,它们在容器中等价。比较器必须形成严格弱序;把 <= 用作比较会破坏容器不变量。
#include <algorithm>
#include <cctype>
struct CaseInsensitiveLess {
bool operator()(const std::string &a, const std::string &b) const {
return std::lexicographical_compare(
a.begin(), a.end(), b.begin(), b.end(),
[](char x, char y) {
return std::tolower(static_cast<unsigned char>(x)) <
std::tolower(static_cast<unsigned char>(y));
});
}
};
std::map<std::string, int, CaseInsensitiveLess> counts;因此 key 在 map 的 value_type 中是 const,set 的元素也不能原地修改:若键变化却不重新定位,顺序将被破坏。需要改键时应删除旧元素并插入新元素。
无序键:Hash 与 KeyEqual 必须一致
无序容器同时保存哈希函数与键相等函数。若 key_equal(a,b) 为 true,则 hasher(a) 与 hasher(b) 必须相同;反过来哈希值相同不代表键相等,容器仍会比较键来处理冲突。自定义大小写不敏感键时,哈希与相等必须采用同一规范化规则。
bucket_count、load_factor、max_load_factor 与 rehash 暴露桶接口,但标准不规定桶内一定是链表,也不规定桶号必须用简单取模计算。rehash 会使迭代器失效;元素引用和指针仍保持有效,删除则只使被删元素的引用、指针和迭代器失效。
插入、下标与范围查询
唯一键容器的 insert 返回 pair<iterator,bool>,multi 容器只返回指向新元素的迭代器。map::operator[] 在键缺失时插入值初始化的 mapped value,因此只适合“查找或创建”;纯查询用 find。lower_bound、upper_bound 与 equal_range 依赖有序比较,能表达区间与重复键范围;无序容器不提供排序范围查询。
map——你的键值字典
↡存储唯一键到值映射的有序关联容器。遍历顺序由比较器决定,value_type 是键为 const 的 pair;下标在键缺失时会插入值初始化对象。 是最常用的关联容器——你给它 key,它返回关联的 value——像一个字典。
#include <map>
#include <string>
#include <iostream>
int main() {
std::map<std::string, int> phonebook;
// 三种插入方式
phonebook["Alice"] = 5551234; // operator[] 插入
phonebook.insert({"Bob", 5555678}); // insert + 列表初始化
phonebook.emplace("Charlie", 5559012); // emplace 原地构造
// 查找——用 find,别用 []([] 在 key 不存在时会插入默认值)
auto it = phonebook.find("Alice");
if (it != phonebook.end())
std::cout << it->second << '\n'; // 5551234
// C++11 遍历——元素类型是 pair<const string, int>
for (std::map<std::string, int>::const_iterator it = phonebook.begin();
it != phonebook.end(); ++it)
std::cout << it->first << ": " << it->second << '\n';
// 输出:Alice: 5551234 Bob: 5555678 Charlie: 5559012
// —— 按键的字母序排列
}注意遍历结果按比较器定义的 key 顺序排列(默认字典序)。标准保证顺序与对数复杂度,但不要求实现必须暴露红黑树或中序遍历细节。
set——只存 key 的去重集合
↡只存唯一键的有序关联容器。元素顺序由比较器决定,适合存在性、去重和范围查询;元素本身视为键,不能原地修改。 是 map 的「value-less」版——你只关心某个 key 是否存在或维护一个不重复的集合。
#include <set>
#include <string>
#include <iostream>
int main() {
std::set<std::string> whitelist = {"admin", "root", "deploy"};
// C++11 检查是否存在
if (whitelist.find("admin") != whitelist.end())
std::cout << "通过\n";
// 插入 + 检查是否成功
std::pair<std::set<std::string>::iterator, bool> result =
whitelist.insert("guest");
std::cout << (result.second ? "插入成功" : "已存在") << '\n';
// 遍历——有序(字母序)
for (const auto &u : whitelist)
std::cout << u << ' '; // admin deploy guest root
}insert 返回 pair<iterator, bool>——second=true 表示插入成功(是新 key),false 表示 key 已存在。
当 key 需要重复——multimap 和 multiset
有时候一个 key 需要对应多个值——比如一个作者写了多本书、一个学生选多门课。这时用 ↡允许同一 key 出现多次的关联容器。multimap 是 map 的去唯一约束版(一个 key→多个 value);multiset 是 set 的去唯一约束版(key 可重复)。仍按 key 排序,但没有 operator[](一个 key 对应多个 value 没有唯一返回)。查找用 equal_range 或 lower_bound/upper_bound。。
#include <map>
#include <iostream>
int main() {
std::multimap<std::string, std::string> books;
books.insert({"Orwell", "1984"});
books.insert({"Orwell", "Animal Farm"});
books.insert({"Huxley", "Brave New World"});
// 查找 Orwell 的所有书
std::pair<std::multimap<std::string, std::string>::iterator,
std::multimap<std::string, std::string>::iterator> range =
books.equal_range("Orwell");
for (std::multimap<std::string, std::string>::iterator it = range.first;
it != range.second; ++it)
std::cout << it->second << ' '; // 1984 Animal Farm
}multimap/multiset 没有 operator[]——如果一个 key 有多个值、应该返回哪一个?没有唯一答案。取而代之用 equal_range 拿到所有值。
pair——两个值的简单打包
↡一个简单的模板结构,保存两个值(first 和 second)。定义在 <utility> 头文件。map 的 value_type 就是 pair<const Key, T>。支持比较(先比 first、first 相等再比 second)。C++11 起可用 {a, b} 列表初始化。 出现在关联容器的各个角落——insert 的返回值是 pair、map 的每个元素是 pair、equal_range 的返回值也是 pair。
#include <utility>
#include <string>
// 创建 pair 的三种方式
std::pair<int, std::string> p1(42, "answer");
auto p2 = std::make_pair(42, "answer"); // 自动推导类型
std::pair<int, std::string> p3 = {42, "answer"}; // C++11 列表初始化
// 访问
std::cout << p1.first << ' ' << p1.second; // 42 answer
// map 的 insert 返回值
std::map<int, std::string> m;
auto ret = m.insert({1, "one"});
// ret.first 是迭代器,指向 {1, "one"}
// ret.second 是 bool,值为 true(插入成功)若项目升级到 C++17,可用结构化绑定简化 pair 解包;这不是本书 C++11 基线语法:
auto [iter, success] = m.insert({2, "two"}); // 解包 pair<iterator, bool>
for (const auto &[key, val] : m) // 解包 pair<const Key, T>
std::cout << key << " -> " << val << '\n';unordered_map——哈希表的速度
右边展示了无序容器的哈希表结构。unordered_map 用哈希表替代红黑树——插入、查找都是均摊 O(1)。
用 Stepper 一步步看哈希表如何工作:
先预测:两个不同 key 得到相同哈希值时,它们是否必须相等?插入触发 rehash 后,之前保存的迭代器、引用和指针分别还能不能用?先写下判断,再用下面的桶示意与键契约核对。
桶数组——哈希表的骨架
哈希表最底层是一个桶数组——每个桶是一个槽位。没有元素时所有桶都是空的。
"桶数"不是固定的——当元素增多、负载因子过高时——自动 rehash——重新分配更多桶。
关键控制——你可以手动调优哈希表:
std::unordered_map<std::string, int> m;
m.reserve(10000); // 按当前 max_load_factor 为约 10000 个元素准备桶
m.rehash(20000); // 手动设桶数为 20000
m.max_load_factor(0.5); // 降负载因子——更稀疏的桶=更快查找=更多内存
m.load_factor(); // 当前负载因子 = size/bucket_count选一个容器——你指向哪里?
来做一个决策——你有一个场景,该选八种关联容器中的哪个?走一遍这个决策流。
用 Stepper 跟着走——每一步做一个判断:
第一问:需要 key→value 映射吗?
第一个问题把关联容器和顺序容器分开——如果你不需要通过 key 找 value——你只是按位置访问、在末尾追加——那回到顺序容器(vector/list/deque)。
No → 顺序容器 | Yes → 继续
实战:四个经典场景
接下来用四个真实场景练习——对每种场景选对容器并写出实现。
场景一:单词统计器——map 统计频率
#include <map>
#include <string>
#include <iostream>
int main() {
std::map<std::string, int> word_count;
std::string word;
while (std::cin >> word)
++word_count[word]; // 不存在则插入 {word,0}→++变1
for (std::map<std::string, int>::const_iterator it = word_count.begin();
it != word_count.end(); ++it)
std::cout << it->first << ": " << it->second << '\n';
}++word_count[word] 这行包含了隐式的「不存在时插入默认值 0」——是 map 的 operator[] 的经典用法。如果你用 find——代码会长得多——但更显式:auto it = word_count.find(word); if (it == word_count.end()) word_count[word] = 1; else ++it->second;。
选 unordered_map 则是均摊 O(1) 插入、输出无序——如果不需要字母序更推荐。
场景二:去重检查器——set 判存在
#include <set>
#include <string>
#include <iostream>
int main() {
std::set<std::string> seen;
std::string word;
while (std::cin >> word) {
if (!seen.insert(word).second) // 已存在——重复
std::cout << word << " 重复了!\n";
// seen.insert(word).second == true → 首次出现,已插入
}
}seen.insert(word) 返回 pair<iterator, bool>——.second 直接告诉你是不是首次。一行就把「判断+插入」做完了——没有额外查找。
场景三:无序翻译器——unordered_map 的高速查找
#include <unordered_map>
#include <string>
#include <iostream>
int main() {
std::unordered_map<std::string, std::string> dict;
dict.reserve(20000); // 预分配——避免 rehash
// 建词库阶段
dict["hello"] = "你好";
dict["world"] = "世界";
// 查询阶段——均摊 O(1)
std::string query;
while (std::cin >> query) {
auto it = dict.find(query);
if (it != dict.end())
std::cout << it->second << '\n';
else
std::cout << query << " 未找到\n";
}
}用 find() 而不是 dict[query] 来做只读查找——避免 operator[] 把不存在的 query 也插入。用 reserve(20000) 预分配桶——避免多次 rehash(在加载词库阶段特别重要)。
场景四:区间定价器——map 的范围查询(lower_bound)
#include <map>
#include <iostream>
int main() {
// 分段边界 → 费率:每个阈值是「该级起步价」
std::map<int, double> tiers = {
{0, 0.05}, // 0-99 费率 5%
{100, 0.08}, // 100-199 费率 8%
{200, 0.12}, // 200+ 费率 12%
};
int price = 150;
auto it = tiers.upper_bound(price); // 第一个 >price 的边界
--it;
std::cout << "价格 " << price << " 费率: " << it->second << '\n';
// 输出: 价格 150 费率: 0.08
}upper_bound(150) 返回指向 {200, 0.12} 的迭代器(第一个 >150 的边界)——往回退一步(--it)就是 {100, 0.08}——150 落在它的区间。这就是有序容器的范围查询能力——unordered_map 做不到。
选型速查
| 你的场景 | 选这个 |
|---|---|
| 需要 key→value 映射、key 唯一、需要按键遍历 | map |
| 只关心 key 是否存在、需要按键排列 | set |
| key→value、key 可重复(一对多) | multimap |
| 只关心 key 是否存在、key 可重复、需有序 | multiset |
| key→value、key 唯一、平均查找优先且无需顺序 | unordered_map |
| 只关心存在、哈希与相等判断成本合适 | unordered_set |
| key→value、key 可重复、无序 | unordered_multimap |
| 只关心存在、key 可重复、无序 | unordered_multiset |
容易踩的坑
小结
- 关联容器通过 key 存取——有序容器按比较器顺序遍历并提供对数复杂度与范围查询;无序容器用 Hash/KeyEqual 组织桶,平均 O(1)、最坏 O(n),遍历顺序未指定
- map 是 key→value 的字典——
m[k]自动插入默认值(有副作用)、find(k)纯查找不插入、at(k)抛异常;遍历顺序 = 按键排序;set 是只存 key 的去重集合 - pair 是 map 的构成单元——
pair<const K, V>的 key 不可修改;C++11 用first/second,C++17 才可用结构化绑定简化解包 - 无序容器通过 hash、bucket 与 key equality 工作——
reserve(n)按负载因子准备桶、max_load_factor(x)调控密度;冲突存储与桶映射公式由实现决定 - 选容器三步问——需要 key→value 就选 map 系否则 set 系→要求有序输出选 map/set 否则选 unordered_→允许多个同 key 就加 multi 前缀
练习
问题 1(改代码型) 下面代码想检查 "admin" 是否在 set 中并打印——找出 bug 并写出修正。
#include <set>
#include <string>
#include <iostream>
int main() {
std::set<std::string> users = {"alice", "bob"};
std::cout << users["admin"]; // 打印 admin 的状态?
return 0;
}问题 2(独立实现题) 写一个程序:用 multimap<string, string> 实现一个「书籍作者索引」。支持插入书名和作者名(一个作者可以写多本书),然后输入一个作者名——输出他的所有书。用 equal_range 实现查询部分。
问题 3(选型题) 以下三个场景分别应该选哪个容器?各用一句话解释原因。
- A:需要按日期(int key)排序输出财务流水——每个月有几千条记录,需要支持范围查询「查询 2024年1月 到 3月 的所有记录」。
- B:需要快速检查一个 URL 是否已经被爬虫访问过——URL 数量在千万级——只关心存在性、不需要排序输出。
- C:需要建立 IP 地址到物理地址的映射——每个 IP 唯一对应一个国家/城市——数据量在亿级——希望查询时能 O(log n) 找到对应的地理信息。
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 关联容器(associative container)
按键组织元素而不是按位置索引的容器。四种有序容器由比较器维护顺序,四种无序容器由哈希与键相等关系组织桶;map 系存键值对,set 系只存键。详见本章契约图。
- 红黑树(red-black tree)
常见的自平衡二叉搜索树,通过颜色与旋转限制树高。许多标准库用它满足有序关联容器的顺序和对数复杂度要求,但 C++ 标准不指定必须使用红黑树,也不规定节点布局。
- 哈希表(hash table)
通过 Hash 把键组织到桶,再用 KeyEqual 区分等价键的数据结构。分布良好时操作平均 O(1),最坏可退化到 O(n)。标准暴露桶接口,但不规定桶内必须是链表或桶号必须用取模计算。
- map
存储唯一键到值映射的有序关联容器。遍历顺序由比较器决定,value_type 是
pair<const Key, T>;m[k]在键缺失时会插入值初始化对象,纯查询应使用 find。- set
只存唯一键的有序关联容器。元素顺序由比较器决定,适合存在性、去重和范围查询;没有 operator[],insert 返回的 pair 中 bool 表示是否插入了新键。
- multimap/multiset
允许同一 key 出现多次的关联容器。multimap 是 map 的去唯一约束版(一个 key→多个 value);multiset 是 set 的去唯一约束版(key 可重复)。仍然按 key 排序,但没有 operator[](一个 key 对应多个 value 没有唯一返回)。查找用 equal_range 或 lower_bound/upper_bound 取得该 key 的所有元素范围。详见本章「当 key 需要重复」一节的代码示例。
- pair
一个简单的模板结构(在
<utility>中),保存两个值:first(第一个)和second(第二个)。map 的 value_type 就是pair<const Key, T>。map 的 insert 返回值也是 pair(pair<iterator, bool>)。创建 pair 的三种方式:构造pair<int,string>(42,"x")、make_pair(42,"x")、列表{42,"x"}。详见本章「pair——两个值的简单打包」一节的代码示例。
原版目录概念补充核对
以下条目补齐官方目录中容易被示例主线掩盖的概念。它们不重复罗列目录,而是明确每项概念的机制、适用边界和验收证据。
关键字类型要求:机制、边界与证据
在《关联容器》的官方单元 cppp-11 中,关键字类型要求连接本章第 2 组知识约束。学习时要同时说明它接受什么输入、改变什么状态、在何种边界失效;再以本章示例的编译诊断、固定输入输出或失败用例复核结论,不能只记术语名称。