C++ STL中unique函数源码解析:从相邻去重到sort+unique+erase实践
在C的标准模板库STL里unique是一个又常用又容易被低估的算法。说它常用是因为几乎所有写C的人第一次做“数组去重”时都会抄到类似的代码先sort再unique最后erase。说它容易被低估是因为大多数人只把unique当成一个“去重机器”却很少去想它内部到底是怎么跑的、返回值为什么不是void、能不能用来做自定义规则去重、以及它有哪些天然的边界。我写这篇东西的目的就是把unique掰开揉碎从源码层面的执行脉络讲清楚。搞清楚这些你在工程里遇到与“相邻元素去重”“区间压缩”相关的场景时就会有底气得多。先说一个现象很多刚工作两三年的同学对unique的认识停留在“能去掉重复元素”但问到“如果数据不是有序的直接用unique会怎样”往往就答不准。答案是不排序直接执行unique程序不会报错但它只能去掉相邻的重复项全局重复的项一个都去不干净。这不是unique的缺陷而是它的设计定位。理解了这一点你才算真正开始掌握这个函数。1. unique函数到底做了什么——从一段最朴素的去重代码说起1.1 拿到手就能直接用的基础用法unique的声明位于algorithm头文件中标准的两个重载长这样template class ForwardIt ForwardIt unique( ForwardIt first, ForwardIt last ); template class ForwardIt, class BinaryPredicate ForwardIt unique( ForwardIt first, ForwardIt last, BinaryPredicate p );第一个版本用operator判断两个元素是否相等第二个版本允许你传入一个自定义二元谓词p用来表达“两个元素在该规则下等价”。返回类型是一个迭代器指向“新逻辑容器的尾部”也就是“最后一个未被移除元素的下一个位置”。最常见的用法是配合vector#include algorithm #include vector #include iostream int main() { std::vectorint v{1, 1, 2, 3, 3, 3, 4, 5, 5, 6}; auto last std::unique(v.begin(), v.end()); v.erase(last, v.end()); for (auto x : v) { std::cout x ; } // 输出: 1 2 3 4 5 6 }unique把相邻的重复元素“逻辑删除”后真正缩小容器体型的工作要交给erase完成。很多初学者第一次写std::unique(v.begin(), v.end())之后直接打印容器发现元素还在就以为函数没生效其实是因为没有执行erase这段“清扫尾巴”的动作。1.2 关键认知unique处理的是“相邻重复”不是“全局重复”来看这段代码std::vectorint v{1, 2, 1, 3, 3, 2, 4}; auto last std::unique(v.begin(), v.end()); for (auto it v.begin(); it ! last; it) { std::cout *it ; } // 输出: 1 2 1 3 2 4输入里有三个1和两个2但因为它们没有相邻所以unique只把中间那两个连续的3压缩成了一个其他“重复但不相邻”的元素原封不动保留。如果你要的是“把整个序列里重复的元素全部去掉”就必须先让相同的元素聚在一起——最典型的做法就是先sort。注意unique的前提假设是“重复项彼此相邻”。所以在工程里凡是使用unique做全序列去重前面几乎一定会跟一个sort。这是unique最容易被误解的地方也是面试官特别爱问的考点。1.3 返回值到底有什么用——它为什么不是voidunique返回的是“新逻辑区间的尾部迭代器”。它不是一个“计数器”也不是一个“标记值”而是可以直接参与后续算法的迭代器。这有三个层面上的用途第一配合erase做真正的容器缩短这是标准范式。第二如果不erase只看[first, last)这个区间的元素容器本身没有变的更小但是“有效数据”的集合变成了一段紧凑区间后面可以继续对这段区间做其他算法操作。第三如果想要“被剔除的元素”[last, 原end())区间里残留的值是“曾经的被覆盖元素”但它们的值已经无意义一般没有人去读。如果一个算法函数返回void你是没法把它串联到erase里的。正是因为unique返回迭代器才使一行式去重写法成为可能v.erase(std::unique(v.begin(), v.end()), v.end());2. 源码之前——unique函数的完整执行链路“源码之前了无秘密”这句话讲的其实就是一件事任何看似神秘的库函数拆到内部都很朴素。unique的内部实现思路和remove、remove_if这类“搬移覆盖型算法”如出一辙。2.1 手动模拟unique的内部循环下面我用一个简化的实现来模拟unique的核心执行逻辑。这段代码不是标准库的逐字拷贝但行为语义是一致的templateclass ForwardIt ForwardIt unique_impl(ForwardIt first, ForwardIt last) { if (first last) { return last; } ForwardIt result first; // “守备区”的尾巴 while (first ! last) { if (!(*result *first)) { // 发现新元素向前搬运 *(result) std::move(*first); } } return result; }这里的核心变量有两个first是“巡逻指针”不断向后扫描整个区间result是“守备区指针”指向已确认无重复的最后一个元素。一开始result指向第一个元素因为第一个元素无论如何都会被保留。每次first向前走一步拿新元素和*result做比较。如果相等说明新元素和守备区末尾重复跳过不搬运。如果不相等则把新元素移动到守备区尾部之后的位置同时result前进一步。它的快就在于比较只在相邻的两个位置之间进行每次都是O(1)的赋值或移动没有哈希表的开销也不申请额外堆内存。2.2 为什么不预先reserve也能保持高效有人可能会问vector内部在unique执行过程中会改变 size 吗不会。无论是被“跳过”的元素还是被“搬运”的元素都在同一个容器内部做覆盖vector的size()在整个unique调用期间不变。数据从后面的位置覆盖到前面的位置这个过程不会触发扩容也没有内存重分配。真正改变容量的是后续的erase它会把尾部的“垃圾区间”释放掉。2.3 一个数组的完整变化过程我们用数组{1, 1, 2, 3, 3, 3, 4}来走一遍它的执行轨迹result指向下标0的1。first指向下标1的11 1跳过。first指向下标2的22 ! 1把2赋值给下标1的位置。result指向下标1的2。first指向下标3的33 ! 2把3赋值给下标2的位置。result指向下标2的3。first指向下标4的33 3跳过。first指向下标5的33 3跳过。first指向下标6的44 ! 3把4赋值给下标3的位置。result指向下标3的4。循环结束返回result 1也就是逻辑尾部的下标4。此时数组内容是{1, 2, 3, 4, 3, 3, 4}注意后面三个位置还残留着3, 3, 4但已经不属于有效区间了。如果你不做erase打印整个容器会看到这些“尾巴”这也是许多人产生困惑的来源。提示标准库里的unique实现可能会有if (result ! first)之类的优化判断当result和first指向同一个位置时就不额外做一次自赋值。但这只是细节优化不影响整体逻辑。3. unique的进阶玩法——与sort、erase、自定义谓词的配合3.1 标准去重三板斧sort unique erase全序列去重是unique最经典的组合我直接给出一个完整可运行的例子。这个场景特别常见比如后台统计一批用户ID需要把这个列表变成“唯一用户ID列表”并保持有序。#include algorithm #include vector #include iostream int main() { std::vectorint ids{42, 7, 42, 7, 7, 100, 3, 100}; std::sort(ids.begin(), ids.end()); auto last std::unique(ids.begin(), ids.end()); ids.erase(last, ids.end()); for (int id : ids) { std::cout id ; } // 输出: 3 7 42 100 }注意sort之后相同元素必然相邻unique的“相邻去重”规则就会把后续所有重复项识别出来。在实际工程中我还会提醒一点如果ids后续要频繁做查找可以考虑在erase之后顺手做一个shrink_to_fit或者等价格式。虽然erase不会释放容量但如果是内存敏感的场景std::vectorint(ids).swap(ids)这种写法能真正释放多余内存。不过大多数情况下不必这么做因为shrink_to_fit会带来一次重新分配而如果容器的生命周期不长这笔开销不如省下来。3.2 自定义比较器unique的“同义”判断有时候你需要的不是“完全相等”而是“某种规则下等价”。比如保存了一批订单结构体希望去掉“订单号相同的重复条目”或者保存了一批坐标点希望去掉“x和y都相同”的点。这种场景就要动用第二个重载版本。举例按“姓氏相同即视为重复”来去重。#include algorithm #include vector #include string #include iostream struct Person { std::string name; std::string surname; }; int main() { std::vectorPerson people { {Alice, Smith}, {Bob, Smith}, {Carol, Jones}, {Dave, Smith}, {Eve, Jones} }; // 先按 surname 排序确保同姓相邻 std::sort(people.begin(), people.end(), [](const Person a, const Person b) { return a.surname b.surname; }); auto last std::unique(people.begin(), people.end(), [](const Person a, const Person b) { return a.surname b.surname; }); people.erase(last, people.end()); for (const auto p : people) { std::cout p.name p.surname \n; } // 输出: // Carol Jones // Alice Smith }这个例子的关键点不在unique本身而在sort和unique必须使用“同一套比较规则”。如果sort按surname排而unique按“姓名全相等”判断结果依然是正确的但没有意义因为你已经把所有字段都排在一起了。真正有用的是排序和去重规则一致时才能压缩掉“同姓”的人。再提一个实践细节unique的谓词不能表达“严格弱序”的判断它只回答“a和b在语义上是否相同”。如果你写一个返回a.x b.x的谓词unique的行为将是未定义的因为算法假定该谓词满足等价关系。所以写自定义谓词时务必看清楚你给的是“等价判断”不是“大小判断”。3.3 只去重相邻重复项的场景有时候你不想要全序列唯一只想去掉“连续出现”的重复项。比如一个日志序列里用户连续点击了好几次同一个按钮你只保留一次点击记录但用户点了A按钮又点了B按钮之后再次点A按钮这两次A按钮是要保留的。这个时候sort做不得一排序顺序就乱了直接用unique反而正合适因为它只压缩相邻重复。std::vectorstd::string clicks{A, A, B, C, C, C, A, B}; auto last std::unique(clicks.begin(), clicks.end()); clicks.erase(last, clicks.end()); // 结果: A B C A B很多业务语义本身就关注“相邻事件合并”。unique在这种场景下意外地好用而且代码读起来非常直观。4. 性能分析与实战避坑4.1 复杂度与move语义带来的收益unique的时间复杂度是O(n)其中n是区间长度因为它只需要一趟扫描。对比一下其他去重手段方案时间复杂度额外空间是否保持顺序sort unique eraseO(n log n)O(1)不保持原顺序unordered_set 手动去重平均 O(n)O(n)不保持原顺序两层循环暴力去重O(n^2)O(1)保持原顺序仅 unique 压缩相邻重复O(n)O(1)保持原顺序注意第一行的“O(1)额外空间”指的是std::sort内部通常使用栈递归宏观上不额外申请大规模堆内存。unordered_set方案虽然平均时间复杂度好看但哈希表的内存开销不容小觑。当数据量很大且内存紧张时sort unique是一条非常划算的路线。unique在 C11 之后使用std::move而非拷贝赋值。对于std::string、std::vector这类拥有动态资源的类型move 之后源对象变为“有效但未指定”的状态搬运成本远低于深拷贝。这也是为什么unique能高效处理vectorstring的原因。4.2 四个容易踩的坑第一个坑忘记erase。只调用unique不给erase容器只是“逻辑上缩小”了size()不变打印全部元素时会看到残留尾巴。这个坑最基础也最常见。第二个坑对无序数据直接调用unique。如果没有提前排序你以为在去重其实只是压缩了相邻重复项。结果正确与否完全取决于输入数据是否碰巧把相同元素排在一起。第三个坑erase的位置错误。有些人会把v.erase(v.begin(), last)当成清扫动作这是方向性错误。erase应该清理[last, v.end())这个尾部区间而不是头部区间。写反了会把有效数据清掉留下垃圾。第四个坑自定义类型未实现或未正确实现operator。第一个重载的unique依赖operator如果你在一个自定义结构体上直接用而它没有定义operator编译器直接报错如果定义了但语义不对比如比较了指针地址而非内容unique会给出错误结果。4.3 关于“先unique再erase”的容器选择unique适用于任意前向迭代器ForwardIterator所以标准库容器里vector、deque、list都能用。但要注意list有自己的成员函数unique()成员版本的复杂度同样是线性而且不需要你再调用erase来缩短容器因为它本质上直接修改了链表节点个数。如果你确定用的是list那么lst.unique()是更顺手的写法。array是固定大小容器不能erase但可以做区间压缩然后只对[begin, last)区间做逻辑处理。关联容器如set、map本身就不允许重复就不需要unique了。5. 工程实践一段生产级去重代码示例最后分享一个我在实际项目里用过的写法。当时的需求是从日志中提取一批“事件编码”这些编码可能因为上游重试而产生大量连续重复我要把“连续重复”压缩保留“编码首次出现”的事件并且还要统计不同编码的数量。#include algorithm #include vector #include string #include iostream // 压缩连续重复事件返回压缩后的事件数量 size_t CompressEvents(std::vectorstd::string events) { auto last std::unique(events.begin(), events.end()); events.erase(last, events.end()); // 此时“连续重复”已经被合并接下来按有序统计不同编码数量 std::vectorstd::string sorted_events(events.begin(), events.end()); std::sort(sorted_events.begin(), sorted_events.end()); auto sorted_last std::unique(sorted_events.begin(), sorted_events.end()); return static_castsize_t(std::distance(sorted_events.begin(), sorted_last)); } int main() { std::vectorstd::string logs{ EVT_001, EVT_001, EVT_002, EVT_003, EVT_003, EVT_003, EVT_001, EVT_004 }; size_t distinct_count CompressEvents(logs); for (const auto e : logs) { std::cout e \n; } std::cout distinct count: distinct_count \n; // 输出: // EVT_001 // EVT_002 // EVT_003 // EVT_001 // EVT_004 // distinct count: 4 }这里我先用unique压缩连续重复保留了“事件发生顺序”这一层语义再复制一份做全序列统计虽然多了一次复制和排序开销但整体的时间和空间消耗都在可控范围。如果你连一次复制都不想做也可以改为遍历logs把元素逐个插入一个unordered_set里计数。两种写法各有取舍一份数据循环走多次 vs 多一份内存换简单逻辑。根据我个人的实际体会unique在工程中出现频率最高的组合仍然是把sort unique erase当作“数组全去重”的标准范式。它看起来简单但你越了解它的内部执行方式就越能判断什么时候该用它、什么时候该换哈希表、什么时候该用list::unique。当你明白它是在原地做“覆盖式压缩”、只处理相邻重复、返回值是给后续操作铺路的迭代器时你就不会再写出“调了 unique 但容器没变”或者“没排序却想全去重”的代码了。算法源码的阅读最有意思的地方就在于此很多复杂的面纱下面逻辑往往朴素得像一个双指针循环而问题真正的复杂度其实藏在你对问题域的理解里。

