镜像谜题与动态魔法:解锁回文的隐藏密码 🚀

191 阅读9分钟

一. 前言:什么是回文?🤔

你有没有照过镜子?镜子里的你和现实中的你,左右对称,浑然天成。回文字符串就像一面 “字符镜子”—— 从左往右和从右往左读,都是同一个样子。例如:“abcba”、“上海自来水来自海上”,都是妥妥的回文!

在算法世界,回文问题常常考察我们对字符串的理解和动态规划的掌握。今天,我们就来聊聊两道经典回文题目,带你从暴力到动态规划,一步步揭开回文的神秘面纱。

二. 题目一:647. 回文子串

题目解析

给定一个字符串 s,统计并返回 s 中回文子串的个数。注意,子串是连续的(字符在原字符串中必须相邻),且每个单字符也算回文。

举个栗子🌰

  • 输入:"abc"

输出:3("a"、"b"、"c")

  • 输入:"aaa"

输出:6("a"、"a"、"a"、"aa"(0-1)、"aa"(1-2)、"aaa"(0-2))

解法一:暴力枚举 + 回文判断

思路很直接:枚举所有子串,逐个判断是不是回文。就像在超市里把每个商品都拿起来照一遍镜子,看看是不是对称的。

function isPalindrome(s, left, right) {
    while (left < right) {
        if (s[left] !== s[right]) return false;
        left++;
        right--;
    }
    return true;
}
var countSubstrings = function (s) {
    let res = 0;
    for (let i = 0; i < s.length; i++) {
        for (let j = i; j < s.length; j++) {
            if (isPalindrome(s, i, j)) res++;
        }
    }
    return res;
};

复杂度分析:

  • 时间复杂度:O (n³)(两重循环枚举子串,内层判断回文需 O (n),效率感人,适合小数据量)
  • 空间复杂度:O (1)

解法二:动态规划(二维 DP)

暴力太慢怎么办?我们可以用 “备忘录” 思想,把已经判断过的子串结果存起来,避免重复劳动。

状态定义

  • dp[i][j] 表示子串 s [i...j](从索引 i 到 j 的连续字符)是否为回文。

状态转移:为什么这么设计?

判断 s [i...j] 是否为回文,核心看两点:

  1. 首尾字符是否相等:s [i] === s [j]
  1. 中间子串是否为回文:s [i+1...j-1] 是否为回文

但有两种特殊情况不需要看中间:

  • 当子串长度为 1(i = j):单个字符必然是回文(比如 "a")
  • 当子串长度为 2(j = i+1):只要首尾相等就是回文(比如 "aa")

因此状态转移逻辑为:

  • 若 s [i] === s [j]:
    • 若 j - i <= 1(长度 1 或 2):dp [i][j] = true
    • 否则:dp [i][j] = dp [i+1][j-1](依赖中间子串的结果)
  • 若 s [i] !== s [j]:dp [i][j] = false

代码实现

var countSubstrings = function (s) {
    let res = 0;
    let dp = new Array(s.length).fill().map(() => new Array(s.length).fill(false));
    // i从右往左遍历,保证计算dp[i][j]时,dp[i+1][j-1]已经被计算过
    for (let i = s.length - 1; i >= 0; i--) {
        // j从i往右遍历,只考虑i <= j的情况(子串从i到j)
        for (let j = i; j < s.length; j++) {
            if (s[i] === s[j]) {
                if (j - i <= 1) {
                    dp[i][j] = true;
                } else if (dp[i + 1][j - 1]) {
                    dp[i][j] = true;
                }
            }
            if (dp[i][j]) res++; // 统计所有回文子串
        }
    }
    return res;
};

动态规划过程:分步拆解(以 s = "aaa" 为例)

我们用表格展示 dp [i][j] 的计算过程(i 从右到左,j 从 i 到右):

初始状态j=0j=1j=2
i=2?
i=1??
i=0???

第一步:计算 i=2(最后一个字符)

  • j=2:子串 "a"(长度 1),s [2]===s [2] 且 j-i=0 <=1 → dp [2][2] = true → res=1
i=2j=0j=1j=2
T

