【C++ 面试真题】聊聊 C++ 的关联容器

【C++ 面试真题】聊聊 C++ 的关联容器

关联容器是"按 key 找值"的一族,也是标准库面试的分水岭。背得出"map 是红黑树、unordered_map 是哈希表"只是及格,真考你的是"为什么选红黑树不选 AVL、operator\[\] 有什么坑、负载因子和 rehash 怎么回事、两族的迭代器失效规则差在哪、自定义类型怎么当 key"。本文把有序、无序两族一次讲透。


一、开场:关联容器有哪些?

❓ 介绍一下标准库的关联容器?

✅ "按 key 找值"的两族,共八个:

成员 底层 查找
有序 map / set / multimap / multiset 红黑树 O(log n)
无序 [C++11] unordered_map / set / multimap / multiset 哈希表 均摊 O(1)
  • map / multimap :存 pair<const Key, Value>,multimap 允许 key 重复;
  • set / multiset:只有 key,value 就是 key 本身;
  • multi 的允许重复 key,不带则互斥。

回答思路:先按"有序(红黑树)/ 无序(哈希)"两族报成员,再点一句"带 multi 的允许重复 key"------最后落到选型口诀上,面试官自然会顺着追问底层实现。

选型口诀:要有序遍历或范围查询用有序族;纯查找、追求速度用无序族


二、有序族底层:红黑树

❓ map 为什么用红黑树?

✅ 红黑树是近似平衡的二叉搜索树 ------节点着红/黑色,靠五条性质约束,保证最长路径不超过最短路径的两倍 。效果:增、删、查全部稳定在 O(log n) ,且中序遍历恰好有序------这正是 map"按 key 排序"的来源。

💡 为什么不是 AVL 树? AVL 是严格平衡(左右子树高度差 ≤ 1),查询略快;但插入删除后维持平衡要做的旋转更多 。红黑树"松一点":插入最多 2 次旋转、删除最多 3 次,维护便宜。容器场景增删频繁,综合下来红黑树赢------所以工业界容器(C++ 标准库、Java TreeMap)清一色选它。

红黑树的另一个好处:节点地址稳定。插入删除只改指针链接、不搬节点,这直接决定了有序族宽松的迭代器失效规则(第六节细讲)。


三、map 使用考点:operator\[\] 与范围查询

❓ map 的 operator\[\] 和 at 有什么区别?

✅ 一张表看清:

操作 key 不存在时 备注
m[k] 插入默认值再返回 读操作有写入副作用!
m.at(k) out_of_range 只读安全
m.find(k) 返回 end() 只读,最稳
cpp 复制代码
map<string, int> m;
m["tom"] = 18;       // 插入或覆盖
int a = m["bob"];    // ⚠ 没有 bob?
// 也会插入 {"bob", 0},再返回 0
int b = m.at("bob"); // 没有则抛异常

⚠️ 典型翻车:判断分支里随手写 if (m["k"] > 0)------本想查询,却把 {"k",0} 塞进了 map 。只读场景一律 findcontains [C++20]

cpp 复制代码
if (m.count("k")) { /* 存在 */ }
if (m.contains("k")) { /* C++20 */ }

⚠️ 另外:multimap 没有 operator\[\]------key 可重复,"下标取值"语义根本无法定义。

查询之外,有序 还给 map/set 留了独门功夫------lower_bound / upper_bound / equal_range 范围操作:

cpp 复制代码
set<int> s{1, 3, 5, 7, 9};
// 第一个 >= 4 的元素
auto lo = s.lower_bound(4);   // → 5
// 第一个 > 5 的元素
auto up = s.upper_bound(5);   // → 7
// [lo, up) 就是"值为 5 的区间"
  • lower_bound(k):第一个 ≥ k
  • upper_bound(k):第一个 > k
  • 遍历某时间段的日志、找某分数段的用户、前缀匹配------这类范围型需求无序族做不了(哈希只支持"点查")。

四、无序族底层:哈希表

❓ unordered_map 的底层长什么样?

桶数组 + 拉链:key 先过哈希函数得出桶下标,落进同一个桶的元素串成链表。

text 复制代码
桶数组: [0]→"cat"
        [1]→(空)
        [2]→"dog"→"owl"   ← 哈希碰撞
        [3]→(空)

查找 = 算哈希(O(1))+ 桶内比对(桶越长越慢)。衡量拥挤程度的指标是负载因子

cpp 复制代码
// load_factor = size / bucket_count
unordered_map<string,int> m;
m.max_load_factor(0.7f); // 阈值,默认 1.0
m.reserve(1000);  // 预开足够桶,避免 rehash

负载因子超阈值触发 rehash :开更大的桶数组,所有元素重新分桶------一次性 O(n)。这就是"均摊 O(1)"的来由:n 次插入的总代价摊到每次是常数。

⚠️ 最坏 O(n) :哈希函数选得差(或被恶意构造碰撞攻击),所有 key 挤进同一个桶,查找退化成链表扫描。所以无序族的 O(1) 是平均,不是保证。


五、两族对比:性能与迭代器失效

❓ 有序和无序到底怎么权衡?

✅ 四个维度对比:

维度 map/set unordered_*
查找 O(log n) 稳定 均摊 O(1),最坏 O(n)
有序遍历/范围查
插入使迭代器失效 否(不失效) rehash 时全失效
key 需要提供 operator<(严格弱序) hash 函数 + operator==

迭代器失效(高频考点,规则不对称):

  • 有序族 :插入不失效任何 迭代器;删除只失效被删的那个
  • 无序族 :rehash 后迭代器全部失效 ------但指针和引用仍然有效(节点没搬,只是重新挂桶)。这是个反直觉的高分点。
