41、STL算法(删除与去重):unique、remove、erase-remove惯用法
说实话,很多C++开发者写了几年代码,还在用手动循环去删vector里的元素。嗯,我以前也是这么干的。直到有一次在项目里处理一个百万级的数据去重,手动循环慢得让人抓狂,还被同事笑话说「你这是C++98的写法吧?」。从那以后,我彻底把remove、unique和erase这套组合拳练得滚瓜烂熟。
今天咱们就把这三个算法一次性讲透。你想想看,STL设计者为什么要把「删除」和「去重」搞得这么绕?说白了,背后有它的设计哲学。
1. 先搞清楚:remove 到底删了什么?
很多人第一次用std::remove会懵。明明调用了remove,容器大小怎么没变?
我直接告诉你真相:remove不删除元素,它只是把要「删除」的元素挪到容器末尾,然后返回一个指向新逻辑结尾的迭代器。原来的元素还在,只是被覆盖了。
核心理解: remove 是「逻辑删除」,不是「物理删除」。它改变的是元素顺序,不是容器大小。
看个例子就明白了:
#include <iostream>
#include <vector>
#include <algorithm>
int main() {
std::vector<int> v = {1, 2, 3, 2, 4, 2, 5};
auto new_end = std::remove(v.begin(), v.end(), 2);
// 此时v的内容:1, 3, 4, 5, ?, ?, ?
// 问号处是未定义的值(其实是原来末尾的元素)
std::cout << "容器大小: " << v.size() << "\n"; // 输出7
// 真正删除需要配合erase
v.erase(new_end, v.end());
std::cout << "删除后大小: " << v.size() << "\n"; // 输出4
for (int x : v) std::cout << x << " "; // 1 3 4 5
return 0;
}
看到没?remove返回的迭代器指向了新的逻辑末尾。你手动erase一下,容器才真正变小。这就是经典的erase-remove惯用法。
我的习惯: 永远把 remove 和 erase 写在一行:v.erase(std::remove(...), v.end());。这样既清晰又不会忘掉erase。
2. unique:去重前必须先排序
std::unique的逻辑和remove很像——它也是把重复元素挪到末尾,返回新的逻辑结尾。区别在于:unique只去除连续重复的元素。
什么意思?如果数据是 1, 2, 2, 3, 1,unique只会把中间的两个2去掉一个,末尾的1不会动。因为末尾的1和前面的1不连续。
我曾经踩过的坑: 有一次做日志分析,直接对未排序的数据调了unique,结果去重没生效,排查了半天。后来才想起来——unique只处理相邻重复!所以去重前一定要先sort。
正确的去重姿势:
std::vector<int> v = {3, 1, 4, 1, 5, 9, 2, 6, 5, 3};
// 第一步:排序
std::sort(v.begin(), v.end());
// 第二步:unique + erase
v.erase(std::unique(v.begin(), v.end()), v.end());
// 结果:1 2 3 4 5 6 9
你想想看,为什么STL要把去重设计成「先排序再unique」?其实是为了灵活性。有些场景你不想改变原始顺序,那就可以自己写去重逻辑。STL给你的是最通用的工具。
3. erase-remove惯用法的完整套路
这个惯用法其实就一个公式:
container.erase(
std::remove(container.begin(), container.end(), value),
container.end()
);
或者用lambda做条件删除:
container.erase(
std::remove_if(container.begin(), container.end(),
[](int x) { return x % 2 == 0; }),
container.end()
);
为什么非要这么写?直接遍历删除不行吗?
嗯,你试试就知道了。在遍历vector时删除元素,迭代器会失效,代码又臭又长还容易出bug。而erase-remove惯用法是O(n)时间复杂度,一次遍历搞定,干净利落。
适用容器: vector、deque、string 等连续存储的容器。list有自己的remove成员函数,效率更高。set/map直接用erase成员函数。
4. 知识体系总览
下面这张图帮你理清这三个算法的关系和使用场景:
5. 实战:综合案例
假设你有一个学生成绩列表,需要完成两个任务:
- 删除所有不及格(小于60分)的记录
- 去重(同一个学生只保留一条记录)
#include <iostream>
#include <vector>
#include <algorithm>
#include <string>
struct Student {
std::string name;
int score;
};
int main() {
std::vector<Student> students = {
{"Alice", 85}, {"Bob", 45}, {"Alice", 85},
{"Charlie", 92}, {"Bob", 45}, {"David", 58}
};
// 任务1:删除不及格
students.erase(
std::remove_if(students.begin(), students.end(),
[](const Student& s) { return s.score < 60; }),
students.end()
);
// 任务2:去重(先按姓名排序)
std::sort(students.begin(), students.end(),
[](const Student& a, const Student& b) {
return a.name < b.name;
});
students.erase(
std::unique(students.begin(), students.end(),
[](const Student& a, const Student& b) {
return a.name == b.name;
}),
students.end()
);
// 输出结果
for (const auto& s : students) {
std::cout << s.name << ": " << s.score << "\n";
}
// 输出:
// Alice: 85
// Charlie: 92
return 0;
}
个人建议: 写lambda时尽量用const引用,避免不必要的拷贝。特别是处理复杂对象时,性能差距很明显。
6. 避坑指南
我总结几个常见的坑,你遇到了可以少走弯路:
- 忘记erase: 只调remove不调erase,容器大小不变,后续遍历会出问题。我曾经在线上环境因为这个bug导致内存暴涨。
- 对关联容器用remove: set、map有自己的erase成员函数,用remove会编译报错。记住:remove只适用于序列容器。
- unique前没排序: 去重效果不对,只去掉了相邻重复。如果你需要全局去重,先sort。
- 迭代器失效: erase-remove惯用法是安全的,但如果你在循环里手动erase,一定要小心迭代器失效。
嗯,差不多就这些。STL的算法设计其实很巧妙——remove和unique只做「移动」的工作,把「删除」交给容器自己。这种职责分离的思想,你在很多优秀代码里都能看到。
下次写删除逻辑时,别再用for循环了。试试erase-remove惯用法,代码简洁、性能还好。相信我,用习惯了你就不想回去了。
公众号:蓝海资料掘金营,微信deep3321