第二步:计算 i=1

  • j=1:子串 "a"(长度 1)→ dp [1][1] = true → res=2
  • j=2:子串 "aa"(长度 2),s [1]===s [2] 且 j-i=1 <=1 → dp [1][2] = true → res=3
i=1j=0j=1j=2
TT

第三步:计算 i=0

  • j=0:子串 "a"(长度 1)→ dp [0][0] = true → res=4
  • j=1:子串 "aa"(长度 2),s [0]===s [1] 且 j-i=1 <=1 → dp [0][1] = true → res=5
  • j=2:子串 "aaa"(长度 3),s [0]===s [2],且中间子串 dp [1][1] = true → dp [0][2] = true → res=6
最终结果j=0j=1j=2
i=0TTT
i=1TT
i=2T

所有为 true 的格子都是回文子串,最终结果为 6,和预期一致。

解法三:动态规划(空间优化版)

观察发现,计算 dp [i][j] 时只用到了 dp [i+1][j-1](下一行左一列),因此可以用一维数组压缩空间:

var countSubstrings = function (s) {
    let res = 0;
    let dp = new Array(s.length).fill(false);
    for (let i = s.length - 1; i >= 0; i--) {
        let prev = false; // 保存上一轮的dp[j](即i+1时的dp[j])
        for (let j = i; j < s.length; j++) {
            let temp = dp[j]; // 暂存当前dp[j],下一轮作为prev
            if (s[i] === s[j]) {
                if (j - i <= 1) {
                    dp[j] = true;
                } else if (prev) { // prev即dp[i+1][j-1]
                    dp[j] = true;
                } else {
                    dp[j] = false;
                }
            } else {
                dp[j] = false;
            }
            if (dp[j]) res++;
            prev = temp;
        }
    }
    return res;
};

趣味类比

动态规划就像 “记账本”—— 每次判断一个区间是不是回文时,先翻翻账本(查 dp 表),看看中间子串的结果有没有记过,不用重复计算,省时省力!

三. 题目二:516. 最长回文子序列

题目解析

给定一个字符串 s,求它的最长回文子序列的长度。注意,子序列可以不连续(字符在原字符串中顺序不变即可,但不必相邻)。

子串 vs 子序列:直观对比

以字符串 "abc" 为例:

  • 子串(连续):"a"、"b"、"c"、"ab"、"bc"、"abc"(共 6 个)
  • 子序列(非连续):"a"、"b"、"c"、"ab"、"ac"、"bc"、"abc"(共 7 个,多了 "ac")

举个栗子🌰

  • 输入:"bbbab"

输出:4(最长回文子序列为 "bbbb",可由索引 0、1、2、3 组成)

  • 输入:"cbbd"

输出:2(最长回文子序列为 "bb")

动态规划解法

状态定义

  • dp[i][j] 表示 s [i...j] 区间内最长回文子序列的长度。

状态转移:为什么这么设计?

核心逻辑:最长回文子序列的长度,取决于首尾字符是否相等:

  • 若 s [i] === s [j]:这两个字符可以加入回文子序列,因此长度 = 中间子序列长度 + 2(即 dp [i+1][j-1] + 2)
  • 若 s [i] !== s [j]:最长子序列只能是 “不含 s [i] 的子序列” 或 “不含 s [j] 的子序列” 中的较长者(即 max (dp [i+1][j], dp [i][j-1]))

初始化

  • 当 i = j 时(单个字符),最长回文子序列就是它自己,因此 dp [i][i] = 1。

遍历顺序

  • i 从右往左,j 从 i+1 往右(保证计算 dp [i][j] 时,dp [i+1][j]、dp [i][j-1]、dp [i+1][j-1] 已计算)。

动态规划过程:分步拆解(以 s = "bbbab" 为例)

字符串索引为 0:"b"、1:"b"、2:"b"、3:"a"、4:"b",目标是求 dp [0][4]。

初始状态(只填 i=j 的位置)j=0j=1j=2j=3j=4
i=41
i=31
i=21
i=11
i=01

