使用dp解决回文串和回文序列问题

647. 回文子串

https://leetcode.cn/problems/palindromic-substrings/

给你一个字符串 s ,请你统计并返回这个字符串中 回文子串 的数目。

回文字符串 是正着读和倒过来读一样的字符串。

子字符串 是字符串中的由连续字符组成的一个序列。

示例 1:

输入:s = "abc"
输出:3
解释:三个回文子串: "a", "b", "c"

示例 2:

输入:s = "aaa"
输出:6
解释:6个回文子串: "a", "a", "a", "aa", "aa", "aaa"

提示:

  • 1 <= s.length <= 1000
  • s 由小写英文字母组成

普通思路

本题和前面写过的26. 单词拆分有点像

需要将s拆分成长度为1、2、…L的子串,做法:使用双指针,i作为子串结尾位置;j作为子串起点位置,从而对所有子串进行判断是否为回文串;

比如s= ‘abc’,将得到a, ab, b, abc, bc, c 再对每个子串判断是否为回文串(调用个函数即可)

但这种写法会超时,因为有太多的重复内容被反复计算

所以需要有dp的方法来判断回文串

普通代码(超时)

class Solution:
    def countSubstrings(self, s: str) -> int:
        def isLegal(s: str) -> bool:
            left = 0
            right = len(s) - 1
            while left < right:
                if s[left] != s[right]:
                    return False
                left += 1
                right -= 1
            return True
        
        L = len(s)
        res = 0
        for i in range(1, L + 1):
            for j in range(i):
                # print(s[j: i])
                if isLegal(s[j: i]):
                    res += 1
        
        return res

动态规划思路

一个更好的思路,把问题拆成子问题

比如为了判断abcba是否为子串,可以先判断bcb是否为子串->判断c是否为子串

所以问题可以变成

img
img

同时使用两个指针遍历,和普通思路一样,也需要将s拆分成长度为1、2、…L的子串s[j: i](左闭右闭)

  • 如果长度为1,即s[j]==s[i]且j==i,那一定是回文,令dp[j][i]=1
  • 如果长度为2,即s[j]==s[i]且j+1==i,也是回文,令dp[j][i]=1
  • 如果长度大于2,比如abcba,即s[j]==s[i]=a,且j+1<i,此时需要判断s[j+1:i-1](左闭右闭)这一段(bcb)是否为回文串,如果bcb已经是回文串(即令dp[j + 1][i - 1]=1),那s[j:i](左闭右闭),也是回文,令dp[j][i]=1;否则就不是回文

反之

  • 如果s[j]!=s[i],那一定不是回文串

动态规划代码

class Solution:
    def countSubstrings(self, s: str) -> int:
        # 下面使用动态规划解决
        L = len(s)
        res = 0
        dp = [[False] * (L + 1) for _ in range(L + 1)]
        # 全部初始化为False即可

        for i in range(1, L + 1):
            for j in range(1, i + 1):
                # print(s[j - 1], s[i - 1])
                if s[j - 1] == s[i - 1]:
                    if j == i:
                        # 同一个字符且位置相同
                        res += 1
                        dp[j][i] = True    
                    elif j + 1 == i:
                        # 相邻两个字符
                        res += 1
                        dp[j][i] = True    
                    else:
                        # j与i之间有其它字符,此时要判断s[j+1: i-1](左闭右闭)是否为回文,如果它也是回文,才能说明s[j: i](左闭右闭)是回文
                        if dp[j + 1][i - 1]:
                            dp[j][i] = True 
                            res += 1                   
        # print(dp)
        return res

注意这里的遍历顺序,最好是i从后向前遍历,因为在状态转换时需要使用dp[i + 1][j - 1]

所以i的范围从[L-1, 0]而j的范围从[i, L-1],而dp的含义还是s[j-1,i+1]这段字符串是否为回文

代码改进

# 下面使用动态规划解决
L = len(s)
res = 0
dp = [[False] * (L) for _ in range(L)]
# 全部初始化为False即可

