在C++标准库中,map和set是两种极为重要的关联容器,它们的底层实现都基于红黑树这一数据结构。红黑树本质上是一种平衡二叉搜索树(Balanced Binary Search Tree),通过特定的平衡规则确保在最坏情况下仍能保持O(logN)的时间复杂度。这种设计使得map和set在需要频繁查找、插入和删除元素的场景中表现出色。
set是一个纯键值(key)集合,适用于只需要判断某个元素是否存在的场景。它的模板声明中,第一个参数T表示存储元素的类型,第二个参数Compare用于定义元素比较规则,第三个参数Alloc负责内存分配。默认情况下,set使用less<T>作为比较器,这意味着存入set的元素需要支持<运算符,或者用户需要提供自定义的比较仿函数。
map则是一个键值对(key-value)映射结构,适用于需要通过键快速查找对应值的场景。与set不同,map存储的是pair<const Key, T>类型的元素,其中键部分是const修饰的,确保在元素存入后不会被修改(修改键会破坏红黑树的结构)。
重要提示:set的iterator和const_iterator都不允许通过迭代器修改元素值,因为这会破坏底层红黑树的排序结构。如果需要修改元素,正确的做法是先删除旧元素,再插入新元素。
set提供了多种构造函数以适应不同的初始化需求:
cpp复制std::set<int> s1; // 创建一个空的int集合
cpp复制std::vector<int> vec = {3,1,4,1,5};
std::set<int> s2(vec.begin(), vec.end()); // s2包含{1,3,4,5}
cpp复制std::set<int> s3 = {6,2,8,3,1}; // s3包含{1,2,3,6,8}
cpp复制std::set<int> s4(s3); // s4是s3的副本
在实际工程中,初始化列表构造函数因其简洁性而广受欢迎,特别是在测试代码或配置数据时。但要注意,初始化列表中的重复元素会被自动去重。
set提供了一套完整的迭代器接口,包括正向和反向迭代器:
cpp复制std::set<int> s = {5,2,8,1,6};
// 正向迭代(升序)
for(auto it = s.begin(); it != s.end(); ++it) {
std::cout << *it << " "; // 输出:1 2 5 6 8
}
// 反向迭代(降序)
for(auto rit = s.rbegin(); rit != s.rend(); ++rit) {
std::cout << *rit << " "; // 输出:8 6 5 2 1
}
// C++11范围for循环(本质也是迭代器)
for(int x : s) {
std::cout << x << " ";
}
需要注意的是,set的迭代器属于双向迭代器,支持++和--操作,但不支持随机访问(如it + 3这样的操作)。这是因为红黑树的物理存储结构不是连续内存,无法像vector那样直接计算偏移量。
set提供了多种插入方式:
cpp复制std::set<std::string> names;
// 直接插入值
auto [it1, inserted1] = names.insert("Alice");
// it1指向插入位置的迭代器,inserted1表示是否实际插入
// 使用hint位置提示(当知道大致插入位置时可提高效率)
auto hint = names.lower_bound("Bob");
auto [it2, inserted2] = names.insert(hint, "Bob");
// 插入初始化列表
names.insert({"Charlie", "David", "Eve"});
删除元素也有多种方式:
cpp复制// 通过值删除(返回删除的元素数量,对set总是0或1)
size_t n = names.erase("Alice");
// 通过迭代器删除
auto it = names.find("Bob");
if(it != names.end()) {
names.erase(it);
}
// 删除一个范围
names.erase(names.begin(), names.find("David"));
// 清空整个set
names.clear();
set提供了高效的查找方法:
cpp复制// count方法(对set返回0或1)
if(names.count("Eve") > 0) {
std::cout << "Eve exists\n";
}
// find方法(返回迭代器)
auto it = names.find("Charlie");
if(it != names.end()) {
std::cout << "Found: " << *it << "\n";
}
// 边界查找(用于范围查询)
auto lower = names.lower_bound("C"); // 第一个>= "C"的元素
auto upper = names.upper_bound("D"); // 第一个> "D"的元素
for(auto it = lower; it != upper; ++it) {
std::cout << *it << " "; // 输出所有以C开头的名字
}
性能提示:set的查找操作(count/find/lower_bound等)时间复杂度都是O(logN),这比线性容器如vector/list的O(N)查找快得多,特别适合元素数量大的场景。
map与set最大的区别在于它存储的是键值对(key-value pairs)。在C++中,map的元素类型是std::pair<const Key, Value>,其中key部分是const的,确保不会被修改。
map的模板声明如下:
cpp复制template <
class Key,
class T,
class Compare = less<Key>,
class Alloc = allocator<pair<const Key, T>>
> class map;
构造map的方式与set类似:
cpp复制// 默认构造
std::map<std::string, int> ageMap;
// 初始化列表构造
std::map<std::string, int> scores = {
{"Alice", 90},
{"Bob", 85},
{"Charlie", 88}
};
// 迭代器范围构造
std::vector<std::pair<std::string, int>> entries = {{"Dave", 92}, {"Eve", 87}};
std::map<std::string, int> moreScores(entries.begin(), entries.end());
map提供了多种访问元素的方式,各有特点:
cpp复制// 使用[]运算符访问(如果key不存在会自动插入)
scores["Alice"] = 95; // 修改现有元素
scores["Frank"] = 89; // 插入新元素
// 使用at方法访问(key不存在时抛出out_of_range异常)
try {
int aliceScore = scores.at("Alice");
int unknown = scores.at("Unknown"); // 抛出异常
} catch(const std::out_of_range& e) {
std::cerr << "Key not found: " << e.what() << "\n";
}
// 安全的查找方式
auto it = scores.find("Bob");
if(it != scores.end()) {
std::cout << "Bob's score: " << it->second << "\n";
}
重要区别:
operator[]会在key不存在时自动插入一个默认构造的value,而at()和find()则不会。在不确定key是否存在时,优先使用find()或count()来避免意外插入。
向map中插入元素有几种常见模式:
cpp复制// 直接插入pair
auto [it1, inserted1] = scores.insert({"David", 93});
// 使用make_pair
auto [it2, inserted2] = scores.insert(std::make_pair("Eve", 91));
// 使用emplace构造元素(避免临时对象)
auto [it3, inserted3] = scores.emplace("Frank", 94);
// 插入或更新(C++17引入的try_emplace和insert_or_assign)
auto [it4, inserted4] = scores.try_emplace("Grace", 87); // 仅当key不存在时插入
scores.insert_or_assign("Grace", 88); // 无论key是否存在都会设置值
在C++17及以上版本中,try_emplace和insert_or_assign提供了更精细的控制,前者只在key不存在时构造value,后者则总是设置value,避免了不必要的临时对象构造。
当默认的less<Key>不满足需求时,可以自定义比较函数。例如,想要一个不区分大小写的string set:
cpp复制struct CaseInsensitiveCompare {
bool operator()(const std::string& a, const std::string& b) const {
return strcasecmp(a.c_str(), b.c_str()) < 0;
}
};
std::set<std::string, CaseInsensitiveCompare> names;
names.insert("Alice");
names.insert("alice"); // 不会插入,因为视为相同key
对于map,同样可以自定义比较逻辑:
cpp复制std::map<std::string, int, CaseInsensitiveCompare> scoreMap;
scoreMap["Alice"] = 90;
std::cout << scoreMap["alice"]; // 输出90
当能预测元素的插入位置时,可以使用emplace_hint提高效率:
cpp复制std::set<int> numbers = {1,5,9};
auto hint = numbers.lower_bound(3); // 指向5
numbers.emplace_hint(hint, 3); // 在5之前插入3,效率可能更高
对于有序插入的场景(如初始化时),这种方法可以显著减少红黑树的旋转操作。
当元素类型没有内置的比较运算符时,必须提供自定义比较器。例如,存储自定义类:
cpp复制struct Person {
std::string name;
int age;
};
struct PersonCompare {
bool operator()(const Person& a, const Person& b) const {
return a.name < b.name; // 按name排序
}
};
std::set<Person, PersonCompare> people;
people.insert({"Alice", 30});
people.insert({"Bob", 25});
虽然set/map的查找性能是O(logN),但在不同场景下可能有更优选择:
std::vector+排序+二分查找,因为缓存局部性更好std::unordered_set/unordered_map(哈希表实现),平均O(1)查找set和map的迭代器在以下情况下会失效:
安全的使用模式:
cpp复制std::set<int> s = {1,2,3,4,5};
// 错误:删除元素会使it失效
for(auto it = s.begin(); it != s.end(); ++it) {
if(*it % 2 == 0) {
s.erase(it); // 危险!it已失效
}
}
// 正确:使用返回值更新迭代器
for(auto it = s.begin(); it != s.end(); ) {
if(*it % 2 == 0) {
it = s.erase(it); // erase返回下一个有效迭代器
} else {
++it;
}
}
// C++20更简洁的写法
std::erase_if(s, [](int x) { return x % 2 == 0; });
自定义比较函数必须满足严格弱序(strict weak ordering):
comp(a,a)必须为falsecomp(a,b)为true,则comp(b,a)必须为falsecomp(a,b)和comp(b,c)为true,则comp(a,c)必须为true违反这些规则会导致未定义行为。例如,以下比较函数是错误的:
cpp复制// 错误:不满足严格弱序
struct BadCompare {
bool operator()(int a, int b) const {
return a <= b; // 违反了非自反性和非对称性
}
};
直接使用指针作为key会比较指针地址,通常不是我们想要的:
cpp复制std::set<std::string*> ptrSet; // 按指针地址排序
如果需要比较指针指向的内容,需要自定义比较器:
cpp复制struct StringPtrCompare {
bool operator()(const std::string* a, const std::string* b) const {
return *a < *b;
}
};
std::set<std::string*, StringPtrCompare> stringSet;
更安全的做法是使用智能指针:
cpp复制struct SmartPtrCompare {
bool operator()(const std::shared_ptr<std::string>& a,
const std::shared_ptr<std::string>& b) const {
return *a < *b;
}
};
std::set<std::shared_ptr<std::string>, SmartPtrCompare> safeStringSet;
标准map要求key唯一,如果需要多个相同的key,可以使用std::multimap:
cpp复制std::multimap<std::string, int> scoreMultiMap;
scoreMultiMap.insert({"Alice", 90});
scoreMultiMap.insert({"Alice", 95}); // 允许重复key
// 查找所有"Alice"的记录
auto range = scoreMultiMap.equal_range("Alice");
for(auto it = range.first; it != range.second; ++it) {
std::cout << it->second << "\n"; // 输出90和95
}
对于需要同时按多个字段排序的场景,可以组合key:
cpp复制struct CompositeKey {
std::string name;
int id;
bool operator<(const CompositeKey& other) const {
return std::tie(name, id) < std::tie(other.name, other.id);
}
};
std::map<CompositeKey, std::string> compositeMap;
在实际项目中,map和set的正确使用可以大幅提升代码的效率和可读性。理解它们的内部实现原理有助于做出更合理的设计决策。例如,在需要保证元素唯一性且频繁查找的场景,set是理想选择;而需要建立键值映射关系的,则应该使用map。