C++去重:std::unique相邻压缩与erase-remove惯用法 std::unique这个函数我见过太多人第一次用就翻车——明明调了去重打印出来的size()一点没变或者元素确实少了但剩下的顺序和预期完全对不上。它属于那种“一句话能讲明白但手一抖就出错”的典型 STL 组件。这篇东西想干的事很简单把 unique 函数从语义、实现、选型到踩坑一层层拆开讲透让刚接触 STL 的同学能照着模板抄作业也让写过几年 C 的朋友回头补上几个一直被忽略的细节——比如它为什么只处理相邻元素、返回的那个迭代器到底指到哪儿、erase为什么必须跟在后面、以及什么时候你压根不该用它。全文会围绕几个真实的高频场景展开数据去重、字符串压缩、结构体按字段筛选、日志清洗每个环节都给可运行的代码和参数说明。读完之后你应该能做到看到一段需要去重的逻辑脑子里立刻判断出该用 sortunique、还是该用哈希表、还是该老实手写双指针。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::vectorint v{1, 1, 2, 2, 3}; std::unique(v.begin(), v.end()); // 错返回值丢了 std::cout v.size(); // 依然是 5size()没变是必然的因为 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::vectorint 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)从 C11 起vector提供了它删单个位置的元素。老代码里偶尔能看到有人写v.erase(std::unique(...), v.end())其实就是同一件事因为 unique 返回的就是新结尾但写成erase(it, v.end())在 C11 之前是唯一的写法现在两种都行。如果你在 C20 环境下还可以用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_castint(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 成员是C20 起返回删除个数无lst.unique(pred)list 成员是同上无flst.unique()forward_list 成员是同上无std::forward_list同理也有成员版本。这张表我建议存下来list和forward_list上的成员unique是唯一不用配合erase的场合。3.3 unique_copy输出到另一个容器当你不希望破坏原数据或者想输出到不同类型的目标时用std::unique_copystd::vectorint src {1, 1, 2, 2, 2, 3}; std::vectorint 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::vectorRecord 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::vectorstd::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 unique4.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_distributionint dist(0, 1000000); std::vectorint base(5000000); for (auto x : base) x dist(rng); auto t0 std::chrono::steady_clock::now(); std::vectorint 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::vectorint b; b.reserve(base.size()); std::unordered_setint seen; for (int x : base) if (seen.insert(x).second) b.push_back(x); auto t2 std::chrono::steady_clock::now(); std::cout sortunique: std::chrono::durationdouble, std::milli(t1 - t0).count() ms, size a.size() \n; std::cout unordered_set: std::chrono::durationdouble, std::milli(t2 - t1).count() ms, size b.size() \n; }在普通开发机上跑五百万个随机整数我这边看到的量级是sortunique 大致在几百毫秒unordered_set 方案通常在它的一到两倍之间具体取决于哈希函数质量和整数分布。这个结论不是绝对的换数据类型、换编译器、换机器都可能翻转但它给了一个判断依据整数、字符串这类可哈希且数据量不是极端大的场景两个方案差距在一倍以内那就优先选代码更短、更不容易出错的 sortunique。只有当 n 特别大、或者你能确认哈希路径更快时再上哈希表。如果内存吃紧还有个折中std::vector排序后用 unique比 set 省下大量节点开销。set 每个元素一个红黑树节点指针加颜色位实际占用通常是元素本身的两三倍。4.3 必须保持原序时怎么办假设你在处理用户操作日志按时间先后排列现在要按用户 ID 去重只保留每个用户的第一次操作。这时候排序会毁掉时间顺序必须换方案。方案一哈希表 双指针原地压缩template class T, class Hash std::hashT, class Eq std::equal_toT void stable_unique_inplace(std::vectorT v) { std::unordered_setT, 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_setT 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 不 erasesize 不变。这是出现频率最高的问题几乎每个新手都会撞一次。判断依据很简单打印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上暂时能跑但一旦你在中间做了任何插入或 erasee就失效了。老老实实每次调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。第四条对vectorbool保持警惕。它的元素是代理引用对象不是真正的bool虽然 unique 在这种代理上通常能编译通过因为代理之间的赋值是被支持的但各种边界行为容易出意外我个人的习惯是去重前先转成std::vectorchar处理完再转回去代码丑一点但心里踏实。6. 场景实战几个可以直接抄的模板6.1 日志记录清洗按字段去重并保留最新假设一批日志记录每条带时间戳和消息 ID同一 ID 可能重复上报需要按 ID 去重并保留时间戳最新的那条。思路是先按 ID 升序、时间戳降序排序这样同一个 ID 的记录聚在一起且最新的排在最前然后按 ID 去重留下的自然是最新那条。struct LogEntry { int msg_id; long long ts; std::string body; }; std::vectorLogEntry 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());如果要压成计数形式a3b3c3d2unique 就不够用了得手写扫描。但我见过一种做法用 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::vectorInterval 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去重也比 tupleauto 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::vectorT v) { if (v.size() 2) return; std::unordered_setT 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²)。这种场景很罕见遇到了就该想想数据模型是不是设计得有问题。