☰
深入解析 std::unique:是去重还是元素搬运?源码与工程实践
2026/10/14 21:38:35 网站建设 项目流程

在某个项目里,我遇到一位同事拿着代码满脸疑惑地走过来:他对着一个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相等跳过
22第一个1不等++result指向第二个位置,写入2
3第一个32不等++result指向第三个位置,写入3
4第二个3第一个3相等跳过
5第三个3第一个3相等跳过
64第一个3不等++result指向第四个位置,写入4
7第一个14不等++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 / stringerase(unique(...), end())元素搬移 O(n),无额外内存
list / forward_list成员函数unique()只拆节点,无搬移,直接改 size
arrayunique+distance获取有效长度容量固定,逻辑去重
dequeerase(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这个老朋友彻底讲清楚。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询