【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()只读,最稳
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]

if (m.count("k")) { /* 存在 */ }
if (m.contains("k")) { /* C++20 */ }

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

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

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 先过哈希函数得出桶下标,落进同一个桶的元素串成链表。

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

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

// 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/setunordered_*
查找O(log n) 稳定均摊 O(1),最坏 O(n)
有序遍历/范围查
插入使迭代器失效否(不失效)rehash 时全失效
key 需要提供operator<(严格弱序)hash 函数 + operator==

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

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

六、自定义类型做 key

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

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

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;   // 必须严格弱序
}

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

// 无序族: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 没法定义小于比较(只有 ==)? → 只能用无序族。
// 词频统计:无序更快
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。

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

更多推荐