多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

C++字符串唯一性判断:从暴力枚举到位运算的五种解法与复杂度剖析

C++字符串唯一性判断:从暴力枚举到位运算的五种解法与复杂度剖析 聊到C算法题有一道经典问题几乎每次都会被翻出来判断一个字符串里的字符是否全部唯一。要求很简单输入一个string返回bool但就这一句话能引出暴力枚举、排序、哈希表、定长数组、位运算五种解法。我拿它当面试第一问用了一年多发现能答好的人并没有想象中多——很多人背过答案却说不清为什么哈希集合空间是O(n)也说不上什么时候可以用位运算把空间压到O(1)。这篇就用C把这些方案全部实现一遍把复杂度和踩坑点一次讲透最后告诉你我认为的“优选算法”到底是什么以及面试和工程里到底怎么选。1. 题目剖析与方案选型思路1.1 表面问字符实际考什么这道题本质上是让你实现一个函数给定字符串检查是否所有字符都不同。在C里最自然的函数签名长这样bool isUnique(const std::string s);这里第一个考点就藏在签名里。为什么用const std::string而不是std::string s因为前者是只读引用不会拷贝整个字符串大字符串场景下节省一次O(n)的堆分配后者按值传递实参会被完整复制一遍纯属浪费。如果你面试时直接在参数类型上写std::string s面试官大概率会在心里默默扣一分这就是C工程素养的体现。除了函数签名这道题真正想考察的点有三个第一你有没有“字符集有限”的意识能不能想到用数组或位图来标记状态第二你对STL容器和基础算法熟不熟比如std::sort、std::unordered_set第三边界条件处理得干不干净空字符串、超长字符串、大小写问题都是常见的翻车点。1.2 四个边界条件写代码前先问清楚我见过太多人拿到题就开始写结果写到一半发现需求没定义清楚。动手之前先把下面四件事问明白。第一字符集到底是什么如果输入是纯ASCII一共只有128个字符如果包含扩展ASCII最多256种如果输入是UTF-8编码的中文情况就复杂了一个汉字占用3个字节直接按char逐字节比较会得到乱码结果。字符集的范围直接决定了你能不能使用定长数组或者位运算。第二大小写算不算重复默认情况下a和A是两个不同的字符但有些业务场景要求忽略大小写如果忽略就得先tolower()再比较或者统一转成小写再进入判断逻辑。第三允许不允许修改原字符串这个约束直接毙掉一类解法——排序法必须改变字符顺序才能工作如果你不能原地修改就得先复制一份空间复杂度就上去了。第四空字符串和单字符串应该返回什么按集合论空集没有重复元素所以返回true单字符串当然也返回true。这个答案如果答错属于最可惜的失误。还有一个隐藏线索值得拿出来说如果字符串长度超过了字符集的大小那么根据鸽笼原理必然存在重复字符可以直接返回false一步扫描都不用做。这个简单的剪枝操作就是很多人口中“剪枝算法”的最小应用案例。1.3 五条路线先看全貌再动手在写代码之前先把所有候选方案放在一张草图上过一遍心里有底了再去实现。常见的方案有这么五条第一种暴力枚举两层循环依次比较每对字符第二种排序后比较相邻字符重复字符排完序一定紧挨着第三种用哈希集合记录已经出现过的字符第四种用固定大小的布尔数组做标记第五种位运算把一个整数当作开关数组用。还有一条路线我后面会补充就是std::bitset它本质上是位运算的封装版实现更安全理解起来也更直接。五条路线各有各的前提条件和代价不是越高级越好而是要根据输入约束选。接下来我按从笨到巧的顺序把每一条都用C完整实现一遍并且逐行拆开讲。2. 五种解法的完整实现与逐行解析2.1 暴力枚举法先写对再谈快先从最笨的暴力法开始它的思路简单到不需要任何数据结构拿第一个字符和后面的所有字符比如果找到相等的就对撞了立刻返回false比完再拿第二个字符和后面的所有字符比以此类推。bool isUniqueBruteForce(const std::string s) { int n static_castint(s.length()); for (int i 0; i n; i) { for (int j i 1; j n; j) { if (s[i] s[j]) { return false; } } } return true; }这个实现里有个细节必须说明白为什么内层循环的j要从i 1开始而不是从0开始因为外层已经遍历过前i个位置了i之前的字符都判断过了再比一次就是纯浪费。内层从i 1开始本质上就是一种“剪枝”把重复比较的分支全部砍掉让每对字符只比较一次。比较次数可以精确算出来第一轮比较n-1次第二轮比较n-2次累加到最后就是n(n-1)/2这是标准的等差数列求和。所以时间复杂度是O(n²)空间复杂度是O(1)因为你没有申请任何额外的数据结构。这个解法适合什么场景教学演示、理解双层循环或者字符串长度极小比如小于几十的时候。在实际面试里我不建议你把暴力法作为最终方案交出去但它可以作为优化路线的一个起点先把“显然正确”的方案写出来再往高效方案过渡这个思路本身在面试中也是加分的。2.2 排序法重复字符相邻这个性质很关键第二种方法是排序。思路也很直白如果字符串里有重复字符排序之后它们一定会排在一起你只需要扫描一遍检查每个字符和它前一个字符是否相同。bool isUniqueSort(std::string s) { std::sort(s.begin(), s.end()); for (size_t i 1; i s.size(); i) { if (s[i] s[i - 1]) { return false; } } return true; }这里要注意参数是按值传递的std::string s为什么要这样写因为std::sort会原地修改字符串的字符顺序如果你直接传入一个引用调用方的原始字符串就被改掉了这在很多场景下是不能接受的。按值传递会先拷贝一份排序在副本上进行原字符串保持不动。代价是空间复杂度从O(1)变成了O(n)这个取舍必须想清楚再去写。如果你和面试官确认过“可以原地修改原字符串”那参数类型可以改成std::string省掉拷贝空间退化为O(1)。但大多数情况下我会默认保留原字符串采用按值传递稳妥一些。std::sort的平均时间复杂度是O(n log n)所以排序法比暴力法快了一个量级。不过它有一个致命限制如果字符串包含多字节字符比如中文直接按char排序会拆成单个字节排序结果毫无意义。排完比较相邻字符时同样要按字符比较不能简单按字节比这一点我会在后面的踩坑章节再展开。2.3 哈希集合法字符集不确定时的通用解第三种方案是哈希集合。思路你肯定能猜到了每看到一个新字符就往集合里放一个扫描过程中如果发现字符已经存在于集合里说明重复。bool isUniqueSet(const std::string s) { std::unordered_setchar seen; for (char c : s) { if (seen.find(c) ! seen.end()) { return false; } seen.insert(c); } return true; }std::unordered_set底层是哈希表单次插入和查找的平均时间复杂度都是O(1)所以整体时间开销是O(n)但空间会随着不同字符数量增长最坏情况下要存下所有字符空间复杂度O(n)。这一版解法好在通用性强字符串里是什么字符都无所谓只要是单个char能表示的它都能处理。不过有几个点需要注意。第一哈希表不是免费午餐每个元素都会有哈希计算和可能的冲突处理常数开销比数组大得多当字符集很小的时候反而显得笨重。第二unordered_set本身的内存占用远不止存储一个char那么简单节点还包含指针和哈希值内存消耗可能是字符本身的几十倍。第三如果你需要有序输出或者频繁范围查询用std::set也可以但它是红黑树单次操作O(log n)整体O(n log n)这道题用不上这种有序性。哈希集合法是我在实际工程里最常用的一版因为它能应对“不知道字符集有多大”的模糊场景。面试时如果你拿不准输入的字符范围写这一版绝对不会错。2.4 定长数组法用128个bool标记第四种方案利用了字符集有限这个性质。如果字符集范围已知比如ASCII的128个字符那就可以用一个固定大小的布尔数组来记录哪些字符出现过索引直接取字符的编码。bool isUniqueArray(const std::string s) { if (s.length() 128) { return false; } bool seen[128] {false}; for (char c : s) { unsigned char idx static_castunsigned char(c); if (seen[idx]) { return false; } seen[idx] true; } return true; }代码里有两处容易踩坑。第一处是提前剪枝if (s.length() 128) return false;这就是鸽笼原理的应用字符总共只有128种字符串长度超过128必然有重复。这个判断把最耗时的场景直接挡在门外连一次扫描都不用做。第二处是unsigned char idx static_castunsigned char(c);这一步很多人会漏掉。为什么非要转成unsigned char因为C标准并没有规定char一定是有符号还是无符号在x86架构的GCC环境里char默认是有符号的。如果某个字符的编码大于127直接转成int会得到一个负数用它作为数组下标访问seen[idx]就是经典的越界错误。先转成unsigned char再隐式提升为int索引范围就能正确落在0到127之间。时间复杂度和哈希集合一样是O(n)但空间复杂度降到了O(1)这个量级因为不管字符串多长seen数组始终只有128个元素。它的缺点是前提约束太强要求你非常确定输入只在128个ASCII字符范围内如果字符集扩展到256个扩展ASCII码数组就要扩成256代码改成一行的事但思路不变。2.5 位运算法把一个int拆成26个开关最后登场的是我认为最优雅的一版也是很多人说的“优选算法”。它有个明确的前提字符串只包含小写字母a到z一共26个字符。如果不满足这个前提请回到哈希集合。思路是化用位图思想用一个int的32个二进制位来做标记每一位代表一个字母是否出现过。第0位代表a第1位代表b第25位代表z剩下的高位不用反正26个字母放得下。bool isUniqueBit(const std::string s) { if (s.length() 26) { return false; } int checker 0; for (char c : s) { int bit c - a; if ((checker (1 bit)) ! 0) { return false; } checker | (1 bit); } return true; }这段代码需要逐行看。int bit c - a;把字符映射成0到25的整数比如a变0b变1z变25。1 bit的意思是构造一个“只有第bit位为1”的整数比如1 3就是二进制1000十进制8。checker (1 bit)用来探测第bit位是否为1。运算的规则是两个位都为1结果才为1。如果探测结果不是0说明这一位已经被点亮了也就是这个字母之前出现过直接返回false。如果探测结果是0就用checker | (1 bit)把这一位置1|运算只要有一个是1结果就是1。整个过程可以形象地理解成你面前有一排26个开关每看到一个字母就拨动对应开关如果拨到一个已经打开的开关说明字母重复了。时间复杂度和前面两种高效方案一样是O(n)空间却是真正的O(1)——只用了1个int连数组都不需要。为什么它是最优的因为理论任何方案至少都要扫一遍字符串时间下界就是O(n)空间上数组还要128个bool位运算连数组都省了。在面试官追加“不允许使用额外数据结构”这个经典约束时位运算是唯一还能成立的解法。如果你不想自己手写位运算标准库也提供了封装好的版本std::bitset实际上就是按位存储的布尔数组用起来更安全理解门槛更低bool isUniqueBitset(const std::string s) { std::bitset256 seen; for (unsigned char c : s) { if (seen.test(c)) { return false; } seen.set(c); } return true; }bitset版不需要提前限定只有小写字母把容量开到256字符集就覆盖了扩展ASCII。它的底层和手写位运算一样都是按位存储但由标准库帮你管理边界和内存不会出现1 bit越界这种未定义行为。如果你的项目允许C11及以上我更推荐在实际代码里用std::bitset而不是手写int。3. 复杂度对比与工程实战中的取舍3.1 五种解法的复杂度对照聊到这里五种主流的解法都摆在眼前了放在同一张表里看它们的差异最直观解法时间复杂度空间复杂度核心前提适用场景暴力枚举O(n²)O(1)无超短字符串、教学演示排序法O(n log n)O(1)或O(n)允许修改字符串可排序的字符集哈希集合O(n)O(n)任意字符字符集不确定的通用场景定长数组O(n)O(1)固定128/256字符集有限且已知ASCII/扩展ASCII位运算O(n)O(1)1个int字符集≤32且可映射为整数小写字母等受限字符集这张表最有价值的信息是时间上O(n)已经是下界你不可能比O(n)更快因为总要遍历一遍字符串真正的变量是空间从O(n)到O(1)差的可能是一整个unordered_set的开销和单个int的开销这个差距在长字符串和内存敏感的设备上是天壤之别。3.2 为什么面试里更认可位运算每次讨论到最后总会有人问如果你只交一个解法交哪个我的回答是如果约束允许交位运算。原因有三层。第一层位运算是“不用额外数据结构”这个经典约束下的唯一解。面试官问这道题时十有八九会追加一句“能不能不用哈希表”很多人到这里就卡住了但位运算可以轻松应对。第二层位运算的时间和空间同时达到最优时间O(n)是必然下界空间O(1)是理论最优综合起来没有比它更省的方案。第三层位运算的执行效率极高和|是CPU单周期指令比哈希函数求值和链表节点操作快得多在超长字符串上差距会非常明显。但我不建议一上来就写位运算。如果你连暴力法和哈希集合都写不出直接甩一个位运算代码面试官很容易怀疑你在背答案。正确节奏是先写哈希集合再在“不用额外数据结构”的追问下顺势过渡到位运算同时清楚地说出1 bit的每一位是什么意思这样才能证明你是真的理解而不是背的模板。还有一个现实层面的问题位运算是可读性最差的方案。工程代码是给人读的checker | (1 bit)这一行新手看半天未必能理解而bool seen[128]一眼就明白。所以“优选算法”这四个字要看语境面试里求复杂度最优位运算胜出实际工程项目里求可维护性定长数组甚至std::bitset可能才是优选。3.3 真实工程里唯一性校验的用途这道题看起来像是面试专用题但字符唯一性校验在真实系统里比你想象的常见。我随手能列出一串场景。第一个场景是注册用户名和密码的弱口令检测。某些平台会限制用户名不能包含重复字符或者密码不能全部是相同字符这种校验不要求你把整张用户表拉出来比对只需要对当前输入字符串做一次唯一性判断正是这道题的代码逻辑。第二个场景是数据清洗。假设你在处理一批批量导入的订单号或设备号字段长度很短但量很大先对单个记录的字符串做唯一性检查可以过滤掉明显不合法的脏数据再进入后续的数据库写入流程。这个场景里输入往往是短字符串暴力法甚至都够用但数据量上来之后还是用O(n)的方案稳。第三个场景是编译器和协议解析器里的符号表构建。词法分析阶段要判断一个标识符是否已经被声明过查重操作频繁且对延迟敏感这种时候用固定数组或位图标记可以节省大量内存分配。协议解析里有些字段本身就是枚举标识判断同一字段里有没有重复的子标识也可以用同样的思路。第四个场景是游戏和抽卡逻辑里的牌面校验。某些玩法规则要求一组手牌不能出现重复花色或数值判断一组固定范围的枚举值是否有重复用位运算几乎零成本。所以不要以为这是道刷题玩具题它的位图思想在真实系统里是大量出现的基础技巧只不过封装在不同名字的外衣下面而已。3.4 字符串很长或包含Unicode怎么办上面的所有高效方案都建立在字符集有限的假设上。如果输入字符串非常长比如几GB的文本文件或者包含完整的Unicode字符集事情就变了。先说字符串很长的情况。整个字符串无法一次性载入内存时你需要流式读取一次读一个块对每一块执行字符去重判断。但真正的难题是重复字符可能跨块出现你需要在内存里维护一个全局的“已出现字符”表这个表只能保留字符集规模的标记信息不能随字符串增长而膨胀。这时候定长数组和位图又一次胜出因为不管数据量多大标记表的大小是固定的。再说Unicode的情况。Unicode的码点范围远超一个字节一个字符可能需要多字节编码char逐字节判断会直接出错。正宗的解法是先对字符串做UTF-8解码得到一个个完整的码点codepoint再把码点放进哈希集合里查重。这种场景下哈希集合是唯一现实的选择因为没有哪个定长数组能覆盖上百万个码点。如果字符基数大到连哈希集合的内存都吃不消比如要从海量数据中判断某个字段是否重复而且允许极小的误判率就可以引入布隆过滤器。布隆过滤器本质是一个超大的位数组加多个哈希函数空间只有哈希表的几十分之一代价是可能出现“假阳性”——它告诉你重复了实际上未必重复。放在这道题里可能有些超纲但如果你能主动提到这一步面试官对你的印象绝对会拉满。4. 踩坑实录与排查技巧4.1 新手高频Bug速查表这道题代码量不大但新手翻车的点非常多。我把这几年带人时见过的高频Bug整理成一张表每一个我都亲眼见过不是凭空编的坑点错误写法正确做法原因char符号性bool seen[c]unsigned char idx static_castunsigned char(c)char可能是有符号的大于127会变成负数索引位运算溢出int checker; checker (1 35)确保字符映射不超过31size_t死循环for (size_t i s.size() - 1; i 0; --i)用int或i ! SIZE_MAX做边界size_t无符号减到0会回绕成最大值空字符串直接返回false返回true空集没有重复元素大小写把A和a误判为重复默认不忽略大小写除非需求明确要求A和a编码不同中文字符直接按char比较按完整码点比较或改用哈希集合UTF-8中一个汉字占3字节最常踩的是第一个坑。很多人觉得bool seen[256]容量开到256就不会越界了却没想过char转成索引时会先经过符号扩展如果原字符编码大于127转出来的可能是-1这种负数照样越界。解决之道只有一个养成习惯凡是把char当作整数下标用的地方先转unsigned char。std::string::length()返回的是size_t无符号类型这一点也很阴。如果你写for (int i s.size() - 1; i 0; --i)还好因为int有符号循环能正常结束但如果你图省事写成for (size_t i s.size(); i 0; --i)这个循环永远不会结束因为i减小到0再减1就回绕成SIZE_MAX条件依然成立。排查这种Bug非常痛苦因为程序不会崩溃只是莫名其妙地卡住。4.2 面试官追问应对实录面试官问完第一轮实现通常还会追几个问题我把高频追问和应对思路整理在下面。第一个追问“能不能不用任何额外数据结构”这时的标准答案是位运算。前提是你能说明白字符串只包含小写字母或者某种可以映射到有限整数的字符集。讲解顺序先画开关图再说1 bit怎么构造掩码最后用checker mask和checker | mask解释检测和写入两步。如果你开场就把位运算当作唯一答案交上去这个追问就失去了意义所以还是建议先给哈希集合版本留一个上升空间。第二个追问“如果输入是Unicode字符呢”你要立刻意识到数组和位图都不适用了因为码点范围太大。标准解法是先把UTF-8解码成完整码点再用std::unordered_setuint32_t存储时间复杂度仍然是O(n)空间取决于字符种类数。如果面试官进一步问“码点很多怎么办”能引出布隆过滤器就算超预期表现。第三个追问“你这个位运算为什么必须假设只有小写字母”这个问题的潜台词是考察你对位图适用边界的理解。正确答案是一个int只有32位最多只能标记32种状态小写字母有26种放得下。如果字符集更大要么换std::bitset256要么换数组。把边界条件说清楚比死记代码更能展示能力。第四个追问“还能再优化吗”时间上O(n)已经是下限因为必须看一遍所有字符空间上到位运算的O(1)也是下限因为至少要有一个变量的空间。换句话说常规模型下已经没有优化空间。如果你非要说并行分块可以提但要自己先指出小字符串并行反而更慢线程开销大于收益这样才算思考过。4.3 VSCode下编译调试配置建议这道题代码量小很多人直接在在线编译器里跑一遍就完事但我还是建议你在本地配置一套C环境真正动手断点调试一遍。VSCode是目前最轻量的选择配置流程并不复杂。第一步安装MinGW-w64或者MSVC编译器。MinGW-w64在Windows上很常用装完把g所在的bin目录加入系统PATH。第二步在VSCode里安装C/C扩展来自Microsoft那个它提供语法高亮、智能提示和调试支持。第三步配置编译任务。你可以直接在项目根目录创建.vscode/tasks.json内容如下{ version: 2.0.0, tasks: [ { label: build, type: shell, command: g, args: [-stdc17, -g, main.cpp, -o, main], group: { kind: build, isDefault: true } } ] }编译时按CtrlShiftB就会执行这条任务生成带调试信息的main可执行文件。然后配置启动调试.vscode/launch.json里指向这个可执行文件就可以在代码里打断点逐行观察checker变量如何从0变成一个又一个二进制标记位被点亮。如果你不想折腾图形界面直接在集成终端里编译运行也完全够用一条命令的事g -stdc17 -Wall -Wextra -O2 main.cpp -o main ./main我强烈建议编译时加上-Wall -Wextra。这两个参数会打开几乎所有常见警告很多同学在char符号性和size_t无符号比较上的坑编译器其实都会发出警告只是默认界面下这些警告被淹没在输出里没人看。养成“警告也当错误”的习惯能帮你预防一大半的Bug。4.4 用测试用例把五种解法钉死代码写完了测试才是真正验证正确性的手段。不要只跑一个abcabc就收工边界条件全都要覆盖。这里我习惯用一组断言把五种解法统一验收一遍void testAll() { assert(isUniqueBruteForce(abcdefg) true); assert(isUniqueBruteForce(hello) false); assert(isUniqueBruteForce() true); assert(isUniqueBruteForce(a) true); assert(isUniqueBruteForce(abcABC) true); // 大小写不同 assert(isUniqueBruteForce(abcabc) false); assert(isUniqueBit(abcdefghijklmnopqrstuvwxyz) true); assert(isUniqueBit(abcdefghijklmnopqrstuvwxyza) false); }空字符串用例最容易漏很多人下意识觉得“空串没有字符”应该返回false但正确答案是true。大小写用例也值得单独写一个确保A和a不会因为编码接近而误判。位运算版本还要额外验证26个字母全不重复的极限情况和26个字母加一个a的溢出情况正好帮我们验证鸽笼原理的剪枝逻辑。正式项目里我不会用assert而是用GoogleTest或者Catch2这类测试框架但小练习用assert就够了。注意assert在NDEBUG宏定义下会被编译器直接删除所以它只适合开发阶段不适合发布版本。我个人在实际带人的过程中最喜欢用这道题观察一个人的思维习惯。能一口气写出位运算的人通常对bit操作有肌肉记忆能在动手前反问清楚字符集的人说明有工程意识这两种我都愿意给好评。反而是那种背了“标准答案”上来就甩一个unordered_set的人追问一句为什么不用数组就哑火了。这道题做完之后我建议你把五种解法各写一遍把复杂度对照表背下来再想一想如果要在内存只有1MB的嵌入式设备上运行你会选哪个方案。想清楚这些这道题才算真正吃透以后再碰到类似的字符串判定问题你一眼就能看穿它该用哈希、数组还是位图。
返回列表