C++ STL关联式容器 set与 map使用指南
set 和 map 是 C++ STL 里的关联式容器,底层都用红黑树实现,插入、删除、查找都是 O(log n) 复杂度。set 适合“去重 + 排序 + 快速判断在不在”,map 适合“键值对映射 + 按键自动排序”。 你已熟悉 Java 和 C++ 基础,可直接对比记忆:set 类似 Java 的 TreeSet,map 类似 TreeMap,但 C++ 的迭代器和 pair 用法更灵活。
1. 序列式容器和关联式容器
在 C++ STL 中,容器主要分为两大类:
1. 序列式容器:如 vector、list、deque 等,这些容器统称为序列式容器。因为逻辑结构为线性序列的数据结构,两个位置存储的值之间一般没有紧密的关联关系,比如交换一下,它依旧是序列式容器。顺序容器中的元素是按它们在容器中的存储位置来顺序保存和访问的。
2. 关联式容器:如 set、map、multiset、multimap,也是用来存储数据的,与序列式容器不同的是,关联式容器逻辑结构通常是非线性结构,两个位置有紧密的关联关系,交换一下,它的存储结构就被破坏了。关联式容器中的元素是按照 Key (键值)来保存和访问的。
关联式容器的底层几乎无一例外地采用了 红黑树(一种自平衡二叉搜索树)。这使得它们在进行插入、删除、查找等操作时,均能稳定保持 O ( log N ) O(\log N) O(logN) 的时间复杂度。
2. 关联式容器 set 的核心用法
2.1 set和multiset 参考文档
set 是存储单一键值(Key)的关联式容器。它有两个最核心的特性:自动排序与强制去重。
在 set 中,Value 就是 Key 本身。
2.1 set 的元素插入、去重与遍历限制
默认情况下,set 会使用 less<T> 仿函数将元素按升序排列。如果试图插入已存在的重复元素,插入操作将会宣告失败。
示例1:默认升序与去重机制
// 1. 默认升序且去重的 set
set<int> s;
s.insert(5);
s.insert(2);
s.insert(7);
// ⚠️ 尝试插入重复元素 5
// insert 返回一个 pair<iterator, bool>
// 插入失败时,bool 值为 false,iterator 指向已存在的节点 5
auto ret = s.insert(5);
if (!ret.second)
{
cout << "元素 5 插入失败,已存在!" << endl;
}
cout << "迭代器遍历 set (自动升序 + 去重): ";
for (auto it = s.begin(); it != s.end(); ++it)
{
cout << *it << " "; // 输出: 2 5 7
}
cout << endl;⭐ 语法细节与行为机制:
- 返回值结构:
s.insert(val)返回一个std::pair<iterator, bool>。first是一代迭代器,指向容器中Key与val相等的节点。second是一个bool值,插入成功返回true,因重复导致插入失败返回false。
- 遍历只读性:在
set中,*it返回的是const K&。因为set内部节点的位置依赖Key的大小顺序,如果允许修改元素值,就会破坏红黑树“左小右大”的结构,因此 C++ 语法禁止通过迭代器修改元素。
示例 2:利用 initializer_list 批量插入
// 2. 利用 initializer_list 批量插入
s.insert({ 2, 8, 3, 9 });
cout << "批量插入后 (自动排序去重): ";
for (auto e : s)
{
cout << e << " "; // 输出: 2 3 5 7 8 9
}
cout << endl;⭐ 语法细节与行为机制:
- 批量插入机制:
insert({ 2, 8, 3, 9 })重载了std::initializer_list接口。 - 重复处理:其中的
2因为在容器中已存在,会被自动忽略,其余新元素8, 3, 9会按顺序插入树中并维持整棵树的有序状态。
示例 3:类型构造与 string 类型的 set
// 3. 隐式类型转换与 string 类型的 set
set<string> strset = { "sort", "insert", "add" };
cout << "字符串 set (按 ASCII 码序): ";
for (const auto& str : strset)
{
cout << str << " "; // 输出: add insert sort
}
cout << endl;⭐ 语法细节与行为机制:
- 比较逻辑:
string类型的set默认通过string重载的operator<(按 ASCII 码字典序)来进行节点大小判定与排序。 - 初始化语法:
set<string> strset = { ... }触发了 C++11 针对initializer_list的多参数隐式类型转换构造。
2.2 set 的高效查找与多种删除方式
set 提供了按 Key 值删除 与 按迭代器删除两种形态,并且提供了专属的高效查找算法。
示例 1:按迭代器删除与越界风险
set<int> s = { 4, 2, 7, 2, 8, 5, 9 }; // 实际存储: 2 4 5 7 8 9
// 1. 按迭代器删除(删除最小值/头节点)
// ⚠️ 注意:必须保证迭代器有效!如果传递 s.end() 会导致未定义行为 (Segmentation Fault)
s.erase(s.begin()); // 删除 2⭐ 语法细节与行为机制:
- 迭代器安全性:
s.erase(pos)要求pos必须是合法且有效的迭代器。如果传入了s.end()或者无效的野指针,程序会直接发生越界崩溃。
示例 2:按 Key 值直接删除
// 2. 按 Key 值直接删除
// 返回值是成功删除的元素个数(对于 set 来说只能是 0 或 1)
int x = 5;
size_t num = s.erase(x);
if (num == 0)
{
cout << x << " 不存在,删除失败!" << endl;
}⭐ 语法细节与行为机制:
- 接口设计:
s.erase(val)接受Key参数。由于set不允许键值重复,其返回值表示真正抹去的节点数,只能是1(存在并删除成功)或0(不存在,不发生改变)。
示例 3:安全删除的标准范式(find + erase)
// 3. 安全删除的标准范式:先查找,校验后再用 iterator 删除
int x = 7;
auto pos = s.find(x);
if (pos != s.end())
{
s.erase(pos); // 确认 pos != end() 后再删除,绝对安全
}⭐ 语法细节与行为机制:
- 性能对比:
s.find(x)是set的成员函数,时间复杂度为 O ( log N ) O(\log N) O(logN)(利用搜索二叉树的性质);而算法库中的std::find(s.begin(), s.end(), x)是全局函数,对迭代器做线性遍历,时间复杂度为 O ( N ) O(N) O(N)。因此必须优先选用成员函数。 - 校验逻辑:
find没找到目标时会返回s.end(),通过pos != s.end()的断言校验,可以防止迭代器失效导致的运行错误。
示例 4:利用 count 间接判断存在性
// 4. 利用 count 间接判断存在性
int x = 8;
if (s.count(x)) // 返回 1 即为存在
{
cout << x << " 存在于 set 中!" << endl;
}⭐ 语法细节与行为机制:
- 逻辑等价性:
s.count(x)的底层的执行机制也是查找逻辑。因为set中元素唯一,所以它的返回值非 1 即 0,常常被开发者当作简短的bool条件表达式来判断节点是否存在。
2.3 区间提取:lower_bound 与 upper_bound
当我们需要提取或删除某个闭区间 [ A , B ] [A, B] [A,B] 内的所有元素时,C++ 关联式容器提供了 lower_bound 与 upper_bound 两个效率极高的成员函数。
示例 1:界限迭代器的查找
std::set<int> myset;
for (int i = 1; i < 10; i++)
myset.insert(i * 10); // 存储: 10 20 30 40 50 60 70 80 90
// 目标:精准删除闭区间 [30, 60] 内的所有元素
// lower_bound(25): 返回第一个 Key >= 25 的节点迭代器 (即 30)
auto itlow = myset.lower_bound(25);
// upper_bound(60): 返回第一个 Key > 60 的节点迭代器 (即 70)
auto itup = myset.upper_bound(60);⭐ 语法细节与行为机制:
- 下界逻辑(lower_bound):返回指向第一个 ≥ v a l \ge val ≥val 节点的迭代器。在上述代码中,离
25最近且大于等于25的节点是30。 - 上界逻辑(upper_bound):返回指向第一个 > v a l > val >val 节点的迭代器。对于目标区间上限
60来说,严格大于60的第一个节点是70。
示例 2:利用界限迭代器进行区间删除
// s.erase(first, last) 删除的是左闭右开区间 [first, last) // 即删除 [30, 70) -> 包含 30, 40, 50, 60 myset.erase(itlow, itup); cout << "区间删除后剩余元素: "; for (auto e : myset) cout << e << " "; // 输出: 10 20 70 80 90 cout << endl;
⭐ 语法细节与行为机制:
- 左闭右开匹配原则:所有 STL 容器的范围删除接口
s.erase(first, last)均遵循 [ f i r s t , l a s t ) [first, last) [first,last) 的区间设计。 - 精准闭区间匹配公式:若要删除闭区间 [ A , B ] [A, B] [A,B] 内的数据,只需组合使用
[s.lower_bound(A), s.upper_bound(B))即可,上界的upper_bound会精准把开区间的右侧定位在 B B B 的后一个节点上。
2.4 multiset:允许键值重复的变体容器
multiset 与 set 同样定义在 <set> 头文件中,接口几乎完全一致。
唯一区别是multiset允许插入 Key 相同的节点。(允许键值重复)
示例 1:multiset 中 find 的返回位置机制
multiset<int> s = { 4, 2, 7, 2, 4, 8, 4, 5, 4, 9 };
// 排序后存储: 2 2 4 4 4 4 5 7 8 9
// 1. multiset 的 find 机制
int x = 4;
auto pos = s.find(x); // 关键:当有多个 4 时,find 返回哪一个?
// STL 规范保证:find 返回的是按中序遍历遇到的第一个 key == 4 的节点迭代器
cout << "顺序遍历所有的 4: ";
while (pos != s.end() && *pos == x)
{
cout << *pos << " "; // 连续输出所有的 4
++pos;
}
cout << endl;⭐ 语法细节与行为机制:
- 相交节点定位:当树中存在多个相等元素时,
s.find(x)不会随机返回,而是保证返回中序遍历树时遇到的第一个满足Key == x的节点迭代器。因此配合++pos可以顺畅地访问到所有相同的数据。
示例 2:count 与两种不同参的 erase 操作
// 2. count 与 erase 的差异 cout << "4 出现的实际次数: " << s.count(x) << endl; // 输出 4 // ⚠️ 重点区分 erase 的两种传参: // 方式 A: s.erase(x) -> 传入 Key,会抹去容器中所有 Key == x 的节点,返回删除数量 // 方式 B: s.erase(s.find(x)) -> 传入迭代器,仅删除当前这单个节点! s.erase(x); // 删除所有的 4 cout << "删除所有的 4 后,count(4) = " << s.count(4) << endl; // 输出 0
⭐ 语法细节与行为机制:
- 传 Key 删除(
s.erase(val)):会清理整棵树中所有等于 val 的节点,返回值是实际删掉的节点总数(例如上例中返回 4)。 - 传迭代器删除(
s.erase(iterator)):仅精准剔除当前迭代器指向的这一个节点,树中其他重复的元素保持不动。
3. 键值对结构 std::pair 与 map 的关系
map 是存储键值对(Key-Value)的关联容器。在深入 map 前,需要先了解 C++ 用于封装映射关系的基础结构 —— std::pair。
3.1std::pair接口结构与类型定义
在 STL 源码中,pair 被定义为一个简易的类模板结构体:
template <class T1, class T2>
struct pair
{
typedef T1 first_type;
typedef T2 second_type;
T1 first; // 键 Key
T2 second; // 值 Value
// 默认构造函数
pair() : first(T1()), second(T2()) {}
// 值构造函数
pair(const T1& a, const T2& b) : first(a), second(b) {}
// 支持隐式类型转换的拷贝构造函数
template<class U, class V>
pair(const pair<U, V>& pr) : first(pr.first), second(pr.second) {}
};- 在 map 内部,每一个节点存储的元素类型为:
pair<const Key, Value>。 - Key 的只读限制:因为
pair的第一个成员类型显式声明为了const Key,所以从map中获取元素时,无法修改其first成员。
4. 映射容器 map 的核心用法与场景
map 容器将 Key 作为排序和去重的依据,而 Value 作为与 Key 绑定的映射数据。
4.1 map 的四种常用插入方式
向 map 中插入节点需要构造 pair<const Key, Value>,下面逐一拆解四种常见写法:
示例 1:有名对象与匿名对象构造插入
map<string, string> dict;
// 写法 1:定义有名 pair 对象
pair<string, string> kv1("left", "左边");
dict.insert(kv1);
// 写法 2:构造匿名 pair 对象
dict.insert(pair<string, string>("right", "右边"));⭐ 语法细节与行为机制:
- 显式模板实例化:这种方式需要手动填写
pair<string, string>模板类型,代码冗长,实际开发中较少使用。
示例 2:make_pair函数模板构造(最推荐)
// 写法 3:利用 make_pair 函数模板 (最推荐,自动推导类型)
dict.insert(make_pair("sort", "排序"));⭐ 语法细节与行为机制:
- 类型自动推导:
make_pair是标准库提供的全局模板函数,能够根据传入的实参自动推导类型,无需显式写出pair<string, string>,写法最方便。
示例 3:C++11 列表初始化构造
// 写法 4:C++11 列表初始化 (隐式类型转换)
dict.insert({ "string", "字符串" });⭐ 语法细节与行为机制:
- 多参数隐式转换:大括号
{ "string", "字符串" }会隐式调用pair的构造函数生成临时对象,在 C++11 及以上版本中十分简洁高效。
示例 4:验证 Key 重复时的插入行为
// ⚠️ 验证 Key 重复时的插入行为
// insert 返回 pair<iterator, bool>
auto ret = dict.insert({ "left", "左边,剩余" });
if (!ret.second)
{
cout << "插入失败!Key ['left'] 已存在,对应的 Value 仍为: "
<< ret.first->second << endl;
}⭐ 语法细节与行为机制:
- 不覆盖原则:
map的insert操作只负责“当Key不存在时进行插入”。如果Key已经存在,插入放弃,ret.second为false,且已有节点的Value不会被更新。
4.2 迭代器访问与“双箭头”现象解密
示例 1:通过解引用(*)访问成员
map<string, string> dict = { {"left", "左边"}, {"right", "右边"} };
auto it = dict.begin();
// 写法 A:先解引用获取 pair 对象,再通过 . 访问成员
cout << (*it).first << ":" << (*it).second << endl;⭐ 语法细节与行为机制:
- 解引用逻辑:
*it得到的是pair<const string, string>&对象的引用,因此使用成员访问符 . 访问其内部成员first与second。
示例 2:通过指针重载(->)与语法简化
// 写法 B:直接使用指针重载运算符 -> (最常用写法) cout << it->first << ":" << it->second << endl; // 修改限制验证: // it->first += "x"; // ❌ 编译报错!Key 为 const,无法修改 it->second += " (已核对)"; // ✅ 合法!Value 可自由修改
⭐ 语法细节与行为机制(双箭头 -> 的语法简化):
- 底层机制:
it是迭代器类对象,it.operator->()返回的是指向节点的原生指针pair<const Key, Value>*。 - 语法简化:理论上严格写法应当是 (
it.operator->())->first(双箭头),但 C++ 编译器为了可读性做了特殊处理,省略了一个箭头,允许开发者直接写出it->first。
4.3 核心接口:operator[]的逻辑与防坑指南
4.3.1operator[]的内部逻辑展开
在 STL 标准实现中,operator[] 的功能彻底依赖于 insert() 函数。其逻辑等价于:
// operator[] 的等价逻辑实现
mapped_type& operator[] (const key_type& k)
{
// 1. 尝试插入 {k, mapped_type()}
// mapped_type() 是 Value 的默认构造值 (如 int 为 0,string 为 "")
pair<iterator, bool> ret = this->insert(make_pair(k, mapped_type()));
// 2. 无论插入成功与否,ret.first 都是指向该 Key 节点的迭代器
iterator it = ret.first;
// 3. 返回节点中 second (Value) 的引用
return it->second;
}4.3.2operator[]的四大细分场景拆解
为了深刻理解 operator[] 的机制,我们把它拆解为四个单独的场景:
场景 1:Key不存在,纯插入
map<string, string> dict;
// 场景 1:Key 不存在 -> 纯插入 {"insert", ""}
dict["insert"];⭐ 行为剖析:由于字典里没有 "insert",程序会使用 string() 的默认构造(即 "")补全 Value 并插入到 map 中。
场景 2:Key不存在,插入 + 修改Value
// 场景 2:Key 不存在 -> 插入 + 修改 Value dict["left"] = "左边";
⭐ 行为剖析:首先插入节点 {"left", ""},随后 operator[] 返回对节点 Value 的引用,最后通过赋值运算符 = 将该引用修改为了 “左边”。
场景 3:Key存在,纯修改Value
// 场景 3:Key 存在 -> 纯修改 Value dict["left"] = "左边、剩余";
⭐ 行为剖析:insert 检测到 "left" 已存在,插入失败,但依然返回了指向已存在节点的迭代器;后续的赋值操作把原本的 "左边" 覆写成了 "左边、剩余"。
场景 4:Key确定存在,读取Value
// 场景 4:Key 确定存在 -> 读取 Value cout << dict["left"] << endl; // 输出: 左边、剩余
⭐ 行为剖析:直接拿到关联节点的 Value 引用并读取输出。
4.3.3 防坑指南:只读场景滥用 operator[] 的安全隐患💥
// ⚠️ 错误示范:只是想查一下 "apple" 是否存在,却引发了非预期插入
if (dict["apple"] == "苹果")
{
// ...
}⭐ 语法细节与防坑逻辑:
- 如果
"apple"原本并不在字典中,上面的条件判断调用dict["apple"]时,会直接强行插入一个全新的节点{"apple", ""},造成数据污染。 - 规则:如果在只读场景(如判断键是否存在、查看键对应的值)下,请一律使用
dict.find("apple")明确进行查找!
4.4 实战案例:词频统计的代码演进
通过“水果词频统计”场景,感受 operator[] 带来的代码简化。
方案一:使用find + insert显式处理(旧写法)
string arr[] = { "苹果", "西瓜", "苹果", "西瓜", "苹果", "苹果", "香蕉" };
map<string, int> countMap;
// 1. 使用 find 显式判断 (逻辑直接,但代码较多)
for (const auto& str : arr)
{
auto pos = countMap.find(str);
if (pos == countMap.end())
{
countMap.insert(make_pair(str, 1)); // 不存在则插入计数 1
}
else
{
pos->second++; // 存在则计数自增
}
}⭐ 行为剖析:逻辑严密,但每次都需要手动执行分支判断,代码行数较多。
方案二:利用operator[]一行代码搞定(推荐写法✅️)
countMap.clear();
// 2. 利用 operator[] 一行代码搞定!
for (const auto& str : arr)
{
countMap[str]++;
}
// 打印结果
for (const auto& kv : countMap)
{
cout << kv.first << " 出现了: " << kv.second << " 次" << endl;
}countMap.clear();
// 2. 利用 operator[] 一行代码搞定!
for (const auto& str : arr)
{
countMap[str]++;
}
// 打印结果
for (const auto& kv : countMap)
{
cout << kv.first << " 出现了: " << kv.second << " 次" << endl;
}⭐ countMap[str]++ 的内部执行轨迹:
- 首次遇到字符串
"苹果":countMap["苹果"]内部调用insert({"苹果", 0})插入新节点,返回Value(0)的引用,最后通过外层的++使其变成1。 - 再次遇到字符串
"苹果":countMap["苹果"]发现已存在,insert放弃插入,但顺利返回已有节点Value(1)的引用,最后通过外层的++使其变为2。
到此这篇关于C++ STL关联式容器 set与 map使用指南的文章就介绍到这了,更多相关C++ set与 map使用内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!
相关文章
C++中template方法undefined reference to的问题解决
Undefined reference to 错误:这类错误是在连接过程中出现的,本文就来介绍一下C++中template方法undefined reference to的问题解决,具有一定的参考价值,感兴趣的可以了解一下2024-03-03
C++的cout.tellp()和cout.seekp()语法介绍
无论是使用 cout 输出普通数据,用 cout.put() 输出指定字符,还是用 cout.write() 输出指定字符串,数据都会先放到输出流缓冲区,待缓冲区刷新,数据才会输出到指定位置,本文给大家介绍一下C++的cout.tellp()和cout.seekp()语法,需要的朋友可以参考下2023-09-09


最新评论