算法学习笔记——动态规划:构造回文串最少插入次数、最长回文子序列问题

解题思路

涉及“子序列”和“最值”的问题,基本要使用动态规划(暴力列举子序列所有可能,指数级复杂度)

  • 一个字符串/数组的问题,使用一维/二维dp数组
    如“最长递增子序列”中,dp[i]代表以s[i]结尾的最长递增序列的长度
    如“最长回文子序列”中,dp[i][j]代表s[i..j]中最长回文子序列的长度
  • 两个字符串/数组的问题,使用二维dp数组
    如“最长公共子序列”中,dp[i][j]代表s1[0...i]s2[0...j]中最长公共子序列的长度
  • 如果是“连续”的子串问题,i与 以s1[i]结尾的子串 挂钩
  • 若果是“不连续”的子序列问题,i与 s1[0…i]的子序列 挂钩

以最小插入次数构造回文串

LeetCode 1312. 让字符串成为回文串的最少插入次数
返回让 s 成为回文串的 最少插入次数,如s = “mcadm”,返回2,因为插入2次可将字符串可变为 “mcdadcm” 或者 “mdcacdm”

思路:
显然,对于回文串,左边有一个字符,右边就应该有一个同样的字符

  1. 状态:各个子串s[i...j]
    选择:可以根据子串首尾字符,选择插入字符,构成回文串

  2. dp[i][j]代表将s[i..j]变为回文串的最小插入次数

  3. 找状态转移方程
    数学归纳思想,假定已知dp[i+1][j-1],怎么求dp[i][j]:此时应该认为s[i+1][j-1]已经是回文串(因为已知将其变为回文串的最小插入次数)
    转移方程
    s[i]==s[j],则dp[i][j] = dp[i+1][j-1]
    s[i]!=s[j],则dp[i][j] = min(dp[i+1][j] , dp[i][j-1]) + 1

    含义:直接在首尾插入s[j]s[i]不一定是最好的方案,应该先分别尝试s[j]s[i]能否与内部的s[i+1][j-1]构成回文串(若能,就可以少插入一个字符),然后再来插入另一个字符(后面这次插入是免不掉的,因为首位字符不同,至少插入一次才有回文串)
    例如'aaab',虽然首尾不同,但是'aaa'已经是回文串,最终只用插入一个'b'即可

实现:

class Solution:
    def minInsertions(self, s: str) -> int:
        """求构造回文串的最小插入次数"""
        L = len(s)
        # dp[i][j]代表s[i..j]构造回文串的最小插入次数,i>j时无意义
        # base case:i==j时dp[i][j]=0,无需插入就是回文串
        dp = [[0 for _ in range(L)] for _ in range(L)]
        for i in range(L - 2, -1, -1):
            for j in range(i + 1, L):
                # 已知dp[i + 1][j - 1],认为s[i+1,j-1]已经是回文串
                if s[i] == s[j]:
                    # 首尾相同,无需插入
                    dp[i][j] = dp[i + 1][j - 1]
                else:
                    # 首尾不同,不要直接做两次插入
                    # 先分别看s[i]或s[j]能否与内部的s[i+1,j-1]直接构成回文串
                    # 再来插入另一个字符(后面这次插入是免不掉的,因为首位字符不同,至少插入一次才有回文串)
                    # 如果直接构成回文串,省去一次插入;若不行,则对应于在首尾两次插入的一般情况
                    dp[i][j] = min(dp[i + 1][j], dp[i][j - 1]) + 1

        return dp[0][L - 1]

状态压缩:

class Solution:
    def minInsertions(self, s: str) -> int:
        """求构造回文串的最小插入次数"""
        L = len(s)
        # base case:i==j时dp[i][j]=0,无需插入就是回文串
        dp = [0 for _ in range(L)]
        for i in range(L - 2, -1, -1):
            # 每行最初就是下一行的拷贝
            bottomLeft = 0  # 记录每个单元格的左下角的值,每行左侧格子的左下角都是0
            for j in range(i + 1, L):
                temp = dp[j]
                if s[i] == s[j]:
                    # dp[i][j] = dp[i + 1][j - 1]
                    dp[j] = bottomLeft
                else:
                    # dp[i][j] = min(dp[i + 1][j], dp[i][j - 1]) + 1
                    dp[j] = min(dp[j], dp[j - 1]) + 1
                bottomLeft = temp
                # 在被覆盖前,记录”当前列的下一行“的值,作为下一格的”左下角“的值
        return dp[L - 1]

