从快慢指针到一行秒杀:我用这道题搞懂了字符串处理的底层逻辑

317 阅读6分钟

刷LeetCode时,我遇到了一道看起来简单但暗藏玄机的题——「翻转字符串里的单词」(LeetCode 151)。题目要求:给定一个字符串,翻转单词的顺序,同时删除单词间的多余空格,结果单词间用单个空格分隔。比如输入"the sky is blue",输出应该是"blue is sky the"

一开始我觉得这题so easy,用splitreverse就能搞定。但真正动手写代码时,才发现空格处理的坑比想象中多——连续空格、开头结尾空格,这些细节让我卡了整整两小时。最后我用快慢指针自己实现了空格处理,结果看到官方题解时惊掉下巴:人家一行代码就搞定了?

今天就用我的「翻车→思考→优化」全过程,带大家彻底搞懂这道题的底层逻辑。

题目痛点:空格处理比翻转更难

先明确题目要求:

  1. 翻转单词顺序:"hello world""world hello"
  2. 去除多余空格:" hello world ""world hello"(首尾无空格,中间一个空格)。

看起来简单,但难点在第二步。如果直接用split(' '),会得到包含空字符串的数组(比如"a b"split(' ')会得到['a', '', '', 'b']),处理起来很麻烦。

image.png

我的解法:快慢指针手动处理空格

思路分两步:

  1. 去除多余空格:保留单词间的单个空格,删除首尾和中间的多余空格;
  2. 翻转单词顺序:先整体翻转字符串,再逐个翻转单词。

第一步:快慢指针去除多余空格

快慢指针是数组/字符串处理的常用技巧,核心是用两个指针(slowfast)分别记录「有效位置」和「遍历位置」。具体步骤如下:

初始化指针

  • slow指针:记录当前有效字符的位置;
  • fast指针:遍历原字符串,寻找有效字符。

遍历处理

我用输入"the sky is blue"来演示:

  1. 跳过开头空格fast从0开始,遇到空格就右移,直到找到第一个非空格字符('t')。
  2. 处理单词间空格:当fast找到非空格字符时,如果slow不在起始位置(说明前面已经有单词),需要先在slow位置补一个空格(因为单词间需要一个空格分隔),然后将fast指向的字符依次复制到slow位置,同时slowfast一起右移,直到fast遇到空格。
  3. 跳过中间多余空格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"

屏幕录制 2025-05-16 151102.gif

关键步骤解释:

  1. 快慢指针去空格fast遍历原字符串,slow记录有效位置。遇到非空格字符时,先补空格(如果不是第一个单词),再复制整个单词到slow位置,确保单词间只有一个空格。
  2. 整体翻转字符串:比如处理后的字符串是"theskysisblue",整体翻转后变成"eulbsiksyeth"
  3. 逐个翻转单词:遇到空格或字符串末尾时,翻转当前单词。比如"eulb"翻转成"blue",最终得到"blue is sky the"

官方题解:一行代码的「降维打击」

当我还在为快慢指针的边界条件调试时,刷到官方题解的我直接懵了:

var reverseWords = function(s) {
    return s.trim().split(/\s+/).reverse().join(' ');
};

就这?一行代码搞定?

逐行拆解官方解法:

  1. s.trim():去除字符串首尾的空格;
  2. split(/\s+/):用正则表达式\s+(匹配一个或多个空格)分割字符串,得到不含空字符串的单词数组;
  3. reverse():翻转数组顺序;
  4. join(' '):用单个空格连接数组,得到结果。

为什么官方解法能「秒杀」?

  • 正则表达式的力量split(/\s+/)能自动处理连续空格,直接得到干净的单词数组;
  • 内置函数的高效trimsplitreversejoin都是JS内置的高效方法,底层用C实现,性能远高于手动循环;
  • 代码简洁性:一行代码完成所有逻辑,可读性和维护性极强。

两种解法的对比:底层逻辑vs工程效率

我的快慢指针解法和官方解法,本质是「理解底层逻辑」和「工程效率优先」的两种思路。

快慢指针的优势:

  • 锻炼底层能力:手动处理空格和翻转,能深入理解字符串操作的底层逻辑,这对面试或理解其他算法(如KMP、字符串匹配)很有帮助;
  • 兼容性更好:如果语言没有trimsplit(/\s+/)(比如C语言),快慢指针是唯一选择。

别骂,这是我最后的体面

eddf510bc2410c9830665a3379453523.jpg

官方解法的优势:

  • 代码简洁:一行代码解决问题,适合工程开发中快速实现;
  • 性能高效:内置函数经过优化,比手动循环更快(比如split的时间复杂度是(O(n)),和快慢指针相当,但常数更小);
  • 不易出错:避免了手动处理边界条件(如slowfast的指针移动、翻转时的索引错误)。

总结:算法学习的「道」与「术」

这道题给我最大的启发是:算法学习既要懂「道」(底层逻辑),也要会用「术」(工程技巧)

  • 快慢指针让我明白了如何手动处理字符串中的复杂情况(如空格、翻转),这是理解更高级算法的基础;
  • 官方解法则提醒我,工程开发中要善用内置函数,避免重复造轮子。

下次遇到类似问题时,我会先想:「这是面试题吗?需要考察底层能力吗?」如果是,用快慢指针;如果是工程需求,直接用官方解法的一行代码——这才是真正的「灵活运用」。

(PS:写完这篇博客后,我特意去LeetCode提交了两种解法(两个解法的时间复杂度和空间复杂度是一样的),发现官方解法的执行时间比快慢指针快了20%——果然内置函数YYDS!)