在C++中实现字符串去重,最直接的思路是从前往后扫描,把每个字符与结果串比较,不存在才追加。这样做逻辑清晰,但效率不高,尤其是长字符串场景下会反复执行线性查找。标准库提供了一套更优雅的组合:先调用std::sort对字符串排序,让相同的字符相邻,再调用std::unique把不重复的元素前移,最后用erase真正删除尾部多余元素。这个过程不需要手动循环,代码简洁,时间复杂度也稳定可控。下面从std::unique的底层行为开始分析。

一、std::unique的底层行为与返回值含义
std::unique并不是真正意义上的删除算法,它更像是一个压缩算法。它遍历一个有序区间,每当发现当前元素与上一个保留元素不同时,就把该元素移动到前面的保留区域中。整个过程使用两个迭代器:一个指向待检查元素,另一个指向下一个可以写入的位置。最终所有不重复元素都被连续地排到区间开头,而尾部剩下的元素值处于未指定状态,容器的实际长度并没有发生改变。
例如字符串经过排序后变成abbbcc,调用std::unique后,区间开头会变成abc,但容器整体长度仍然是6。函数返回的迭代器指向新的逻辑结尾,也就是abc之后的位置。要真正把多余的bbc删掉,必须把这个返回迭代器传递给string的erase成员函数。下面的代码展示了这一过程:
#include <iostream>
#include <string>
#include <algorithm>
int main() {
std::string s = "abbbcc";
auto it = std::unique(s.begin(), s.end());
std::cout << "原始大小: " << s.size() << "\n";
std::cout << "逻辑去重后内容: " << s.substr(0, it - s.begin()) << "\n";
std::cout << "容器实际内容: " << s << "\n";
return 0;
}
运行这段代码会发现,虽然s.size()仍然是6,但前3个字符已经是abc。这种“只压缩不删除”的设计让std::unique可以同时适用于vector、string等连续容器,也避免了频繁调整容器长度带来的开销。理解这一点对于正确使用erase去尾至关重要。
另外,std::unique还可以接受第三个参数作为自定义的相等判断函数。比如对于用户自定义类型、忽略大小写的字符比较,都可以通过传入二元谓词来扩展默认的==判断逻辑。不过对于普通字符串去重,默认版本已经足够简洁。
二、为什么必须先用std::sort排序
std::unique只处理相邻元素,它判断重复的依据是当前元素与上一个已保留元素是否相同。因此如果输入字符串没有排序,重复字符可能分散在不同位置,std::unique就无法发现它们是重复的。比如字符串banana中,字母a出现在第2、第4、第6位,字母n出现在第3、第5位,它们并不相邻。直接调用std::unique再去尾,结果仍然是banana,去重完全无效。
下面这个错误示例展示了直接使用std::unique的后果:
#include <iostream>
#include <string>
#include <algorithm>
int main() {
std::string s = "banana";
auto it = std::unique(s.begin(), s.end());
s.erase(it, s.end());
std::cout << s << std::endl; // 输出 banana,去重无效
return 0;
}
要让std::unique发挥全局去重的作用,必须先对字符串排序。排序后相同的字符会聚在一起,std::unique只需一次线性扫描就能把所有重复项压缩掉。完整写法如下:
#include <iostream>
#include <string>
#include <algorithm>
std::string dedupe_sorted(std::string s) {
std::sort(s.begin(), s.end());
auto it = std::unique(s.begin(), s.end());
s.erase(it, s.end());
return s;
}
int main() {
std::string input = "banana";
std::string output = dedupe_sorted(input);
std::cout << "去重结果: " << output << std::endl; // abn
return 0;
}
这种先排序再去重的方案会改变原字符串的字符顺序。如果业务要求保持第一次出现的顺序,就不能使用std::sort。但它胜在代码短、无额外内存分配,并且对于只关心去重后字符集合的场景非常合适。排序时间开销为O(n log n),后续unique和erase均为O(n),整体复杂度由排序决定。
三、保持原顺序的替代方案与对比
如果需求是保留字符首次出现的顺序,可以使用std::unordered_set配合一次遍历。每次读到一个字符时,尝试插入集合,插入成功说明该字符第一次出现,将它追加到结果串;插入失败则跳过。这种方法时间复杂度平均为O(n),但需要使用额外的哈希表空间。
对应的实现如下:
#include <iostream>
#include <string>
#include <unordered_set>
std::string dedupe_keep_order(const std::string& s) {
std::unordered_set<char> seen;
std::string result;
for (char c : s) {
if (seen.insert(c).second) {
result.push_back(c);
}
}
return result;
}
int main() {
std::string input = "banana";
std::cout << dedupe_keep_order(input) << std::endl; // ban
return 0;
}
三种常见方案在顺序保持、时间复杂度和空间使用上有明显差异。手动循环加find的方法不需要排序,但每插入一个字符都可能要对结果串做一次线性查找,最坏情况退化为O(n²)。排序加std::unique不保留原顺序,时间复杂度稳定,空间无额外消耗。std::unordered_set保留原顺序且平均速度快,但需要额外的哈希表空间。
实际选择时可以用一个简单标准:如果字符串长度较短且对顺序没有要求,直接使用排序加std::unique;如果字符串很长且必须保留原顺序,优先考虑std::unordered_set;如果只使用C++标准库但又不想引入哈希表,可以使用std::set替代,但它的复杂度会上升到O(n log n)。下面表格简要对比了这些方案的差异。
| 方案 | 是否保持原顺序 | 时间复杂度 | 额外空间 |
|---|---|---|---|
| 排序 + std::unique | 否 | O(n log n) | 无 |
| unordered_set遍历 | 是 | 平均O(n) | O(n) |
| 手动find追加 | 是 | 最坏O(n²) | 无 |
| set遍历 | 是 | O(n log n) | O(n) |
四、工程实践中的注意事项
使用std::unique时最容易犯的错误就是忽略它的返回值。很多人以为调用std::unique(s.begin(), s.end())之后字符串已经变短,后续直接使用s可能读到尾部未定义内容。实际上必须使用返回值配合erase,这一步不能省略。另一个误区是把std::unique作用在未排序的字符串上,只去除了相邻重复,造成表面上的去重,埋下逻辑隐患。
对于需要忽略大小写的字符串去重,可以在排序前把字符串统一转为小写,再执行排序和unique。如果原始大小写仍需要保留,建议先复制一份用于去重判断,或者使用自定义比较函数。不过自定义比较函数只能影响unique的判断逻辑,不能影响排序阶段,因此排序和unique最好使用一致的大小写规则,否则仍然可能出现重复字符不连续的问题。
当处理中文或其他多字节编码字符串时,直接对std::string按char去重会破坏多字节字符边界,产生无效编码。此时应该考虑使用std::wstring或按UTF-8码点拆分后的容器。对于宽字符串,代码结构完全相同,只需要把类型换为std::wstring、头文件换为<string>并注意输出方式即可。std::sort和std::unique作为模板算法,对连续字符容器具有很好的通用性。
性能敏感场景下,可以先通过reserve为结果串预留容量,减少多次push_back带来的内存重分配。对于排序加unique方案,则没有这种必要,因为直接操作原串,不会产生新的字符串副本。如果输入字符串非常大,排序本身可能成为瓶颈,此时可以考虑使用基数排序或频率统计法,但实现复杂度也会随之上升。总体而言,std::sort与std::unique的组合已经足够应对绝大多数常规字符串去重需求。
C++字符串去重std::sortstd::unique修改时间:2026-09-19 00:49:51