相关新闻

YOLOv8光伏缺陷检测实战:从EL图像到小目标识别全流程解析

YOLOv8光伏缺陷检测实战:从EL图像到小目标识别全流程解析

简介:一套YOLOv8光伏缺陷检测项目源码,面向光伏运维、计算机视觉开发者和相关专业学生,针对光伏板脏污、异物遮挡、鸟粪、阳光反射、组件变形、面板破碎、积雪等12类常见缺陷,提供基于55200张真实图像的端到端检测方案&#xff0c…

2026/10/14 17:45:07 阅读更多 →
软件工程毕业设计容易的题目帮助

软件工程毕业设计容易的题目帮助

文章目录🚩 1 前言1.1 选题注意事项1.1.1 难度怎么把控?1.1.2 题目名称怎么取?1.2 选题推荐1.2.1 起因1.2.2 核心- 如何避坑(重中之重)1.2.3 怎么办呢?🚩2 选题概览🚩 3 项目概览题目1 : 深度学习社交距离检…

2026/10/14 17:44:06 阅读更多 →
数据采集卡怎么学?先看这6个核心部分!

数据采集卡怎么学?先看这6个核心部分!

2026/10/14 17:44:06 阅读更多 →

最新新闻

快速阅读不靠眼快:筛选与理解的实用框架

