判断回文字符串,从一行代码到双指针优化
摘要 :回文字符串是面试常考题。本文从一行
split().reverse().join()出发,分析其性能问题,引出双指针解法,并延伸到"最多删除一个字符"的变体题,重点讲解"带状态标记的哨兵双指针"解法及其性能优势与局限性。
📑 目录
- 什么是回文?正反读都一样
- 解法一:API 一行流
- 解法二:双指针(面试官更想看到)
- 双指针为什么更好?
- 变体题:最多删除一个字符
- 哨兵解法深入分析:为什么它比递归更快?
- 哨兵解法的局限性:为什么只适用于"最多删1次"?
- 一点总结
- 互动讨论
什么是回文?正反读都一样
"yessey"正着读和反着读都是"yessey",这就是回文。
回文字符串最核心的特征是对称性:第 0 个字符等于倒数第 0 个,第 1 个等于倒数第 1 个......一直到中间。
理解了对称性,就有了双指针的思路------但很多人会先想到更简单的方法。
解法一:API 一行流
字符串没有 reverse() 方法,但数组有。所以思路是:
text
perl
字符串 → split成数组 → reverse反转 → join成字符串 → 和原字符串比较
代码来自 3.js:
javascript
ini
function isPalindrome(str) {
const reversedStr = str.split("").reverse().join("");
return reversedStr === str;
}
1.js 演示了这个过程:
javascript
perl
const str = 'abc';
const res = str.split(""); // ['a', 'b', 'c']
console.log(res.reverse()); // ['c', 'b', 'a']
console.log(res.join("")); // "cba"
优点 :思路直接,代码极简,一行核心逻辑。
缺点:生成了中间数组和反转字符串,空间复杂度 O(n)。对超长字符串不友好。面试官通常期望你给出更"底层"的解法。
解法二:双指针(面试官更想看到)
利用回文的对称性,从两端往中间比较。
代码来自 3.js:
javascript
ini
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;
}
i 从左往右走,len - i - 1 从右往左走。只要发现一对不相等,直接返回 false。全部通过,就是回文。
双指针为什么更好?
- 时间复杂度不变 :O(n),但只需要遍历一半长度(
len/2),比 API 解法少走一半。 - 空间复杂度 O(1) :不生成额外数组和字符串,在内存上更优。
- 没有包装开销:readme.md 中提到,"简单数据类型在栈内存中直接存值,而对象的值在堆内存中,取值开销更大。而且 JS 会包装 str 成对象,这样开销就更大了。"双指针解法直接用下标访问字符,避免了包装类的额外开销。
变体题:最多删除一个字符
题目变体:给定一个非空字符串 s,最多删除一个字符,判断是否能成为回文字符串。
4.js 中给出了一种递归风格的实现,但还有一种更高效的"带状态标记的哨兵双指针"解法。
递归解法(思路清晰,但性能有代价)
javascript
lua
function validPalindrome(s) {
let i = 0, j = s.length - 1;
while (i < j && s[i] === s[j]) {
i++;
j--;
}
if (isPalindrome(i + 1, j)) return true;
if (isPalindrome(i, j - 1)) return true;
return false;
function isPalindrome(st, ed) {
while (st < ed) {
if (s[st] !== s[ed]) return false;
st++;
ed--;
}
return true;
}
}
逻辑很好理解:遇到第一对不相等时,尝试删左边或者删右边,哪边能通就返回 true。
但递归有代价 :每次调用 isPalindrome 都会产生函数调用栈的开销,空间复杂度 O(n)。
哨兵双指针解法(性能最优的工程实现)
下面这个版本用 状态标记 x 记录是否已经删除过一个字符,全程只用一个 while 循环,没有额外的递归调用。
javascript
ini
function validPalindrome(s) {
let i = 0, j = s.length - 1;
let x = 0; // 0: 未删除, 1: 已跳过左边, -1: 已跳过右边
while (i < j) {
if (s[i] === s[j]) {
i++;
j--;
} else {
// 已经跳过了一个字符,又遇到不相等 → 失败
if (x !== 0) return false;
// 尝试跳过左边字符(删 s[i])
let l = i + 1, r = j;
let leftOk = true;
while (l < r) {
if (s[l] !== s[r]) { leftOk = false; break; }
l++;
r--;
}
if (leftOk) {
x = 1;
i++; // 跳过左边字符
continue;
}
// 尝试跳过右边字符(删 s[j])
l = i;
r = j - 1;
let rightOk = true;
while (l < r) {
if (s[l] !== s[r]) { rightOk = false; break; }
l++;
r--;
}
if (rightOk) {
x = -1;
j--; // 跳过右边字符
continue;
}
// 两种删除都不行
return false;
}
}
return true;
}
关键点:
x是一个状态标记,记录"是否已经行使过删除特权",以及删的是左边还是右边。- 遇到第一处不匹配时,分别检查"删左边"和"删右边"两种方案是否可行。
- 如果某一种可行,执行删除(移动指针),继续后续比较。
- 如果两种都不行,返回
false。 - 如果已经删除过一次(
x !== 0)又遇到不匹配,直接返回false。
哨兵解法深入分析:为什么它比递归更快?
很多人的直觉是"递归写法更清晰",但从性能角度看,哨兵迭代版本全面碾压递归:
| 对比维度 | 递归版本 | 哨兵迭代版本 |
|---|---|---|
| 函数调用开销 | 有,每次 isPalindrome 都要压栈出栈 |
无,全程一个 while 循环 |
| 空间复杂度 | O(n)(递归深度) | O(1)(仅几个变量) |
| 提前终止 | 需要等递归函数返回 | 发现 leftOk 成立直接 continue,不计算右边 |
| 额外内存分配 | 递归帧占用栈内存 | 无 |
举例 :在 "abcecba" 这种字符串上,哨兵版本发现删左边可行后,直接 continue 继续主循环,根本不会去检查右边的情况。而递归版本在 isPalindrome(i+1, j) 返回 true 后,还要去执行 isPalindrome(i, j-1) 的调用(即使短路了,也要先计算完左半部分再返回,结构上有额外开销)。
所以,在生产环境或算法竞赛中,带状态标记的迭代双指针是比递归更优的工程实现。
哨兵解法的局限性:为什么只适用于"最多删1次"?
你的判断完全正确。哨兵变量 x 本质上是一个 "是否已行使特权"的布尔标志位,它只能记录 0(未用)和 1(已用),所以它只能处理"最多错 1 次"的场景。
扩展到 k 次为什么不行?
当允许删除 2 次或更多时,问题从"二选一"变成了 决策树:
- 遇到第一次不相等,你可以选删左边,也可以选删右边。
- 如果选了删左边,后面又遇到不相等,你还要再次面临"删左边还是删右边"的选择。
x只能告诉你"我用过一次了",但它无法记录"我上一次删的是左边还是右边",更无法在后续路径走不通时回溯(换一条路试试)。
反例
假设字符串 "abecbea",目标允许删 2 个字符变成回文。
实际可行方案:删掉 e(索引2)和 c(索引3)后得到 "abeba",是回文。
哨兵版本会这样执行:
- 第一次遇到不相等(
i=2的evsj=4的b),尝试删左边(删e),检查通过,x=1,跳过左边。 - 继续比较,又遇到不相等(
i=3的cvsj=4的b),此时x !== 0,直接返回false。
但实际上,如果第一次选择删右边(删 b),然后再删某个字符,可能成功。哨兵没有能力"回溯"尝试另一条路径,所以它只能处理一次错误。
结论 :哨兵方法是"一锤子买卖"。对于"最多删除 k 个字符(k≥2)",需要用动态规划 或递归回溯,且要加记忆化来避免指数爆炸。
一点总结
| 场景 | 推荐解法 | 原因 |
|---|---|---|
| 标准回文判断 | 双指针 | O(1) 空间,无额外开销 |
| 最多删 1 个字符 | 哨兵双指针 | 迭代,O(1) 空间,性能最优 |
| 最多删 k 个字符(k≥2) | 递归/DP + 回溯 | 需要决策树和状态回溯 |
回文题的核心就是对称性 + 边界决策。API 一行流适合快速原型,双指针是面试标准,哨兵解法是性能优化,而递归则是理解回溯的起点。
互动讨论
split('')对中文、emoji 等字符处理是否完全正确? 试试'👨👩👦'.split('')。- 双指针解法中,循环条件是
i < len / 2,为什么不用i <= len / 2? - 哨兵版本中,
x = 1和x = -1分别代表什么? 为什么需要区分方向? - 如果题目改成"最多删除 2 个字符",哨兵方法会出什么问题? 你能构造一个反例吗?
- 如果用动态规划处理"删除 k 个字符"的回文判断,状态转移方程是什么?
📌 一点心得:好的算法不只在代码层面简洁,还要在性能上经得起推敲。哨兵方法就是这种"用工程思维优化算法"的典范。