刷LeetCode时,我遇到了一道看起来简单但暗藏玄机的题——「翻转字符串里的单词」(LeetCode 151)。题目要求:给定一个字符串,翻转单词的顺序,同时删除单词间的多余空格,结果单词间用单个空格分隔。比如输入"the sky is blue"
,输出应该是"blue is sky the"
。
一开始我觉得这题so easy,用split
和reverse
就能搞定。但真正动手写代码时,才发现空格处理的坑比想象中多——连续空格、开头结尾空格,这些细节让我卡了整整两小时。最后我用快慢指针自己实现了空格处理,结果看到官方题解时惊掉下巴:人家一行代码就搞定了?
今天就用我的「翻车→思考→优化」全过程,带大家彻底搞懂这道题的底层逻辑。
题目痛点:空格处理比翻转更难
先明确题目要求:
- 翻转单词顺序:
"hello world"
→"world hello"
; - 去除多余空格:
" hello world "
→"world hello"
(首尾无空格,中间一个空格)。
看起来简单,但难点在第二步。如果直接用split(' ')
,会得到包含空字符串的数组(比如"a b"
用split(' ')
会得到['a', '', '', 'b']
),处理起来很麻烦。
我的解法:快慢指针手动处理空格
思路分两步:
- 去除多余空格:保留单词间的单个空格,删除首尾和中间的多余空格;
- 翻转单词顺序:先整体翻转字符串,再逐个翻转单词。
第一步:快慢指针去除多余空格
快慢指针是数组/字符串处理的常用技巧,核心是用两个指针(slow
和fast
)分别记录「有效位置」和「遍历位置」。具体步骤如下:
初始化指针
slow
指针:记录当前有效字符的位置;fast
指针:遍历原字符串,寻找有效字符。
遍历处理
我用输入"the sky is blue"
来演示:
- 跳过开头空格:
fast
从0开始,遇到空格就右移,直到找到第一个非空格字符('t'
)。 - 处理单词间空格:当
fast
找到非空格字符时,如果slow
不在起始位置(说明前面已经有单词),需要先在slow
位置补一个空格(因为单词间需要一个空格分隔),然后将fast
指向的字符依次复制到slow
位置,同时slow
和fast
一起右移,直到fast
遇到空格。 - 跳过中间多余空格:
fast
遇到空格后,继续右移,直到找到下一个非空格字符,重复步骤2。
代码实现
var reverseWords = function(s) {
// 将字符串转为数组,方便修改
let sArr = s.split('');
let slow = 0; // 慢指针记录有效位置
const n = sArr.length;
for (let fast = 0; fast < n; fast++) {
// 遇到非空格字符时处理
if (sArr[fast] !== ' ') {
// 如果不是第一个单词,需要先补一个空格
if (slow !== 0) {
sArr[slow] = ' ';
slow++;
}
// 复制连续的非空格字符(即一个单词)到slow位置
while (fast < n && sArr[fast] !== ' ') {
sArr[slow] = sArr[fast];
slow++;
fast++;
}
}
}
// 截断数组到有效长度(slow是最后一个有效字符的下一个位置)
sArr.length = slow;
// 翻转整个字符串(此时字符串是"theskysisblue",需要先整体翻转)
sArr.reverse();
// 翻转每个单词(比如整体翻转后是"eulbsiksyeth",需要逐个单词翻转)
let start = 0;
for (let i = 0; i <= sArr.length; i++) {
// 遇到空格或字符串末尾时,翻转前面的单词
if (i === sArr.length || sArr[i] === ' ') {
reverse(sArr, start, i - 1);
start = i + 1;
}
}
return sArr.join('');
};
// 辅助函数:翻转数组从left到right的部分
function reverse(arr, left, right) {
while (left < right) {
[arr[left], arr[right]] = [arr[right], arr[left]];
left++;
right--;
}
}
console.log(reverseWords("the sky is blue")); // 输出"blue is sky the"
关键步骤解释:
- 快慢指针去空格:
fast
遍历原字符串,slow
记录有效位置。遇到非空格字符时,先补空格(如果不是第一个单词),再复制整个单词到slow
位置,确保单词间只有一个空格。 - 整体翻转字符串:比如处理后的字符串是
"theskysisblue"
,整体翻转后变成"eulbsiksyeth"
。 - 逐个翻转单词:遇到空格或字符串末尾时,翻转当前单词。比如
"eulb"
翻转成"blue"
,最终得到"blue is sky the"
。
官方题解:一行代码的「降维打击」
当我还在为快慢指针的边界条件调试时,刷到官方题解的我直接懵了:
var reverseWords = function(s) {
return s.trim().split(/\s+/).reverse().join(' ');
};
就这?一行代码搞定?
逐行拆解官方解法:
s.trim()
:去除字符串首尾的空格;split(/\s+/)
:用正则表达式\s+
(匹配一个或多个空格)分割字符串,得到不含空字符串的单词数组;reverse()
:翻转数组顺序;join(' ')
:用单个空格连接数组,得到结果。
为什么官方解法能「秒杀」?
- 正则表达式的力量:
split(/\s+/)
能自动处理连续空格,直接得到干净的单词数组; - 内置函数的高效:
trim
、split
、reverse
、join
都是JS内置的高效方法,底层用C实现,性能远高于手动循环; - 代码简洁性:一行代码完成所有逻辑,可读性和维护性极强。
两种解法的对比:底层逻辑vs工程效率
我的快慢指针解法和官方解法,本质是「理解底层逻辑」和「工程效率优先」的两种思路。
快慢指针的优势:
- 锻炼底层能力:手动处理空格和翻转,能深入理解字符串操作的底层逻辑,这对面试或理解其他算法(如KMP、字符串匹配)很有帮助;
- 兼容性更好:如果语言没有
trim
或split(/\s+/)
(比如C语言),快慢指针是唯一选择。
别骂,这是我最后的体面
官方解法的优势:
- 代码简洁:一行代码解决问题,适合工程开发中快速实现;
- 性能高效:内置函数经过优化,比手动循环更快(比如
split
的时间复杂度是(O(n)),和快慢指针相当,但常数更小); - 不易出错:避免了手动处理边界条件(如
slow
和fast
的指针移动、翻转时的索引错误)。
总结:算法学习的「道」与「术」
这道题给我最大的启发是:算法学习既要懂「道」(底层逻辑),也要会用「术」(工程技巧)。
- 快慢指针让我明白了如何手动处理字符串中的复杂情况(如空格、翻转),这是理解更高级算法的基础;
- 官方解法则提醒我,工程开发中要善用内置函数,避免重复造轮子。
下次遇到类似问题时,我会先想:「这是面试题吗?需要考察底层能力吗?」如果是,用快慢指针;如果是工程需求,直接用官方解法的一行代码——这才是真正的「灵活运用」。
(PS:写完这篇博客后,我特意去LeetCode提交了两种解法(两个解法的时间复杂度和空间复杂度是一样的),发现官方解法的执行时间比快慢指针快了20%——果然内置函数YYDS!)