C++关联容器map与set详解:从红黑树原理到实战应用
1. 从“容器”到“关联容器”:为什么需要 map 和 set?
如果你写过 C++,肯定用过
vector
或者
list
。这些顺序容器(Sequence Containers)帮我们解决了“存储一组数据”的问题,比如存一堆整数、存一堆字符串。但很多时候,我们面临的问题更复杂:我需要根据一个“键”(Key)快速找到对应的“值”(Value),比如根据学生学号查成绩;或者,我需要一个能自动去重、并且能快速判断某个元素是否存在的集合,比如记录所有登录过的用户 ID。
这时候,
vector
就显得力不从心了。你想在
vector
里根据学号找成绩,最坏情况得遍历整个列表,时间复杂度是 O(n)。数据量一大,性能瓶颈就来了。C++ 标准库提供的关联容器(Associative Containers)——
std::map
和
std::set
,就是为了高效解决这类“查找”和“存在性判断”问题而生的。
简单来说:
-
std::map: 存储的是键值对(key-value pairs)。它像一个真正的字典,你给出一个单词(key),它能立刻告诉你释义(value)。在 C++ 里,map保证键是唯一的,并且所有元素会根据键自动排序。 - std::set` : 只存储键(key)。它像一个数学上的集合,或者一个不允许重复元素的袋子。你主要用它来快速判断“某个元素在不在集合里”,或者维护一个有序且无重复的序列。
它们背后的核心数据结构通常是红黑树(Red-Black Tree),这是一种自平衡的二叉搜索树。正是这种结构,使得
map
和
set
在插入、删除、查找操作上都能保持 O(log n) 的时间复杂度,远比线性查找的 O(n) 高效。网络上很多关于“C++面试题”、“
unordered_map
和
map
的区别”的讨论,其根源都在于对它们底层实现和特性的探究。
2.
std::map
详解:你的高效键值对字典
std::map
定义在
<map>
头文件中,是 C++ 中最常用的关联容器之一。它管理着一系列
std::pair<const Key, T>
类型的元素。
2.1 基础操作:创建、插入与访问
让我们从一个具体场景开始:管理一个班级的学生成绩,学号(
int
)作为键,姓名(
std::string
)作为值。
#include <iostream>
#include <map>
#include <string>
int main() {
// 1. 声明一个 map,键是 int 类型,值是 string 类型
std::map<int, std::string> student_map;
// 2. 插入数据的几种方式
// 方式一:使用 insert 函数和 make_pair
student_map.insert(std::make_pair(1001, "张三"));
student_map.insert(std::make_pair(1002, "李四"));
// 方式二:使用 insert 函数和初始化列表(C++11)
student_map.insert({1003, "王五"});
// 方式三(最常用、最直观):使用下标运算符 []
student_map[1004] = "赵六"; // 如果键1004不存在,会先创建它并关联一个空字符串,然后赋值
student_map[1001] = "张三丰"; // 键1001已存在,此操作是修改其对应的值
// 3. 访问元素
// 方式一:使用下标运算符 [](如果键不存在,会创建该键并值初始化,可能非预期!)
std::cout << "学号1002的学生是:" << student_map[1002] << std::endl;
// 方式二(更安全):使用 at() 成员函数(键不存在时抛出 std::out_of_range 异常)
try {
std::cout << "学号1003的学生是:" << student_map.at(1003) << std::endl;
// std::cout << student_map.at(9999) << std::endl; // 会抛出异常
} catch (const std::out_of_range& e) {
std::cout << "访问错误:键不存在。" << std::endl;
}
// 方式三(用于判断是否存在并获取):使用 find() 成员函数
auto it = student_map.find(1004);
if (it != student_map.end()) { // end() 返回一个指向末尾的迭代器,表示未找到
std::cout << "找到了,学号1004的学生是:" << it->second << std::endl;
} else {
std::cout << "未找到学号1004。" << std::endl;
}
// 检查一个不存在的键
auto it_not_found = student_map.find(9999);
if (it_not_found == student_map.end()) {
std::cout << "键9999不存在于map中。" << std::endl;
}
return 0;
}
注意 :
map的下标运算符[]是一个需要警惕的操作。map[key]的行为是:如果key存在,返回其对应值的引用;如果key不存在,则会 自动插入 一个以key为键、以值类型默认构造函数创建的对象为值的元素,然后返回这个新值的引用。这有时会导致意外的插入行为。如果你只是想检查是否存在,应该优先使用find()。
2.2 遍历:与顺序容器的不同
由于
map
存储的是
pair
,遍历时需要处理这个结构。通常使用基于范围的 for 循环(C++11)或迭代器。
#include <iostream>
#include <map>
int main() {
std::map<int, std::string> score_map = {{1, "优秀"}, {2, "良好"}, {3, "及格"}};
std::cout << "=== 使用基于范围的for循环遍历 ===" << std::endl;
// 使用 const auto& 避免拷贝,pair 的 first 是键,second 是值
for (const auto& kv_pair : score_map) {
std::cout << "Key: " << kv_pair.first << ", Value: " << kv_pair.second << std::endl;
}
std::cout << "\n=== 使用结构化绑定(C++17)遍历 ===" << std::endl;
// 更清晰的写法,直接将 pair 解构到两个变量中
for (const auto& [key, value] : score_map) {
std::cout << "Key: " << key << ", Value: " << value << std::endl;
}
std::cout << "\n=== 使用迭代器遍历 ===" << std::endl;
for (auto it = score_map.begin(); it != score_map.end(); ++it) {
std::cout << "Key: " << it->first << ", Value: " << it->second << std::endl;
}
return 0;
}
你会发现,遍历输出的顺序是按键(
int
)从小到大排序的(1,2,3)。这是
std::map
的一个重要特性:
元素始终按照键的顺序(升序)排列
。排序的依据是键类型的比较运算符
<
。对于自定义类型作为键,你需要提供比较规则,这我们后面会讲到。
2.3 删除与清空
删除元素主要使用
erase
方法,它有三种重载形式:
#include <map>
#include <iostream>
int main() {
std::map<int, char> m{{1, 'a'}, {2, 'b'}, {3, 'c'}, {4, 'd'}, {5, 'e'}};
// 1. 通过键删除
size_t num_removed = m.erase(3); // 删除键为3的元素,返回删除的数量(0或1)
std::cout << "删除了 " << num_removed << " 个元素。\n";
// 2. 通过迭代器删除
auto it = m.find(2);
if (it != m.end()) {
m.erase(it); // 删除迭代器指向的元素
}
// 3. 通过迭代器范围删除
auto first = m.find(4);
if (first != m.end()) {
// 删除从 first 到 m.end() 之前的所有元素
m.erase(first, m.end());
}
// 此时 map 中只剩下 {1, 'a'}
for (const auto& [k, v] : m) {
std::cout << k << "->" << v << " ";
}
std::cout << std::endl;
// 4. 清空整个 map
m.clear();
std::cout << "清空后map大小: " << m.size() << std::endl;
return 0;
}
2.4 容量查询与判断空
这些操作和顺序容器类似:
-
size(): 返回元素个数。 -
empty(): 判断是否为空。 -
count(key): 返回指定键出现的次数。对于map,返回值只能是 0 或 1,因为键是唯一的。这个方法常用来快速检查键是否存在,比find()写法更简洁,但无法获取迭代器。
std::map<int, int> my_map = {{1, 10}, {2, 20}};
if (!my_map.empty()) {
std::cout << "Map 中有 " << my_map.size() << " 个元素。\n";
}
if (my_map.count(1) > 0) {
std::cout << "键 1 存在。\n";
}
3.
std::set
详解:有序且唯一的元素集合
std::set
定义在
<set>
头文件中。它只存储键(或者说,值本身就是键),并且同样保证元素的唯一性和有序性。
3.1 基础操作:插入、查找与遍历
假设我们有一个线上会议系统,需要维护一个当前已登录用户的 ID 集合,用于快速判断用户是否在线。
#include <iostream>
#include <set>
#include <string>
int main() {
// 声明一个存储字符串的 set
std::set<std::string> online_users;
// 插入元素
online_users.insert("user_001");
online_users.insert("user_002");
online_users.insert("user_003");
online_users.insert("user_001"); // 重复插入,会被忽略
// 查找元素:判断用户是否在线
std::string user_to_check = "user_002";
if (online_users.find(user_to_check) != online_users.end()) {
std::cout << user_to_check << " 在线。\n";
} else {
std::cout << user_to_check << " 不在线。\n";
}
// 使用 count 判断是否存在(对于 set,结果也是 0 或 1)
if (online_users.count("user_999") == 0) {
std::cout << "user_999 不在线。\n";
}
// 遍历 set(元素是有序的,这里是字符串的字典序)
std::cout << "当前在线用户(按ID排序): ";
for (const auto& user_id : online_users) { // 注意:set 存储的就是单个元素,不是 pair
std::cout << user_id << " ";
}
std::cout << std::endl;
// 删除元素
online_users.erase("user_002");
std::cout << "移除 user_002 后,在线用户数: " << online_users.size() << std::endl;
return 0;
}
set
的遍历比
map
简单,因为每个元素就是值本身。它的排序特性使得你可以很方便地得到一个有序且无重复的序列,这在很多算法题(比如“合并两个有序数组并去重”)或数据处理场景中非常有用。
3.2
set
的插入返回值
set::insert
的返回值比
vector::push_back
更有信息量,它是一个
pair<iterator, bool>
。
-
first: 一个迭代器,指向被插入的元素(如果插入成功),或者指向集合中导致插入失败的那个已存在的等价元素(如果插入失败)。 -
second: 一个布尔值,表示插入是否成功(true表示成功,false表示元素已存在)。
这个返回值在需要知道插入是否真正发生,或者需要获取已存在元素的迭代器时非常有用。
#include <set>
#include <iostream>
int main() {
std::set<int> my_set {10, 20, 30};
auto [it1, success1] = my_set.insert(40); // C++17 结构化绑定
if (success1) {
std::cout << "成功插入 40,迭代器指向新元素。\n";
}
auto [it2, success2] = my_set.insert(20); // 20 已存在
if (!success2) {
std::cout << "插入 20 失败,迭代器指向已存在的元素 " << *it2 << "。\n";
}
// C++11/14 写法
std::pair<std::set<int>::iterator, bool> ret = my_set.insert(50);
if (ret.second) {
std::cout << "成功插入 50。\n";
}
return 0;
}
3.3 为什么
set
的
insert
比
vector
慢?
这是一个常见的面试点。
vector
的
push_back
在尾部插入,平均时间复杂度是 O(1)(不考虑扩容)。而
set
的
insert
是 O(log n),因为它需要在红黑树中找到正确的插入位置以维持有序性。所以,如果你只需要存储而不关心顺序和唯一性,
vector
更快;但如果你需要频繁检查元素是否存在或维护有序序列,
set
的综合效率更高。
4. 进阶话题:自定义类型作为键与性能考量
4.1 自定义类型作为
map
的键
map
和
set
默认使用
<
运算符来比较键,从而排序和判断唯一性。如果你想用一个自定义的类或结构体作为键,你必须让这个类型支持“小于比较”。有两种主要方式:
方式一:重载
<
运算符
这是最直接的方法。你需要确保比较逻辑定义了一个“严格弱序”。
#include <map>
#include <string>
#include <iostream>
struct Student {
int id;
std::string name;
// 重载小于运算符
bool operator<(const Student& other) const {
// 先按 id 排序,如果 id 相同再按 name 排序
if (id != other.id) {
return id < other.id;
}
return name < other.name;
}
};
int main() {
std::map<Student, int> exam_score; // 键是 Student 结构体,值是分数
exam_score[{101, "Alice"}] = 95;
exam_score[{102, "Bob"}] = 88;
exam_score[{101, "Alice"}] = 96; // 修改 Alice 的分数
// 查找
Student key {101, "Alice"};
auto it = exam_score.find(key);
if (it != exam_score.end()) {
std::cout << it->first.name << " 的分数是 " << it->second << std::endl;
}
return 0;
}
方式二:提供自定义的比较函数对象(仿函数) 这种方式更灵活,特别是当你无法修改自定义类型的源代码,或者想使用不同的排序规则时。
#include <map>
#include <string>
#include <iostream>
struct Product {
std::string sku; // 库存单位码
double price;
};
// 自定义比较器:按 price 排序
struct CompareByPrice {
bool operator()(const Product& a, const Product& b) const {
return a.price < b.price;
}
};
int main() {
// 在模板参数中传入比较器类型
std::map<Product, int, CompareByPrice> inventory_by_price;
inventory_by_price[{"A001", 99.9}] = 50;
inventory_by_price[{"B002", 59.9}] = 100;
inventory_by_price[{"C003", 199.9}] = 20;
// 遍历时,map 会按 price 升序排列
for (const auto& [product, stock] : inventory_by_price) {
std::cout << "SKU: " << product.sku << ", Price: " << product.price
<< ", Stock: " << stock << std::endl;
}
// 输出顺序会是 B002, A001, C003
return 0;
}
重要提示 :作为
map或set键的类型,其比较函数必须满足“严格弱序”的数学要求。简单来说,它必须具有以下性质:
- 非自反性:
comp(a, a)必须为false。- 非对称性:如果
comp(a, b)为true,则comp(b, a)必须为false。- 传递性:如果
comp(a, b)为true且comp(b, c)为true,则comp(a, c)必须为true。- 等价传递性:如果
!comp(a, b) && !comp(b, a)(即 a 和 b 等价),并且!comp(b, c) && !comp(c, b),那么必须有!comp(a, c) && !comp(c, a)。 对于简单的数值或字符串比较,<运算符天然满足。对于自定义比较器,需要小心设计。
4.2
map
/
set
与
unordered_map
/
unordered_set
的选择
这是另一个高频面试点。我们一直在讨论的
std::map
和
std::set
是有序的,底层是红黑树。C++11 引入了无序版本:
std::unordered_map
和
std::unordered_set
,它们底层基于哈希表(Hash Table)。
它们的核心区别如下表所示:
| 特性 |
std::map
/
std::set
|
std::unordered_map
/
std::unordered_set
|
|---|---|---|
| 底层数据结构 | 红黑树(平衡二叉搜索树) | 哈希表 |
| 元素顺序 | 有序 (按键排序) | 无序 (取决于哈希函数和桶) |
| 查找/插入/删除平均时间复杂度 | O(log n) | O(1) |
| 查找/插入/删除最坏时间复杂度 | O(log n) | O(n) (哈希冲突极端情况) |
| 需要键提供什么 |
可比较(
<
运算符或自定义比较器)
|
可哈希(
std::hash
特化)和可比较相等(
==
运算符)
|
| 内存开销 | 相对较低(树节点) | 相对较高(需要维护桶数组) |
| 迭代器稳定性 | 插入/删除不会使其他元素的迭代器失效(除非删除当前元素) | 插入可能导致 重哈希 ,使所有迭代器失效 |
| 适用场景 | 需要元素有序遍历;键类型不易定义好的哈希函数;内存相对紧张 | 对单次查找/插入速度要求极高;不需要有序遍历;能提供良好的哈希函数 |
如何选择?
-
默认情况下,如果你需要有序性,或者对最坏性能有要求,选
map/set。 它们的性能是稳定可预测的 O(log n)。当你需要按顺序输出所有元素,或者进行范围查询(如“找出所有键在 10 到 20 之间的元素”)时,必须使用有序版本。 -
如果你追求极致的平均查找速度,且不关心顺序,选
unordered_map/unordered_set。 在哈希函数良好的情况下,O(1) 的访问速度非常诱人。这也是为什么在很多网络热词如“Python字典”、“Java Map”的上下文中,大家默认讨论的是哈希表实现的无序字典。
一个关键陷阱:迭代器失效
对于
unordered_map
,当插入元素导致容器需要扩容(重哈希)时,
所有迭代器都会失效
,包括指向未修改元素的迭代器。而
map
的插入和删除通常只会使指向被删除元素的迭代器失效,其他迭代器保持有效。这在需要长期持有迭代器或指针的场景下至关重要。
// unordered_map 迭代器失效示例(危险!)
std::unordered_map<int, int> umap = {{1, 100}, {2, 200}};
auto it = umap.find(1);
// ... 做一些操作
umap[3] = 300; // 可能导致重哈希
// 此时 it 可能已经失效,再使用 *it 是未定义行为!
// map 则安全得多
std::map<int, int> omap = {{1, 100}, {2, 200}};
auto it2 = omap.find(1);
omap[3] = 300; // 不会导致重哈希
// it2 仍然有效,可以安全使用
4.3 性能实测与经验之谈
理论归理论,实际性能如何?我写过一个简单的基准测试,分别向
map
和
unordered_map
插入 100 万个随机整数键,然后进行 10 万次随机查找。在典型的 x86-64 机器上,使用
-O2
优化,结果大致如下:
-
插入
:
unordered_map通常比map快 2-3 倍。 -
查找
:
unordered_map通常比map快 5-10 倍。
但是!这个优势高度依赖于哈希函数的质量和数据的分布。如果你的键是连续整数,哈希表性能爆表。但如果你的自定义类型哈希函数写得很差,导致大量冲突,性能可能退化到比
map
还慢。而
map
的 O(log n) 虽然慢一些,但非常稳定。
我的经验是 :
-
对于
int,std::string等标准类型作为键 ,如果不需要顺序,优先用unordered_map。标准库为它们提供了高质量的哈希函数。 -
对于自定义类型作为键
,如果你能轻松写出一个高效、均匀的哈希函数,并且不需要有序遍历,可以用
unordered_map。否则,用map更省心。 - 如果需要频繁遍历所有元素 ,考虑一下遍历的成本。哈希表的遍历可能因为内存不连续(在多个桶之间跳转)而比红黑树的遍历慢一些,尽管复杂度都是 O(n)。
- 在性能关键路径上,一定要实测 。用真实的数据和操作模式进行性能剖析(Profiling),数据会告诉你哪个更合适。
5. 实战技巧与常见“坑点”
5.1
map
的下标操作符
[]
的副作用再强调
这是新手最容易踩的坑,值得单独再说一次。
std::map<std::string, int> word_count;
int count = word_count["apple"]; // 危险!如果"apple"不存在,会被插入,其值被值初始化(int为0)
// 此时 word_count 中已经有一个 {"apple", 0} 的键值对了!
// 安全的做法:只想检查是否存在时,用 find 或 count
auto it = word_count.find("banana");
if (it != word_count.end()) {
// 存在,使用 it->second
} else {
// 不存在
}
// 或者,如果你想在键不存在时提供一个默认值
int count = 0;
if (word_count.count("banana") > 0) {
count = word_count["banana"]; // 此时使用[]是安全的,因为键一定存在
}
C++17 提供了更优雅的解决方案:
try_emplace
和
insert_or_assign
,它们能更精确地控制插入行为。
5.2 高效插入:
emplace
与
insert
的对比
在 C++11 之后,推荐使用
emplace
系列函数进行插入,它们可以直接在容器内部构造元素,避免不必要的拷贝或移动。
std::map<int, std::string> m;
// 传统 insert,需要构造一个临时的 pair
m.insert(std::make_pair(1, "one")); // 可能涉及临时对象的构造和拷贝/移动
// 使用 emplace,参数直接转发给 pair 的构造函数
m.emplace(1, "one"); // 更高效,直接在 map 内部构造 pair
// 对于 set 也一样
std::set<std::string> s;
s.emplace("hello"); // 直接在 set 内部构造 string,优于 s.insert("hello")(虽然对于字面量编译器可能优化)
emplace
的效率优势在存储大型或不可拷贝的对象时尤为明显。
5.3 遍历时删除元素
这是一个经典问题。直接使用基于范围的 for 循环并在循环体内删除当前元素会导致迭代器失效,引发未定义行为。
错误示范:
std::map<int, int> m = {{1, 10}, {2, 20}, {3, 30}, {4, 40}};
for (const auto& kv : m) { // 基于范围的for循环
if (kv.first % 2 == 0) {
m.erase(kv.first); // 运行时错误!迭代器失效。
}
}
正确做法:使用迭代器循环,并利用
erase
的返回值。
erase
函数会返回被删除元素之后元素的迭代器。
std::map<int, int> m = {{1, 10}, {2, 20}, {3, 30}, {4, 40}};
for (auto it = m.begin(); it != m.end(); /* 这里不递增 */) {
if (it->first % 2 == 0) {
it = m.erase(it); // erase 返回下一个有效迭代器,赋值给 it
} else {
++it; // 只有没删除元素时,才手动递增迭代器
}
}
// 现在 m 中只剩下 {1, 10}, {3, 30}
对于 C++11 及以上,也可以利用
erase_if
算法(C++20 引入到标准库,但很多编译器在更早的版本就支持在
std
命名空间中):
// C++20 风格,最简洁
std::erase_if(m, [](const auto& kv) { return kv.first % 2 == 0; });
// 或者使用通用的 remove-erase idiom(对于 map 稍显繁琐)
// auto it = std::remove_if 不能直接用于 map,因为 map 的迭代器不是可写的。
5.4 使用
lower_bound
和
upper_bound
进行范围查询
因为
map
是有序的,所以可以高效地进行范围查询。
lower_bound(key)
返回第一个
不小于
key
的元素的迭代器。
upper_bound(key)
返回第一个
大于
key
的元素的迭代器。它们通常配合使用。
#include <map>
#include <iostream>
int main() {
std::map<int, char> m {{1, 'a'}, {2, 'b'}, {4, 'd'}, {5, 'e'}, {7, 'g'}};
// 找出所有键在 [3, 6] 区间内的元素
auto low = m.lower_bound(3); // 指向键为4的元素(第一个 >=3 的)
auto up = m.upper_bound(6); // 指向键为7的元素(第一个 >6 的)
std::cout << "Keys in range [3, 6]: ";
for (auto it = low; it != up; ++it) {
std::cout << it->first << "->" << it->second << " ";
}
std::cout << std::endl; // 输出: 4->d 5->e
// 还有一个 equal_range(key),返回一个 pair<lower_bound, upper_bound>
auto range = m.equal_range(4);
// range.first 指向键为4的元素,range.second 指向键为5的元素
for (auto it = range.first; it != range.second; ++it) {
std::cout << it->first << "->" << it->second << " ";
}
// 因为键唯一,所以这里只会输出 4->d
return 0;
}
这个特性使得
map
在某些场景下可以当作一个简单的有序索引来使用。
5.5
multimap
和
multiset
:允许重复键的版本
标准库还提供了
std::multimap
和
std::multiset
,它们允许键重复。当你需要存储多个相同键的值时(比如一个作者对应多本书),
multimap
就派上用场了。
它们的主要区别在于:
-
insert总是成功(因为允许重复)。 -
erase(key)会删除所有键等于key的元素,返回删除的数量。 -
find(key)返回指向 第一个 键等于key的元素的迭代器(如果存在)。 -
由于键可以重复,
operator[]和at()函数 不存在 ,因为你无法通过键唯一地确定一个值。 -
要获取某个键对应的所有值,需要使用
equal_range(key),它返回一个迭代器对,表示该键对应的元素范围。
#include <iostream>
#include <map> // multimap 也在 <map> 中
int main() {
std::multimap<std::string, std::string> author_books;
author_books.insert({"鲁迅", 《狂人日记》});
author_books.insert({"鲁迅", 《呐喊》});
author_books.insert({"金庸", 《射雕英雄传》});
author_books.insert({"金庸", 《神雕侠侣》});
// 查找金庸的所有书
auto range = author_books.equal_range("金庸");
std::cout << "金庸的作品: ";
for (auto it = range.first; it != range.second; ++it) {
std::cout << it->second << " ";
}
std::cout << std::endl;
// 计算某个键出现的次数
std::cout << "鲁迅的作品数量: " << author_books.count("鲁迅") << std::endl;
return 0;
}
选择
map
还是
multimap
,根本在于你的数据模型是否需要一对多的关系。
更多推荐
所有评论(0)