☰
反转字符串中的单词:从split到双指针原地算法的完整拆解
2026/10/6 13:39:11 网站建设 项目流程

1. 这道经典题到底卡在哪:两个被大多数人忽略的坑

151反转字符串中的单词,在力扣里挂着"中等"难度标签,但实际做起来你会发现,它根本不是考察你能不能写出一个翻转逻辑——真正拦人的是两个隐藏考点:字符串里的空格处理方式,以及不同语言里字符串本身的可变性。

我先说结论:这道题是面试高频题,几乎每个刷过力扣热题100的人都会撞上它。它的价值在于,用一道看起来非常简单的"反转"操作,把字符串处理里的边界情况、原地修改能力、API调用与底层实现的取舍全部串起来了。哪怕你已经有几年工作经验,随手写一版也不一定能一次通过全部测试用例。

先看题目要求:给你一个字符串 s,需要反转字符串中单词的顺序,同时要求:

  • 单词内部字符顺序保持不变。
  • 单词之间由单个空格分隔,原始字符串中首尾多余的空格以及单词之间的多个连续空格,都要被清理掉。

举例来说,输入 "the sky is blue",输出 "blue is sky the";输入 " hello world ",输出 "world hello";输入 "a good example",输出 "example good a"。

这三个例子基本覆盖了全部边界:头部空格、尾部空格、中间多个连续空格。如果你自己动手写过一版,大概率会遇到的问题是——为什么我的反转结果中间还留着多余空格?为什么开头结尾还在?这就是第一个坑:题目要求的"单词反转"本质上是"先切词,再重组",而不是纯字符串逆序。

第二个坑更隐蔽,跟语言特性有关:在 C++ 里字符串是可变对象,可以原地操作;在 JavaScript、Python 里字符串是不可变的,你无法直接修改字符串的某个位置。很多从 C++ 起步的人习惯性地用双指针原地交换字符,换到 JavaScript 里发现根本不支持 s[i] = s[j] 这种写法。这道题恰恰用来区分你对你所用语言的字符串模型是否有清晰认知。

我见过不少人在评论区抱怨:"这题简单啊,split 一下就完事了。"确实,JavaScript 里一句 s.split(' ').filter(Boolean).reverse().join(' ') 就能过。但面试官如果追问一句"如果让你在 O(1) 额外空间内完成呢?"场面就会立刻尴尬。所以这篇我打算把这道题从"能跑"到"会讲"完整拆开,从 API 快餐到原地实现,再到变式题的举一反三,一次讲透。

2. 上策不如下策稳:先从"暴力但正确"的 split 方案说起

很多人有一个误区,觉得刷题就应该一步到位写最优解,跳过基础方案。我个人的建议恰恰相反:**在任何一道题上,先写一版一定能跑通的朴素实现,再思考优化,这才是工程思维。**因为朴素实现能帮你把题目逻辑彻底理清,后面做优化时你才知道在优化什么。

2.1 split、filter、reverse、join 的完整拆解

以 JavaScript 为例,最容易想到的路径就是按空格把字符串切成数组,再把空字符串元素过滤掉,数组反转,最后用单个空格拼接。代码如下:

function reverseWords(s) { return s .split(' ') .filter(word => word !== '') .reverse() .join(' '); }

这里有个细节值得单独拎出来说:JavaScript 的 split(' ') 对连续空格会切出空字符串。比如 "a b" 按单个空格切,得到 ["a", "", "b"];" hello " 按空格切,得到 ["", "", "hello", "", ""]。如果不做 filter 这一步,反转拼接后会出现多个空格或者首尾空格,正好踩中题目要求的坑。

所以 filter(Boolean) 是一个很经典的写法,因为空字符串是 falsy 值,Boolean('') 返回 false,可以直接作为过滤条件。写成 filter(word => word !== '') 更直白,结果一样。

2.2 时间与空间复杂度的精确账

这个方案的复杂度非常好算:split 遍历一遍字符串,reverse 遍历一遍数组,join 再遍历一遍,整体时间复杂度 O(n)。空间上,split 产生的数组长度最大为 n(比如每个字符都是单词),所以额外空间也是 O(n)。

这是最优解吗?不是。但在实际工程代码里,这恰恰是最常见的写法。因为它的意图极端清晰,任何一个接手你代码的人,一眼就能读懂:切分、过滤、反转、拼接。工程里,可读性往往比那点空间省下来更有价值。刷题时要清楚这点,面试时主动先说"我能用一个 O(n) 空间的简单实现,然后我还能说出 O(1) 空间的原地做法",这才是完整的答题节奏。

2.3 为什么我建议你即使会最优解,也要先跑一遍这个版本

