JavaScript 字符串面试题:反转、回文与双指针

3 阅读7分钟

JavaScript 字符串常见面试题:反转、回文与最多删除一个字符

字符串题是前端面试里很常见的一类题。它们通常不会考特别复杂的语法,而是考你对基础 API、指针思想、边界条件和时间复杂度的理解。

本文结合几个常见例子,梳理 3 类高频字符串问题:

  • 如何反转字符串
  • 如何判断字符串是否为回文字符串
  • 如何判断一个字符串最多删除一个字符后能否成为回文字符串

同时也会顺带讲清楚 JavaScript 字符串、包装类型、callObject.prototype.toString.call 这些容易被问到的基础点。

一、字符串反转

JavaScript 的字符串本身没有 reverse() 方法。

也就是说,下面这样写是错误的:

const str = "abc";

str.reverse(); // TypeError: str.reverse is not a function

因为 reverse() 是数组的方法,不是字符串的方法。

常见写法是:

const str = "abc";

const result = str.split("").reverse().join("");

console.log(result); // "cba"

这行代码分成三步:

str.split("")      // ["a", "b", "c"]
   .reverse()      // ["c", "b", "a"]
   .join("");      // "cba"

完整函数可以写成:

function reverseString(str) {
  return str.split("").reverse().join("");
}

console.log(reverseString("hello")); // "olleh"

这个方法简单直接,适合大多数普通字符串面试题。

需要注意:split("") 是按 UTF-16 码元拆分的,如果字符串里包含某些 emoji,可能会把一个字符拆坏。普通面试题一般不考这个细节,如果要比 split("") 更稳妥,可以使用 Array.from(str),它会按 Unicode 码点遍历:

function reverseString(str) {
  return Array.from(str).reverse().join("");
}

二、为什么字符串可以访问 length 和方法?

字符串是基本数据类型:

const str = "abc";

严格来说,str 不是对象。但我们却可以这样访问:

console.log(str.length); // 3
console.log(str.toUpperCase()); // "ABC"

原因是 JavaScript 在访问基本类型的属性或方法时,会临时把它包装成对应的包装对象。

可以理解为底层临时做了类似这样的事:

const temp = new String("abc");
temp.length;
temp.toUpperCase();

用完之后,这个临时包装对象就会被释放,原来的 str 仍然是基本类型字符串。

所以更准确的说法是:

  • string 是基本类型
  • String 是包装类型
  • 基本类型在访问属性或方法时,会发生临时装箱

示例:

const str1 = "abc";
const str2 = new String("abc");

console.log(typeof str1); // "string"
console.log(typeof str2); // "object"

实际开发中,通常不要主动写 new String("abc"),直接使用字符串字面量即可。

三、toString、JSON.stringify 和 Object.prototype.toString.call 的区别

这几个 API 容易混在一起,但它们的用途不一样。

1. x.toString()

toString() 通常用于把一个值转换成字符串。

不同类型可以有不同的实现:

console.log([1, 2, 3].toString()); // "1,2,3"
console.log((123).toString()); // "123"

普通对象默认的 toString() 结果不太适合展示对象内容:

const obj = { a: 1, b: 2 };

console.log(obj.toString()); // "[object Object]"

2. JSON.stringify()

如果想把对象或数组的内容序列化成 JSON 字符串,应该使用 JSON.stringify()

const obj = { a: 1, b: 2 };

console.log(JSON.stringify(obj)); // '{"a":1,"b":2}'

3. Object.prototype.toString.call()

Object.prototype.toString.call(value) 常用于判断更精确的类型标签:

console.log(Object.prototype.toString.call("abc")); // "[object String]"
console.log(Object.prototype.toString.call([])); // "[object Array]"
console.log(Object.prototype.toString.call({})); // "[object Object]"
console.log(Object.prototype.toString.call(null)); // "[object Null]"
console.log(Object.prototype.toString.call(undefined)); // "[object Undefined]"

所以它更多是类型判断工具,而不是用来序列化对象内容。

四、call:改变函数执行时的 this

call 的作用是:调用函数,并指定函数运行时的 this 指向。

在 JavaScript 中,函数是一等公民,也可以像对象一样拥有属性和方法。call 就是函数可以访问到的方法之一,它来自 Function.prototype

看一个例子:

const user1 = {
  name: "落月",
  say() {
    console.log(this.name);
  }
};

const user2 = {
  name: "落叶"
};

user1.say(); // "落月"
user1.say.call(user2); // "落叶"

user1.say.call(user2) 的意思是:

  • 执行 user1.say 这个函数
  • 函数内部的 this 指向 user2

所以最终打印的是 user2.name

注意,call 第一个参数是 this 指向,后面的参数才是传给函数本身的参数。

function introduce(age, city) {
  console.log(`${this.name}, ${age}, ${city}`);
}

const user = {
  name: "落月"
};

introduce.call(user, 21, "杭州"); // "落月, 21, 杭州"

五、判断一个字符串是否为回文字符串

回文字符串指的是:正着读和反着读都一样的字符串。

例如:

yessey
level
abba

方法一:反转后比较

最直观的写法是先反转,再和原字符串比较:

function isPalindrome(str) {
  return str === str.split("").reverse().join("");
}

console.log(isPalindrome("level")); // true
console.log(isPalindrome("hello")); // false

这种写法很好理解,但它需要额外创建数组和反转后的字符串。

时间复杂度:O(n)