for i in range(L - 1, -1, -1):
    for j in range(i, L):
        # print(s[j - 1], s[i - 1])
        if s[j] == s[i]:
            if j == i:
                # 同一个字符且位置相同
                res += 1
                dp[i][j] = True    
            elif j - 1 == i:
                # 相邻两个字符
                res += 1
                dp[i][j] = True    
            else:
                # j与i之间有其它字符,此时要判断s[j+1: i-1](左闭右闭)是否为回文,如果它也是回文,才能说明s[j: i](左闭右闭)是回文
                if dp[i + 1][j - 1]:
                    dp[i][j] = True 
                    res += 1                   
# print(dp)
return res

516. 最长回文子序列

https://leetcode.cn/problems/longest-palindromic-subsequence/

给你一个字符串 s ,找出其中最长的回文子序列,并返回该序列的长度。

子序列定义为:不改变剩余字符顺序的情况下,删除某些字符或者不删除任何字符形成的一个序列。

示例 1:

输入:s = "bbbab"
输出:4
解释:一个可能的最长回文子序列为 "bbbb" 。

示例 2:

输入:s = "cbbd"
输出:2
解释:一个可能的最长回文子序列为 "bb" 。

提示:

  • 1 <= s.length <= 1000
  • s 仅由小写英文字母组成

动态规划思路

注意这题是回文子序列(不要求连续)

仍然可以使用dp数组做,和647. 回文子串思路相似

  1. dp下标与含义

dp[i][j]表示s[i:j](左闭右闭)的最长的回文子序列长度

  1. 递推公式

在判断回文子串的题目中,关键逻辑就是看s[i]与s[j]是否相同。

如果s[i]与s[j]相同,那么dp[i][j] = dp[i + 1][j - 1] + 2

如图:

516.最长回文子序列
516.最长回文子序列

如果不相同,说明s[i]和s[j]的同时加入 并不能增加[i,j]区间回文子序列的长度,那么分别加入s[i]、s[j]看看哪一个可以组成最长的回文子序列。

加入s[j]的回文子序列长度为dp[i + 1][j]。

加入s[i]的回文子序列长度为dp[i][j - 1]。

那么dp[i][j]一定是取最大的,即:dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);

516.最长回文子序列1
516.最长回文子序列1
  1. 初始化

首先要考虑当i 和j 相同的情况,从递推公式:dp[i][j] = dp[i + 1][j - 1] + 2; 可以看出 递推公式是计算不到 i 和j相同时候的情况。

所以需要手动的将i==j的情况全部初始化为1,其它值初始化为0

当然,也可以不手动初始化,但需要在s[i]==s[j]时,手动添加判断条件,如果i==j则赋值为1;而且j - 1==i时需要手动赋值为2
  1. 遍历顺序

从递归公式中,可以看出,dp[i][j] 依赖于 dp[i + 1][j - 1] ,dp[i + 1][j] 和 dp[i][j - 1],如图:

img
img

所以遍历i的时候一定要从下到上遍历,这样才能保证下一行的数据是经过计算的。

动态规划代码

  • 时间复杂度O(N^2)
  • 空间复杂度O(N^2)
class Solution:
    def longestPalindromeSubseq(self, s: str) -> int:
        # dp下标与含义
        # dp[i][j]表示s[j:i](左闭右闭)的最长回文子序列

        L = len(s)
        dp = [[0] * L for _ in range(L)]


        for i in range(L - 1, -1, -1):
            for j in range(i, L):
                if s[i] == s[j]:
                    if i == j:
                        dp[i][j] = 1
                    elif j - 1 == i:
                        dp[i][j] = 2
                    elif dp[i + 1][j - 1] != 0:
                        dp[i][j] = dp[i + 1][j - 1] + 2
                else:
                    dp[i][j] = max(dp[i][j - 1], dp[i + 1][j])

        return dp[0][-1]

动态规划代码二

手动初始化

  • 时间复杂度O(N^2)
  • 空间复杂度O(N^2)
class Solution:
    def longestPalindromeSubseq(self, s: str) -> int:
        # dp下标与含义
        # dp[i][j]表示s[j:i](左闭右闭)的最长回文子序列

        L = len(s)
        dp = [[0] * L for _ in range(L)]

        # 对角线全为1
        for i in range(L):
            dp[i][i] = 1
            
        for i in range(L - 1, -1, -1):
            for j in range(i + 1, L):
                if s[i] == s[j]:
                    dp[i][j] = dp[i + 1][j - 1] + 2
                else:
                    dp[i][j] = max(dp[i][j - 1], dp[i + 1][j])

        return dp[0][-1]