C++去重:std::unique相邻压缩与erase-remove惯用法

发布时间:2026/10/31 9:59:25
C++去重:std::unique相邻压缩与erase-remove惯用法

std::unique这个函数,我见过太多人第一次用就翻车——明明调了去重,打印出来的size()一点没变;或者元素确实少了,但剩下的顺序和预期完全对不上。它属于那种“一句话能讲明白,但手一抖就出错”的典型 STL 组件。这篇东西想干的事很简单:把 unique 函数从语义、实现、选型到踩坑,一层层拆开讲透,让刚接触 STL 的同学能照着模板抄作业,也让写过几年 C++ 的朋友回头补上几个一直被忽略的细节——比如它为什么只处理相邻元素、返回的那个迭代器到底指到哪儿、erase为什么必须跟在后面、以及什么时候你压根不该用它。全文会围绕几个真实的高频场景展开:数据去重、字符串压缩、结构体按字段筛选、日志清洗,每个环节都给可运行的代码和参数说明。读完之后你应该能做到:看到一段需要去重的逻辑,脑子里立刻判断出该用 sort+unique、还是该用哈希表、还是该老实手写双指针。

1. 先把 unique 函数的语义掰开揉碎

1.1 它做的不是“去重”,而是“压缩相邻重复”

这是理解 unique 函数的第一道门槛。很多人脑子里把它等同于“集合去重”,实际上它的行为更接近于“相邻元素压缩”:从头到尾扫一遍,只要发现当前元素和上一个保留下来的元素相等,就跳过;不相等,就把它往前挪到该待的位置。所以对[1, 1, 2, 1, 1, 3]调一次std::unique,得到的逻辑序列是[1, 2, 1, 3, ...]——注意那个夹在中间的1依然在,因为它和前面的2不相邻。想得到真正的全局唯一序列,前置条件是先排序。这一点决定了 unique 函数的定位:它是一个“针对已排序区间的线性压缩工具”,而不是通用的去重器。

我第一次踩这个坑是在处理一批传感器采样数据的时候,数据按时间戳写入,但同一个时间戳偶尔有重复记录,而且这些重复并不挨着。当时直接unique一把,打印结果看着“少了一些”,就以为完事了,后面统计总数对不上才发现问题。正确的做法要么先按时间戳排序再 unique,要么换一套思路用哈希表记录已出现的键。

从接口上看,它有两个重载:不带谓词的版本用operator==判断相邻元素是否相等,带谓词的版本让你自定义“相等”的规则。返回值都是迭代器,指向压缩之后新逻辑序列的尾部。这一点下面单独说,因为它是绝大多数 bug 的源头。

1.2 返回值不是“删了几个”,而是“新结尾在哪”

unique 函数的返回值类型和输入迭代器类型一致,指向压缩后序列的末尾后一位。换句话说,[first, new_end)是有效的新序列,[new_end, last)里躺着的是被“挪走”之后留下的残余数据。这些残余数据的值在标准里被描述为“有效但未指定”——对象还在,析构也正常,但你不能假设里面是什么。我见过有人拿 unique 的返回值去减begin()当作“删除了多少个元素”,这在数值上确实等于删除数量,但请务必搞清楚语义:它是新长度,不是删除计数。写auto removed = v.size() - (new_end - v.begin())才是删除数量。

更常见的错误是压根不接返回值:

std::vector<int> v{1, 1, 2, 2, 3}; std::unique(v.begin(), v.end()); // 错:返回值丢了 std::cout << v.size(); // 依然是 5

size()没变是必然的,因为 unique 函数根本没有修改容器的 size 字段,它只是搬动元素。容器的大小是容器自己管的,算法拿不到这个信息,这也是 STL 把算法和容器分离设计带来的必然结果。

1.3 为什么它不肯自己删元素

这是个设计哲学问题,值得说清楚。STL 的算法层只依赖迭代器,不知道背后是vector、deque还是原生数组。原生数组压根没有“删除元素”这个概念,只有一块连续内存。如果 unique 函数内部去做 erase,那它就必须知道容器的具体类型,算法和容器就耦合死了。所以标准的分工是:算法负责重排,容器负责增删。这就是经典的 erase-remove 惯用法(erase-remove idiom)的由来,std::remove和std::remove_if是同样的套路。