空间复杂度:O(n)

方法二:双指针

回文字符串的核心特征是左右对称。

所以可以用两个指针:

  • left 从左往右走
  • right 从右往左走
  • 每次比较 str[left]str[right]
  • 只要有一组不相等,就不是回文

代码如下:

function isPalindrome(str) {
  let left = 0;
  let right = str.length - 1;

  while (left < right) {
    if (str[left] !== str[right]) {
      return false;
    }

    left++;
    right--;
  }

  return true;
}

console.log(isPalindrome("level")); // true
console.log(isPalindrome("reser")); // true
console.log(isPalindrome("hello")); // false

也可以使用 for 循环:

function isPalindrome(str) {
  const len = str.length;

  for (let i = 0; i < len / 2; i++) {
    if (str[i] !== str[len - i - 1]) {
      return false;
    }
  }

  return true;
}

这里提前保存 len 是一个好习惯,代码更清晰,也避免在循环中反复写 str.length。不过现代 JavaScript 引擎对这类访问通常已经有优化,不必把它理解成很大的性能差异。

双指针版本的复杂度:

  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

面试时更推荐说双指针方案,因为它不需要额外反转字符串。

六、最多删除一个字符后是否能成为回文字符串

这是回文判断的经典变形题。

题目描述:

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

例如:

"aba"   -> true,不需要删除
"abca"  -> true,删除 b 或 c 后变成 "aca" 或 "aba"
"abc"   -> false,删除任意一个字符都不能变成回文

解题思路

仍然使用双指针。

从两端开始比较:

  • 如果 s[left] === s[right],说明这一组字符没问题,继续向中间移动
  • 如果 s[left] !== s[right],说明出现了冲突
  • 因为最多只能删除一个字符,所以此时只有两种选择:
    • 删除左边字符,继续判断 left + 1right
    • 删除右边字符,继续判断 leftright - 1
  • 只要其中一种情况能形成回文,就返回 true

代码如下:

function validPalindrome(s) {
  function isPalindromeRange(left, right) {
    while (left < right) {
      if (s[left] !== s[right]) {
        return false;
      }

      left++;
      right--;
    }

    return true;
  }

  let left = 0;
  let right = s.length - 1;

  while (left < right) {
    if (s[left] !== s[right]) {
      return (
        isPalindromeRange(left + 1, right) ||
        isPalindromeRange(left, right - 1)
      );
    }

    left++;
    right--;
  }

  return true;
}

console.log(validPalindrome("aba")); // true
console.log(validPalindrome("abca")); // true
console.log(validPalindrome("abbda")); // true
console.log(validPalindrome("abc")); // false

"abca" 为例:

a b c a
^     ^
left  right

第一轮 a === a,继续往中间走。

a b c a
  ^ ^
  l r

此时 b !== c,出现冲突。

我们尝试两种情况:

  • 跳过 b,判断 "c",成立
  • 跳过 c,判断 "b",也成立

所以 "abca" 最多删除一个字符后可以成为回文字符串。

再看 "abbda"

a b b d a
^       ^

两边 a === a,继续。

a b b d a
  ^   ^

此时 b !== d,出现冲突。

尝试:

  • 跳过左边 b,剩余区间是 "bd",不是回文
  • 跳过右边 d,剩余区间是 "bb",是回文

所以 "abbda" 实际上应该返回 true,删除 d 后得到 "abba"

如果代码中把 "abbda" 当成 false,那就是示例预期写错了。

复杂度分析:

  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

虽然冲突时会调用两次区间回文判断,但它们最多也只是继续扫描剩余区间,所以整体仍然是线性的。

七、完整代码汇总

function reverseString(str) {
  return Array.from(str).reverse().join("");
}

function isPalindrome(str) {
  let left = 0;
  let right = str.length - 1;

  while (left < right) {
    if (str[left] !== str[right]) {
      return false;
    }

    left++;
    right--;
  }

  return true;
}

function validPalindrome(s) {
  function isPalindromeRange(left, right) {
    while (left < right) {
      if (s[left] !== s[right]) {
        return false;
      }

      left++;
      right--;
    }

    return true;
  }

  let left = 0;
  let right = s.length - 1;

  while (left < right) {
    if (s[left] !== s[right]) {
      return (
        isPalindromeRange(left + 1, right) ||
        isPalindromeRange(left, right - 1)
      );
    }

    left++;
    right--;
  }

  return true;
}

console.log(reverseString("abc")); // "cba"
console.log(isPalindrome("level")); // true
console.log(validPalindrome("abca")); // true
console.log(validPalindrome("abbda")); // true
console.log(validPalindrome("abc")); // false

八、面试表达建议

如果面试官问“怎么判断回文字符串”,可以这样回答:

最直接的方式是把字符串反转后和原字符串比较,时间复杂度是 O(n),空间复杂度也是 O(n)。更优的方式是使用双指针,从左右两端向中间比较,时间复杂度仍然是 O(n),但空间复杂度可以降到 O(1)。

如果面试官继续问“最多删除一个字符后是否能成为回文字符串”,可以这样回答:

还是使用双指针。当左右字符相等时继续向中间移动;第一次遇到不相等时,因为最多只能删除一个字符,所以只需要分别尝试跳过左字符和跳过右字符。只要其中一个剩余区间是回文,就返回 true。

这类题的重点不是 API 背诵,而是要看出字符串的对称性,并用双指针把空间复杂度优化下来。