在 JavaScript 面试中,字符串算法几乎是“必考题”,尤其是反转、回文判断这类看似简单却暗藏玄机的问题。今天,我们就从一道经典题目出发,由浅入深地拆解其中的思路、优化与底层原理,让你读完就能从容应对面试官的连环追问。

算法题的魅力不在于背答案,而在于从暴力到优雅的思考跃迁。


一、反转字符串:为何字符串没有 reverse

先看一个最基础的问题:如何反转一个字符串?

const str = 'abc';
// 我们希望得到 'cba'

也许你会下意识想到 str.reverse(),但运行会报错 —— 字符串并没有 reverse 方法。

1.1 数组的 reverse 与字符串的间接反转

数组有 reverse 方法,所以我们常用的“曲线救国”方式就是:

const reversed = str.split('').reverse().join('');
console.log(reversed); // 'cba'

三步走:split('') 转成字符数组 → reverse() 反转数组 → join('') 拼回字符串。

1.2 为什么字符串不直接提供 reverse

这背后其实是 JavaScript 的设计哲学:字符串是不可变的(immutable),而 reverse 会修改原数组。如果字符串上直接提供 reverse,要么破坏不可变性,要么就得返回新字符串,这并非不可能,但语言设计者选择将“可变操作”留给数组,字符串保持简单。

1.3 再探:基本类型如何拥有方法?

你可能会疑惑:明明 str 是基本类型(string),为什么能调用 split 和 join 这些看起来像对象的方法?

这里藏着 JS 的“包装类”机制:

let str = 'abc';
console.log(str.length); // 3

当访问基本类型的属性或方法时,JS 会临时用 new String(str) 将其包装成一个 String 对象,调用完方法后,这个临时对象就被销毁了。这就像灰姑娘的水晶鞋 —— 只在需要时魔法附身,用完即恢复素颜。

JS 的包装类让基本类型“假装”是对象,既保持了简单,又享受了面向对象的便利。


二、回文字符串判断:从暴力到双指针

回文字符串是指正读和反读都一样的字符串,例如 'yessey''racecar'

2.1 暴力法:反转后比较

有了反转字符串的方法,判断回文就很容易了:

function isPalindrome(str) {
    const reversed = str.split('').reverse().join('');
    return reversed === str;
}

但这种方式会额外创建数组和新字符串,空间复杂度 O(n),且性能不是最优。

2.2 双指针法:边走边比

更高效的方式是利用对称性,从两端向中间比较:

function isPalindrome(str) {
    const len = str.length;
    for (let i = 0; i < len / 2; i++) {
        if (str[i] !== str[len - 1 - i]) {
            return false;
        }
    }
    return true;
}
  • 时间复杂度 O(n),空间复杂度 O(1)。

  • 只需比较前一半与后一半的对应字符,一旦发现不等立即返回 false

面试时,这一解法足以让面试官点头。


三、衍生问题:最多删除一个字符,能否成为回文?

这是回文字符串的经典升级版:

给定一个非空字符串 s,最多删除一个字符。判断是否能成为回文字符串。

例如 'abca',删除 'b' 或 'c' 后得到 'aca' 或 'aba',都是回文,返回 true

3.1 思路拆解

我们还是用双指针,但遇到不匹配的字符时,不能直接返回 false,因为有机会删除一个字符。此时有两条路可走:

  1. 删除左指针指向的字符,检查剩余子串 [i+1, j] 是否为回文;

  2. 删除右指针指向的字符,检查剩余子串 [i, j-1] 是否为回文。

只要其中一条路能走通,就返回 true

3.2 代码实现

function validPalindrome(s) {
    const len = s.length;
    let i = 0, j = len - 1;

    // 先正常双指针比较,直到遇到不匹配
    while (i < j && s[i] === s[j]) {
        i++;
        j--;
    }

    // 辅助函数:检查 s 的 [left, right] 区间是否为回文
    function isPalindromeRange(left, right) {
        while (left < right) {
            if (s[left] !== s[right]) {
                return false;
            }
            left++;
            right--;
        }
        return true;
    }

    // 尝试删除左指针或右指针的字符
    return isPalindromeRange(i + 1, j) || isPalindromeRange(i, j - 1);
}

测试一下:

console.log(validPalindrome('abca'));   // true
console.log(validPalindrome('abc'));    // false
console.log(validPalindrome('deeee'));  // true(删除 'd')

3.3 复杂度与细节

  • 时间复杂度:O(n),最多扫描两遍。

  • 空间复杂度:O(1),只用了常数级变量。

  • 注意 isPalindromeRange 的边界,当删除左字符时,区间为 [i+1, j];删除右字符时为 [i, j-1]。若原字符串本身是回文,while 循环会一直走到 i>=j,然后进入返回语句,此时两个子区间检查都会返回 true(因为区间无效,循环直接跳过),所以结果正确。

遇到“最多删除一个”的题目,牢记“分而治之”——把问题拆成两个子回文判断,再取或。


四、延伸:从 toString 到 stringify 的底层闲谈