快速阅读不靠眼快:筛选与理解的实用框架

经常有人问我:“你读书怎么那么快?是不是一目十行、走马观花?”说实话,我刚开始试水快速阅读的时候也踩过不少坑,最典型的表现是字都看完了,合上书一问三不知。后来我花了很长时间去拆解“快速阅读”和“快…

2026/10/14 18:20:44 阅读更多 →
存量市场服务升级:低风险高回报的商业模式

存量市场服务升级:低风险高回报的商业模式

1. 为什么说"稳妥生意"比创业更值得关注最近三年,我身边有37位朋友尝试创业,最终存活下来的只有3家。这个残酷的数字让我开始思考:对于大多数普通人来说,是否存在着比创业风险更低、回报更稳定的商业机会?经…

2026/10/14 18:20:44 阅读更多 →
Decepticon 实战手册:EtherNet/IP + CIP 协议攻击全流程(44818/2222 端口下的 PLC 侦察、标签读写与模式切换)

Decepticon 实战手册:EtherNet/IP + CIP 协议攻击全流程(44818/2222 端口下的 PLC 侦察、标签读写与模式切换)

【免费下载链接】Decepticon Autonomous Hacking Agent for Red Team 项目地址: https://gitcode.com/gh_mirrors/de/Decepticon 点击查看 免费下载 本指南基于 Decepticon 仓库中 enip-cip 技能文档 编写,系统讲解针对北美工业控制系统(ICS…