第一步:计算长度为 2 的子序列(j = i+1)

  • i=3, j=4:s [3]="a" vs s [4]="b" 不等 → dp [3][4] = max (dp [4][4], dp [3][3]) = max (1,1)=1
  • i=2, j=3:s [2]="b" vs s [3]="a" 不等 → dp [2][3] = max (dp [3][3], dp [2][2])=1
  • i=1, j=2:s [1]="b" vs s [2]="b" 相等 → dp [1][2] = dp [2][1](无效,视为 0) + 2 = 2
  • i=0, j=1:s [0]="b" vs s [1]="b" 相等 → dp [0][1] = 0 + 2 = 2

第二步:计算长度为 3 的子序列(j = i+2)

  • i=2, j=4:s [2]="b" vs s [4]="b" 相等 → dp [2][4] = dp [3][3] + 2 = 1 + 2 = 3
  • i=1, j=3:s [1]="b" vs s [3]="a" 不等 → dp [1][3] = max (dp [2][3], dp [1][2]) = max (1,2)=2
  • i=0, j=2:s [0]="b" vs s [2]="b" 相等 → dp [0][2] = dp [1][1] + 2 = 1 + 2 = 3

第三步:计算长度为 4 的子序列(j = i+3)

  • i=1, j=4:s [1]="b" vs s [4]="b" 相等 → dp [1][4] = dp [2][3] + 2 = 1 + 2 = 3
  • i=0, j=3:s [0]="b" vs s [3]="a" 不等 → dp [0][3] = max (dp [1][3], dp [0][2]) = max (2,3)=3

第四步:计算长度为 5 的子序列(j = i+4)

  • i=0, j=4:s [0]="b" vs s [4]="b" 相等 → dp [0][4] = dp [1][3] + 2 = 2 + 2 = 4

最终 dp 表如下,答案为 dp [0][4] = 4:

最终结果j=0j=1j=2j=3j=4
i=012334
i=11223
i=2113
i=311
i=41

代码实现

var longestPalindromeSubseq = function(s) {
    let n = s.length;
    let dp = new Array(n).fill().map(() => new Array(n).fill(0));
    // 初始化:单个字符的最长回文子序列长度为1
    for (let i = n - 1; i >= 0; i--) {
        dp[i][i] = 1;
        // j从i+1开始,计算i到j的子序列
        for (let j = i + 1; j < n; j++) {
            if (s[i] === s[j]) {
                dp[i][j] = dp[i + 1][j - 1] + 2;
            } else {
                dp[i][j] = Math.max(dp[i + 1][j], dp[i][j - 1]);
            }
        }
    }
    return dp[0][n - 1];
};

趣味类比

如果说 647 题的 dp 表像 “藏宝图”(标记所有回文子串的位置),那 516 题的 dp 表就是 “最长回文拼图”—— 每一格的数值都是周围格子拼接的结果,最后拼出整个字符串中最长的回文子序列!

四. 复杂度对比与总结

题目解法时间复杂度空间复杂度
647暴力O(n³)O(1)
647二维 DPO(n²)O(n²)
647一维 DPO(n²)O(n)
516二维 DPO(n²)O(n²)
516一维 DPO(n²)O(n)

核心区别与联系

维度647. 回文子串516. 最长回文子序列
核心目标统计回文子串的数量求最长回文子序列的长度
字符连续性子串必须连续子序列可非连续
DP 状态含义dp [i][j] 表示 “是否为回文”(布尔值)dp [i][j] 表示 “最长长度”(数值)
转移逻辑依赖首尾相等 + 中间回文依赖首尾相等时 + 2,否则取最大值

五. 总结与思考

回文问题是动态规划的经典应用,两道题虽然都涉及 “回文”,但因 “子串” 和 “子序列” 的差异,解法细节大不相同。但核心思路一致:用 dp [i][j] 表示区间 [i...j] 的状态,通过已知的小区间状态推导未知的大区间状态

记住动态规划的 “四步走”:

  1. 定义状态(dp [i][j] 代表什么)
  1. 设计转移方程(如何从已知状态推未知)
  1. 初始化(最小子问题的解)
  1. 确定遍历顺序(保证推导时依赖的状态已计算)

下次遇到回文题,不妨先试试这四步,再结合 “记账本” 式的 dp 表,轻松拿下!✨