C++ 竞赛入门
unique 去重
只合并相邻的相等元素,返回逻辑末尾;真正删除要再配 erase。
unique 去重
读完能做什么
- 能说出
unique只合并相邻的相等元素,并能预测哪些元素会被保留。 - 能解释
unique的返回值是“新的逻辑末尾”,而不是新的容器长度。 - 能用
sort+unique+erase三行把一组数据真正去重。 - 能区分“逻辑删除”(有效前缀变短)和“容器真的变小”(
size()改变)。
为什么需要它
题目常常要求:读入 n 个整数,把重复的只保留一个,再按从小到大输出。
不借助算法的手工做法是:先排序,然后从头扫一遍,遇到和“上一个保留的值”不同的就把它留下。第二步“扫描相邻元素并合并”是固定的套路,标准库把它做成了函数 unique。
这里要先分清两件事:排序是为了让相等的值聚到一起,是“全体去重”这个任务需要的;“合并相邻相等元素”才是 unique 的本职工作。unique 并不要求你先排序,后面会用 [1,2,1] 把这层区别展示出来。
它是什么
一句话:unique(first, last) 把区间里相邻的相等元素合并成一个,返回新的逻辑末尾位置。
先解释两个必须用到的概念。
迭代器与区间 [first, last)。 迭代器是“指向容器中某个位置的游标”。v.begin() 指向第一个元素,v.end() 指向最后一个元素的后面一个位置(注意不是最后一个元素本身)。两个迭代器一起表示一段连续的位置,写成左闭右开区间 [first, last):包含 first 指向的元素,不包含 last 指向的元素。所以 [v.begin(), v.end()) 正好是整个 vector。本页的 unique、sort、erase 都用这种区间来描述要处理的范围。
sort(v.begin(), v.end())。 把区间里的元素按从小到大重新排列,默认用 < 比较。排序之后,值相等的元素一定挨在一起,正好满足 unique 需要的“相邻”条件。sort 的详细用法见 sort 排序。
以数据 A [1, 1, 2, 2, 2, 3] 为例,逐个元素看“相邻”是什么意思:
| 下标 | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| 元素 | 1 | 1 | 2 | 2 | 2 | 3 |
| 与左边相邻元素比较 | — | 与 1 相等 | 与 1 不同 | 与 2 相等 | 与 2 相等 | 与 2 不同 |
| 处理结果 | 保留 | 合并 | 保留 | 合并 | 合并 | 保留 |
保留下来的是 1、2、3,也就是有效前缀 [1,2,3],逻辑长度 3。
unique 会做和不会做的事:
- 会:合并相邻的相等元素,并返回有效前缀末尾的位置。
- 不会:不改变容器大小(数据 A 处理后
size()仍然是 6);不处理不相邻的重复值(见后面的[1,2,1]);不规定返回位置之后的内容。
最小可运行示例
#include <bits/stdc++.h>
using namespace std;
int main() {
vector<int> v = {1, 1, 2, 2, 2, 3};
auto last = unique(v.begin(), v.end()); // 返回新的“逻辑末尾”
cout << "调用 unique 后:size = " << v.size() << '\n';
cout << "逻辑长度 = " << (last - v.begin()) << '\n';
cout << "有效前缀:";
for (auto it = v.begin(); it != last; it++) cout << *it << ' ';
cout << '\n';
cout << "容器里的全部内容:";
for (int i = 0; i < (int)v.size(); i++) cout << v[i] << ' ';
cout << '\n';
v.erase(last, v.end()); // 真正删除尾段
cout << "erase 后:size = " << v.size() << ",内容:";
for (int i = 0; i < (int)v.size(); i++) cout << v[i] << ' ';
cout << '\n';
return 0;
}
输出:
调用 unique 后:size = 6
逻辑长度 = 3
有效前缀:1 2 3
容器里的全部内容:1 2 3 2 2 3
erase 后:size = 3,内容:1 2 3
逐行看:
unique(v.begin(), v.end())处理整个vector,返回一个新的位置。auto last = ...里的auto表示“让编译器根据右边的值推断类型”,这里last的类型就是vector<int>::iterator。调用 unique 后:size = 6:unique没有删除任何元素,容器的大小没变。last - v.begin()是“返回的位置减去起始位置”,得到有效前缀的长度 3。for (auto it = v.begin(); it != last; it++)从起点走到新的逻辑末尾,用*it取出元素,打印出有效前缀1 2 3。- 接下来一行打印容器里的全部内容。这一行只是本机(GCC 16.2.0)的实测结果:标准没有规定返回位置之后放什么,换一个编译器可能不同。所以只能读
[begin, last)这一段,last之后的内容不能当作结果使用。 - 最后
v.erase(last, v.end())把尾段真正删掉,size()才变成 3,内容变成1 2 3。erase的用法在“真正删除”一节展开。
一步步看变化
unique 从前往后扫描,拿每个元素和“上一个保留下来的元素”比较:
| 处理到下标 | 元素 | 与上一个保留值比较 | 动作 | 有效前缀 |
|---|---|---|---|---|
| 0 | 1 | — | 保留(第一个元素总是保留) | [1] |
| 1 | 1 | 与 1 相等 | 合并,不新增 | [1] |
| 2 | 2 | 与 1 不同 | 保留 | [1,2] |
| 3 | 2 | 与 2 相等 | 合并 | [1,2] |
| 4 | 2 | 与 2 相等 | 合并 | [1,2] |
| 5 | 3 | 与 2 不同 | 保留 | [1,2,3] |
扫描结束时返回有效前缀末尾的位置,所以逻辑长度是 3。这时容器可以看成两块区域:
| 区域 | 下标 | 是否作为结果读取 |
|---|---|---|
| 有效前缀 | 0 ~ 2(内容 1 2 3) |
是 |
| 尾段 | 3 ~ 5 | 否,标准不规定内容 |
注意尾段不是“被删掉的重复值集合”,它只是一段不再读取的旧数据。这也是为什么只调用 unique 还不够——如果后面照旧用 v.size() 遍历,就会把尾段一起读出来。
逐项扩展核心操作
真正删除:sort + unique + erase
要既去掉重复值又让容器真的变小,就在 unique 之后调用 erase(last, v.end()) 删掉尾段。用数据 B [1, 2, 1](有不相邻的重复值):
#include <bits/stdc++.h>
using namespace std;
int main() {
vector<int> v = {1, 2, 1};
sort(v.begin(), v.end()); // 先排序:相等的值会挨在一起
cout << "排序后:";
for (int i = 0; i < (int)v.size(); i++) cout << v[i] << ' ';
cout << "size = " << v.size() << '\n';
auto last = unique(v.begin(), v.end()); // 相邻去重
cout << "unique 后:逻辑长度 = " << (last - v.begin())
<< ",size = " << v.size() << '\n';
v.erase(last, v.end()); // 真正删除尾段
cout << "erase 后:";
for (int i = 0; i < (int)v.size(); i++) cout << v[i] << ' ';
cout << "size = " << v.size() << '\n';
return 0;
}
输出:
排序后:1 1 2 size = 3
unique 后:逻辑长度 = 2,size = 3
erase 后:1 2 size = 2
三步各自负责什么:
| 步骤 | 语句 | 作用 | 这一行输出里的证据 |
|---|---|---|---|
| 1 | sort(v.begin(), v.end()) |
把 1 2 1 排成 1 1 2,让相等的值相邻 |
排序后:1 1 2 |
| 2 | unique(v.begin(), v.end()) |
合并相邻相等元素,返回逻辑末尾 | 逻辑长度 2,但 size 仍是 3 |
| 3 | v.erase(last, v.end()) |
删除 [last, end) 这段区间,容器真的变小 |
size = 2,内容 1 2 |
erase(first, last) 删除的是左闭右开区间 [first, last)。这里 first 是 unique 返回的逻辑末尾,last 是 v.end(),正好覆盖整个尾段。
不相邻的重复不会被合并
同一个数据 [1, 2, 1],不排序直接调用 unique 会怎样?
#include <bits/stdc++.h>
using namespace std;
int main() {
vector<int> a = {1, 2, 1};
auto la = unique(a.begin(), a.end()); // 只合并相邻的相等元素
cout << "直接 unique:逻辑长度 = " << (la - a.begin()) << ",有效前缀:";
for (auto it = a.begin(); it != la; it++) cout << *it << ' ';
cout << '\n';
vector<int> b = {1, 2, 1};
sort(b.begin(), b.end()); // 排成 1 1 2
auto lb = unique(b.begin(), b.end());
b.erase(lb, b.end());
cout << "先排序再处理:size = " << b.size() << ",内容:";
for (int i = 0; i < (int)b.size(); i++) cout << b[i] << ' ';
cout << '\n';
return 0;
}
输出:
直接 unique:逻辑长度 = 3,有效前缀:1 2 1
先排序再处理:size = 2,内容:1 2
| 程序 | 做了什么 | 结果 |
|---|---|---|
直接 unique |
只合并相邻的相等元素,1 2 1 里没有相邻相等的一对 |
逻辑长度仍是 3,一个也没去掉 |
sort + unique + erase |
先排成 1 1 2 让重复值相邻,再去重删除 |
得到 1 2,大小 2 |
两个程序完成的是不同任务:前者的任务叫“压缩相邻重复”,后者的任务叫“全体数值去重”。unique 本身只负责前者,排序是为了把后者变成前者。
综合:10 个整数去重
把数据量放大一点,读入 n 个整数,按从小到大的顺序输出去重后的结果:
#include <bits/stdc++.h>
using namespace std;
int main() {
int n;
cin >> n;
vector<int> a(n);
for (int i = 0; i < n; i++) cin >> a[i];
sort(a.begin(), a.end());
auto last = unique(a.begin(), a.end());
a.erase(last, a.end());
cout << a.size() << '\n';
for (int i = 0; i < (int)a.size(); i++) {
if (i > 0) cout << ' ';
cout << a[i];
}
cout << '\n';
return 0;
}
输入:
10
1 2 9 1 2 9 8 7 6 8
输出:
6
1 2 6 7 8 9
输入里的 10 个数排序后是 1 1 2 2 6 7 8 8 9 9,去重后剩下 6 个:1 2 6 7 8 9。vector<int> a(n); 表示创建有 n 个元素的 vector(这里 n 由输入决定),然后逐个读入。
什么时候选它、哪里容易错
先纠正两个关于 unique 的常见错误说法。
错误说法一:“使用 unique 前必须先排序”。 排序不是调用 unique 的前提,unique 的语义就是合并相邻的相等元素,不排序也能正常调用。只有当你的任务是“把所有重复值都去掉”时,才需要先排序把相等的值聚到一起——这一步是你为了完成任务自己加的,不是 unique 的要求。前面的 [1,2,1] 例子正好说明了两者的区别。
错误说法二:“unique 把重复的元素放到数组最后面藏起来了”。 unique 只保证返回位置之前的有效前缀是正确的,返回位置之后的内容标准没有规定,既不是“重复值集合”,也不保证数量或顺序。看本页实测:[1,1,2,2,2,3] 处理后容器里是 1 2 3 2 2 3,尾段是 2 2 3,并不是“被去掉的重复值”。所以不要读取尾段,也不要打印它当成结果。
其他容易错的地方:
- 只调用
unique而忘记erase:size()不变,后面按size()遍历就会读到无效的尾段。要么用last控制遍历终点,要么补上erase。 - 逻辑长度不是
size():要得到保留下来的元素个数,用last - v.begin();size()只有在erase之后才等于它。 erase的写法:v.erase(last, v.end())两个参数构成一个区间,删掉的是[last, end)。写成v.erase(last)只会删掉一个元素。- 复杂度:
sort是 O(n log n),unique是 O(n)(顺着走一遍),erase删除尾段时元素本身不需要搬动,但整体任务的开销由排序决定。
操作速查表
| 写法 | 作用 | 备注 |
|---|---|---|
sort(v.begin(), v.end()) |
把区间按从小到大排序 | 让相等的值相邻 |
unique(v.begin(), v.end()) |
合并相邻的相等元素 | 返回新的逻辑末尾 |
auto last = unique(...) |
保存返回的位置 | 类型是迭代器 |
last - v.begin() |
逻辑长度 | 有效前缀的元素个数 |
v.erase(last, v.end()) |
真正删除尾段 | 之后 size() 才变小 |
unique(a, a + n) |
对普通数组使用 | 返回的是指针,unique(a, a+n) - a 是逻辑长度 |
unique(v.begin(), v.end(), cmp) |
按自定义的相等规则合并 | 选读:cmp 判断两个元素是否“相等” |
动手检查
第 1 题:预测输出
下面是一段片段(省略了头文件和 main),请先手推出输出,再运行核对:
vector<int> v = {5, 5, 5, 7, 7, 6};
auto last = unique(v.begin(), v.end());
cout << (last - v.begin()) << '\n';
第 2 题:修改代码
下面这段片段只做了相邻去重,对 [1, 2, 1] 一个元素都去不掉。请把它改成“全体数值去重”的程序,让输出变成 2 和 1 2:
vector<int> v = {1, 2, 1};
auto last = unique(v.begin(), v.end());
v.erase(last, v.end()); // 还缺一步,想想缺什么
cout << v.size() << '\n';
for (int i = 0; i < (int)v.size(); i++) cout << v[i] << ' ';
验证条件:先说出缺的那一步为什么必须放在 unique 之前,再运行核对。
参考结果与解释
第 1 题:输出是 3。从左往右看:5 5 5 是相邻相等的一组,只保留一个 5;7 7 是第二组,只保留一个 7;最后的 6 和前面的 7 不同,自己保留。有效前缀是 5 7 6,所以逻辑长度是 3。完整程序与实测输出:
#include <bits/stdc++.h>
using namespace std;
int main() {
vector<int> v = {5, 5, 5, 7, 7, 6};
auto last = unique(v.begin(), v.end());
cout << (last - v.begin()) << '\n';
return 0;
}
输出:
3
第 2 题:缺的一步是 sort(v.begin(), v.end());,要放在 unique 之前。[1, 2, 1] 里两个 1 被 2 隔开,不属于相邻相等,unique 不会合并它们;排序把它们变成 1 1 2,重复值相邻了,unique 才能合并,最后 erase 删掉尾段。顺序不能颠倒:erase 之后容器只剩有效前缀,此时再排序或 unique 都没有意义。
#include <bits/stdc++.h>
using namespace std;
int main() {
vector<int> v = {1, 2, 1};
sort(v.begin(), v.end());
auto last = unique(v.begin(), v.end());
v.erase(last, v.end());
cout << v.size() << '\n';
for (int i = 0; i < (int)v.size(); i++) cout << v[i] << ' ';
cout << '\n';
return 0;
}
输出:
2
1 2
立即练习
| 难度 | 题目 | 题解 | 训练目标 |
|---|---|---|---|
| 普及- | 洛谷 P1059:[NOIP 2006 普及组] 明明的随机数 | 题解 | 读入一组数,先去重再按从小到大输出:练习 sort + unique + erase 三行组合,并注意输出的是去重后的个数。 |
做完后请说明:如果把题目改成“按输入顺序输出,重复的只保留第一次出现的那个”,sort 还能用吗?为什么?