ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

反转字符串中的单词:从split到双指针原地算法的完整拆解

反转字符串中的单词:从split到双指针原地算法的完整拆解 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 theresize(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 blueblue is sky the基础反转 hello world world hello首尾多余空格清理a good exampleexample good a中间连续空格压缩aa单单词不变化 全空格输出空串第五组最容易被忽略。如果全是空格整体反转后还是空格双指针扫描时 cur 始终为 0resize(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(vectorint 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。解法同样是三步反转只是分段位置换到了 kstring 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()如果你用的是 JavaString 不可变要转 char[] 或者 StringBuilder只有 C 的 string 允许直接原地修改。这个认知会直接影响你选择哪套实现策略。第四个经验不要小看空输入和全空格输入这两个测试点。我在力扣上提交时第一次把自己的版本跑挂就是在全空格用例上——因为我的代码在找不到任何单词时会给 result 加上一个空格而正确结果是空字符串。加上一个 if 判断提前返回问题立刻解决。刷题时建议把这类边界 case 整理成自己的固定检查清单每次提交前挨个过一遍。最后说一个关于力扣刷题节奏的个人建议151 这种标签中等、实则高频、解法有多种层次的题非常值得你认真做三遍。第一遍用 split 方案秒掉只求快速理解题意第二遍用原地双指针方案把边界全部跑通第三遍隔一周回来不看任何提示独立写出双反转版本并尝试举一反三推导 189 题和旋转字符串变体。三遍下来这套双重反转 双指针的心法基本就长在你脑子里了远比一次性背十道题有用得多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进