1. 为什么需要map和set?
在C++开发中,我们经常需要处理各种数据集合。想象一下这样的场景:你需要统计一篇文章中每个单词出现的次数,或者需要维护一个不允许重复的用户ID列表。这时候,map和set就派上用场了。
map和set是C++标准模板库(STL)中的关联容器,它们基于红黑树实现,提供了高效的查找、插入和删除操作。与vector和list这样的序列容器不同,关联容器通过键(key)来快速访问元素,而不是通过位置索引。
提示:在C++11之后,还新增了unordered_map和unordered_set,它们基于哈希表实现,平均时间复杂度更低,但不保证元素的顺序。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. map的基本使用
2.1 map的声明和初始化
map是一个键值对(key-value)容器,每个元素包含一个键和一个值。键必须是唯一的,而值可以重复。声明一个map的基本语法如下:
cpp复制#include <map>
#include <string>
std::map<std::string, int> wordCount; // 键是string类型,值是int类型
我们可以用多种方式初始化map:
cpp复制// 直接初始化
std::map<int, std::string> idToName = {
{1, "Alice"},
{2, "Bob"},
{3, "Charlie"}
};
// 使用insert方法添加元素
idToName.insert({4, "David"});
2.2 访问和修改map元素
访问map元素最常用的方式是使用[]运算符:
cpp复制wordCount["hello"] = 1; // 如果"hello"不存在,会创建一个新元素
wordCount["hello"]++; // 增加计数
// 使用at方法访问(如果键不存在会抛出异常)
try {
int count = wordCount.at("world");
} catch (const std::out_of_range& e) {
std::cout << "Key not found!" << std::endl;
}
2.3 遍历map
map支持迭代器遍历,元素会按键的升序排列:
cpp复制for (const auto& pair : wordCount) {
std::cout << pair.first << ": " << pair.second << std::endl;
}
// 使用迭代器
for (auto it = wordCount.begin(); it != wordCount.end(); ++it) {
std::cout << it->first << ": " << it->second << std::endl;
}
3. set的基本使用
3.1 set的声明和初始化
set是一个只存储键的容器,且所有键都是唯一的。它常用于需要快速判断元素是否存在的场景。声明一个set的基本语法如下:
cpp复制#include <set>
std::set<int> uniqueNumbers;
初始化set的方式与map类似:
cpp复制std::set<std::string> names = {"Alice", "Bob", "Charlie"};
names.insert("David"); // 添加新元素
3.2 set的常用操作
set提供了一些特别有用的成员函数:
cpp复制std::set<int> numbers = {1, 2, 3, 4, 5};
// 检查元素是否存在
if (numbers.find(3) != numbers.end()) {
std::cout << "3 is in the set" << std::endl;
}
// 删除元素
numbers.erase(3);
// 获取元素数量
std::cout << "Set size: " << numbers.size() << std::endl;
3.3 set的遍历
与map类似,set也支持迭代器遍历,元素会按键的升序排列:
cpp复制for (const auto& num : numbers) {
std::cout << num << " ";
}
std::cout << std::endl;
4. 底层实现与性能分析
4.1 红黑树基础
map和set在C++标准库中通常基于红黑树实现。红黑树是一种自平衡的二叉搜索树,它保证了最坏情况下的查找、插入和删除时间复杂度都是O(log n)。
红黑树有以下几个重要特性:
- 每个节点要么是红色,要么是黑色
- 根节点是黑色
- 每个叶子节点(NIL)是黑色
- 如果一个节点是红色,则它的两个子节点都是黑色
- 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点
4.2 时间复杂度比较
| 操作 | map/set | unordered_map/unordered_set |
|---|---|---|
| 插入 | O(log n) | O(1) average, O(n) worst |
| 删除 | O(log n) | O(1) average, O(n) worst |
| 查找 | O(log n) | O(1) average, O(n) worst |
| 遍历 | O(n) | O(n) |
4.3 内存占用
map和set由于基于红黑树实现,每个元素都需要额外的指针来维护树结构,因此内存占用比基于数组的容器要大。每个节点通常需要存储:
- 键(和值,对于map)
- 父指针
- 左孩子指针
- 右孩子指针
- 颜色标记
5. 实际应用案例
5.1 使用map统计词频
cpp复制#include <iostream>
#include <map>
#include <string>
#include <sstream>
void countWords(const std::string& text) {
std::map<std::string, int> wordCount;
std::istringstream iss(text);
std::string word;
while (iss >> word) {
// 去除标点符号(简单处理)
if (!isalpha(word.back())) {
word.pop_back();
}
++wordCount[word];
}
// 输出结果
for (const auto& pair : wordCount) {
std::cout << pair.first << ": " << pair.second << std::endl;
}
}
5.2 使用set实现白名单过滤
cpp复制#include <iostream>
#include <set>
#include <vector>
void filterAllowedUsers(const std::vector<std::string>& users) {
std::set<std::string> allowedUsers = {"Alice", "Bob", "Charlie"};
for (const auto& user : users) {
if (allowedUsers.find(user) != allowedUsers.end()) {
std::cout << user << " is allowed" << std::endl;
} else {
std::cout << user << " is NOT allowed" << std::endl;
}
}
}
5.3 使用map实现缓存
cpp复制#include <map>
#include <string>
#include <iostream>
class SimpleCache {
private:
std::map<std::string, std::string> cache;
public:
bool contains(const std::string& key) const {
return cache.find(key) != cache.end();
}
std::string get(const std::string& key) const {
auto it = cache.find(key);
return it != cache.end() ? it->second : "";
}
void put(const std::string& key, const std::string& value) {
cache[key] = value;
}
void remove(const std::string& key) {
cache.erase(key);
}
};
6. 常见问题与解决方案
6.1 自定义比较函数
默认情况下,map和set使用std::less来比较键。如果需要自定义排序规则,可以提供一个比较函数或函数对象:
cpp复制struct CaseInsensitiveCompare {
bool operator()(const std::string& a, const std::string& b) const {
return std::lexicographical_compare(
a.begin(), a.end(),
b.begin(), b.end(),
[](char c1, char c2) {
return tolower(c1) < tolower(c2);
});
}
};
std::map<std::string, int, CaseInsensitiveCompare> caseInsensitiveMap;
6.2 处理不存在的键
当使用[]运算符访问不存在的键时,map会自动插入一个默认构造的值。这可能不是我们想要的行为。有几种替代方案:
cpp复制std::map<std::string, int> myMap;
// 方法1:使用find检查
auto it = myMap.find("nonexistent");
if (it != myMap.end()) {
// 键存在
}
// 方法2:使用count检查
if (myMap.count("nonexistent")) {
// 键存在
}
// 方法3:C++17引入的try_emplace和insert_or_assign
auto [it, inserted] = myMap.try_emplace("newkey", 42);
if (inserted) {
std::cout << "New element inserted" << std::endl;
}
6.3 性能优化技巧
-
预分配空间:虽然map和set不像vector那样可以reserve,但如果你知道元素数量,可以在插入前调用max_load_factor来优化哈希表版本的性能。
-
使用emplace代替insert:emplace可以直接在容器内构造元素,避免不必要的拷贝或移动。
cpp复制std::map<std::string, std::string> myMap;
myMap.emplace("key", "value"); // 比insert(make_pair(...))更高效
- 批量操作:尽量使用范围插入而不是单个元素插入。
cpp复制std::set<int> source = {1, 2, 3};
std::set<int> target;
target.insert(source.begin(), source.end()); // 批量插入
7. 进阶话题
7.1 multimap和multiset
标准库还提供了multimap和multiset,它们允许键重复。这在某些场景下非常有用,比如一个作者对应多本书的情况:
cpp复制#include <map>
#include <string>
std::multimap<std::string, std::string> authorToBooks;
authorToBooks.insert({"J.K. Rowling", "Harry Potter 1"});
authorToBooks.insert({"J.K. Rowling", "Harry Potter 2"});
// 查找一个作者的所有书
auto range = authorToBooks.equal_range("J.K. Rowling");
for (auto it = range.first; it != range.second; ++it) {
std::cout << it->second << std::endl;
}
7.2 与unordered容器的比较
C++11引入了unordered_map和unordered_set,它们基于哈希表实现,提供了平均O(1)的访问时间。选择使用哪种容器取决于具体需求:
- 如果需要元素有序,使用map/set
- 如果只需要快速查找,不关心顺序,使用unordered_map/unordered_set
- 如果键的自定义哈希函数复杂或容易冲突,map/set可能更稳定
7.3 C++17的新特性
C++17为map和set添加了几个有用的方法:
- extract:允许在不分配内存的情况下移动元素
cpp复制std::set<int> src = {1, 2, 3};
std::set<int> dst;
auto node = src.extract(2);
dst.insert(std::move(node));
- merge:合并两个容器
cpp复制std::set<int> src = {1, 2, 3};
std::set<int> dst = {4, 5, 6};
dst.merge(src); // src中已存在的元素不会被移动
- insert_or_assign和try_emplace:更安全的插入操作
8. 实际项目中的经验分享
在我多年的C++开发经验中,map和set是最常用的容器之一。以下是一些实战心得:
-
键的选择:尽量使用简单类型作为键,如int或std::string。如果必须使用自定义类型,确保正确实现了比较运算符或哈希函数。
-
避免频繁的插入删除:虽然红黑树的插入删除是O(log n),但频繁操作仍会影响性能。考虑批量操作或使用其他数据结构。
-
注意迭代器失效:与vector不同,map和set的插入操作不会使迭代器失效(除非被删除的元素)。这是一个重要的优势。
-
内存考虑:在内存受限的环境中,map和set可能不是最佳选择,因为每个元素都有额外的指针开销。可以考虑使用排序的vector配合二分查找。
-
调试技巧:在调试时,可以重载键类型的<<运算符,方便打印map/set内容:
cpp复制struct Point {
int x, y;
bool operator<(const Point& other) const {
return x < other.x || (x == other.x && y < other.y);
}
friend std::ostream& operator<<(std::ostream& os, const Point& p) {
return os << "(" << p.x << "," << p.y << ")";
}
};
std::map<Point, std::string> pointMap;
// ...填充数据...
for (const auto& [point, name] : pointMap) {
std::cout << point << ": " << name << std::endl;
}