因为它是一把尺子。后续写原地算法时,你可能在某个边界条件上反复调不通,这时把暴力版结果打印出来对比,立刻能定位是整体反转没做对,还是某个单词内的反转范围算错了。我自己调试这类字符串题时,最喜欢干的事情就是一边写最优解,一边在注释里保留暴力版做对拍验证。

另一个原因:不是所有场景都需要原地算法。如果你只是处理一份一次性数据,split 版就是对的工程选择。刷题和工程的区别就在这——刷题教你在约束条件下逼近极限,工程教你在约束条件下选择合适方案。

3. 面试官真正想听的:三步走实现 O(1) 额外空间的原地反转

当面试官追问"能不能不用额外空间"时,这道题才真正露出它的獠牙。核心思路其实只有一句话:**先把整个字符串反转,再逐个反转每个单词。**但这句话背后藏着至少三个必须想清楚的细节。我以一个典型例子 "the sky is blue" 现场手推给你看。

3.1 为什么"整体反转 + 单词反转"能恰好还原单词顺序

先做整体反转:"the sky is blue" 变成 "eulb si yks eht"。此时你会发现,单词内部的字符顺序反了,但单词之间的相对顺序也反了。接下来,如果我们再对每一个单词内部做一次反转,比如把 "eulb" 反转回 "blue",把 "si" 反转回 "is",把 "yks" 反转回 "sky",把 "eht" 反转回 "the",最终得到的就是 "blue is sky the"。

这背后的本质是数学上的逆运算性质:反转操作是它自身的逆操作,连续做两次反转就回到原状态。整体反转把单词顺序和字符顺序同时反转,单词级反转把字符顺序再反转一次,净效果就是只反转了单词顺序。这个"双重反转"技巧在字符串和数组题里极其常见,LeetCode 189 旋转数组用的也是同一套思路,后面我会专门展开。

3.2 空格清理不能单独做,必须和单词反转同步完成

这是整道题最容易写崩的地方。很多人的第一反应是:先整体反转,再写一个循环去掉多余空格,再逐词反转。这个顺序虽然逻辑上没错,但会导致代码里要多维护一个"清理后的字符串",空间复杂度又上去了。

更优雅的做法是:用双指针在原字符串上边清理边反转。具体来说,用 cur 指针记录当前"已经处理好的新字符串"的写入位置,用 i 指针扫描原始字符串。当 i 遇到非空格字符时,说明一个单词开始了:

  • 如果 cur 不为 0,说明这不是第一个单词,需要在写入前先加一个空格。
  • 然后从 i 开始,把整个单词逐字符复制到 cur 位置,直到遇到空格或字符串结尾。
  • 复制完这个单词后,对它刚才写入的那一段做一次反转。

这个流程走完后,cur 的位置就是新字符串的有效长度。最后把字符串 resize 到 cur,截掉末尾多余的残留字符。一次遍历同时完成了三件事:整体反转后的单词内字符纠正、多余空格清理、单词顺序重组。代码比"分三步"直观得多,也快得多。

3.3 完整 C++ 实现与逐行解读

C++ 的 string 是可变对象,天然适合这类原地操作。实现如下:

class Solution { public: string reverseWords(string s) { int n = s.size(); // 第一步:整体反转整个字符串 reverse(s.begin(), s.end()); int cur = 0; // 维护新字符串的写入位置 for (int i = 0; i < n; ++i) { if (s[i] != ' ') { // 单词之间补一个空格 if (cur != 0) { s[cur++] = ' '; } // 记录这个单词开始复制的位置 int start = cur; // 复制单词字符 while (i < n && s[i] != ' ') { s[cur++] = s[i++]; } // 反转这一小段,恢复单词原本的字符顺序 reverse(s.begin() + start, s.begin() + cur); } } // 截掉多余部分 s.resize(cur); return s; } };

核心就两个 reverse 调用和一段双指针复制。第一次 reverse 处理的是整体顺序问题;第二次 reverse 处理的是单词内部字符顺序问题。中间的双指针循环解决了空格收缩问题。三者缺一不可。

我手动走一遍 "the sky is blue":

