C++ std::unique算法详解:高效去除相邻重复元素

发布时间:2026/7/30 12:25:24
C++ std::unique算法详解:高效去除相邻重复元素 1. 项目概述为什么我们需要std::unique在C的日常开发中尤其是在处理容器数据时我们经常会遇到一个看似简单却让人头疼的问题如何高效地移除一个序列中连续的重复元素比如你从数据库里拉出了一长串用户操作日志里面有很多连续重复的错误码或者你处理一个排序后的整数数组想把相邻的相同值去掉。手动写循环去判断vec[i] vec[i1]然后erase这不仅代码冗长更重要的是对于像std::vector这样的序列容器频繁的erase操作会导致大量的元素移动性能堪忧。这就是std::unique函数大显身手的地方。它是C标准库algorithm头文件中的一个算法专门用来“去除”一个范围内相邻的重复元素。注意这里的“去除”打了引号因为它并不像erase那样真正地把元素从容器里抹掉。这是理解unique的第一个关键点也是很多新手容易踩坑的地方。它通过重新排列元素将不重复的元素移动到范围的前部并返回一个指向新的“逻辑结尾”的迭代器。真正的删除操作通常需要配合容器的erase方法来完成也就是著名的“erase-remove”惯用法在unique上的应用。对于刚接触STL算法的小白来说std::unique的官方文档可能显得有些干涩和抽象。但只要你理解了它的核心思想——“原地整理返回新终点”你就会发现它既强大又优雅。它能帮你写出更简洁、更高效、更“C标准范儿”的代码。接下来我们就一层层剥开它的外壳从原理到实践让你彻底搞懂这个工具。2. 核心原理std::unique到底做了什么要理解std::unique绝对不能只看它的名字必须深入它的行为。我们可以把它想象成一个在序列上工作的“整理员”。2.1 算法行为模拟假设我们有一个已经排序的向量std::vectorint v {1, 1, 2, 2, 2, 3, 4, 4, 5};。std::unique会从第二个元素开始逐个与前面的元素比较默认使用operator。初始化设定一个“写入位置”指针通常指向第二个元素和一个“读取位置”指针从第二个元素开始遍历。遍历比较读取v[1](1) 比较v[1] v[0] 是都是1。那么它被视为重复跳过不进行任何写入。读取位置前进。读取v[2](2) 比较v[2] v[1] 注意此时v[1]仍然是原来的1。不相等。那么算法将v[2]的值2复制或移动到“写入位置”当前是v[1]。然后写入位置前进读取位置前进。读取v[3](2) 比较v[3] 当前写入位置前一个元素 此时写入位置在v[2]前一个元素是刚刚写入的2。相等所以跳过。以此类推...最终结果遍历结束后“写入位置”指向了第一个不应该再属于最终唯一序列的元素。在上例中经过整理容器前部变成了{1, 2, 3, 4, 5, ...}而“写入位置”就指向了那个...的开始处。...代表的是原序列中剩余的元素3, 4, 4, 5不对这里有个大坑它们的值是不确定的通常是被“遗留”下来的原末尾元素。重要提示标准只保证[v.begin(), new_end)这个区间内的元素是唯一的、且保持原相对顺序。[new_end, v.end())这个区间的元素状态是“未指定的”你不能依赖它们的值。它们可能是原始值的副本也可能是被移动后的残留物。直接访问它们是未定义行为。2.2 “去除重复”的真相所以std::unique(v.begin(), v.end())执行后容器v的物理大小size()并没有改变它只是把“唯一”的元素挤到了前面并告诉你“嗨从我开始后面的元素你就不用管了可以扔掉了”。这个“我”就是它返回的迭代器我们通常命名为new_end。因此完整的“去重”操作是两步auto new_end std::unique(v.begin(), v.end()); // 第一步整理获得新的逻辑终点 v.erase(new_end, v.end()); // 第二步删除尾部多余元素这行代码才是真正改变容器大小、实现去重功能的完整语句。这也是std::unique最经典、最常用的用法。2.3 自定义“相等”准则std::unique的威力不止于此。它还有一个重载版本允许你传入一个二元谓词来自定义两个元素何时被视为“相等”。这大大扩展了其应用场景。template class ForwardIt, class BinaryPredicate ForwardIt unique( ForwardIt first, ForwardIt last, BinaryPredicate p );这个谓词p接受两个元素通常是迭代器解引用后的值返回一个布尔值。当p(a, b)返回true时a和b就被认为是“相等”的b会被跳过。应用场景举例你有一个vectorstring想忽略大小写去除相邻重复的单词。bool caseInsensitiveCompare(const std::string a, const std::string b) { // 这里需要实际实现大小写不敏感比较例如转换为小写后比较 // 为简化示例我们假设有这样一个函数 return toLower(a) toLower(b); } std::vectorstd::string words {Hello, hello, WORLD, world, World}; auto it std::unique(words.begin(), words.end(), caseInsensitiveCompare); words.erase(it, words.end()); // 结果可能是 {Hello, WORLD, World} 取决于哪个被保留注意std::unique是稳定的即在多个连续相等的元素中它会保留第一个出现的那个。3. 实战演练从基础用法到高级场景理解了原理我们就要上手操作了。我会通过几个逐渐深入的例子展示std::unique的正确打开方式。3.1 基础用法对已排序容器去重这是最经典、最高效的用法。因为std::unique只处理相邻重复所以如果想让容器中所有重复元素无论位置都被去除必须先排序。#include iostream #include vector #include algorithm // for std::sort, std::unique #include iterator // for std::distance int main() { std::vectorint vec {9, 3, 3, 7, 1, 9, 3, 5, 7, 7}; // 1. 先排序让所有相同的值挨在一起 std::sort(vec.begin(), vec.end()); // 此时 vec {1, 3, 3, 3, 5, 7, 7, 7, 9, 9} // 2. 使用 unique 将唯一元素整理到前面并获取新的逻辑终点 auto unique_end std::unique(vec.begin(), vec.end()); // 此时 vec 内部可能变为 {1, 3, 5, 7, 9, ...}size() 仍为 10 // unique_end 指向第一个“多余”元素的位置 // 3. 真正删除尾部多余的元素 vec.erase(unique_end, vec.end()); // 输出结果 for (int num : vec) { std::cout num ; } // 输出: 1 3 5 7 9 std::cout \n容器大小: vec.size() std::endl; // 输出: 5 return 0; }3.2 处理自定义类型处理自定义的结构体或类时我们需要确保该类型支持operator或者为std::unique提供自定义谓词。#include algorithm #include vector #include string #include iostream struct Person { std::string name; int age; // 为了让 std::unique 的默认版本工作我们需要定义 operator // 这里我们定义“同名同年龄”即为同一个人仅作示例现实中需更复杂判断 bool operator(const Person other) const { return name other.name age other.age; } }; int main() { std::vectorPerson people { {Alice, 30}, {Bob, 25}, {Alice, 30}, // 重复项 {Charlie, 35}, {Bob, 25} // 重复项 }; // 注意对于自定义类型std::unique 默认使用 operator // 但它仍然只移除相邻重复所以通常需要先排序。 // 但排序需要定义 operator 或提供比较函数。我们先假设顺序已使重复项相邻。 // 更常见的做法是结合 std::sort 和自定义比较器。 // 例如按姓名和年龄排序 std::sort(people.begin(), people.end(), [](const Person a, const Person b) { if (a.name ! b.name) return a.name b.name; return a.age b.age; }); // 然后使用 unique auto new_end std::unique(people.begin(), people.end()); people.erase(new_end, people.end()); for (const auto p : people) { std::cout p.name ( p.age )\n; } // 输出: Alice (30), Bob (25), Charlie (35) return 0; }3.3 高级场景利用自定义谓词实现复杂去重逻辑自定义谓词让std::unique变得非常灵活。比如你想去除一个点集中相邻的、距离非常近的点视为同一个点。#include vector #include algorithm #include cmath #include iostream struct Point { double x, y; }; int main() { std::vectorPoint points { {0.0, 0.0}, {0.1, 0.0}, // 距离 (0,0) 很近 {0.2, 0.0}, {5.0, 5.0}, {5.05, 5.05}, // 距离 (5,5) 很近 {10.0, 10.0} }; // 自定义谓词如果两点欧氏距离小于阈值则认为“相等” const double distance_threshold 0.15; auto pointsTooClose [distance_threshold](const Point a, const Point b) { double dx a.x - b.x; double dy a.y - b.y; return (dx*dx dy*dy) (distance_threshold * distance_threshold); }; // 重要使用这种基于距离的谓词通常需要先按空间位置排序例如按x坐标 // 才能保证可能“相等”的点是相邻的。这里为了演示假设输入序列已大致有序。 // 更严谨的做法是先排序std::sort(points.begin(), points.end(), [](const Point a, const Point b){ return a.x b.x; }); auto new_end std::unique(points.begin(), points.end(), pointsTooClose); points.erase(new_end, points.end()); std::cout 去重后点数: points.size() std::endl; for (const auto p : points) { std::cout ( p.x , p.y ) ; } // 输出可能为: (0.0, 0.0) (5.0, 5.0) (10.0, 10.0) // 取决于阈值和点的分布 return 0; }4. 避坑指南与性能剖析std::unique用起来简单但陷阱也不少。下面是我在实际项目中总结的几个关键点和性能考量。4.1 常见误区与陷阱误区一忘记排序针对全局去重这是新手最常犯的错误。std::unique只移除相邻重复。如果你的向量是{1, 2, 1, 2}执行unique后不会有任何变化因为1和2都不与它们的邻居重复。必须先std::sort。注意排序会改变元素的原始顺序。如果你需要保留原顺序同时去重std::unique就不适用了。你需要考虑其他方法比如使用std::unordered_set不保留顺序或手动遍历并配合std::vector::erase时间复杂度较高。误区二忘记erase直接使用容器执行完auto it std::unique(vec.begin(), vec.end());后如果你直接遍历整个vec或使用vec.size()你会发现重复元素好像还在或者末尾有奇怪的值。一定要记得vec.erase(it, vec.end());。误区三对未排序容器使用自定义谓词去重即使你提供了自定义的“相等”谓词std::unique依然只检查相邻元素。如果相等的元素分散在容器各处它们不会被去重。谓词只是改变了判断两个元素是否“相等”的标准并没有改变算法只做相邻比较的本质。陷阱迭代器失效在vec.erase(unique_end, vec.end());这行代码中erase操作会使指向被删除元素及其之后的所有迭代器、指针和引用失效。但在这个特定惯用法中unique_end是由std::unique刚刚返回的且紧接着就被用于erase之后不再使用所以是安全的。但如果你在复杂逻辑中保存了其他迭代器就需要小心。4.2 性能考量与最佳实践时间复杂度std::unique本身是线性时间复杂度 O(N)因为它只遍历序列一次。但是通常与之配套的std::sort是 O(N log N)。所以整个“排序去重”操作的主导因素是排序。空间复杂度std::unique是原地算法除了临时变量和谓词对象不需要额外空间空间复杂度 O(1)。与std::set/std::unordered_set的对比std::uniquestd::sort适用于已经需要排序的序列或者去重后结果需要有序的场景。优点是去重后的元素保持原排序顺序稳定且对于已排序或接近排序的数据效率高。std::set在插入过程中自动去重并排序。适用于需要动态维护一个有序唯一集合的场景。每次插入是 O(log N)。std::unordered_set在插入过程中自动去重但不保证顺序。适用于只需要去重不关心顺序且对查找性能要求极高的场景。平均插入是 O(1)。如何选择如果你有一个现成的vector想一次性去重用sortuniqueerase。如果你需要持续向一个集合中添加元素并始终保持其唯一性用set或unordered_set。如果你需要保留元素第一次出现的原始顺序不能用sort可以考虑遍历vector用unordered_set记录已出现元素将未出现的加入新vector。这种方法空间换时间。对非vector容器的支持std::unique要求迭代器是前向迭代器。因此它可以直接用于std::list,std::deque,std::array, 原生数组等。对于std::list它还有一个成员函数list.unique()功能类似但可能针对链表结构有优化。4.3 一个综合案例清理日志时间戳假设你有一组可能带有重复时间戳的日志条目你需要清理这些重复项时间戳相差1秒内视为重复并保持时间顺序。#include vector #include algorithm #include iostream #include string #include ctime struct LogEntry { std::time_t timestamp; // 时间戳 std::string message; }; int main() { // 模拟日志数据假设时间戳可能因采集问题有微小重复 std::vectorLogEntry logs { {1000, System start}, {1001, User login}, {1001, User login}, // 完全重复 {1005, Error: DB timeout}, {1006, Error: DB timeout}, // 1秒后重复的日志 {1020, Task completed}, }; // 1. 首先确保按时间戳排序保持日志顺序 std::sort(logs.begin(), logs.end(), [](const LogEntry a, const LogEntry b) { return a.timestamp b.timestamp; }); // 2. 使用自定义谓词去重时间戳相差小于2秒视为重复 const std::time_t duplicate_threshold 2; auto isDuplicate [duplicate_threshold](const LogEntry a, const LogEntry b) { return std::abs(a.timestamp - b.timestamp) duplicate_threshold; }; auto new_end std::unique(logs.begin(), logs.end(), isDuplicate); logs.erase(new_end, logs.end()); // 输出清理后的日志 for (const auto log : logs) { std::cout [ log.timestamp ] log.message std::endl; } // 输出可能为 // [1000] System start // [1001] User login // [1005] Error: DB timeout // [1020] Task completed // 注意1006的日志因为与1005相差1秒2被视为重复被移除。 return 0; }5. 深入理解迭代器与算法协作要真正掌握std::unique必须理解它返回的迭代器以及它如何与其他STL算法协作。5.1 返回值new_end的妙用std::unique返回的迭代器指向的是“最后一个唯一元素之后的位置”。这个迭代器本身就是一个有用的分界点。std::vectorint vec {1, 2, 2, 3, 3, 3, 4, 5}; auto unique_end std::unique(vec.begin(), vec.end()); // 你可以直接使用这两个区间 // [vec.begin(), unique_end) 是唯一化后的有效区间 std::for_each(vec.begin(), unique_end, [](int x){ std::cout x ; }); // 输出: 1 2 3 4 5 // [unique_end, vec.end()) 是“垃圾”区间内容未指定 // 在调用 erase 之前你可以把这个区间的空间用作临时缓冲区但需谨慎。5.2 与std::remove和std::remove_if的对比std::unique、std::remove、std::remove_if都属于“重排-擦除”惯用法中的“重排”算法。它们有相似之处但目的不同算法目的操作依据典型用法std::unique移除相邻的重复元素元素是否与前一个元素“相等”vec.erase(std::unique(vec.begin(), vec.end()), vec.end());std::remove移除所有等于特定值的元素元素是否等于某个给定值vec.erase(std::remove(vec.begin(), vec.end(), value), vec.end());std::remove_if移除所有满足某个条件的元素一元谓词是否返回truevec.erase(std::remove_if(vec.begin(), vec.end(), predicate), vec.end());核心共同点它们都不真正删除元素只是通过移动/复制元素将“要保留的”元素移动到范围前部并返回新的逻辑终点。5.3 手写一个简化版的unique为了加深理解我们可以尝试实现一个只针对int向量、使用比较的简化版unique#include vector #include iostream // 模拟 std::unique 的基本行为仅针对 vectorint std::vectorint::iterator my_unique(std::vectorint::iterator first, std::vectorint::iterator last) { if (first last) return last; // 空范围 std::vectorint::iterator result first; // “写入位置” first; // 从第二个元素开始“读取” while (first ! last) { // 如果当前读取的元素不等于已写入的最后一个元素 if (!(*result *first)) { result; // 移动写入位置 *result *first; // 将新元素复制/移动到写入位置 } first; // 继续读取下一个 } return result; // 返回最后一个唯一元素的下一个位置 } int main() { std::vectorint v {1, 1, 2, 3, 3, 2, 2, 4}; // 注意未排序 auto new_end my_unique(v.begin(), v.end()); std::cout 逻辑唯一区间: ; for (auto it v.begin(); it ! new_end; it) { std::cout *it ; } std::cout \n整个容器: ; for (int i : v) std::cout i ; // 输出: // 逻辑唯一区间: 1 2 3 2 4 // 整个容器: 1 2 3 2 4 2 2 4 // 验证了它只去除了相邻重复的 (1,1) 和 (3,3)而非相邻的2没有被去除。 return 0; }这个简易实现清晰地展示了“双指针”读写指针的核心思想。标准库的实现更加通用和优化但本质逻辑与此一致。6. 扩展与变种std::unique_copy有时你不想修改原始序列而是想将去重后的结果输出到另一个地方。这时就该std::unique_copy出场了。#include iostream #include vector #include algorithm #include iterator int main() { std::vectorint src {1, 2, 2, 3, 4, 4, 4, 5}; std::vectorint dst; // 方法1使用 back_inserter std::unique_copy(src.begin(), src.end(), std::back_inserter(dst)); // 方法2如果知道大致大小可以先 reserve dst.clear(); dst.reserve(src.size()); // 最大不会超过源大小 std::unique_copy(src.begin(), src.end(), std::back_inserter(dst)); for (int n : dst) std::cout n ; // 输出: 1 2 3 4 5 std::cout std::endl; // 同样它也有接受自定义谓词的重载 std::vectorint src2 {5, 10, 15, 20, 25}; std::vectorint dst2; // 一个“奇怪”的谓词差值为5的视为“相等”仅作演示 auto pred [](int a, int b) { return std::abs(a - b) 5; }; std::unique_copy(src2.begin(), src2.end(), std::back_inserter(dst2), pred); for (int n : dst2) std::cout n ; // 输出: 5 15 25 因为10与5“相等”20与15“相等” return 0; }std::unique_copy在需要保留原数据、或者向不同类别的输出迭代器如文件流、std::cout写入结果时非常有用。7. 总结与个人心得std::unique是一个典型的“STL风格”算法它做一件小事移除相邻重复但做得很好高效、通用。它的设计体现了C标准库的一个重要理念算法与容器分离通过迭代器协作。在实际项目中我几乎从未单独使用过std::unique它总是和std::sort以及erase成对出现。记住这个“黄金组合”sort-unique-erase。当你在代码评审中看到这三行代码紧密相连时立刻就能明白作者的意图对容器进行排序并去重。有几个点值得反复强调作用对象相邻重复项。前提条件全局去重要先排序。必要操作必须配合erase才能真正缩小容器。灵活扩展自定义谓词可以定义复杂的“相等”关系。最后性能上不必过早优化。对于大多数情况sortO(N log N) uniqueO(N)的组合已经足够高效。只有当数据量极大百万级以上且性能分析表明此处是瓶颈时才需要考虑使用unordered_set进行一轮哈希去重等替代方案但那通常会牺牲元素的原始顺序。理解std::unique是理解STL算法“重排-擦除”范式的一个绝佳起点。掌握了它你再学习std::remove、std::partition等算法时就会感到非常自然和顺畅。