在某个项目里,我遇到一位同事拿着代码满脸疑惑地走过来:他对着一个std::vector<int>调用了std::unique,结果遍历容器时发现末尾还躺着重复的值,size()也纹丝不动——他一度怀疑是自己用错了编译器。我让他把unique返回的迭代器和end()之间的元素打出来看看,他打印完愣了一下:原来重复项还在那儿,只是没人管它们了。
这个故事特别典型,因为unique大概是 STL 里名字最容易引起误解的算法之一。它并不真正“删除”任何元素,它做的是把相邻重复元素压缩成一份,把多出来的副本移动到区间末尾,然后返回一个新的逻辑结尾迭代器。真正的物理删除,还得靠容器自己的erase来完成。标题里说“源码之前,了无秘密”,既然要讲透这个函数,我们就不能只把它当个黑盒:把它拆成源码来看,也就是十几行代码的事,读懂之后,网上那些“unique 就是去重啊”“去重必须配合 sort 啊”的说法,到底对在哪、错在哪,一眼就能分辨。
这篇文章我会从函数行为、源码实现、谓词版本的坑、与erase的配合、以及和list成员函数的对比这几个角度,把这个老朋友彻底讲透。适合刚学 STL 的初学者,也适合想搞清楚内部机制的进阶读者。
1. 先把“去重”说清楚:unique 处理的是相邻重复,不是全局重复
1.1 一个反直觉的输出结果
很多人第一次看到unique的输出都会愣一下。看这段代码:
#include <algorithm> #include <iostream> #include <vector> int main() { std::vector<int> v{1, 1, 2, 3, 3, 3, 4, 1, 1}; auto last = std::unique(v.begin(), v.end()); std::cout << "逻辑区间: "; for (auto it = v.begin(); it != last; ++it) { std::cout << *it << ' '; } std::cout << '\n'; std::cout << "容器全部元素: "; for (int x : v) std::cout << x << ' '; std::cout << '\n'; std::cout << "容器大小: " << v.size() << '\n'; return 0; }输出结果是:
逻辑区间: 1 2 3 4 1 容器全部元素: 1 2 3 4 1 1 1 容器大小: 7注意逻辑区间最后那个1。为什么它没有被去掉?因为它虽然和开头的1相等,但中间隔了2、3、4,在“相邻”这个意义上,它和前面的4不相等,它自己也没有紧接着的重复项,所以它不算“相邻重复”,被保留了。
这就是unique的核心语义:它只消除每一组相邻等价元素中除第一个以外的部分。它是一个局部算法,扫描的是紧挨着的两个元素,不是全局统计。
1.2 “不删除元素”到底是什么意思
再强调一次:unique不改变容器大小。v.size()是 7,unique之后还是 7。那它做了什么?
它把有效元素往前搬,把重复的“副本”移到尾部,最后返回的迭代器last指向新的逻辑结尾。区间[begin(), last)里的数据是去重后的有效数据;区间[last, end())里那些遗留值,本质上已经是被“放弃”的元素——内容可能是被移动过的旧值,甚至有些值是未指定的,你不应该再去读它们。
我习惯用一个比喻来解释这件事:整理书架的时候,把相同封面的书抽出一本留作代表,剩下的副本统统堆到书架最右侧,然后贴一张便签写上“有效区到此为止”。书架还是那个书架,书的数量没变,但你知道真正要用的书只到便签为止。右侧那堆书要真的清掉,你得自己动手扔。
1.3 用在哪里:排序 + unique + erase 的三段式
既然unique只处理相邻重复,那么想让所有重复值都“聚在一起”,前提就是排序。只有当序列有序时,所有相等的元素才会连续出现,成为合法的“相邻重复”。
所以“对一个容器真正去重”的标准姿势是:
std::sort(v.begin(), v.end()); v.erase(std::unique(v.begin(), v.end()), v.end());这个三段式几乎出现在所有 C++ 去重相关的代码里:先排序,让相等的元素变成相邻组;再用unique把每组压成一份,返回新结尾;最后用erase把尾部的垃圾清理掉。三步各司其职,缺一不可。
2. 主流标准库实现里那十几行代码:去掉神秘感
2.1 源码到底长什么样
与其对着文档猜行为,不如直接把实现摆出来。我平时阅读的那个 C++ 标准库实现里,std::unique去掉宏、异常处理和内部函数分层之后,核心骨架就是下面这个样子:
template <typename ForwardIt> ForwardIt unique(ForwardIt first, ForwardIt last) { if (first == last) return last; ForwardIt result = first; while (++first != last) { if (!(*result == *first)) { *++result = std::move(*first); } } return ++result; }这段代码对应的是默认版本,也就是用operator==判断“是否相等”的那个重载。标准库里还实现了带谓词的版本,内部通常会把核心逻辑拆成__unique之类的辅助函数,C++11 之后赋值动作改成std::move,避免不必要的拷贝。但不管怎么包装,真正的循环就是这个样子。
我第一次读到这段实现的时候很震惊:一个让无数人误解的函数,源码就这么点?是的。它朴素得像remove的孪生兄弟,没有花哨的技巧,就是一个双指针同向扫描。
2.2 逐行推演:双指针如何一趟完成
拿{1, 1, 2, 3, 3, 3, 4, 1, 1}来推演。初始时first指向第一个元素,result也指向第一个元素。注意循环条件是++first != last,所以first每一次都会先向后走一步,再和result指向的元素比较。
| 步骤 | first 指向 | result 指向 | 比较结果 | 动作 |
|---|---|---|---|---|
| 1 | 第二个1 | 第一个1 | 相等 | 跳过 |
| 2 | 2 | 第一个1 | 不等 | ++result指向第二个位置,写入2 |
| 3 | 第一个3 | 2 | 不等 | ++result指向第三个位置,写入3 |
| 4 | 第二个3 | 第一个3 | 相等 | 跳过 |
| 5 | 第三个3 | 第一个3 | 相等 | 跳过 |
| 6 | 4 | 第一个3 | 不等 | ++result指向第四个位置,写入4 |
| 7 | 第一个1 | 4 | 不等 | ++result指向第五个位置,写入1 |
| 8 | 第二个1 | 第一个1 | 相等 | 跳过 |
循环结束,返回++result,指向第六个元素。此时[begin, result)区间就是1 2 3 4 1,和 1.1 节的输出一致。
有两个细节值得注意。第一,result和first都只要求是前向迭代器,不需要随机访问,所以unique可以作用于list,后面讲list成员函数时还会提到。第二,比较的对象是“已经保留下来的最后一个元素”和“当前扫描到的元素”,而不是“原序列里紧挨着的两个元素”,这一点在第 4 节谓词部分会引出很多坑。
2.3 为什么它不需要额外内存、为什么是 O(n)
这个算法的空间复杂度是 O(1),没有申请任何堆内存,全部在原区间内覆写完成。时间复杂度是 O(n),因为first每轮只前进一次,每次比较和赋值都是常数操作。
对比一下“手动去重”的常见思路:很多人会新建一个vector,遍历原序列,用find判断新容器里是否已存在当前值,不存在才push_back。这个做法空间是 O(n),时间最坏是 O(n²)。而unique在算法层面就赢在“一趟、零额外空间”,这也是 STL 算法设计的典型风格:能用迭代器区间解决的问题,绝不引入容器层面的开销。
3. 从算法族谱看 unique:它和 remove 是亲兄弟
3.1 两类算法的共同套路:搬移 + 返回逻辑结尾
STL 里有一类算法,它们的共同特征是:不改变容器大小,只把要保留的值往区间前部搬,把要丢弃的值留在后部,然后返回一个新的逻辑结尾。remove、remove_if、unique都属此类。
拿remove来说,它的典型用法也是配合erase:
v.erase(std::remove(v.begin(), v.end(), 0), v.end());这被称为 erase-remove 惯用法。unique走的完全是同一个套路,所以erase(std::unique(...), end())也可以理解成 erase-unique 惯用法。这两兄弟的结构几乎一样:一个读指针往前扫描,一个写指针在安全位置等待,遇到“不该要的”就跳过,遇到“该要的”就搬过来。
3.2 为什么 STL 算法不直接删除元素
很多人会问:既然都判断出重复了,为什么不直接删掉?原因有三点,都很本质。
第一,算法只接收迭代器,它不知道这个迭代器属于哪个容器。是vector还是list,还是原生数组?算法层无法得知,也就无法调用容器的erase。
第二,erase会改变容器大小,而算法不应该有这种副作用。STL 的核心哲学是“算法操作区间,容器管理内存”,两者解耦。算法只负责把数据整理成你想要的样子,物理内存的增删是容器的事。
第三,返回迭代器给调用者留了选择空间。你拿到新的逻辑结尾后,可以决定是erase掉尾部,还是仅仅用这个结尾做后续处理(比如只输出前 N 个有效元素)。强制删除反而会剥夺这种灵活性。
3.3 容器自己也能去重:list 与 forward_list 的成员函数
有意思的是,list和forward_list各自提供了成员函数版本的unique。它们和算法版最大的区别是:成员函数版会真正删除节点。链表去重只需要改指针,把重复节点的前驱后继重新连接起来,再释放多余节点,整个过程没有元素搬移。
算法版unique虽然也能用在list上(因为list的双向迭代器满足前向迭代器要求),但算法版只能通过迭代器赋值覆写,会产生元素移动开销。所以对链表来说,成员函数版是更优选择。
对比一下两个版本:
| 维度 | 算法版std::unique | 链表成员函数list::unique |
|---|---|---|
| 是否改变 size | 否,需要手动erase | 是,直接删除节点 |
| 元素搬移 | 有,move赋值 | 无,只改指针 |
| 适用容器 | 所有前向迭代器区间 | 仅 list / forward_list |
| 返回值 | 新逻辑结尾迭代器 | void |
4. 谓词版本的核心黑坑:比较对象到底是什么
4.1 带谓词的 unique:先定义什么叫“重复”
std::unique还有一个重载:
template <typename ForwardIt, typename BinaryPredicate> ForwardIt unique(ForwardIt first, ForwardIt last, BinaryPredicate pred);pred(a, b)返回true表示a和b属于同一组。标准对pred的要求是它必须是一个等价关系(自反、对称、传递)。最常见的用法是按某个字段去重。比如按工资去重:
v.erase(std::unique(v.begin(), v.end(), [](const auto& a, const auto& b) { return a.salary == b.salary; }), v.end());这里的 lambda 没有状态,纯粹判断两个元素的 salary 是否相等,这就是pred最该有的样子。
4.2 错误示范:想自己维护“上一个值”,结果翻车
我见过有人写出这种代码:试图在 lambda 里捕获一个prevSalary,自己记录上一份工资,然后再判断当前值是不是重复。
auto it = std::unique(v.begin(), v.end(), [prevSalary = std::optional<double>{}](const auto& a, const auto& b) mutable -> bool { if (!prevSalary) prevSalary = a.salary; bool dup = (a.salary == b.salary || b.salary == *prevSalary); prevSalary = b.salary; return dup; });这段代码在某个编译器版本上可能“碰巧”工作,但它是错误的,而且错得很典型。原因在于:标准只要求pred是一个等价关系,从不保证它的调用顺序和调用次数。实现内部到底是用“已保留的最后一个元素”和“当前扫描元素”比较,还是用原始序列里相邻的两个元素比较,严格来说都是实现细节。你一旦让谓词带上可变状态、依赖“上一次调用时传入的是谁”,就等于把自己绑在了某个实现的行为上,换个标准库版本、换个优化选项,结果都可能变。
正确写法前面已经给了:不需要自己记录什么“上一个值”,unique的内部逻辑天然保证了“等价组里第一个元素被保留”。因为result初始指向第一个元素,循环从来不拿第一个元素和自己比较,它只有遇到不等时才会前进,所以第一份记录永远不会被“判定为重复”。
4.3 谓词必须无副作用、具备传递性
除了不能有状态,谓词还有一个隐藏要求:等价关系要传递。比如你想按“工资差不超过 50 算同一档”来去重,这就毁了——a和b差 30,b和c差 30,但a和c差 60,不满足传递性。这种情况下unique的行为就没有明确定义,结果完全依赖实现细节。
遇到这种“模糊等价”的去重需求,正确做法是先把数据聚类或分桶,再用unique处理已经严格分好组的序列。让unique做它擅长的事,不要把模糊逻辑塞进谓词里。
5. 真正删除要联手 erase:完整惯用法与效率账
5.1 erase-unique 惯用法
unique本身不删除元素,erase才是真正动手清理的那个。标准写法:
std::vector<int> v{4, 1, 1, 2, 3, 3, 3, 2}; std::sort(v.begin(), v.end()); // 先排序 v.erase(std::unique(v.begin(), v.end()), v.end()); // 再 unique,再 erase执行后v变成{1, 2, 3, 4}。std::string里字符去重也一样,std::string也有erase成员函数,直接套同样句式。
为什么erase的第一个参数传unique的返回值,而不是last - 1或别的?因为unique返回的迭代器恰好是“有效区间结束”的位置,从它到end()之间的元素正是那些被移动出来的重复副本,这段区间才是要删的。
5.2 array 和 C 数组怎么处理
std::array没有erase,C 风格数组更不可能改变大小。这时只能做“逻辑去重”,用std::distance算出有效元素个数:
#include <array> #include <algorithm> #include <iostream> int main() { std::array<int, 8> a{1, 1, 2, 2, 3, 5, 5, 5}; auto last = std::unique(a.begin(), a.end()); size_t newSize = std::distance(a.begin(), last); for (size_t i = 0; i < newSize; ++i) { std::cout << a[i] << ' '; } return 0; }newSize是去重后的有效长度,写文件、发网络、遍历都只用前newSize个元素。C 数组也是同样的思路,只是把std::distance(a, last)换成last - a即可。
5.3 搬移成本与容器选择
vector上的unique + erase,最坏情况下每个元素会被move赋值一次,这是 O(n) 的搬移成本。对于元素很大的对象(比如包含大量字符串的结构体),这个成本不可忽视。
链表的情况要好得多。list::unique和forward_list::unique是纯指针操作,没有任何元素搬移,只是重新连接节点然后释放。所以如果你有一个大对象组成的、需要频繁删除重复项的链表,直接用成员函数版,别用算法版。
各容器配合unique的适用性如下:
| 容器 | 推荐的去重方式 | 特点 |
|---|---|---|
| vector / string | erase(unique(...), end()) | 元素搬移 O(n),无额外内存 |
| list / forward_list | 成员函数unique() | 只拆节点,无搬移,直接改 size |
| array | unique+distance获取有效长度 | 容量固定,逻辑去重 |
| deque | erase(unique(...), end()) | 可 erase,但开销略高于 vector |
| 原始数组 | unique+ 指针相减 | 容量固定,逻辑去重 |
如果只是“去重但不要求保持原始顺序”,可以把数据丢进unordered_set再回填,时间近似 O(n),但注意结果顺序会变。需要有序且稳定的去重结果,还是排序 + unique 最稳。
6. 边界情况与容易混淆的兄弟函数
6.1 空区间和单元素区间
unique对空区间返回last,对单元素区间也是直接返回last。原因看源码就懂:if (first == last) return last;空区间直接返回;单元素区间++first == last立刻成立,循环不执行,返回last。
所以调用unique之前不需要特判“元素是否大于 1”,它自己就处理了这些边界情况。这也是源码公开的好处:你知道它不会越界,不需要多余的防御代码。
6.2 全部相同、全部不同的极端情况
全部相同的序列,比如{5, 5, 5, 5}:循环里first每步都和result指向的第一个5相等,全部跳过,最终返回first + 1,逻辑区间只剩第一个5。
全部不同的序列,比如{1, 2, 3, 4}:每步都不等,result每步前进并写入当前值,返回last。看起来什么都没变,但每个元素其实都被赋值了一遍。了解这个行为有助于理解循环内部的写入逻辑——result前进和写入是配对的,每一对“不等”都会产生一次覆写。
6.3 unique_copy:把结果写到别处
如果不想改动原区间,又想得到去重后的结果,用std::unique_copy。它把结果输出到目标迭代器,底层逻辑和unique几乎一样,只是写入动作变成了对输出迭代器的赋值。
std::vector<int> src{1, 1, 2, 3, 3, 4}; std::vector<int> dst; std::unique_copy(src.begin(), src.end(), std::back_inserter(dst));dst得到{1, 2, 3, 4},src保持原样。这个函数在需要保留原始数据做后续处理时很实用。
6.4 unique 和 adjacent_find:一个侦察兵
std::adjacent_find会返回第一对相邻相等元素的位置。如果你想判断“排序后的序列是否存在重复”,不需要做完整unique,只要:
if (std::adjacent_find(v.begin(), v.end()) != v.end()) { // 有相邻重复,说明存在至少一对相等元素 }这就够用了。unique是“动手整理”,adjacent_find是“只看不动手”。两者底层都有“相邻比较”的影子,理解了其中一个,再看另一个会非常顺畅。
我在项目里第一次用unique时也踩过“size 没变”的坑,后来发现调试代码里总拿v.end()去遍历逻辑区间,把末尾遗留值也打印出来,导致结果怎么看都不对。从那以后我养成了一个习惯:凡是配合unique或remove使用,遍历一律以返回的迭代器为终点,绝不用end()。后来帮别人 review 去重代码,我也总是先问一句:“unique返回值和end()之间的区间,你知道里面是什么吗?”
这些细节看起来小,但搞懂之后,很多去重相关的代码一眼就能看穿。unique往后延伸还有ranges::unique之类的现代接口,不过核心思想没有变——还是那十几行双指针搬移的算法。源码之前,了无秘密,希望这篇能把unique这个老朋友彻底讲清楚。