  • 整体反转后得到 "eulb si yks eht"。
  • i 从 0 开始,s[0] 是 'e',非空格。cur 为 0,不加空格。start = 0,复制 "eulb" 到 s[0..3],i 走到空格处停下。此时 s[0..3] 仍是 "eulb"。执行 reverse(s.begin()+0, s.begin()+4),s[0..3] 变成 "blue"。cur = 4。
  • i 继续走到单词 "si" 的 's'。cur 不为 0,所以先 s[cur++] = ' ',即 s[4] = ' '。start = 5,复制 "si" 到 s[5..6],再 reverse 成 "is"。cur = 7。
  • 同理处理 "yks" 和 "eht",分别变回 "sky" 和 "the"。
  • 最终 s[0..14] 为 "blue is sky the",resize(15) 截断,结束。

你可以自己手推一遍 " hello world " 这个用例,你会发现首尾空格在前半程整体反转后到了中间,双指针扫描时遇到空格直接跳过,自动完成了清理,最后 resize 恰好把多余尾巴切除。这就是这个写法的精妙之处。

4. 语言差异与边界陷阱:JavaScript 版原地实现和那些容易翻车的测试用例

写完 C++ 版本,很多人会想:能不能在其他语言里也做到 O(1) 额外空间?这就要回到第一节说的语言特性问题。如果 JavaScript 的字符串不可变,那"原地"就无从谈起——你连改一个字符都不行。这时候有两条路:一是干脆放弃原地,用数组模拟可变字符串,本质上空间仍是 O(n);二是面试时直接说明"JS 字符串不可变,所以 O(1) 空间原地修改在这个语言里不成立,但可以做到 O(n) 空间且逻辑完全一致",这本身就是一种考察点——看你是否理解语言底层约束。

4.1 JavaScript 不可变字符串的变通方案

如果非要用 JS 写出逻辑等价版,可以先把字符串转成字符数组,操作完再拼回来:

function reverseWords(s) { // 先整体反转 s = s.split('').reverse().join(''); // 此时 s 中单词内部字符是反的,单词顺序也是反的 // 逐词反转恢复单词内部顺序,同时清理空格 let result = ''; let i = 0; while (i < s.length) { if (s[i] !== ' ') { let end = i; while (end < s.length && s[end] !== ' ') end++; // s.slice(i, end) 是当前单词的反转形态,再反转一次恢复原单词 result += s.slice(i, end).split('').reverse().join(''); result += ' '; i = end; } else { i++; } } return result.trimEnd(); // 去掉最后一个多余空格 }

注意看,这个版本的思路和 C++ 版完全一致:整体反转,再逐词反转,同时跳过空格。但因为字符串不可变,每做一次修改都产生新字符串,所以实际空间复杂度不是 O(1)。如果你在面试中写这个版本,必须把这点跟面试官讲清楚,否则会被认为对语言理解不透。

另外还有一个细节——很多人在 JS 里习惯用 s.split(' ') 按空格切分来做反转题,但在这里我用的是按字符反转整体、再按单词反转。这两种思路的差异,正好对应"正则化处理空格"和"原地双指针"两种流派。前者易读,后者通用,建议你都掌握。

4.2 从"字符串反转怎么打印出来 C++"看常见误区

我在搜索热词里看到一句很典型的话:字符串反转怎么打印出来 c++。这透露出一个初学者常见问题:很多人学了 reverse 函数,但不知道 reverse 是原地操作,返回值是 void,需要直接操作容器本身。如果直接写 cout << reverse(s) 这类代码,自然编译不过。这是 C++ 新手特有的困扰。

回到题目,C++ 的 std::reverse 接受两个迭代器,左闭右开区间 [first, last)。这个区间语义如果不熟,很容易写出 reverse(s.begin(), s.end() - 1),导致最后一个字符永远不动。我在上面实现里用了 reverse(s.begin() + start, s.begin() + cur),start 指向单词首字符,cur 指向单词尾字符的下一个位置,正好符合"尾迭代器指向最后一个元素之后"的语义。

关于测试用例,力扣的判题器对这道题非常严格,我建议你至少跑全这五组输入,缺一组都可能漏掉边界:

输入预期输出考察点
"the sky is blue""blue is sky the"基础反转
" hello world ""world hello"首尾多余空格清理
"a good example""example good a"中间连续空格压缩
"a""a"单单词不变化
" """全空格输出空串

第五组最容易被忽略。如果全是空格,整体反转后还是空格,双指针扫描时 cur 始终为 0,resize(0) 得到空字符串,结果是正确的。但如果你在某个版本里忘了处理 cur 为 0 时输出为空串的情况,就会在输出上多出一个空格或者 undefined,判题直接报错。

5. 双指针的更深一层:同一套思路怎么迁移到左旋字符串和旋转数组

这道题刷完,千万别急着划走。它最值钱的部分其实是"双重反转"这个技巧的迁移能力。我在开头提到 LeetCode 189 旋转数组,这里展开说一下,因为它们是同一个思想的两个马甲。

5.1 LeetCode 189 旋转数组:双反转的统一解法

题目要求把数组右移 k 位。比如 [1,2,3,4,5,6,7] 右移 3 位变成 [5,6,7,1,2,3,4]。最简单的 O(1) 额外空间做法是什么?同样是两次反转:

void rotate(vector<int>& nums, int k) { int n = nums.size(); k %= n; // 重要:k 可能大于 n reverse(nums.begin(), nums.end()); // 整体反转 -> [7,6,5,4,3,2,1] reverse(nums.begin(), nums.begin() + k); // 反转前 k 个 -> [5,6,7,4,3,2,1] reverse(nums.begin() + k, nums.end()); // 反转剩余 -> [5,6,7,1,2,3,4] }

你对比一下 151 题的双反转:整体反转,然后单词级反转;189 题:整体反转,然后分段反转。逻辑结构完全一样,区别只在于 151 题的分段是"按空格动态切分",189 题的分段是"按 k 固定切分"。这就是算法题的迷人之处——表面不同的问题,底层可能是同一把钥匙。

5.2 剑指 Offer 58-II 左旋字符串:向右转会的,向左一样能转

左旋字符串是另一个高频变体:给定 "abcdefg" 和 k = 2,左旋得到 "cdefgab"。解法同样是三步反转,只是分段位置换到了 k:

string reverseLeftWords(string s, int n) { reverse(s.begin(), s.end()); // "gfedcba" reverse(s.begin(), s.end() - n); // 反转前 len-n 个 -> "cdefgba" reverse(s.end() - n, s.end()); // 反转后 n 个 -> "cdefgab" return s; }

有意思的是,左旋和右旋本质上是同一个操作:左旋 k 位等价于右旋 len - k 位。面试时如果被问到旋转字符串,你只要记住"整体反转 + 两段反转",所有旋转类问题都能手到擒来。

5.3 进阶变体:按单词反转但保留单词内字符顺序的更多考法

除了上面两种,还有几类常见变体值得提一嘴:

  • 直接反转每个单词的字符,但保持单词顺序不变。比如 "hello world" 变成 "olleh dlrow"。做法是只做单词级反转,不做整体反转。这个变体在 C++ 里就是去掉整体反转那一步,难度比 151 低一档,适合作为热身题。
  • 反转字符串中的单词,但是要求单词之间的空格数量保持原样(不压缩)。这道题 151 的原版是压缩空格,但有些面试题会反过来问"保留原始空格数量",这时 split 方案就失效了,需要更精细的边界处理。
  • 单词内部包含标点符号。比如 "Hello, world!" 这类句子。原题默认单词只由字母组成,但如果面试官扩展了标点,你需要定义清楚"标点算单词的一部分"还是"标点要单独处理"。我建议默认把连续的非空格字符都当一个单词,这样标点就自然随单词走了。

这些变体不需要全部刷完,但建议你每看到一个,就心里过一遍"它在双反转框架里改的是哪一步"。能答上来,说明你真正理解了这题,而不是背了这道题的代码。

6. 调试心得与提交经验:真正跑完这道题你会记住的事情

这道题我第一次提交时,犯过一个非常蠢的错误:忘了 resize。用 C++ 写原地版本时,整体反转后字符串长度不变,双指针清理后 cur 只写入有效部分,但字符串尾部还残留着旧字符。如果不 resize,输出会是 "blue is sky thehe" 之类带着尾巴的脏数据。这个错误非常典型,我在好几个刷题群里看到新手反复踩。记住:原地操作就必须手动管理有效长度。

第二个经验是关于调试策略的。遇到这类字符串处理题,我强烈建议你写个临时把字符串每一步打印出来的辅助函数。比如在整体反转后打印一次,在每处理完一个单词后打印一次。肉眼看到中间状态,比盯着代码各种推理高效十倍。C++ 里直接 cout << s 加换行即可,跑完记得删掉这些调试输出,否则提交时会多打印一堆东西导致判题错误。

第三个经验:做这类题之前,先确认语言特性。如果你用的是 Python,字符串不可变,但 list 是可变的,可以先 list(s) 再操作最后 ''.join();如果你用的是 Java,String 不可变,要转 char[] 或者 StringBuilder;只有 C++ 的 string 允许直接原地修改。这个认知会直接影响你选择哪套实现策略。

第四个经验:不要小看"空输入"和"全空格输入"这两个测试点。我在力扣上提交时,第一次把自己的版本跑挂就是在全空格用例上——因为我的代码在找不到任何单词时会给 result 加上一个空格,而正确结果是空字符串。加上一个 if 判断提前返回,问题立刻解决。刷题时建议把这类边界 case 整理成自己的固定检查清单,每次提交前挨个过一遍。

最后说一个关于力扣刷题节奏的个人建议:151 这种"标签中等、实则高频、解法有多种层次"的题,非常值得你认真做三遍。第一遍用 split 方案秒掉,只求快速理解题意;第二遍用原地双指针方案,把边界全部跑通;第三遍隔一周回来,不看任何提示独立写出双反转版本,并尝试举一反三推导 189 题和旋转字符串变体。三遍下来,这套"双重反转 + 双指针"的心法基本就长在你脑子里了,远比一次性背十道题有用得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询