1. C++中的set和map容器详解
作为C++标准库中重要的关联容器,set和map在实际开发中有着广泛的应用。它们基于红黑树实现,提供了高效的查找、插入和删除操作。本文将深入探讨这两种容器的使用方法和底层原理。
1.1 set容器基础
set是C++标准库提供的一种关联容器,它存储唯一键值并按特定顺序排列。从底层实现来看,set通常基于红黑树(一种自平衡二叉搜索树)实现,这保证了元素的有序性和操作的高效性。
set的基本特性包括:
- 元素自动排序(默认升序)
- 不允许重复元素
- 查找、插入和删除操作的时间复杂度为O(log n)
cpp复制#include <iostream>
#include <set>
int main() {
std::set<int> mySet = {5, 2, 8, 1, 4};
// 自动排序且去重
for(int num : mySet) {
std::cout << num << " ";
}
// 输出: 1 2 4 5 8
}
1.2 set的模板参数解析
set的模板声明如下:
cpp复制template <class Key, class Compare = less<Key>, class Allocator = allocator<Key>>
class set;
三个模板参数分别代表:
- Key:存储元素的类型
- Compare:比较函数对象类型,默认为std::less
- Allocator:内存分配器类型,默认为std::allocator
我们可以通过自定义比较函数来改变元素的排序方式:
cpp复制#include <functional>
std::set<int, std::greater<int>> descendingSet = {5, 2, 8, 1, 4};
for(int num : descendingSet) {
std::cout << num << " ";
}
// 输出: 8 5 4 2 1
1.3 set的常用操作
1.3.1 插入元素
set提供了几种插入元素的方法:
cpp复制std::set<int> s;
// 方法1:直接插入值
s.insert(10);
// 方法2:使用emplace(C++11引入)
s.emplace(20);
// 方法3:插入一个范围
std::vector<int> vec = {30, 40, 50};
s.insert(vec.begin(), vec.end());
insert方法返回一个pair,其中first是指向插入元素的迭代器,second是一个bool值,表示是否成功插入(对于set,元素已存在时返回false)。
1.3.2 查找元素
set提供了多种查找方法:
cpp复制std::set<int> s = {10, 20, 30, 40, 50};
// 方法1:使用find
auto it = s.find(30);
if(it != s.end()) {
std::cout << "Found: " << *it << std::endl;
}
// 方法2:使用count
if(s.count(30)) {
std::cout << "30 exists in set" << std::endl;
}
// 方法3:使用lower_bound和upper_bound进行范围查找
auto low = s.lower_bound(20); // 第一个不小于20的元素
auto up = s.upper_bound(40); // 第一个大于40的元素
for(auto it = low; it != up; ++it) {
std::cout << *it << " "; // 输出: 20 30 40
}
1.3.3 删除元素
删除元素也有多种方式:
cpp复制std::set<int> s = {10, 20, 30, 40, 50};
// 方法1:通过值删除
size_t numRemoved = s.erase(30); // 返回删除的元素数量(0或1)
// 方法2:通过迭代器删除
auto it = s.find(20);
if(it != s.end()) {
s.erase(it);
}
// 方法3:删除一个范围
auto first = s.find(10);
auto last = s.find(40);
s.erase(first, last); // 删除[10,40)之间的元素
1.4 set的特性与限制
set的一个重要特性是元素不可修改:
cpp复制std::set<int> s = {1, 2, 3};
auto it = s.begin();
// *it = 10; // 错误!不能修改set中的元素
这是因为修改元素可能会破坏红黑树的排序性质。如果需要修改元素,正确的做法是先删除旧元素,再插入新元素。
2. multiset容器
multiset是set的变体,允许存储重复的元素。它的接口与set基本相同,但有以下区别:
- 允许重复元素
- insert总是成功(返回指向新元素的迭代器)
- erase返回删除的元素数量(可能大于1)
- count可能返回大于1的值
cpp复制#include <set>
std::multiset<int> ms = {1, 2, 2, 3, 3, 3};
std::cout << ms.count(2); // 输出: 2
std::cout << ms.count(3); // 输出: 3
auto ret = ms.erase(3); // 删除所有3
std::cout << ret; // 输出: 3
3. map容器基础
map是关联容器,存储键值对,其中键是唯一的。与set类似,map也基于红黑树实现,保证了元素的有序性。
map的基本特性:
- 每个元素是一个pair<const Key, Value>
- 按键自动排序(默认升序)
- 不允许重复键
- 通过键快速查找值(O(log n)时间复杂度)
cpp复制#include <iostream>
#include <map>
#include <string>
int main() {
std::map<std::string, int> population;
population["China"] = 1411780000;
population["India"] = 1380004385;
population["USA"] = 331002651;
// 自动按键排序
for(const auto& [country, num] : population) {
std::cout << country << ": " << num << std::endl;
}
}
3.1 map的模板参数
map的模板声明:
cpp复制template <class Key, class T, class Compare = less<Key>,
class Allocator = allocator<pair<const Key, T>>>
class map;
参数说明:
- Key:键的类型
- T:值的类型
- Compare:键的比较函数对象类型
- Allocator:内存分配器类型
3.2 map的常用操作
3.2.1 插入元素
cpp复制std::map<std::string, int> m;
// 方法1:使用insert
m.insert({"apple", 5});
// 方法2:使用emplace
m.emplace("banana", 3);
// 方法3:使用operator[]
m["orange"] = 8; // 如果键不存在,会创建新元素
注意:operator[]会在键不存在时插入新元素(值初始化),而insert不会修改已存在的元素。
3.2.2 访问元素
cpp复制std::map<std::string, int> m = {{"apple", 5}, {"banana", 3}};
// 方法1:使用operator[](键不存在时会插入)
int count = m["apple"];
// 方法2:使用at(键不存在时抛出异常)
try {
int count = m.at("pear");
} catch(const std::out_of_range& e) {
std::cerr << e.what() << std::endl;
}
// 方法3:使用find
auto it = m.find("banana");
if(it != m.end()) {
std::cout << it->second << std::endl;
}
3.2.3 修改和删除元素
cpp复制std::map<std::string, int> m = {{"apple", 5}, {"banana", 3}};
// 修改值
m["apple"] = 10;
// 删除元素
m.erase("banana"); // 通过键删除
auto it = m.find("apple");
if(it != m.end()) {
m.erase(it); // 通过迭代器删除
}
4. multimap容器
multimap是map的变体,允许键重复。与multiset类似,它的接口与map基本相同,但有如下区别:
- 允许重复键
- 没有operator[](因为可能有多个相同键)
- insert总是成功
- erase返回删除的元素数量
- count可能返回大于1的值
cpp复制#include <map>
#include <string>
std::multimap<std::string, int> mm;
mm.insert({"apple", 5});
mm.insert({"apple", 8}); // 允许重复键
auto range = mm.equal_range("apple"); // 获取所有"apple"键的范围
for(auto it = range.first; it != range.second; ++it) {
std::cout << it->second << std::endl; // 输出5和8
}
5. 实际应用案例
5.1 使用set解决唯一元素问题
cpp复制#include <vector>
#include <set>
std::vector<int> findUniqueElements(const std::vector<int>& nums) {
std::set<int> uniqueSet(nums.begin(), nums.end());
return std::vector<int>(uniqueSet.begin(), uniqueSet.end());
}
5.2 使用map实现词频统计
cpp复制#include <string>
#include <map>
#include <vector>
std::map<std::string, int> wordFrequency(const std::vector<std::string>& words) {
std::map<std::string, int> freq;
for(const auto& word : words) {
++freq[word];
}
return freq;
}
5.3 使用multimap实现一对多映射
cpp复制#include <map>
#include <string>
void buildStudentCourseMap() {
std::multimap<std::string, std::string> studentCourses;
studentCourses.insert({"Alice", "Math"});
studentCourses.insert({"Alice", "Physics"});
studentCourses.insert({"Bob", "Chemistry"});
// 查询Alice的所有课程
auto range = studentCourses.equal_range("Alice");
for(auto it = range.first; it != range.second; ++it) {
std::cout << it->second << std::endl;
}
}
6. 性能分析与使用建议
- 当需要快速查找且元素唯一时,使用set或map
- 当允许重复元素时,使用multiset或multimap
- 对于频繁插入删除的场景,关联容器比顺序容器更高效
- 如果需要保持插入顺序而非排序顺序,考虑使用unordered_set/unordered_map
- 对于小型数据集,vector+sort+unique可能比set更高效
注意事项:虽然set/map的查找效率很高(O(log n)),但如果需要更快的查找(O(1)),可以考虑基于哈希表的unordered_set和unordered_map,但要注意它们不保持元素顺序。
7. 常见问题解答
7.1 为什么不能直接修改set中的元素?
直接修改set中的元素可能会破坏红黑树的排序性质。正确的做法是先删除旧元素,再插入新元素。
7.2 map的operator[]和insert有什么区别?
operator[]会在键不存在时插入新元素(值初始化),而insert不会修改已存在的元素。因此,当不希望意外插入新元素时,应该使用insert或find。
7.3 如何遍历map中的所有元素?
有几种方式:
cpp复制std::map<K, V> m;
// 方法1:使用迭代器
for(auto it = m.begin(); it != m.end(); ++it) {
// it->first是键,it->second是值
}
// 方法2:使用range-based for循环(C++11)
for(const auto& pair : m) {
// pair.first是键,pair.second是值
}
// 方法3:使用结构化绑定(C++17)
for(const auto& [key, value] : m) {
// 直接使用key和value
}
7.4 如何自定义set/map的比较函数?
可以通过模板参数指定比较函数对象类型:
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::set<std::string, CaseInsensitiveCompare> caseInsensitiveSet;
8. 高级用法与技巧
8.1 使用自定义类型作为键
当使用自定义类型作为set/map的键时,需要提供比较方法:
cpp复制struct Person {
std::string name;
int age;
// 方法1:重载operator<
bool operator<(const Person& other) const {
return std::tie(name, age) < std::tie(other.name, other.age);
}
};
std::set<Person> personSet;
// 或者使用方法2:提供比较函数对象
struct PersonCompare {
bool operator()(const Person& a, const Person& b) const {
return a.age < b.age; // 只比较年龄
}
};
std::set<Person, PersonCompare> personSetByAge;
8.2 高效合并两个set
cpp复制std::set<int> set1 = {1, 2, 3};
std::set<int> set2 = {3, 4, 5};
// 方法1:使用merge(C++17)
set1.merge(set2); // set1: {1,2,3,4,5}, set2: {3}
// 方法2:使用insert
set1.insert(set2.begin(), set2.end());
8.3 使用map实现缓存
cpp复制template<typename Key, typename Value>
class SimpleCache {
std::map<Key, Value> cache;
size_t maxSize;
public:
SimpleCache(size_t size) : maxSize(size) {}
bool get(const Key& key, Value& value) {
auto it = cache.find(key);
if(it == cache.end()) return false;
value = it->second;
return true;
}
void put(const Key& key, const Value& value) {
if(cache.size() >= maxSize) {
cache.erase(cache.begin()); // 简单策略:删除第一个元素
}
cache[key] = value;
}
};
9. 底层实现原理
set和map通常基于红黑树实现,红黑树是一种自平衡的二叉搜索树,具有以下特性:
- 每个节点是红色或黑色
- 根节点是黑色
- 红色节点的子节点必须是黑色(不能有连续红色节点)
- 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点
这些特性保证了红黑树在最坏情况下的基本操作(插入、删除、查找)时间复杂度为O(log n)。
红黑树通过旋转和重新着色来维持平衡,相比AVL树,它的平衡条件更宽松,因此在插入和删除操作时需要的旋转操作更少,适合频繁修改的场景。
10. 最佳实践总结
-
选择合适的容器:
- 需要唯一键且有序:set/map
- 允许重复键且有序:multiset/multimap
- 不需要有序但需要快速查找:unordered_set/unordered_map
-
性能优化技巧:
- 预先分配空间(如果知道元素数量)
- 批量插入时使用insert的范围版本
- 对于自定义类型键,确保比较操作高效
-
避免常见错误:
- 不要直接修改set中的元素
- 使用map时注意operator[]的副作用
- 对于multimap,使用equal_range来查找所有相同键的元素
-
代码可读性建议:
- 使用typedef或using简化复杂类型声明
- 使用C++17的结构化绑定简化map遍历
- 为自定义比较函数提供清晰的命名
通过深入理解set和map的特性和底层实现,我们可以在实际开发中更有效地利用这些强大的容器,编写出既高效又易于维护的代码。