C++ 竞赛入门
set 有序集合
自动去重并保持有序,增删查都是 O(log n);multiset 允许重复。
set 有序集合
读完能做什么
- 能创建
set,用insert加入元素,并说出为什么重复插入不会改变容器。 - 能遍历
set,并解释输出为什么总是从小到大,和插入的先后无关。 - 能用
find、count查询元素(查询不会往容器里添加数据),用erase删除元素,并分清按值删除和按迭代器删除的区别。 - 能说出
multiset和set的差别,判断什么时候该用哪一个。
为什么需要它
有三个常见的需求,用数组处理都很别扭。
需求一:快速判断“某个值之前出现过吗”。 用数组保存见过的数,每次判断都得从头扫一遍,数据一多就慢。
需求二:保存一组“不重复的数”,还要能从小到大输出。 用数组得先读进来,再 sort 排序,再用 unique 去重、erase 收缩——三步都要自己写(见 unique 去重)。
需求三:一边加入新数据,一边保持有序且不重复。 用数组每插一个数都可能要搬动一大片元素。
我们希望有一个容器,加进去的时候顺手去重、顺手排好序,查询、删除都快。这就是 set。
它是什么
一句话:set 是标准库提供的关联容器,它保存一组不重复的元素,并按比较规则自动保持有序。
关联容器与键。 关联容器用**键(key)**来组织数据,而不是像 vector 那样用连续的下标。set 的元素本身就是键:一个值要么在集合里,要么不在。
比较规则决定顺序。 默认的比较规则是 <(写作 less<T>),所以 set<int> 的元素按从小到大排列,set<string> 按字典序排列。想换顺序可以在创建时传入别的规则,见比较规则与函数对象。
有序不等于插入顺序。 这是最容易误解的一点:set 的顺序由比较规则决定,不是你插入的先后。依次插入 3、1、2,遍历出来是 1 2 3,不是 3 1 2。
自动去重。 一个值在 set 里最多出现一次。对已经存在的值再 insert 一次,容器内容不变。
元素是常量。 set 里的元素不能直接改写。因为元素的位置由它自己的值决定,改了值就可能破坏有序性。要“改”一个元素,只能先删掉旧的、再插入新的。
复杂度 O(log n)。 插入、删除、查询都是 O(log n),n 是元素个数。这意味着数据量翻倍,比较次数只多一次左右,比数组的线性扫描(O(n))快很多。
适用场景:需要“快速判断某个值是否存在”“保存一组去重且有序的数据”。使用时要 #include <set>,本页示例用 #include <bits/stdc++.h> 一次包含全部标准头文件。
选读。 set 内部通常用平衡二叉搜索树(红黑树)实现,这正是它能“边插入边有序、查找还很快”的原因。不了解树的结构也能正常使用 set。
最小可运行示例
下面的数据写在代码里,不需要输入。依次插入 3、1、3、2,其中 3 插了两次:
#include <bits/stdc++.h>
using namespace std;
int main() {
set<int> s;
s.insert(3);
s.insert(1);
s.insert(3); // 3 已经在集合里,这次插入不会改变容器
s.insert(2);
cout << "size = " << s.size() << '\n';
cout << "遍历:";
for (int x : s) cout << x << ' ';
cout << '\n';
return 0;
}
输出:
size = 3
遍历:1 2 3
逐行看:
set<int> s;创建一个空的set<int>,尖括号里的<int>表示每个元素是整数。- 四次
insert一共插入了 4 次,但第二次插入3时它已经在里面了,所以size()是 3,不是 4。 for (int x : s)是范围 for,从前往后取出每个元素。输出是1 2 3,说明元素被自动排成了升序,而且重复的3只保留了一个。
一步步看变化
沿同一组数据 3 1 3 2 走一遍,每一步都打印容器内容和大小:
#include <bits/stdc++.h>
using namespace std;
void show(const set<int>& s) {
cout << "size = " << s.size() << ",内容:";
for (int x : s) cout << x << ' ';
cout << '\n';
}
int main() {
set<int> s;
cout << "初始:"; show(s);
s.insert(3); cout << "insert(3):"; show(s);
s.insert(1); cout << "insert(1):"; show(s);
s.insert(3); cout << "insert(3):"; show(s);
s.insert(2); cout << "insert(2):"; show(s);
return 0;
}
输出:
初始:size = 0,内容:
insert(3):size = 1,内容:3
insert(1):size = 2,内容:1 3
insert(3):size = 2,内容:1 3
insert(2):size = 3,内容:1 2 3
把每一步和输出对应起来:
| 步骤 | 操作 | 容器内容 | size() |
|---|---|---|---|
| 0 | 初始 | (空) | 0 |
| 1 | insert(3) |
3 |
1 |
| 2 | insert(1) |
1 3 |
2 |
| 3 | insert(3) |
1 3 |
2 |
| 4 | insert(2) |
1 2 3 |
3 |
- 第 2 步插入
1之后,输出是1 3而不是3 1:1比3小,被放到了前面。 - 第 3 步再插一次
3,输出和上一步完全一样,size()也没变——这就是去重。 - 第 4 步插入
2,它落在1和3中间,输出变成1 2 3。
逐项扩展核心操作
查询:find 与 count
#include <bits/stdc++.h>
using namespace std;
int main() {
set<int> s = {3, 1, 2}; // 用列表初始化:元素会自动排序并去重
cout << "查 3:count = " << s.count(3)
<< ",find 命中 = " << (s.find(3) != s.end()) << '\n';
cout << "查 5:count = " << s.count(5)
<< ",find 命中 = " << (s.find(5) != s.end()) << '\n';
cout << "两次查询后 size = " << s.size() << '\n';
return 0;
}
输出:
查 3:count = 1,find 命中 = 1
查 5:count = 0,find 命中 = 0
两次查询后 size = 3
| 操作 | 返回值 | 含义 |
|---|---|---|
s.count(x) |
size_t,对 set 是 0 或 1 |
元素 x 在不在集合里 |
s.find(x) |
指向 x 的迭代器 |
找到返回位置,找不到返回 s.end() |
s = {3, 1, 2}用列表初始化,结果同样是排好序的1 2 3。- 查
3时count是 1,find(3) != s.end()是 1(真),说明找到了。 - 查
5时count是 0,find返回s.end(),所以比较结果是 0(假)。 - 关键:两次查询之后
size()还是 3。 查询只是“看一眼”,不会把查的值加进容器——这一点和map的下标很不一样(见 map 键值映射)。
要取出找到的元素,用 *it;对 set 来说,这个值就是查询的键本身:
auto it = s.find(3); // 片段:找到 3 的位置
if (it != s.end()) cout << *it; // 输出 3
删除:erase
#include <bits/stdc++.h>
using namespace std;
int main() {
set<int> s = {3, 1, 2};
cout << "删除前:";
for (int x : s) cout << x << ' ';
cout << "size = " << s.size() << '\n';
s.erase(2); // 按键删除
cout << "erase(2) 后:";
for (int x : s) cout << x << ' ';
cout << "size = " << s.size() << '\n';
auto it = s.find(3); // 先找到位置
s.erase(it); // 再按迭代器删除
cout << "erase(find(3)) 后:";
for (int x : s) cout << x << ' ';
cout << "size = " << s.size() << '\n';
return 0;
}
输出:
删除前:1 2 3 size = 3
erase(2) 后:1 3 size = 2
erase(find(3)) 后:1 size = 1
erase 有两个版本,作用范围不同:
| 写法 | 删除什么 | 返回值 |
|---|---|---|
s.erase(x) |
按键删除值为 x 的元素 |
删掉的个数(set 上是 0 或 1) |
s.erase(it) |
按迭代器删除 it 指向的那一个元素 |
指向被删元素后面一个位置的迭代器 |
在 set 里每个值只有一个,两种写法删的都是同一个元素。但在 multiset 里差别很大,下面马上会看到。
对比 multiset:允许重复
multiset 是“允许重复的 set”,同样自动排序,但同一个值可以出现多次:
#include <bits/stdc++.h>
using namespace std;
int main() {
multiset<int> ms;
ms.insert(3);
ms.insert(1);
ms.insert(3);
ms.insert(2);
cout << "multiset 遍历:";
for (int x : ms) cout << x << ' ';
cout << "size = " << ms.size() << '\n';
cout << "count(3) = " << ms.count(3) << '\n';
ms.erase(3); // 按键删除:删掉该键的全部元素
cout << "erase(3) 后:";
for (int x : ms) cout << x << ' ';
cout << "size = " << ms.size() << '\n';
ms.insert(3);
ms.insert(3);
cout << "重新插入两个 3 后:";
for (int x : ms) cout << x << ' ';
cout << "size = " << ms.size() << '\n';
auto it = ms.find(3); // 只删一个 3
ms.erase(it);
cout << "按迭代器删一个 3 后:";
for (int x : ms) cout << x << ' ';
cout << "size = " << ms.size() << '\n';
return 0;
}
输出:
multiset 遍历:1 2 3 3 size = 4
count(3) = 2
erase(3) 后:1 2 size = 2
重新插入两个 3 后:1 2 3 3 size = 4
按迭代器删一个 3 后:1 2 3 size = 3
| 对比项 | set |
multiset |
|---|---|---|
| 同一个值能否重复 | 不能 | 能 |
| 遍历 | 升序,每个值一个 | 升序,重复值挨在一起 |
count(x) |
0 或 1 | x 出现的次数 |
erase(x)(按值) |
删掉那一个 | 删掉 x 的全部 |
erase(it)(按迭代器) |
删一个 | 只删 it 指向的那一个 |
- 插入
3 1 3 2后遍历是1 2 3 3:重复的3都保留了,而且紧挨着。 count(3)是 2,说明multiset的计数是有意义的。- 调用
erase(3)之后只剩1 2,size()从 4 掉到 2——按键删除会删掉该键的全部记录。 - 想只删一个,就要像最后一步那样先用
find(3)定位,再erase(it)。
综合:用 set 保存排除词
set 最典型的用途是“快速判断某个东西在不在集合里”。下面读入若干要排除的词,再读入正文,统计不在排除集合里的词有多少个:
#include <bits/stdc++.h>
using namespace std;
int main() {
set<string> excluded;
string word;
while (cin >> word && word != "stop") { // 先读入要排除的词,stop 结束
excluded.insert(word);
}
int kept = 0;
while (cin >> word) { // 再读入正文
if (excluded.count(word) == 0) { // 不在排除集合里才算数
kept++;
}
}
cout << "未被排除的词数 = " << kept << '\n';
return 0;
}
输入:
the a stop
the apple a banana apple
输出:
未被排除的词数 = 3
第一段读到 stop 为止,把 the、a 放进排除集合;第二段依次读入 the apple a banana apple,其中 the、a 命中排除集合被跳过,apple、banana、apple 三次没有命中,所以结果是 3。这里用固定词 stop 结束输入,不依赖终端的结束快捷键。
什么时候选它、哪里容易错
有序不等于插入顺序。 set 的顺序完全由比较规则决定。想让数据保持你插入的先后,应该用 vector;想自动排序去重,才用 set。
查询不会增添数据。 find、count 只读不写,查一个不存在的值不会把它加进去。这一点和 map 的 operator[] 相反——map["new"] 会插入一个新键(见 map 键值映射)。
erase 的两个版本要分清。 erase(x) 按键删除,在 multiset 上会删掉全部同键元素;erase(it) 按迭代器删除,只删一个。要“只删一个重复值”,必须先用 find 拿到迭代器。
元素是常量,不能改。 set 里的元素不能直接赋值修改。要修改就 erase 旧的、insert 新的。这也解释了为什么 set<int>::iterator 解引用出来的是只读的值。
复杂度 O(log n)。 插入、删除、查询都是 O(log n),比数组线性扫描快,但比 vector 尾部操作、unordered_set(哈希表,选读)的平均 O(1) 慢。数据量不大时三者的差别可以忽略。
选读。 set 保持有序靠的是平衡二叉搜索树:每个节点比较一次就能决定往左还是往右走,所以查找只需 O(log n) 次比较。底层细节不影响日常使用。
操作速查表
| 操作 | 作用 | 备注 |
|---|---|---|
set<T> s; |
创建空集合 | 尖括号里填元素类型 |
set<T> s = {...}; |
用一组初值创建 | 自动排序并去重 |
s.insert(x) |
插入 x |
已存在则不改变容器,O(log n) |
s.count(x) |
x 的个数 |
set 上是 0 或 1 |
s.find(x) |
返回指向 x 的迭代器 |
找不到返回 s.end() |
s.erase(x) |
按键删除 | 删掉值为 x 的元素 |
s.erase(it) |
按迭代器删除 | 只删 it 指向的那一个 |
s.empty() |
是否为空 | 打印成 0/1 |
s.size() |
元素个数 | 类型是无符号整数 |
s.clear() |
删除全部元素 | size() 变 0 |
multiset<T> ms; |
创建允许重复的集合 | 同键可出现多次 |
ms.erase(x) |
按键删除 | 删掉 x 的全部元素 |
动手检查
第 1 题:预测输出
下面是一段片段(省略了头文件和 main),请先手推出输出,再运行核对。注意有两次重复插入:
set<int> s;
s.insert(2);
s.insert(5);
s.insert(2); // 重复插入
s.insert(5); // 重复插入
s.insert(1);
cout << "size = " << s.size() << ",内容:";
for (int x : s) cout << x << ' ';
cout << '\n';
第 2 题:修改代码
下面这段片段用 multiset 保存 3 1 3 2,但 erase(3) 把两个 3 都删掉了。请改成只删一个 3,让输出变成 size = 3,内容:1 2 3 :
multiset<int> ms = {3, 1, 3, 2};
ms.erase(3); // 这句会把两个 3 都删掉,请改成只删一个
cout << "size = " << ms.size() << ",内容:";
for (int x : ms) cout << x << ' ';
cout << '\n';
验证条件:先说出 erase(3) 和只删一个元素的结果分别是多少,再运行核对。
参考结果与解释
第 1 题:输出是 size = 3,内容:1 2 5 。set 会自动去重,插了两次的 2、5 各只保留一个,所以 size() 是 3;元素按升序排成 1 2 5,与插入顺序 2 5 2 5 1 无关。完整程序与实测输出:
#include <bits/stdc++.h>
using namespace std;
int main() {
set<int> s;
s.insert(2);
s.insert(5);
s.insert(2); // 重复插入
s.insert(5); // 重复插入
s.insert(1);
cout << "size = " << s.size() << ",内容:";
for (int x : s) cout << x << ' ';
cout << '\n';
return 0;
}
输出:
size = 3,内容:1 2 5
第 2 题:把 ms.erase(3) 换成先 find 再按迭代器删除,即 ms.erase(ms.find(3))。erase(3) 是按键删除,会把两个 3 都删掉,剩下 1 2;erase(ms.find(3)) 只删掉 find 找到的那一个 3,剩下 1 2 3。
#include <bits/stdc++.h>
using namespace std;
int main() {
multiset<int> ms = {3, 1, 3, 2};
ms.erase(ms.find(3)); // 只删一个 3
cout << "size = " << ms.size() << ",内容:";
for (int x : ms) cout << x << ' ';
cout << '\n';
return 0;
}
输出:
size = 3,内容:1 2 3
立即练习
| 难度 | 题目 | 题解 | 训练目标 |
|---|---|---|---|
| 普及- | 洛谷 P4305:[JLOI2011] 不重复数字 | 题解 | 边读边判断“这个数之前出现过吗”,用 set 的 count 或 insert 解决去重与查询。注意输出要保持读入的先后顺序,而 set 的遍历序是升序,所以 set 只用来判断“出现过吗”,输出仍按原顺序写。 |
| 普及/提高- | 洛谷 P5250:【深基17.例5】木材仓库 | 题解 | 挑战题:在一个有序集合里找“最接近给定长度的木材”。提示:用 set 的成员 lower_bound 定位插入点,再比较它和它前一个元素;每次操作都扫一遍全表会超时。成员 lower_bound 的完整用法在二分查找里讲,也可以读完那页再回来做本题。 |
两道题都只需要数组、循环和本页讲过的 set 操作。做完后请说明:这里为什么用 set 比用数组更省事?