判断回文字符串,从一行代码到双指针优化

判断回文字符串,从一行代码到双指针优化

摘要 :回文字符串是面试常考题。本文从一行 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。全部通过,就是回文。

双指针为什么更好?

  1. 时间复杂度不变 :O(n),但只需要遍历一半长度(len/2),比 API 解法少走一半。
  2. 空间复杂度 O(1) :不生成额外数组和字符串,在内存上更优。
  3. 没有包装开销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 次或更多时,问题从"二选一"变成了 决策树

  1. 遇到第一次不相等,你可以选删左边,也可以选删右边。
  2. 如果选了删左边,后面又遇到不相等,你还要再次面临"删左边还是删右边"的选择。
  3. x 只能告诉你"我用过一次了",但它无法记录"我上一次删的是左边还是右边",更无法在后续路径走不通时回溯(换一条路试试)。

反例

假设字符串 "abecbea",目标允许删 2 个字符变成回文。

实际可行方案:删掉 e(索引2)和 c(索引3)后得到 "abeba",是回文。

哨兵版本会这样执行:

  • 第一次遇到不相等(i=2e vs j=4b),尝试删左边(删 e),检查通过,x=1,跳过左边。
  • 继续比较,又遇到不相等(i=3c vs j=4b),此时 x !== 0,直接返回 false

但实际上,如果第一次选择删右边(删 b),然后再删某个字符,可能成功。哨兵没有能力"回溯"尝试另一条路径,所以它只能处理一次错误。

结论 :哨兵方法是"一锤子买卖"。对于"最多删除 k 个字符(k≥2)",需要用动态规划递归回溯,且要加记忆化来避免指数爆炸。

一点总结

场景 推荐解法 原因
标准回文判断 双指针 O(1) 空间,无额外开销
最多删 1 个字符 哨兵双指针 迭代,O(1) 空间,性能最优
最多删 k 个字符(k≥2) 递归/DP + 回溯 需要决策树和状态回溯

回文题的核心就是对称性 + 边界决策。API 一行流适合快速原型,双指针是面试标准,哨兵解法是性能优化,而递归则是理解回溯的起点。

互动讨论

  1. split('') 对中文、emoji 等字符处理是否完全正确? 试试 '👨‍👩‍👦'.split('')
  2. 双指针解法中,循环条件是 i < len / 2,为什么不用 i <= len / 2
  3. 哨兵版本中,x = 1x = -1 分别代表什么? 为什么需要区分方向?
  4. 如果题目改成"最多删除 2 个字符",哨兵方法会出什么问题? 你能构造一个反例吗?
  5. 如果用动态规划处理"删除 k 个字符"的回文判断,状态转移方程是什么?

📌 一点心得:好的算法不只在代码层面简洁,还要在性能上经得起推敲。哨兵方法就是这种"用工程思维优化算法"的典范。

相关推荐
黄敬峰4 小时前
深入理解算法核心:从递归思想、数组扁平化到快速排序
算法
得物技术5 小时前
从狂野代码到按目标生产:得物推荐 AI Harness 的工程化实践|AICon 演讲整理
人工智能·算法·架构
AI小老六8 小时前
SkillOpt 架构拆解:把 Skill 文本当参数,用执行轨迹训练 Agent
后端·算法·ai编程
胡萝卜术9 小时前
从“分数打架”到“排名投票”:为什么你的ChatBI必须用RRF?
算法·设计模式·面试
Asize10 小时前
初识DFS 与 BFS:递归、队列与图遍历
算法
罗西的思考1 天前
机器人 / 强化学习】HIL-SERL:人类在环驱动的具身智能进化框架
人工智能·算法·机器学习
美团技术团队1 天前
LongCat 开源 VitaBench 2.0:长期动态智能体基准新标杆
人工智能·算法
To_OC2 天前
LC 207 课程表:刚学图论那会儿,我连这是拓扑排序都没看出来
javascript·算法·leetcode
To_OC2 天前
LC 208 实现 Trie 前缀树:曾被名字劝退,写完发现是送分题
javascript·算法·leetcode