ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C语言原地反转字符串单词:双指针与O(1)空间实战解析

C语言原地反转字符串单词:双指针与O(1)空间实战解析 在 LeetCode 上刷到 151 这道题时我第一反应是“反转字符串中的单词”听起来挺简单但真正用 C 语言做一遍才发现里面藏着不少值得细抠的点。题目要求在O(1) 额外空间的前提下完成反转这意味着不能开一个新数组来存单词只能原地操作。配合“双指针”这个高频技巧这道题其实很适合用来检验对字符串边界、指针移动、原地修改这三件事的掌握程度。我写这篇东西的初衷是想把这道题的完整推导过程、可运行的 C 代码、以及我做的时候踩过的几个坑一次性说清楚。不管你是刚开始刷题、还是准备面试前想快速过一遍字符串经典题这篇都值得花十分钟读一读。我会从最笨的辅助空间做法讲起再落地到原地双指针的写法全程用“人话”解释每一步为什么这么做。1. 题目分析与整体设计拆解先把题目复述一下给定一个字符串你需要反转字符串中单词的顺序同时把单词之间的多余空格清理掉。比如输入 the sky is blue 输出应该是blue is sky the。注意两点首尾的空格要去掉单词之间如果有多个空格只保留一个。1.1 为什么这道题值得单独写一篇市面上关于这道题的题解很多但大部分是针对 Python、Java 的利用split()可以轻松得到单词数组再反转拼接就完事。C 语言没有现成的 split暴露出来的问题更底层字符串是连续的字符数组怎么原地调换单词顺序怎么在移动字符时不越界、不丢\0。这道题还有一个特殊之处它要求 O(1) 额外空间。很多版本题目描述里会写“在字符串上直接操作”C 语言天然适合这种原地操作因为char*就是一块可变内存。但同时也意味着所有辅助容器比如存储每个单词起始位置的数组都不太好用得靠指针变量自己记。1.2 两种方案的整体思路对比刚开始我想到的是最简单粗暴的做法扫描原串把单词逐个拷贝到一个新字符串里最后把新字符串搬回去。这能解决问题但不符合 O(1) 空间要求。我把它作为“理解题意的辅助方案”代码量少跑通很容易。真正要掌握的方案是经典的“三步走”原地算法清除多余空格把字符串原地“压缩”去掉首尾空格把连续的空格变成一个空格。整体反转把整个字符数组反转单词的先后顺序反转过来。逐单词反转按空格把每个单词切出来对单词内部再做一次反转。这三步做完单词顺序就是目标顺序而单词内部的字母顺序也恢复了正常。这三个步骤里每一步都可以用双指针完成所以这道题被归到“双指针”标签下是非常自然的。我推荐的学习路径是先写辅助空间版本跑通测试用例理解“单词顺序反转”这件事的本质再改成原地版本体会双指针如何把空间复杂度从 O(n) 降到 O(1)。两步之间的落差就是这道题真正的价值所在。2. 核心前置知识C 字符串与双指针基础2.1 C 字符串的几个隐形特性在进入代码之前先把 C 字符串的特点捋一遍。C 字符串本质是char数组以\0结尾。这意味着遍历字符串时不能靠数组长度直接控制循环每次要检查当前字符是不是\0原地修改字符串时字符个数变化删除空格之后新的\0必须手动放到正确的位置strlen返回的长度不包括末尾的\0但数组实际占用的空间多了 1 个字节。这些特性对初学者来说很容易被忽略而这道题恰好每个都会踩到。比如清除空格时字符往前搬了数组长度没变但逻辑上字符串结尾的位置变了最后要手动赋值\0。我在第一次写的时候就漏了这一步结果把后面残留的字符也打印了出来。2.2 双指针到底是什么意思双指针不是某种神秘算法它只是一种“用两个下标或指针协同扫描数组”的套路。最常见的两种形式快慢指针一个指针负责遍历原数组另一个指针负责指向结果写入位置常见于原地删除元素、去重等场景。左右夹逼两个指针分别从数组两端向中间移动常见于反转数组、寻找满足某种条件的区间。本题两个阶段分别用了这两种形式清除空格用的是快慢指针整体反转和单词反转用的是左右两端向中间交换。用生活类比来解释快慢指针相当于一个人在前面巡逻把需要保留的东西往后面传递后面那个人只负责接收左右夹逼相当于从队伍两端往中间对向走边走边交换手里的东西。2.3 原地操作时最容易被忽略的问题原地操作意味着你在修改和读取同一块内存这时候必须小心“覆盖”问题。比如用快慢指针清除空格时慢指针写入的位置可能比快指针当前位置靠前这没问题但如果反过来慢指针在快指针后面就可能把还没读到的字符覆盖掉。写代码时一定要先想清楚两个指针的位置关系再决定能不能写。另外反转字符数组的写法非常统一记住一个模板void reverseRange(char* s, int left, int right) { while (left right) { char tmp s[left]; s[left] s[right]; s[right] tmp; left; right--; } }这个函数接收左右下标对[left, right]区间内的字符做原地反转。后面整体反转和单词反转都复用它。3. 完整实现原地双指针版本3.1 代码全貌下面是我调试通过的一版完整代码基于题目给定的函数签名char* reverseWords(char* s)char* reverseWords(char* s) { int len strlen(s); // 第一步清除多余空格快慢指针 int slow 0; for (int fast 0; fast len; fast) { // 当前字符不是空格或者 slow 位置的前一个不是空格时才写入 if (s[fast] ! || (slow 0 s[slow - 1] ! )) { s[slow] s[fast]; } } // 处理尾部可能残留的空格最后一个字符是空格的话slow 会多推进一次 if (slow 0 s[slow - 1] ) { slow--; } s[slow] \0; len slow; // 第二步整体反转 reverseRange(s, 0, len - 1); // 第三步逐单词反转 int start 0; for (int end 0; end len; end) { if (end len || s[end] ) { reverseRange(s, start, end - 1); start end 1; } } return s; }3.2 第一步快慢指针清除多余空格这一步是整道题最容易写错的地方。思路是用fast指针遍历整个字符串用slow指针记录“下一个要写入的位置”。遍历时遇到两种情况才把字符复制到s[slow]当前字符不是空格当前字符是空格但前一个已保留的字符不是空格。画个图来解释。原始字符串是 hello world fast 从 0 开始逐个扫描 索引: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 字符: h e l l o w o r l d fast0时字符是空格但slow0且slow - 1不合法条件不成立不写入fast1时字符是空格slow0同样不写入fast2时字符是h写入s[0]slow变成 1之后e/l/l/o连续写入slow变成 5fast7时字符是空格检查s[slow-1]也就是s[4]是o不是空格所以这个空格需要保留写入s[5]slow6fast8时字符是空格s[slow-1]是刚才写入的空格条件不成立跳过fast9之后w/o/r/l/d依次写入slow到 11fast14时字符是空格s[slow-1]是d条件成立会把空格写到s[11]slow变成 12。这时候s的前 12 个字符是hello world 末尾多了一个空格。所以我在写完循环后补了一段if (slow 0 s[slow - 1] ) { slow--; }把 slow 回退一位让逻辑结尾停在d上然后赋值s[slow] \0。这一步相当于同时完成了“去首尾空格”和“合并中间连续空格”两件事。3.3 第二步整体反转清洗完成后字符串变得规整了要么是空字符串要么是“单词单个空格单词”的形式且末尾没有空格。这时候调用reverseRange(s, 0, len - 1)把整个数组原地倒过来。这里有个细节len必须使用清洗后的slow值不能再用原来的strlen(s)。因为清洗后\0被提前放到了s[slow]的位置如果还用原来的长度会把后面残留的旧字符一起反转进来结果完全错乱。整体反转后以hello world为例反转前: h e l l o w o r l d 反转后: d l r o w o l l e h可以看到单词顺序反了同时每个单词内部也反了。所以下一步需要对每个单词内部再做一次恢复。3.4 第三步逐单词反转逐单词反转的思路同样朴素扫描字符串遇到空格或到达字符串末尾时说明找到了一个完整的单词区间对这个区间调用reverseRange。需要注意循环边界for (int end 0; end len; end) { if (end len || s[end] ) { reverseRange(s, start, end - 1); start end 1; } }为什么是end len而不是end len因为最后一个单词后面没有空格需要靠end len来触发反转。这算是一个很容易忽略的边界条件。如果写成end len最后一个单词永远不会被反转。从hello world反转后的dlrow olleh开始扫描到索引 5 时s[5]是空格反转start0到end-14的区间dlrow变成worldstart更新为 6继续扫描到end10等于len反转start6到end-19的区间olleh变成hello。最终得到world hello符合预期。3.5 复杂度分析时间复杂度整个字符串被完整扫描了两遍多。第一遍清洗是 O(n)整体反转是 O(n)逐单词反转尽管每个单词都反转了一次但每个字符最多被交换两次总体也是 O(n)。合起来是 O(n)。空间复杂度只用到了几个整型变量作为下标以及交换用的临时字符变量额外空间是 O(1)。符合题目要求。4. 常见问题与排查技巧实录4.1 丢失字符串末尾的\0这是我最初调试时遇到的最典型问题。清洗阶段只移动了字符却没有在新逻辑结尾放上\0导致输出结果后面跟了一串乱码或旧字符。排查方法在清洗循环后给s[slow] \0赋值。注意如果手动把slow回退过\0要放在回退后的位置而不是回退前。4.2 输入字符串全为空格的边界情况比如输入 清洗阶段循环走完后slow一直为 0后面s[slow] \0会让字符串变成空串。这时候整体反转和逐单词反转都要确保不会越界访问。reverseRange里如果left0、right-1while 循环条件left right为假直接跳过不会出错。逐单词反转的循环里len0end0时进入end len分支调用reverseRange(s, 0, -1)同样安全。边界情况需要把条件写成slow 0来保护避免对空串进行无意义的操作。4.3 单词内部被反转两次导致顺序错乱如果整体反转后忘了做第三步逐单词反转或者逐单词反转的区间不对常见症状是输出结果里每个单词内部的字母顺序是反的比如输出blue is sky the变成eulb si yks eht。这类问题排查时我会在纸上手写几组输入输出或者加打印语句观察每一步之后字符串的内容。调试思路很简单确认“整体反转”后字符串应该是什么样再确认“逐单词反转”后字符串应该是什么样分步验证。4.4 快慢指针清洗时多保留了一个末尾空格每个单词之间保留一个空格但要注意句子末尾。比如输入a good example清洗后如果变成a good example 末尾带空格下一步整体反转后最前面就会有一个空格输出不符合题目要求。我在代码里用了一个回退技巧多写一个判断if (slow 0 s[slow - 1] ) { slow--; }也可以在一开始时把条件设计得再严谨一些在写入空格前判断“当前是否已经位于字符串末尾的连续空格区”。两种写法都可以我推荐先用上面的回退法逻辑更直观不容易陷入复杂条件。4.5 数组越界与野指针C 语言里数组越界不一定会立即崩溃但会在输出时表现出各种奇怪现象。我调试时遇到过reverseRange的right被传入strlen(s)导致把\0也反转进来的情况打印字符串时发现内容和预期完全不同。记住一个原则所有传入的下标都应该是“当前有效字符串”的合法下标。使用清洗后的len而不是最初输入的字符串长度这一点至关重要。下面是常见问题小结症状原因解决方案输出字符串后面有乱码清洗后未设置\0在清洗结束位置加上\0输出首字符为空格末尾空格未被清理清洗后检查末尾并回退 slow单词内部字母反序缺少逐单词反转步骤按空格切分区间逐个反转最后一个单词未反转循环边界少了end len循环条件改为end len空字符串时崩溃同步处理了不合法下标对空串做单独保护或检查边界5. 延伸思考从这题提炼出的刷题方法论5.1 空格类字符串题型的通用套路LeetCode 上有一大类题目都是“对字符串里的单词做各种处理”比如反转单词、统计单词个数、压缩字符串等。它们的核心难点往往不是算法本身而是怎么稳妥地处理空格边界。这一题给的三步法其实可以抽象成一个通用流水线清洗数据统一格式去掉前后无效内容把连续分隔符压缩成一个整体操作对整串做一次核心变换通常是反转一类的操作局部恢复针对需要保持原内部顺序的单位单词、子串再做一次逆变换。这套流水线可以迁移到很多题目上。比如“左旋转字符串”“翻转单词顺序”等变体本质上都是换换顺序步骤的写法。5.2 C 语言刷题时值得养成的几个习惯我复盘自己的踩坑经历总结出几条实在建议第一勤用辅助函数。reverseRange这种小的工具函数有它会让主流程干净很多。即使在面试现场手写代码面试官也更愿意看到这种清晰的模块化写法。第二先处理边界再处理主逻辑。写任何字符串操作前先问自己字符串为空时怎么办长度为 1 时怎么办末尾字符是空格还是字母这些边界想清楚后再往中间推。第三打印中间结果。本地调试时在每个阶段后加一句printf(%s\n, s);会省下大量猜代码的时间。LeetCode 上不让你随便打日志但本地调试完全无所谓。第四善用注释标记阶段。我在上面代码里写了“第一步/第二步/第三步”这看起来简单实际很有用。很多刷题的人容易写着写着就乱给自己留注解能快速定位逻辑在哪儿断的。5.3 一个值得尝试的变体练习如果你已经理解了这题我建议再挑战一下类似思路的题目给定一个字符串每个单词内部字母顺序不变但单词顺序反转同时要求左旋字符串一类的操作。比如把字符串按某种规则切分成几个区间然后对区间做旋转。这类变体的代码主体还是reverseRange只是调用的顺序和区间不同。多练几道你会发现指针操作的手感会显著提升。我自己在刷题初期有个体会只读题解永远是“看懂了”只有亲手把每一个下标在纸上推一遍才能说真正掌握了。LeetCode 151 正好是一道适合“慢往细里抠”的题因为它的代码量不大但每一步都有设计的理由。耗一晚上把这一题彻底搞懂比走马观花刷十道题更有价值。如果你在本地运行上面的代码我建议试这几个测试用例输入1: the sky is blue 输入2: hello world 输入3: a good example 输入4: 输入5: a全部通过后再试着把代码里的循环边界随手改一两个数看看会出现什么奇怪的输出这种“主动制造 bug”的方式对理解边界条件很有帮助。最后留个小技巧如果面试时遇到“不允许使用额外空间”的字符串题第一时间考虑能不能用“先整体再局部”的反转思想来解决。这个套路在 LeetCode 上出现的频率比想象中高得多而且一旦熟练很多题变得只是换汤不换药。
返回列表