拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
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) 空间要求。我把它作为“理解题意的辅助方案”,代码量少,跑通很容易。

真正要掌握的方案是经典的“三步走”原地算法:

  1. 清除多余空格:把字符串原地“压缩”,去掉首尾空格,把连续的空格变成一个空格。
  2. 整体反转:把整个字符数组反转,单词的先后顺序反转过来。
  3. 逐单词反转:按空格把每个单词切出来,对单词内部再做一次反转。

这三步做完,单词顺序就是目标顺序,而单词内部的字母顺序也恢复了正常。这三个步骤里每一步都可以用双指针完成,所以这道题被归到“双指针”标签下是非常自然的。

我推荐的学习路径是:先写辅助空间版本,跑通测试用例,理解“单词顺序反转”这件事的本质;再改成原地版本,体会双指针如何把空间复杂度从 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' ' '
  • fast=0时,字符是空格,但slow=0且slow - 1不合法,条件不成立,不写入;
  • fast=1时,字符是空格,slow=0,同样不写入;
  • fast=2时,字符是'h',写入s[0],slow变成 1;
  • 之后e/l/l/o连续写入,slow变成 5;
  • fast=7时,字符是空格,检查s[slow-1]也就是s[4],是'o',不是空格,所以这个空格需要保留,写入s[5],slow=6;
  • fast=8时,字符是空格,s[slow-1]是刚才写入的空格,条件不成立,跳过;
  • fast=9之后,w/o/r/l/d依次写入,slow到 11;
  • fast=14时,字符是空格,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]是空格,反转start=0到end-1=4的区间,dlrow变成world,start更新为 6;
  • 继续扫描到end=10,等于len,反转start=6到end-1=9的区间,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里,如果left=0、right=-1,while 循环条件left < right为假,直接跳过,不会出错。逐单词反转的循环里,len=0,end=0时进入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 上有一大类题目都是“对字符串里的单词做各种处理”,比如反转单词、统计单词个数、压缩字符串等。它们的核心难点往往不是算法本身,而是怎么稳妥地处理空格边界。

这一题给的三步法其实可以抽象成一个通用流水线:

  1. 清洗数据:统一格式,去掉前后无效内容,把连续分隔符压缩成一个;
  2. 整体操作:对整串做一次核心变换,通常是反转一类的操作;
  3. 局部恢复:针对需要保持原内部顺序的单位(单词、子串)再做一次逆变换。

这套流水线可以迁移到很多题目上。比如“左旋转字符串”“翻转单词顺序”等变体,本质上都是换换顺序步骤的写法。

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 上出现的频率比想象中高得多,而且一旦熟练,很多题变得只是换汤不换药。

返回列表