cpp 复制代码
unordered_map<int,string> m;
string& r = m[1];    // 拿到引用
m[2]; m[3]; /* ... */
// 中途可能 rehash
cout << r;  // ✅ 引用仍有效
// 但旧迭代器已失效

六、自定义类型做 key

❓ 自己写的类型想当 key,要提供什么?

✅ 看你用哪族------要求完全不同

cpp 复制代码
struct Key {
    int a; string b;
};
// 有序族:只要一个小于比较
bool operator<(const Key& x,
               const Key& y) {
    if (x.a != y.a) return x.a < y.a;
    return x.b < y.b;   // 必须严格弱序
}

无序族则要两样:哈希函数和相等比较:

cpp 复制代码
// 无序族:hash + ==
struct KeyHash {
    size_t operator()(const Key& k) const {
        return hash<int>()(k.a) ^
               hash<string>()(k.b);
    }
};
struct KeyEq {
    bool operator()(const Key& x,
                    const Key& y) const {
        return x.a == y.a
            && x.b == y.b;
    }
};
unordered_map<Key, int,
              KeyHash, KeyEq> m;

⚠️ 无序族的隐性契约:两个相等的 key 必须哈希出同一个值,否则同一个 key 会落进两个桶,find 永远找不到。相等比较和哈希必须配套写。


七、怎么选:实践建议

❓ 实际项目怎么选?

✅ 三问定案:

  1. 要按序遍历、范围查询、找最大最小? → map/set;
  2. 纯点查、量大、追求吞吐? → unordered_map/set(快一个量级不是梦);
  3. key 没法定义小于比较(只有 ==)? → 只能用无序族。
cpp 复制代码
// 词频统计:无序更快
unordered_map<string, int> freq;
// 排行榜按名次输出:有序更省事
map<string, int> score;

🎯 默认倾向:纯查找场景 unordered_map 是现代默认;一旦涉及顺序、范围、前缀,别犹豫选 map------为排序额外付 O(log n) 比事后 sort 一遍 vector 便宜。


八、面试高频追问

❓ Q1:为什么标准库选红黑树而不是 AVL、跳表、B 树?

✅ AVL 严格平衡、查询略快,但增删旋转多、维护贵;跳表实现简单但空间开销和缓存表现不占优;B 树为磁盘页设计,内存场景节点太小浪费。红黑树在"增删查综合 + 内存布局"上最均衡,是容器场景的工程最优解。

❓ Q2:unordered_map 的最坏情况是什么?

✅ 所有 key 哈希碰撞挤进同一个桶,查找退化成链表扫描 O(n)。哈希函数质量差或遭遇构造碰撞的恶意输入时会发生。

❓ Q3:rehash 之后,指向元素的指针还有效吗?

有效。rehash 只重建桶数组、把节点重新挂链,节点本身不搬家,所以指针和引用不断------失效的只是迭代器。这是 unordered 失效规则里最反直觉、也最常考的一条。

❓ Q4:map 插入会使已有迭代器失效吗?

✅ 不会。红黑树插入只链接新节点、不改老节点地址,所有已有迭代器继续有效;删除也只失效被删元素那一个。

❓ Q5:unordered_map 为什么需要 operator==?哈希不是已经分桶了吗?

✅ 哈希只负责"粗分"到桶,桶内可能有碰撞,还要用 == 做"精确认"------判断到底是不是同一个 key。这也是为什么相等比较必须和哈希函数配套。


九、总结速查表

考点 一句话结论
有序族 红黑树,O(log n),中序即有序
为什么红黑树 插删旋转少,综合维护最便宜
无序族 桶+拉链,均摊 O(1),最坏 O(n)
operator\[\] 不存在则插入默认值,读带写副作用
范围查询 lower/upper_bound,有序族独有
负载因子 size / bucket_count,超阈值 rehash
无序失效 rehash 后迭代器失效,指针引用不失效
自定义 key 有序要 <,无序要 hash + ==

一句话回顾

有序族红黑树 :稳定 O(log n)、中序有序、插入不失效迭代器、吃 operator<无序族哈希表 :均摊 O(1)、怕碰撞、rehash 只废迭代器不废指针、吃 hash + ==。要顺序和范围选 map,纯点查选 unordered------operator\[\] 有坑,只读用 find。

如果您觉得本篇内容对你有帮助,欢迎点赞 👍、收藏 ⭐、转发 📢。下期我们继续标准库篇,聊迭代器------它的分类体系、和指针的关系,以及那张"容器失效规则表",敬请关注 👋

相关推荐
有脚就行40 分钟前
第24篇-Go-gRPC推理服务-高性能跨语言通信
开发语言·人工智能·后端·golang
黄敬峰42 分钟前
用 NestJS 实现待办事项接口:RESTful CRUD 与错误处理一次讲透
面试
丰锋ff1 小时前
7.2Qt中Json的操作
开发语言·qt·json
蔓越莓1 小时前
打包工具:编译器ESBuild
前端·面试
我不是疯子是傻子1 小时前
用DeepSeek生成Qt多协议解析器?「正则+状态机+AI分支裁剪」混合调优实战
开发语言·qt
新鲜势力呀1 小时前
深入理解 Elliot CUDA 编程核心:PHP 实现 CUDA 并行计算思想与实践
android·开发语言·php
Escalating_xu1 小时前
【Linux】进程信号:从产生、阻塞与递达到 sigaction、可重入与内核返回路径
linux·运维·面试·职场和发展
ZJU_统一阿萨姆1 小时前
【推理优化进阶】性能实验科学:工作负载、统计显著性与尾延迟归因
开发语言·人工智能·语言模型·系统架构·vllm