2026/10/14 18:20:44 阅读更多 →
中点画圆算法详解:光栅化生成完美圆的数学原理与代码实现

中点画圆算法详解:光栅化生成完美圆的数学原理与代码实现

圆的生成算法,是计算机图形学课程里最容易被低估的一个实验。很多同学看到“画圆”两个字,第一反应是直接调用窗口库里的画圆函数,几行代码就结束。但实验真正要你做的,是把它当成一道光栅化算法题:输入圆心坐标、半径…

2026/10/14 18:20:44 阅读更多 →
把“学术导航仪”装进书匠策AI:一篇期刊论文的“造路”逻辑

把“学术导航仪”装进书匠策AI:一篇期刊论文的“造路”逻辑

官网直达:www.shujiangce.com 微信公众号搜一搜:书匠策AI 写期刊论文最折磨人的,从来不是“写”这个动作本身。 是你盯着空白文档,知道要做文献综述,却不知道从哪篇开始;知道要有理论框架,却拿…

2026/10/14 18:20:44 阅读更多 →
插件加载失败排查:从生命周期到激活机制,一篇搞定

插件加载失败排查:从生命周期到激活机制,一篇搞定

先说一句,我带过好几个插件化改造的项目,也修过无数跟插件相关的启动报错,所以看到 "failed to load plugins web boot: 2 entries did not activate" 这种日志时,第一反应不是翻框架文档,而是看插件的激活方…