最长回文子序列

LeetCode 516. 最长回文子序列
给出字符串s,求找出其中最长的回文子序列的长度
如s = “bbbab”,返回4,因为最长回文子序列为 “bbbb” 。

思路:
显然,对于回文串,左边有一个字符,右边就应该有一个同样的字符

  1. 状态:各个子串s[i...j],其中包含回文子序列
    选择:可以选择在子串首尾拼接两个同样的字符,构成更长的回文序列

  2. dp[i][j]代表s[i..j]中最长回文子序列的长度

  3. 找状态转移方程
    数学归纳思想,假定已知dp[i+1][j-1],怎么求dp[i][j]
    转移方程
    s[i]==s[j],则dp[i][j] = dp[i+1][j-1] +2
    s[i]!=s[j],则dp[i][j] = max(dp[i+1][j] , dp[i][j-1])

    含义:将s[i]s[j]同时拼接到子串上,并不能获得更长的回文子序列(dp[i][j]仍等于dp[i+1][j-1]的值),转而分别尝试将它们拼接到s[i+1][j-1]的首部/尾部,看能否与其他字符构成回文序列
    算法学习笔记——动态规划:构造回文串最少插入次数、最长回文子序列问题_第1张图片
    可见,每次计算dp[i][j],依赖于左、左下、下方的三个格子,所以要注意dp表的计算顺序:从下往上、从左往右计算

实现:

class Solution:
    def longestPalindromeSubseq(self, s: str) -> int:
        """求最长回文子序列长度"""
        L = len(s)
        # dp[i][j]代表`s[i..j]中最长回文子序列的长度,i>j时无意义
        dp = [[0 for _ in range(L)] for _ in range(L)]
        # base case
        for i in range(L):
            dp[i][i] = 1
        for i in range(L - 2, -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 + 1][j], dp[i][j - 1])
        return dp[0][L - 1]

状态压缩:降低空间复杂度

由于每次计算dp[i][j],仅依赖于左、左下、下方的三个相邻的格子,与dp表中其他位置无关,可以将二维dp表压缩为一维dp表

仍“模拟”从下往上的顺序,逐行计算

  • 每行计算前,一维dp表的内容就是[计算完毕的下一行]的拷贝(原来的dp[i+1]行的内容),然后将从左到右更新本行(原来的dp[i]行)的内容
  • 对于当前要求解的单元格dp[j](原来的dp[i][j]),需要三个值:
    原来的左侧dp[i][j-1]、原来的左下方dp[i+1][j-1]、原来的下方dp[i+1][j]
  • 在当前行开始计算前,其“原来的左下方”值就是dp[j-1]“原来的下方”值就是dp[j]
    但是,开始计算本行后,其“原来的左下方”值会被“原来的左侧”值更新并覆盖掉,dp[j-1]变为“原来的左侧”值,因此,在开始计算当前单元格之前,要额外bottomLeft提前记录下“原来的左下方”值算法学习笔记——动态规划:构造回文串最少插入次数、最长回文子序列问题_第2张图片
  • 另外,要将对角线的base case投影到一维:直接将一维dp数组初始化为全1即可,由于每行是从左到右计算,这样做并不会引起冲突和覆盖问题
class Solution:
    def longestPalindromeSubseq(self, s: str) -> int:
        """求最长回文子序列长度"""
        L = len(s)
        # dp[i][j]代表`s[i..j]中最长回文子序列的长度,i>j时无意义
        # base case
        dp = [1 for _ in range(L)]
        
        for i in range(L - 2, -1, -1):
            bottomLeft = 0  # 每行开头的格子,左下角都是0
            for j in range(i + 1, L):
                temp = dp[j]  # 保存"左下角"的值,接下来将被"左侧"的dp[i][j-1]覆盖
                if s[i] == s[j]:
                    # dp[i][j] = dp[i + 1][j - 1] + 2
                    dp[j] = bottomLeft + 2
                else:
                    # dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])
                    dp[j] = max(dp[j], dp[j - 1])  # 未覆盖前,dp[j]就是下方的值
                # bottomLeft用于记录当前格子“原来的左下角”的值,
                # 留给下一个j使用
                bottomLeft = temp
        return dp[L - 1]

你可能感兴趣的:(算法学习笔记,动态规划,算法,leetcode)