在用户的笔记中,还提到了 Object.prototype.toString 和 JSON.stringify,这虽然和题目不直接相关,但却是 JS 进阶面试的常见考点。我们可以稍作延伸。

4.1 toString 与类型判断

let o = { a: 1 };
o.toString(); // "[object Object]"
[1,2,3].toString(); // "1,2,3"

为什么数组的 toString 结果不一样?因为数组重写了 toString 方法。而如果我们想获取精确的类型,可以用:

Object.prototype.toString.call(o); // "[object Object]"
Object.prototype.toString.call([]); // "[object Array]"

这里 call 的作用是借用 Object.prototype.toString 方法,并指定 this 为我们要判断的对象,从而得到准确的类型标签。

4.2 call 的妙用:函数借调

再看一个例子:

let o = {
    name: 't老板',
    say: function() {
        console.log(this.name);
    }
};
let obj = { name: 't总' };
o.say.call(obj); // 't总'

call 的第一个参数可以改变函数内部的 this 指向,让 say 方法借用给 obj 使用。

这种借调思想在 JS 中非常常见,比如类数组借用数组方法:

Array.prototype.slice.call(arguments);

4.3 重头戏:Object.prototype.toString 与 JSON.stringify 的本质区别

在笔记中你看到了 JSON.stringify,这里必须把它们拎出来讲透,因为太多中级开发者在这里翻车。

① Object.prototype.toString 的核心是“辨型”

它的设计初衷不是为了给对象“化妆”成好看的字符串,而是为了返回对象的内部属性 [[Class]]。这是 JS 底层用来区分对象“家族血统”的终极手段。

为什么我们总用 Object.prototype.toString.call(obj) 而不是直接 obj.toString()?因为绝大多数内置对象(如 Array、Date、Function)都重写了 toString,直接调用拿不到 [object XXX] 格式。借助 call 强行调用原型上的“老祖宗”方法,才能精准判断:

Object.prototype.toString.call(null);       // '[object Null]'
Object.prototype.toString.call(undefined);  // '[object Undefined]'
Object.prototype.toString.call(new Date()); // '[object Date]'

笔试小技巧:typeof null === 'object' 是个历史遗留坑,但 Object.prototype.toString.call(null) 永远不会骗你,它是业界判断数据类型的“金标准”。

② JSON.stringify 的核心是“序列化”

它的目标非常纯粹:把 JS 对象/值转换成符合 JSON 标准的字符串,用于数据传输(比如传给后端)或深拷贝

它和 toString 最大的区别在于:

  • toString 侧重于可读性类型标识,即使重写了,通常也只是给人看的;

  • stringify 侧重于数据完整性格式规范它会自动忽略函数、undefinedSymbol,因为这些不是合法的 JSON 值。

看个对比例子你就全明白了:

let obj = {
    name: '掘金',
    age: 10,
    say: function() { console.log('hi'); },
    hobby: undefined
};

// 1. toString 只是随便打个标记
console.log(obj.toString()); // '[object Object]'

// 2. JSON.stringify 认真做数据序列化
console.log(JSON.stringify(obj)); // '{"name":"掘金","age":10}'   (函数和 undefined 被静默忽略)

说白了就是toString 是 JS 内部用来“贴标签”和“简单展示”的工具方法;而 JSON.stringify 是独立存在的全局 API,负责“数据打包”,两者各有各的赛道,面试时千万别混为一谈。


五、总结与思考

今天我们从一个反转字符串的问题出发,逐步深入到回文判断及其衍生问题,并顺带回顾了 JS 的包装类、toString 和 call 等底层机制。

知识点回顾:

  1. 字符串不可变,反转需通过数组中转。

  2. 双指针是判断回文的最优解法,空间 O(1)。

  3. “最多删除一个字符”问题,拆解为两个子回文判断,用辅助函数解决。

  4. JS 的包装类让基本类型能调用方法,而 call/apply 则是改变 this 的利器。

  5. Object.prototype.toString.call 是类型检测的“照妖镜”,JSON.stringify 是数据序列化的“搬运工”,两者的底层哲学完全不同。

最后送大家一句话:

算法不止是代码,更是思维体操。把每个边界条件想清楚,比背一百道题更有价值。

希望这篇文章能帮你理清思路,下次面试遇到字符串题,信手拈来。

如果你有任何疑问或想深入探讨,欢迎在评论区留言,我们一起交流!


参考代码(汇总)

// 反转字符串
function reverseStr(str) {
    return str.split('').reverse().join('');
}

// 双指针判断回文
function isPalindrome(str) {
    let i = 0, j = str.length - 1;
    while (i < j) {
        if (str[i] !== str[j]) return false;
        i++;
        j--;
    }
    return true;
}

// 最多删除一个字符
function validPalindrome(s) {
    let i = 0, j = s.length - 1;
    while (i < j && s[i] === s[j]) {
        i++;
        j--;
    }
    function isPal(l, r) {
        while (l < r) {
            if (s[l] !== s[r]) return false;
            l++;
            r--;
        }
        return true;
    }
    return isPal(i + 1, j) || isPal(i, j - 1);
}

 

 

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