2026/10/14 18:19:43 阅读更多 →

日新闻

AnyPS5通用适配层设计:兼容性与性能的工程取舍

AnyPS5通用适配层设计:兼容性与性能的工程取舍

1. 从"AnyPS5"这个名字说起:它到底想解决什么问题第一次看到"AnyPS5"这个标题,我脑子里冒出来的第一个念头是:这大概率是一个围绕"跨平台运行"或者"通用适配"做文章的项目。名字里的"Any"…

2026/10/14 0:00:41 阅读更多 →
Flutter跨平台开发实战:二手交易应用与HarmonyOS适配

Flutter跨平台开发实战:二手交易应用与HarmonyOS适配

1. 项目概述:Flutter跨平台二手交易应用开发作为一名长期从事移动应用开发的工程师,我最近完成了一个基于Flutter框架的二手物品交易平台项目。这个项目最吸引我的地方在于它完美结合了Flutter的跨平台优势与HarmonyOS的生态特性,实现了"…

2026/10/14 0:00:41 阅读更多 →
DFS算法解决飞机降落调度问题实战

DFS算法解决飞机降落调度问题实战

1. 项目背景与核心思路飞机降落问题是一个经典的调度优化场景,也是算法竞赛和面试中的高频题目。这个题目要求我们合理安排多架飞机的降落顺序和时间,确保所有飞机都能安全着陆而不发生冲突。在实际航空管制中,这类问题直接关系到飞行安全和机…

2026/10/14 0:00:41 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/14 3:30:28 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/14 16:30:29 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/14 3:29:58 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/13 22:01:27 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/13 14:57:39 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/14 7:53:34 阅读更多 →