理解了这个分工,你就能举一反三:凡是“重排类”算法,返回值都要自己接住,然后交给容器的erase收尾。这条规律适用于 unique、remove、remove_if、stable_partition 之后的手动截断等等。我在带新人的时候会反复强调一句话:看到返回迭代器的重排算法,先想它的返回值该交给谁。

2. 底层实现与复杂度:为什么是 O(n) 且对元素类型有要求

2.1 一个等价的手写版本

主流实现(以 libstdc++ 为例)的思路大致是这样:先用类似adjacent_find的逻辑找到第一对相邻重复,如果找不到直接返回last;找到之后,把那个位置当作写入位置dest,然后继续往后扫,遇到和*dest不相等的元素就*++dest = std::move(*first),最后返回++dest。翻译成等价的手写代码:

template <class ForwardIt, class BinaryPredicate> ForwardIt my_unique(ForwardIt first, ForwardIt last, BinaryPredicate pred) { if (first == last) return last; ForwardIt result = first; while (++first != last) { // 只有当新元素与 result 位置不同,且确实不相等时才搬运 if (!pred(*result, *first) && ++result != first) { *result = std::move(*first); } } return ++result; }

这段代码里有两个细节值得单独拎出来。第一处是++result != first这个判断:当序列里没有重复时,result和first会同步前进,此时如果还去执行*result = std::move(*first)就是自赋值。对内置类型无害,但对自定义类型会白白调用一次移动赋值运算符,性能上不划算,某些实现质量不高的类型还可能出问题。第二处是std::move:搬运用的是移动赋值而不是拷贝赋值,这对std::string、std::vector这类持有堆内存的类型意义重大,意味着 unique 在大对象上的开销远低于“拷贝式去重”。

2.2 元素类型需要满足什么条件

从上面的实现能反推出对元素类型的要求:可移动赋值(MoveAssignable),以及能参与谓词比较。绝大多数类型都满足,但有几个边界情况需要注意。

第一种是元素带const成员或者本身是const类型。比如std::map和std::set的value_type里的键部分是 const 的,迭代器解引用出来不能赋值,对它们调 unique 直接编译不过。这不是缺陷,而是本来就该拒绝——有序容器自身保证键唯一,你没有任何理由对它做去重。

第二种是元素只可拷贝不可移动。老代码里常见的写法是自定义了拷贝构造但没写移动构造,同时把移动构造 delete 了,这种类型在 unique 里会退化到拷贝赋值,能跑但慢。如果你的类型确实有资源需要管理,把移动语义补上,收益立竿见影。

第三种是浮点数里的 NaN。operator==对 NaN 永远返回 false,所以相邻的两个 NaN 不会被判定为重复,会全部保留。如果你的数据里可能出现 NaN 并且需要合并它们,就得自己写谓词,显式判断std::isnan。

2.3 复杂度与尾部元素的状态

标准规定 unique 函数的复杂度是恰好(last - first) - 1次谓词调用,也就是严格的线性时间,且只做一次遍历。这个常数很小,实测在连续内存容器上非常快,因为它访问模式是顺序的,预取器能吃得很好。

关于尾部元素,标准里的措辞是“有效但未指定状态”(valid but unspecified state)。这句话的实际含义是:这些对象依然处于已构造状态,容器的析构函数会正常调用它们的析构函数,不会有资源泄漏;但你不能读取它们的值做任何逻辑判断。如果你在 unique 之后、erase 之前不小心访问了v[new_end - v.begin()]这样的位置,行为是未定义的——严格说是读到了未指定值,逻辑上不可依赖。

顺带说一句,正因为尾部对象是完整的、可析构的,所以用v.resize(new_len)来截断和用v.erase(new_end, v.end())来截断在语义上等价。前者在某些实现里可能少一次迭代器计算,但差别微乎其微,代码可读性上 erase 版本更符合惯用法,我一般还是写 erase。

3. 标准用法:从 vector 到自定义谓词

3.1 最标准的写法长什么样

在std::vector上去重,标准姿势就三行:

#include <algorithm> #include <vector> std::vector<int> v = {3, 1, 2, 3, 1, 2, 3}; std::sort(v.begin(), v.end()); // O(n log n) v.erase(std::unique(v.begin(), v.end()), v.end()); // O(n) + O(k) // 结果:{1, 2, 3}

这里每一步的必要性都值得说。sort是为了让相等的元素聚到一起,这是 unique 生效的前提。unique负责把重复的元素挪到后面,返回新结尾。erase负责真正把它们从容器里摘掉,同时让size()和capacity()的语义恢复一致(注意capacity不会因为 erase 而减少,这个后面会讲)。

erase这里用的是单参数版本erase(iterator),从 C++11 起vector提供了它,删单个位置的元素。老代码里偶尔能看到有人写v.erase(std::unique(...), v.end()),其实就是同一件事,因为 unique 返回的就是新结尾,但写成erase(it, v.end())在 C++11 之前是唯一的写法,现在两种都行。

如果你在 C++20 环境下,还可以用std::ranges::unique配合 views,不过那属于另一套范式,本文还是以传统迭代器风格为主,因为绝大多数生产代码库里跑的仍然是这套。

3.2 不同容器上的差异,列表里藏着一个语义陷阱

std::vector和std::deque上的用法完全一致。原生数组因为没有容器的erase,只能靠返回值自己记录有效长度:

int a[] = {1, 1, 2, 3, 3, 3, 4}; int* new_end = std::unique(a, a + 7); int new_len = static_cast<int>(new_end - a); // 4,后续只用 a[0..3]

std::string也是一样的三行,erase用size_type或迭代器都行。处理文本时这个组合很好用,比如把"aaabbbcccd"压成"abcd"。

真正需要警惕的是std::list。它有一个成员函数list::unique(),行为和std::unique完全不同——成员版本会真正删除元素,容器大小会立即变小,不返回迭代器。这两个名字撞车是 STL 历史遗留问题,很多人写着std::unique(lst.begin(), lst.end())然后奇怪为什么 size 没变,其实他想用的是lst.unique()。我给你整理成一张表,遇到 list 先查一遍:

用法归属是否真正删除返回值典型误用后果
std::unique(b, e)算法否,只重排新逻辑结尾迭代器size 不变,尾部是未指定值
std::unique(b, e, pred)算法否,只重排新逻辑结尾迭代器同上
lst.unique()list 成员是C++20 起返回删除个数无
lst.unique(pred)list 成员是同上无
flst.unique()forward_list 成员是同上无

std::forward_list同理,也有成员版本。这张表我建议存下来,list和forward_list上的成员unique是唯一不用配合erase的场合。

3.3 unique_copy:输出到另一个容器

当你不希望破坏原数据,或者想输出到不同类型的目标时,用std::unique_copy:

std::vector<int> src = {1, 1, 2, 2, 2, 3}; std::vector<int> dst; std::unique_copy(src.begin(), src.end(), std::back_inserter(dst)); // dst = {1, 2, 3}

它同样要求相邻重复,同样有带谓词的重载。注意back_inserter对vector会触发多次扩容,元素量大时建议先dst.reserve(src.size())再塞,避免反复重新分配。这个细节在千万级数据的批处理里能省下不少时间,我实测过,预留空间这一步能让整体耗时下降百分之二三十,因为back_inserter走的是push_back路径,扩容时还要搬运已有元素。

还有一个用法是输出到流或者自定义的输出迭代器,比如直接写文件。这在日志清洗里挺实用:把压缩后的记录直接落到磁盘,不需要在内存里再存一份。

3.4 自定义谓词:结构体、浮点误差、大小写

带谓词的版本把“什么算相等”交给调用方,威力就在这里。最常见的三个场景:

按结构体某个字段去重:

struct Record { int id; std::string payload; double ts; }; std::vector<Record> v = /* ... */; std::sort(v.begin(), v.end(), [](const Record& a, const Record& b) { return a.id < b.id; // 排序用严格弱序 }); v.erase(std::unique(v.begin(), v.end(), [](const Record& a, const Record& b) { return a.id == b.id; // 去重用等价判断 }), v.end());

这里有个必须强调的点:排序谓词和去重谓词必须描述同一个“相等”概念。排序用a.id < b.id,去重就必须用a.id == b.id,两者都围绕id展开。如果你排序按id、去重按payload,结果会是“相邻的 payload 相同项被合并”,但 payload 相同的记录在按 id 排序后未必相邻,最终结果既不是按 id 唯一也不是按 payload 唯一,纯粹是垃圾数据。我见过一个线上问题就是这个原因导致的,排查了半天才发现两个 lambda 里的字段名不一样。

浮点近似相等:

v.erase(std::unique(v.begin(), v.end(), [](double a, double b) { return std::abs(a - b) < 1e-9; }), v.end());

这里必须提醒:这个谓词不要拿去当std::sort的比较器。sort要求严格弱序,abs(a-b) < eps不满足传递性(a 和 b 近似、b 和 c 近似,但 a 和 c 可能差很远),拿它当比较器是未定义行为。正确做法是排序用普通的<,去重才用带 eps 的谓词,或者干脆先按精确值排序,再用自定义的分组逻辑处理误差。

大小写不敏感去重:

std::vector<std::string> words = {"Apple", "apple", "Banana", "banana", "cherry"}; std::sort(words.begin(), words.end(), [](const std::string& a, const std::string& b) { return std::lexicographical_compare( a.begin(), a.end(), b.begin(), b.end(), [](char x, char y) { return std::tolower(x) < std::tolower(y); }); }); words.erase(std::unique(words.begin(), words.end(), [](const std::string& a, const std::string& b) { return a.size() == b.size() && std::equal(a.begin(), a.end(), b.begin(), [](char x, char y) { return std::tolower(x) == std::tolower(y); }); }), words.end());

这段代码有点长,但每一块都有存在的理由:排序和去重的比较逻辑必须一致,都得把大小写折叠掉。保留哪个原始大小写取决于排序的稳定性——std::sort不保证稳定,如果你想保留首次出现的写法,得用std::stable_sort,这一点后面讲“保持原序”时会再提。

4. 选型对比:什么时候不该用 sort + unique

4.1 排序的代价你算过没有

sort + unique 的组合,总复杂度是 O(n log n) + O(n)。当 n 达到千万级,log n 大概在 23 左右,排序这段的常数又不小(比较 + 交换 + 缓存不友好),实测下来往往是整个流程的瓶颈。如果你的场景对原始顺序没有要求,这个代价通常可以接受;但如果顺序有意义,排序本身就是个错误——它会破坏数据原有的逻辑。

用哈希表去重是另一条路:std::unordered_set的插入平均 O(1),整体平均 O(n),但每个元素要算哈希、要分配节点、指针跳转带来的缓存不友好会把常数拉高。所以在小数据量上它不一定比排序快,在超大数据量上它可能赢在渐进复杂度,但内存占用是排序的好几倍。

我把三种常见方案的特性整理成表,方便你按场景挑:

方案时间复杂度是否破坏原序额外内存适用场景
sort + unique + eraseO(n log n)是O(log n) 栈空间大批量、不关心顺序、内存敏感
unordered_set 辅助平均 O(n)可保持O(n) 节点数据量大、需保序、可哈希
set 辅助O(n log n)可保持O(n) 节点需保序、不可哈希、要求稳定
手动双指针 + 已排序前提O(n)是O(1)输入本身已有序

最后一行值得单独说。如果你的数据本来就是有序的(比如从有序文件里顺序读出来的,或者上一环节刚排过),那直接用 unique 就是 O(n),一分钱冤枉钱都不用花。我在做多路归并的时候经常利用这一点:归并结果天然有序,后面接个 unique 就完成了去重,全程线性。

4.2 一个可以照着改的测速框架

空谈复杂度不如自己量一遍。下面这段可以拿去改改就用:

#include <algorithm> #include <chrono> #include <iostream> #include <random> #include <unordered_set> #include <vector> int main() { std::mt19937 rng(42); std::uniform_int_distribution<int> dist(0, 1'000'000); std::vector<int> base(5'000'000); for (auto& x : base) x = dist(rng); auto t0 = std::chrono::steady_clock::now(); std::vector<int> a = base; std::sort(a.begin(), a.end()); a.erase(std::unique(a.begin(), a.end()), a.end()); auto t1 = std::chrono::steady_clock::now(); std::vector<int> b; b.reserve(base.size()); std::unordered_set<int> seen; for (int x : base) if (seen.insert(x).second) b.push_back(x); auto t2 = std::chrono::steady_clock::now(); std::cout << "sort+unique: " << std::chrono::duration<double, std::milli>(t1 - t0).count() << " ms, size=" << a.size() << "\n"; std::cout << "unordered_set: " << std::chrono::duration<double, std::milli>(t2 - t1).count() << " ms, size=" << b.size() << "\n"; }

在普通开发机上跑五百万个随机整数,我这边看到的量级是:sort+unique 大致在几百毫秒,unordered_set 方案通常在它的一到两倍之间,具体取决于哈希函数质量和整数分布。这个结论不是绝对的,换数据类型、换编译器、换机器都可能翻转,但它给了一个判断依据:整数、字符串这类可哈希且数据量不是极端大的场景,两个方案差距在一倍以内,那就优先选代码更短、更不容易出错的 sort+unique。只有当 n 特别大、或者你能确认哈希路径更快时,再上哈希表。

如果内存吃紧,还有个折中:std::vector排序后用 unique,比 set 省下大量节点开销。set 每个元素一个红黑树节点,指针加颜色位,实际占用通常是元素本身的两三倍。

4.3 必须保持原序时怎么办

假设你在处理用户操作日志,按时间先后排列,现在要按用户 ID 去重,只保留每个用户的第一次操作。这时候排序会毁掉时间顺序,必须换方案。

方案一,哈希表 + 双指针原地压缩:

template <class T, class Hash = std::hash<T>, class Eq = std::equal_to<T>> void stable_unique_inplace(std::vector<T>& v) { std::unordered_set<T, Hash, Eq> seen; seen.reserve(v.size()); size_t w = 0; for (size_t r = 0; r < v.size(); ++r) { if (seen.insert(std::move(v[r])).second) { if (w != r) v[w] = std::move(v[r]); ++w; } } v.resize(w); }

这段就是“手写版 unique”,只不过判断重复靠哈希集合而不是相邻比较。注意resize之后,被移动过的尾部元素会被析构,资源正常释放。seen.reserve是为了避免桶反复扩容,我习惯预留与元素数相当的大小。

方案二,用stable_partition表达同样的逻辑,可读性更好但多一次遍历:

std::unordered_set<T> seen; auto it = std::stable_partition(v.begin(), v.end(), [&seen](const T& x) { return seen.insert(x).second; }); v.erase(it, v.end());

注意这里的谓词带副作用,stable_partition会按原相对顺序把返回 true 的放前面。这个写法乍看有点“脏”,因为标准库算法的谓词一般要求无副作用,实践中可以工作,但严格从标准角度看属于灰区,我更倾向于用手写的双指针版本,逻辑一目了然,也不依赖实现细节。

方案三,元素不可哈希时,用std::set或先建索引数组排序再回填。前者简单但慢,后者代码长但可控。

5. 踩坑实录与排查速查表

5.1 五个我真实遇到过的问题

坑一:只调 unique 不 erase,size 不变。这是出现频率最高的问题,几乎每个新手都会撞一次。判断依据很简单:打印v.size()前后一样,但v的内容看起来“对了”。解决办法就是补上erase。

坑二:忘记排序,结果只去掉了一部分。表现是“有些重复没了、有些还在”,因为只有相邻的重复才会被合并。排查方法:在 unique 之前打印一下序列,看重复项是否聚在一起。

坑三:排序和去重用了不同的谓词。表现是结果既不按 A 唯一也不按 B 唯一,非常诡异。排查方法:把两个 lambda 拎出来对照,字段名必须一致。

坑四:拿 unique 的返回值当“删除个数”直接打印。数值上凑巧对,但语义错了,换个容器或换个写法就崩。正确写法是v.size() - std::distance(v.begin(), new_end)。

坑五:在 unique 之后还用旧的end()迭代器。比如先auto e = v.end();然后 unique、然后v.erase(new_end, e);。这段代码在vector上暂时能跑,但一旦你在中间做了任何插入或 erase,e就失效了。老老实实每次调v.end(),成本几乎为零。

5.2 速查表

现象最可能的原因排查动作修法
size()前后不变没接返回值、没 erase打印 size 和返回值补erase(std::unique(...), v.end())
只去掉了部分重复数据未排序打印 unique 前的序列先sort,或用哈希方案
编译报错,提示赋值失败元素不可赋值(如 map/set)看元素类型是否含 const 成员改用容器自身去重逻辑
顺序被打乱得莫名其妙谓词不满足严格弱序检查比较器是否传递排序用<,别用近似相等
结果里 NaN 一个没少==对 NaN 恒假打印含 NaN 的位置谓词里显式判isnan
对 list 调std::unique无效算法版不删元素看size()是否变化改用lst.unique()成员版
去重后仍有重复谓词与排序谓词不一致对比两个 lambda统一字段与比较语义
程序崩溃在析构阶段访问了尾部未指定区域检查是否读了[new_end, end)只用[begin, new_end)

5.3 几条文档里通常不写的经验

第一条,去重之后如果有大量删除,erase之后可以考虑shrink_to_fit。vector::erase只改size,不动capacity,如果这个容器之后要长期持有并且元素很少,多余的容量就是浪费。不过shrink_to_fit是非强制请求,实现可以不理,而且它会触发一次重新分配和整体搬移,只在确定容器会长期存在且容量浪费严重时才用。

第二条,unique 之前先reserve没必要,但unique_copy之前一定要。前者是原地操作,容量早就够了;后者是往新容器塞,不预留就会反复扩容。

第三条,在deque上用 unique 比vector慢,但比list快很多。因为deque是分段连续,迭代器推进有额外判断,而list是完全离散的节点,每次移动赋值都要找内存。如果你的数据量很大又需要频繁去重,能用vector就别用list。

第四条,对vector<bool>保持警惕。它的元素是代理引用对象,不是真正的bool&,虽然 unique 在这种代理上通常能编译通过(因为代理之间的赋值是被支持的),但各种边界行为容易出意外,我个人的习惯是去重前先转成std::vector<char>,处理完再转回去,代码丑一点但心里踏实。

6. 场景实战:几个可以直接抄的模板

6.1 日志记录清洗:按字段去重并保留最新

假设一批日志记录每条带时间戳和消息 ID,同一 ID 可能重复上报,需要按 ID 去重并保留时间戳最新的那条。思路是:先按 ID 升序、时间戳降序排序,这样同一个 ID 的记录聚在一起且最新的排在最前,然后按 ID 去重,留下的自然是最新那条。

struct LogEntry { int msg_id; long long ts; std::string body; }; std::vector<LogEntry> logs = /* ... */; std::sort(logs.begin(), logs.end(), [](const LogEntry& a, const LogEntry& b) { if (a.msg_id != b.msg_id) return a.msg_id < b.msg_id; return a.ts > b.ts; // 同一 id,时间戳大的在前 }); logs.erase(std::unique(logs.begin(), logs.end(), [](const LogEntry& a, const LogEntry& b) { return a.msg_id == b.msg_id; }), logs.end());

这个套路的关键在于排序的次要键方向必须和“保留哪条”的意图一致。想保留最旧的,就把a.ts > b.ts改成a.ts < b.ts。很多人只记得写排序,忘了想清楚次要键的方向,结果留下的是最老的一条,排查起来很费劲。

6.2 字符串压缩:把连续重复字符合并

"aaabbbcccdd"压成"abcd",一行就能搞定:

std::string s = "aaabbbcccdd"; s.erase(std::unique(s.begin(), s.end()), s.end());

如果要压成计数形式"a3b3c3d2",unique 就不够用了,得手写扫描。但我见过一种做法:用 unique 先把连续段合并,再统计每段长度,这反而绕远了。压缩这类任务更适合直接双指针一遍扫完:

std::string compress(const std::string& s) { std::string out; out.reserve(s.size()); size_t i = 0; while (i < s.size()) { size_t j = i; while (j < s.size() && s[j] == s[i]) ++j; out += s[i]; out += std::to_string(j - i); i = j; } return out; }

给这个例子是想说明:unique 擅长“合并相邻重复”,不擅长“带统计的合并”。工具选错方向,代码会越写越绕。

6.3 区间合并:unique 真正好用的地方在哪

给定一堆时间区间,合并所有重叠或相接的区间。标准做法是先按起点排序,然后线性扫描,能合并就扩展当前区间,不能就开新的。这里 unique 并非直接用于合并,而是用于最后的清理:如果排序后出现了完全相同的区间(起点终点都一样),可以用 unique 一次性去掉。

struct Interval { int l, r; }; std::sort(iv.begin(), iv.end(), [](const Interval& a, const Interval& b) { return a.l != b.l ? a.l < b.l : a.r < b.r; }); iv.erase(std::unique(iv.begin(), iv.end(), [](const Interval& a, const Interval& b) { return a.l == b.l && a.r == b.r; }), iv.end()); // 再线性合并 std::vector<Interval> merged; for (const auto& x : iv) { if (!merged.empty() && x.l <= merged.back().r) merged.back().r = std::max(merged.back().r, x.r); else merged.push_back(x); }

这里 unique 的价值在于把“完全重复的区间”这一步从主逻辑里剥离出来,让后面的合并循环不用再考虑重复情况。这种“用 unique 做前置清理,主逻辑只关心核心问题”的结构,是我在很多数据处理代码里反复用的模式,它让每个环节的职责都很单一。

6.4 结构体按多个字段组合去重

实际业务里经常遇到“两个字段组合起来算唯一”的情况,比如订单号加商品号。做法还是那一套,只是谓词里多判一个字段:

std::sort(v.begin(), v.end(), [](const Order& a, const Order& b) { if (a.order_no != b.order_no) return a.order_no < b.order_no; return a.sku < b.sku; }); v.erase(std::unique(v.begin(), v.end(), [](const Order& a, const Order& b) { return a.order_no == b.order_no && a.sku == b.sku; }), v.end());

字段一多,两个 lambda 里重复的条件就多,容易改漏。我的做法是把“组合键”抽成一个返回std::tuple的小函数,排序用std::tie或者直接比 tuple,去重也比 tuple:

auto key = [](const Order& o) { return std::tie(o.order_no, o.sku); }; std::sort(v.begin(), v.end(), [&](const Order& a, const Order& b) { return key(a) < key(b); }); v.erase(std::unique(v.begin(), v.end(), [&](const Order& a, const Order& b) { return key(a) == key(b); }), v.end());

这样字段的增删只改一处,排序和去重永远不会失配。这是我踩过一次“加了字段忘了同步两处”之后固定下来的写法,强烈建议直接用。

还有一点补充:如果你的“去重”其实需要“合并同键的多条记录”(比如把金额加起来),那 unique 只能帮你把记录聚在一起,真正聚合还得自己写循环。此时可以用std::equal_range在有序区间里找同键范围,或者手写分组扫描,比 unique 更直白。

补充:前面提到用std::unordered_set记录已见的方案

最后补充一下前面提到、但没有展开的std::unordered_set保序去重方案,并且这里再给一个完整可复用的模板:

template <class T> void dedup_keep_order(std::vector<T>& v) { if (v.size() < 2) return; std::unordered_set<T> seen; seen.reserve(v.size() * 2); size_t w = 0; for (size_t r = 0; r < v.size(); ++r) { if (seen.insert(std::move(v[r])).second) { if (w != r) v[w] = std::move(v[r]); ++w; } } v.resize(w); }

seen.reserve(v.size() * 2)这个系数是我从实践中调出来的,它能让装载因子保持在 0.5 以下,桶冲突少、插入快。

对于自定义结构体,还需要为std::unordered_set提供哈希函数和相等比较;如果嫌麻烦,就直接退回到std::set版本,也就是把unordered_set换成set,逻辑一行不用改,只是性能从平均 O(1) 变成 O(log n)。我在数据量小于十万级的场景里经常直接选set,因为省事、稳定、不依赖哈希函数的质量。

如果你的类型实在既不可哈希也没法排序(比如含有流对象之类的成员),那就只能老老实实两两比较,O(n²)。这种场景很罕见,遇到了就该想想数据模型是不是设计得有问题。