动态规划状态压缩详解

前言

这篇文章将详细介绍动态规划算法的状态压缩技巧

所谓动态规划,本质上就是带有备忘录的递归:在计算出子问题的解之后先将它存储到备忘录中;在求解子问题之前先查看备忘录中有没有现成的答案,如果有,直接返回这个答案,从而避免了很多重复的计算,降低了时间复杂度;另一方面,我们经常发现子问题的答案在使用一次之后便不再使用了,因此动态规划的备忘录也是可以做优化以降低空间复杂度的,这就是状态压缩。状态压缩的工作可以分为两部分:对状态转移过程的压缩和对初始状态的压缩,而这两部分的本质其实都是两个字:投影;具体来说就是沿着压缩方向用最新的状态覆盖原来的状态,然后根据具体情况对计算过程做一些调整,比如增加临时变量或修改循环方向,这一点相信读者在阅读完本文后会有更深的理解

在开始之前为了避免歧义先提前说明一下“沿某方向压缩”的含义,“沿x方向压缩”即沿x的值变化的方向将二维表格压缩为一维表格,如图:
在这里插入图片描述

在本文中我根据做题积累的经验将状态压缩分成了几种难度(主要是状态转移或状态初始化有所不同;你熟练之后就会觉得这些难度没什么区别,大同小异而已),每种都配合了 l e e t c o d e leetcode leetcode 例题;为了简洁起见,每道题将状态定义、状态转移方程及代码等直接列出(会有简要说明),题目分析从略,更不会单独介绍动态规划算法,以便将重点放在状态压缩的技巧上,如果你对动态规划算法还不是很熟悉建议先阅读相关文章;最后注意本文讲解和编程使用的语言均为 Python

题目一:最简单的状态压缩

在这里插入图片描述

  • 状态定义:
    d p [ i ] [ j ] dp[i][j] dp[i][j] 表示是否可以从数组的前 i i i 个数中挑选一些正整数(每个数只能用一次),使得这些数的和恰好等于 j j j
  • 状态转移方程:
    当我们面对数组前 i i i 个数(即子数组 [ n u m s [ 0 ] , n u m s [ 1 ] , . . . , n u m s [ i − 1 ] ] [nums[0], nums[1], ..., nums[i-1]] [nums[0],nums[1],...,nums[i1]] ) 时有两种情况可以使 d p [ i ] [ j ] dp[i][j] dp[i][j] 为 True:不使用 n u m s [ i − 1 ] nums[i-1] nums[i1],并且在前 i − 1 i - 1 i1 个数中已经可以选出一部分正整数使它们的和为 j j j;或者使用 n u m s [ i − 1 ] nums[i-1] nums[i1],并且在前 i − 1 i - 1 i1 个数中可以选出一部分正整数,使得它们的和为 j − n u m s [ i − 1 ] j - nums[i-1] jnums[i1],综上可得状态转移方程:
    d p [ i ] [ j ] = d p [ i − 1 ] [ j ]    o r    d p [ i − 1 ] [ j − n u m s [ i − 1 ] ] dp[i][j] = dp[i - 1][j] \ \ or \ \ dp[i - 1][j - nums[i-1]] dp[i][j]=dp[i1][j]  or  dp[i1][jnums[i1]]
  • 边界条件:
    j = 0 j=0 j=0 d p [ i ] [ j ] dp[i][j] dp[i][j] 表示是否可以从数组前 i i i 个数中选出一部分数使得它们的和为0,我们可以认为不选任何数字的结果就是0,所以这总可以做到(即为True);另外 j − n u m s [ i − 1 ] j - nums[i-1] jnums[i1] 作为数组的下标,必须大于等于 0 ,因此只有当 n u m s [ i − 1 ] < = j nums[i-1] <= j nums[i1]<=j 时我们才能用上面的状态转移方程;那其他情况呢?思考一下就会发现 d p [ i ] [ j ] dp[i][j] dp[i][j] 在其他情况下至少应该和 d p [ i − 1 ] [ j ] dp[i-1][j] dp[i1][j] 一致;于是得到以下代码:
def canPartition(self, nums: List[int]) -> bool:
        s = sum(nums)
        if s & 1:
            return False
        
        # 初始化
        dp = [[False for _ in range(s//2+1)] for _ in range(len(nums)+1)]

        for i in range(len(nums)+1):
            dp[i][0] = True
        
        # 状态转移
        for i in range(1, len(nums)+1):
            for j in range(1, s//2+1):
                dp[i][j] = dp[i-1][j]
                if nums[i-1] <= j:
                    dp[i][j] = dp[i-1][j] or dp[i-1][j-nums[i-1]]
        
        return dp[-1][-1]

从状态转移部分可以清楚地看出, i i i 这个维度在转移的时候只与上一轮的状态有关,即状态转移方程等式左边只有 i i i,等式右边只有 i − 1 i-1 i1 (注意“上一轮”是相对循环顺序而言的,这里循环是从小到大计算,因此“上一轮”指 i − 1 i-1 i1;如果是从大到小计算,则“上一轮”指 i + 1 i+1 i+1),转移过程如图所示(其中蓝色格子是已经计算的状态;第一行和第一列是边界情况,初始化时已经确定,所以也算作计算好的状态):
在这里插入图片描述
在这种情况下压缩这个维度 i i i 是非常简单的,我们直接将维度 i i i 删去,这样 d p dp dp 就变成了一维数组;然后考虑到原来计算 d p [ i ] [ j ] dp[i][j] dp[i][j] 时用的是 d p [ i − 1 ] [ j ] dp[i-1][j] dp[i1][j] ,而状态压缩后这两个值将被压缩到同一个位置,即后来计算的 d p [ i ] [ j ] dp[i][j] dp[i][j] 会覆盖原来的 d p [ i − 1 ] [ j ] dp[i-1][j] dp[i1][j] ,所以为了保证 d p [ i ] [ k ]   ( k > j ) dp[i][k]\ (k>j) dp[i][k] (k>j) 的计算的正确性我们应该从后往前计算,综上可得:

def canPartition(self, nums: List[int]) -> bool:
    s = sum(nums)
    if s & 1:
        return False
	
	dp = [False for _ in range(s//2+1)]
    dp[0] = True

    for i in range(1, len(nums)+1):
    	# 循环改为从后向前
        for j in range(s//2, 0, -1):
            if nums[i-1] <= j:
                dp[j] = dp[j] or dp[j-nums[i-1]]

        return dp[-1]

题目二:另一种简单的状态压缩

在这里插入图片描述

  • 状态定义:
    d p [ i ] [ j ] dp[i][j] dp[i][j] 表示以 i i i 为起始下标,以 j j j 为末尾下标的子字符串是否是回文串
  • 状态转移方程:
    由回文的特点我们知道如果子字符串 s [ i : j ] s[i:j] s[i:j] 是回文串,那么 s [ i − 1 : j + 1 ] s[i-1:j+1] s[i1:j+1] 是回文串的充要条件是 s [ i − 1 ] = s [ j ] s[i-1] = s[j] s[i1]=s[j] (注意这里是Python语言的表示方法, s [ i : j ] s[i:j] s[i:j] 表示下标从 i i i j − 1 j-1 j1 的子串, s [ i − 1 : j + 1 ] s[i-1:j+1] s[i1:j+1] 表示下标从 i − 1 i-1 i1 j j j 的子串);如果 s [ i − 1 ] s[i-1] s[i1] 不等于 s [ j ] s[j] s[j] ,那么 s [ i − 1 : j + 1 ] s[i-1:j+1] s[i1:j+1] 一定不是回文串,即 d p [ i − 1 ] [ j ] dp[i-1][j] dp[i1][j] 一定为 False;综上得状态转移方程:
    d p [ i ] [ j ] = { d p [ i + 1 ] [ j − 1 ] , i f   s [ j ] = = s [ i ] F a l s e , i f   s [ j ]   ! = s [ i ] dp[i][j] = \begin{cases}dp[i + 1][j - 1], & \mathrm{if} \ s[j] == s[i]\\False, & \mathrm{if} \ s[j] \ != s[i] \end{cases} dp[i][j]={dp[i+1][j1],False,if s[j]==s[i]if s[j] !=s[i]
  • 边界条件:
    单个字符也算是回文串,所以 d p [ i ] [ i ] dp[i][i] dp[i][i] 为 True;另外虽然根据我们对状态的定义, d p [ i ] [ j ] dp[i][j] dp[i][j] 中的 j j j 在逻辑上不应该小于 i i i d p [ i ] [ j ] ( i > j ) dp[i][j] (i>j) dp[i][j](i>j) 应为False,但是当遇到 ′ q a a w ′ 'qaaw' qaaw 这样的字符串时,如果我们计算 d p [ 1 ] [ 2 ] dp[1][2] dp[1][2] (结果应为True),会发现这个状态由 d p [ 2 ] [ 1 ] dp[2][1] dp[2][1] 转移而来,因此为了便于编程,我们将 d p [ i ] [ j ] ( i > j ) dp[i][j] (i>j) dp[i][j](i>j) 都设为 True。于是得到代码:
def longestPalindrome(self, s: str) -> str:
    size = len(s)
    if size < 2:
        return s
	
	# 初始化
    dp = [[False for _ in range(size)] for _ in range(size)]

    max_len = 1
    start = 0
        
    for i in range(size):
        for j in range(i+1):
            dp[i][j] = True

	# 状态转移
    for i in range(size-1, -1, -1):
        for j in range(i+1, size):
            if s[i] == s[j]:
                dp[i][j] = dp[i+1][j-1]
            else:
                dp[i][j] = False

            if dp[i][j]:
                cur_len = j - i + 1
                if cur_len > max_len:
                    max_len = cur_len
                    start = i

    return s[start:start + max_len]

这道题的状态转移过程如图所示(蓝色格子依然表示已经计算好的状态,这里的边界条件是对角线的格子以及紧邻对角线的下方的格子,在程序中我偷懒将所有下三角格子都设为了True):
在这里插入图片描述

和第一道题一样,我们发现每一轮计算无论是哪个维度都只取决于上一轮的状态,不过考虑到循环嵌套的顺序,我们选择压缩维度 i i i (原因很简单,读者可以思考一下),只要直接将维度 i i i d p dp dp 表中删除就行了;此外,注意如果我们依然按 j j j 从小到大遍历计算 d p [ i ] [ j ] dp[i][j] dp[i][j] 的话, d p [ i ] [ j − 1 ] dp[i][j-1] dp[i][j1] 在压缩后会覆盖 d p [ i + 1 ] [ i − 1 ] dp[i+1][i-1] dp[i+1][i1] 导致 d p [ i ] [ j ] dp[i][j] dp[i][j] 的计算出错,因此我们要反过来计算,即按 j j j 从大到小的顺序计算 d p [ i ] [ j ] dp[i][j] dp[i][j];下图给出错误计算的图示(其中红色箭头表示状态转移方向,黄色箭头表示状态覆盖方向,格子里的数字表示计算顺序):
在这里插入图片描述

从图中可以看到我们已经完成了 d p [ 2 ] [ 3 ] dp[2][3] dp[2][3] (即2号格子)的计算,在压缩后的 d p dp dp 表中该结果覆盖了 d p [ 3 ] dp[3] dp[3](即原 d p dp dp 表中的 d p [ 3 ] [ 3 ] dp[3][3] dp[3][3]),这会导致后面 d p [ 4 ] dp[4] dp[4](即原 d p dp dp 表中的 d p [ 2 ] [ 4 ] dp[2][4] dp[2][4],问号所在格子)的计算出错,因为在压缩后的 d p dp dp 表中状态 d p [ 4 ] dp[4] dp[4] 将由状态 d p [ 3 ] dp[3] dp[3] 转移而来,对应原 d p dp dp 表中 d p [ 2 ] [ 4 ] dp[2][4] dp[2][4] d p [ 3 ] [ 3 ] dp[3][3] dp[3][3] 转移而来,但现在 d p [ 3 ] dp[3] dp[3] 中存储的并不是原 d p dp dp 表中的 d p [ 3 ] [ 3 ] dp[3][3] dp[3][3] ,而是我们刚刚计算出来的 d p [ 2 ] [ 3 ] dp[2][3] dp[2][3] ;颠倒内层循环的计算顺序后就避免了这个问题:
在这里插入图片描述

最后是边界条件的压缩,这个直接沿压缩方向投影就行,原因很简单,读者只要想一下每一轮计算的第一个状态转移过程就可以看出这样做的合理性:
在这里插入图片描述

综上可得状态压缩后的代码:

def longestPalindrome(self, s: str) -> str:
    size = len(s)
    if size < 2:
        return s
	
	dp = [True for _ in range(size)]

    start = 0
    max_len = 1

    for i in range(size-1, -1, -1):
        for j in range(size-1, i, -1):
            if s[i] == s[j]:
                dp[j] = dp[j-1]
            else:
                dp[j] = False
            # 以上判断语句还可进一步简化为: dp[j] = dp[j-1] and s[i] == s[j]
                
            if dp[j]:
                cur_len = j - i + 1
                if cur_len > max_len:
                    max_len = cur_len
                    start = i

    return s[start:start + max_len]

题目三:又是一个简单的状态压缩

在这里插入图片描述
在这里插入图片描述

  • 状态定义:
    d p [ i ] [ j ] dp[i][j] dp[i][j] 表示从起点到坐标为 ( i + 1 ,   j + 1 ) (i+1,\ j+1) (i+1, j+1) 有几种不同的路径(注意 d p   t a b l e dp\ table dp table 下标从0开始编号,坐标从1开始编号)
  • 状态转移方程:
    由于机器人只能向右或向下前进,因此 ( i + 1 ,   j + 1 ) (i+1,\ j+1) (i+1, j+1) 位置的上一步只可能是 ( i ,   j − 1 ) (i,\ j-1) (i, j1) ( i − 1 ,   j ) (i-1,\ j) (i1, j) 不同路径的数量当然是两者之和,综上可得状态转移方程:
    d p [ i ] [ j ] = d p [ i − 1 ] [ j ] + d p [ i ] [ j − 1 ] dp[i][j] = dp[i-1][j] + dp[i][j-1] dp[i][j]=dp[i1][j]+dp[i][j1]
  • 边界条件:
    i = 0 i=0 i=0 j = 0 j=0 j=0 时分表表示第一行或第一列的某个格子,从起点到这两种格子显然只有一种走法
def uniquePaths(self, m: int, n: int) -> int:
	# 初始化
    dp = [[1 for _ in range(n)] for _ in range(m)]
	
	# 状态转移
    for i in range(1, m):
        for j in range(1, n):
            dp[i][j] = dp[i-1][j] + dp[i][j-1]

    return dp[-1][-1]

在这道题中当前状态不仅和上一轮的状态有关,还和当前轮的状态有关:
在这里插入图片描述
但是我们发现无论是沿 i i i 方向压缩还是沿 j j j 方向压缩,当前状态依赖的两个状态都不会被之前的计算结果覆盖,也就是说我们可以直接压缩而不会影响 d p [ i ] [ j ] dp[i][j] dp[i][j] 的计算;至于压缩哪个维度,和之前一样,由于循环外层是 i i i ,所以我们沿 i i i 压缩

初始状态依然由投影得到:
在这里插入图片描述
最终我们得到状态压缩后的代码:

def uniquePaths(self, m: int, n: int) -> int:
    dp = [1 for _ in range(n)]

    for i in range(1, m):
        for j in range(1, n):
            dp[j] = dp[j-1] + dp[j]

    return dp[-1]

题目四:稍微麻烦一点的状态压缩

在这里插入图片描述

  • 状态定义:
    d p [ i ] [ j ] dp[i][j] dp[i][j] 表示 s s s 的下标范围在 [ i , j ] [i, j] [i,j] (注意是闭区间)的子字符串的最长回文子序列的长度
  • 状态转移方程:
    由回文的特点我们知道如果子字符串 s [ i : j ] s[i:j] s[i:j] 是回文串,那么 s [ i − 1 : j + 1 ] s[i-1:j+1] s[i1:j+1] 是回文串的充要条件是 s [ i − 1 ] = s [ j ] s[i-1] = s[j] s[i1]=s[j] (注意这里是Python语言的表示方法, s [ i : j ] s[i:j] s[i:j] 表示下标从 i i i j − 1 j-1 j1 的子串, s [ i − 1 : j + 1 ] s[i-1:j+1] s[i1:j+1] 表示下标从 i − 1 i-1 i1 j j j 的子串);如果 s [ i − 1 ] s[i-1] s[i1] 不等于 s [ j ] s[j] s[j] ,那么 d p [ i ] [ j ] dp[i][j] dp[i][j] 应该取 d p [ i ] [ j − 1 ] dp[i][j-1] dp[i][j1] d p [ i + 1 ] [ j ] dp[i+1][j] dp[i+1][j] 两个子问题中的较大者,综上得状态转移方程:
    d p [ i ] [ j ] = { d p [ i + 1 ] [ j − 1 ] + 2 , i f   s [ j ] = = s [ i ] m a x ( d p [ i ] [ j − 1 ] ,   d p [ i + 1 ] [ j ] ) , i f   s [ j ]   ! = s [ i ] dp[i][j] = \begin{cases}dp[i + 1][j - 1] + 2, & \mathrm{if} \ s[j] == s[i]\\max(dp[i][j-1],\ dp[i+1][j]), & \mathrm{if} \ s[j] \ != s[i] \end{cases} dp[i][j]={dp[i+1][j1]+2,max(dp[i][j1], dp[i+1][j]),if s[j]==s[i]if s[j] !=s[i]
  • 边界条件:
    单个字符也属于回文串,因此 i i i 等于 j j j d p [ i ] [ j ] dp[i][j] dp[i][j] 为1
def longestPalindromeSubseq(self, s: str) -> int:
    # 初始化
    dp = [[0 for _ in range(len(s))] for _ in range(len(s))]

    for i in range(len(s)):
        dp[i][i] = 1

	# 状态转移,注意计算顺序
    for i in range(len(s)-1, -1, -1):
        for j in range(i+1, len(s)):
            if s[j] == s[i]:
                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]

这里的状态转移无论是哪个维度都不仅和上一轮的状态有关,也和当前轮的状态有关,图示如下:
在这里插入图片描述
考虑到循环的嵌套顺序,我们还是选择压缩维度 i i i,首先我们任意拿出一个状态转移过程分析一下为了压缩状态我们需要额外记录什么:
在这里插入图片描述
上图即为一个状态转移过程,当我们把它在维度 i i i 上压缩为1维(即纵向压缩)后,可以看到 d p [ i + 1 ] [ j − 1 ] dp[i+1][j-1] dp[i+1][j1] d p [ i + 1 ] [ j ] dp[i+1][j] dp[i+1][j] 被覆盖了,并且 d p [ i + 1 ] [ j − 1 ] dp[i+1][j-1] dp[i+1][j1] 先被覆盖:这是因为根据我们计算的顺序,在 i i i 固定时, d p [ i ] [ j ] dp[i][j] dp[i][j] 是按 j j j 从小到大被计算的,因此 d p [ i ] [ j − 1 ] dp[i][j-1] dp[i][j1] 优先于 d p [ i ] [ j ] dp[i][j] dp[i][j] 被计算,而压缩后的 d p dp dp 只有一维了, d p [ i ] [ j − 1 ] dp[i][j-1] dp[i][j1] 的结果就只能存储在 d p [ i + 1 ] [ j − 1 ] dp[i+1][j-1] dp[i+1][j1] 的位置,所以 d p [ i + 1 ] [ j − 1 ] dp[i+1][j-1] dp[i+1][j1] 将先被覆盖;正是因为这个原因,为了保证 d p [ i ] [ j ] dp[i][j] dp[i][j] 的计算的正确性,我们需要一个额外的变量(暂且命名为 p r e pre pre)提前保存 d p [ i + 1 ] [ j − 1 ] dp[i+1][j-1] dp[i+1][j1] 的值,在 d p [ i ] [ j ] dp[i][j] dp[i][j] 的计算过程中, p r e pre pre 将充当 d p [ i + 1 ] [ j − 1 ] dp[i+1][j-1] dp[i+1][j1] 的角色;在 d p [ i ] [ j ] dp[i][j] dp[i][j] 计算完成后,再用 p r e pre pre 保存 d p [ i + 1 , j ] dp[i+1, j] dp[i+1,j] ,以便进行下一轮的计算;此外这里需要注意编程上的一个trick:在 d p [ i ] [ j ] dp[i][j] dp[i][j] 计算完成后它的值将直接覆盖原来的 d p [ i + 1 ] [ j ] dp[i+1][j] dp[i+1][j] ,所以为了避免原值丢失,我们要再用一个临时变量提前保存 d p [ i + 1 ] [ j ] dp[i+1][j] dp[i+1][j] ,然后再进行 d p [ i ] [ j ] dp[i][j] dp[i][j] 的计算; d p [ i ] [ j ] dp[i][j] dp[i][j] 的计算完成后,再将这个临时变量的值赋给 p r e pre pre

除此之外我们还要考虑状态压缩后的初始化问题,这个比较简单,只要把二维的初始状态投影到一维就可以了:

在这里插入图片描述

根据以上分析,我们可以写出状态压缩后的代码:

def longestPalindromeSubseq(self, s: str) -> int:
    dp = [1 for _ in range(len(s))]

    for i in range(len(s)-1, -1, -1):
        pre = 0
        for j in range(i+1, len(s)):
            tmp = dp[j]
            if s[j] == s[i]:
                dp[j] = pre + 2
            else:
                dp[j] = max(dp[j-1], dp[j])
            pre = tmp
        
    return dp[-1]

题目五:更麻烦一点的状态压缩

在这里插入图片描述
在这里插入图片描述

  • 状态定义:
    d p [ i ] [ j ] dp[i][j] dp[i][j] 表示从左上角到坐标为 [ i + 1 ,   j + 1 ] [i+1,\ j+1] [i+1, j+1] (注意 d p   t a b l e dp\ table dp table 下标从0开始编号,坐标从1开始编号)的格子的最小路径和
  • 状态转移方程:
    由于只能向右或向下移动,因此 ( i + 1 ,   j + 1 ) (i+1,\ j+1) (i+1, j+1) 位置的上一步只可能是 ( i ,   j − 1 ) (i,\ j-1) (i, j1) ( i − 1 ,   j ) (i-1,\ j) (i1, j) ,如果我们已经求得了移动到这两个格子的最小路径和,那么我们就可以贪心地选择两者之中较小的那个再加上当前格子的值作为当前格子的最小路径和。综上可得状态转移方程
    d p [ i ] [ j ] =   m i n ( d p [ i − 1 ] [ j ] ,   d p [ i ] [ j − 1 ] ) +   g r i d [ i ] [ j ] dp[i][j] =\ min(dp[i-1][j],\ dp[i][j-1]) +\ grid[i][j] dp[i][j]= min(dp[i1][j], dp[i][j1])+ grid[i][j]
  • 边界条件:
    显然对于第一行和第一列的每个格子只有一直沿水平方向或竖直方向才能到达,路径和自然是从 g r i d [ 0 ] [ 0 ] grid[0][0] grid[0][0] 到相应格子的值的累加

综上得到原始代码:

def minPathSum(self, grid: List[List[int]]) -> int:
    m = len(grid)
    n = len(grid[0])
    
    # 初始化    
    dp = [[0 for _ in range(n)] for _ in range(m)]
    dp[0][0] = grid[0][0]

    for i in range(1, m):
        dp[i][0] = dp[i-1][0] + grid[i][0]
        
    for i in range(1, n):
        dp[0][i] = dp[0][i-1] + grid[0][i]
	
	# 状态转移
    for i in range(1, m):
        for j in range(1, n):
            dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j]
        
    return dp[-1][-1]

可以看到这道题的状态转移过程还是很简单的,和题目三一样,我们沿 i i i 压缩(或者说压缩维度 m m m),直接将 d p dp dp 表的第一个维度删除;比较麻烦的是初始状态如何压缩,这道题的初始状态显然是没有规律的,因此我们不能直接投影;首先我们观察一下在原 d p dp dp 表和压缩后的 d p dp dp 表中每一轮循环的初始状态是如何参与状态转移的:
在这里插入图片描述

在这里插入图片描述
注意在压缩后的 d p dp dp 表中为了避免误解,我将第一个格子里的值标了出来,即压缩后的 d p dp dp 表中的 d p [ 0 ] dp[0] dp[0] 存储的是原 d p dp dp 表中的 d p [ 1 ] [ 0 ] dp[1][0] dp[1][0] ,其余格子的方括号里的值仍然是下标

可以看到压缩后的 d p dp dp 表的第一轮状态转移还是正确的,但是下一轮就会出错,因为在下一轮的计算开始时 d p [ 0 ] dp[0] dp[0] 存储的依然是原 d p dp dp 表中的 d p [ 1 ] [ 0 ] dp[1][0] dp[1][0] ,然而第二轮状态转移的第一次计算是从 d p [ 2 ] [ 0 ] dp[2][0] dp[2][0] d p [ 1 ] [ 1 ] dp[1][1] dp[1][1] 算出 d p [ 2 ] [ 1 ] dp[2][1] dp[2][1] ,也就是说 d p [ 0 ] dp[0] dp[0] 的值应该是原 d p dp dp 表的 d p [ 2 ] [ 0 ] dp[2][0] dp[2][0] ;后面几轮的状态转移也会遇到同样的问题。这就启示我们需要在每一轮计算开始之前人为地修改 d p [ 0 ] dp[0] dp[0] 的值,因此我们可以提前将 d p [ i ] [ 0 ] dp[i][0] dp[i][0] 的值算好,然后在每一轮循环开始的时候取出需要的值赋给 d p [ 0 ] dp[0] dp[0] ;这样就得到了状态压缩后的代码:

def minPathSum(self, grid: List[List[int]]) -> int:
	m = len(grid)
    n = len(grid[0])
	
	# 初始化
    dp = [0 for _ in range(n)]
    dp[0] = grid[0][0]

    for i in range(1, n):
        dp[i] = dp[i-1] + grid[0][i]
	
	# helper存储的就是每一轮计算所需的初始值
    helper = [grid[0][0]] * m
    for i in range(1, m):
        helper[i] = helper[i-1] + grid[i][0]
       
    # 状态转移
    for i in range(1, m):
    	# 计算前先修改 dp[0]的值
        dp[0] = helper[i]
        for j in range(1, n):
            dp[j] = min(dp[j-1], dp[j]) + grid[i][j]
        
    return dp[-1]

虽然初始状态无法压缩,但我们还是将算法的空间复杂度从 O ( m ∗ n ) O(m*n) O(mn) 降到了 O ( m + n ) O(m+n) O(m+n) ,当 m , n m,n m,n 比较大的时候效益还是很可观的

题目六:比较复杂的状态压缩

在这里插入图片描述

  • 状态定义:
    d p [ i ] [ j ] dp[i][j] dp[i][j] 表示 p p p 的前 i i i 个字符构成的模式子串能否匹配 s s s 的前 j j j 个字符构成的子字符串
  • 状态转移方程:
  1. 如果 p [ i − 1 ] p[i-1] p[i1] 是小写英文字符,且 p [ i − 1 ] = s [ j − 1 ] p[i-1] = s[j-1] p[i1]=s[j1] ,那么 d p [ i ] [ j ] dp[i][j] dp[i][j] 的匹配结果就完全取决于 d p [ i − 1 ] [ j − 1 ] dp[i-1][j-1] dp[i1][j1] 了;反之如果 p [ i − 1 ] p[i-1] p[i1] 不等于 s [ j − 1 ] s[j-1] s[j1],那么 d p [ i ] [ j ] dp[i][j] dp[i][j] 的结果一定为False
  2. 如果 p [ i − 1 ] p[i-1] p[i1] . . . ,那么 p [ i − 1 ] p[i-1] p[i1] 一定能匹配 s [ j − 1 ] s[j-1] s[j1] d p [ i ] [ j ] dp[i][j] dp[i][j] 的匹配结果依然完全取决于 d p [ i − 1 ] [ j − 1 ] dp[i-1][j-1] dp[i1][j1]
  3. 如果 p [ i − 1 ] p[i-1] p[i1] ∗ * ,我们分两种情况:
    如果 p [ i − 2 ] p[i-2] p[i2] 是英文字符,那么 p [ i − 2 : i ] p[i-2:i] p[i2:i] 这个模式串可以匹配0个或多个该英文字符,当匹配0个时,相当于丢弃这个模式串,于是 d p [ i ] [ j ] = d p [ i − 2 ] [ j ] dp[i][j] = dp[i-2][j] dp[i][j]=dp[i2][j];如果匹配多次,那就等价于将 s [ : j ] s[:j] s[:j] 这个子串的最后一个字符 s [ j − 1 ] s[j-1] s[j1] 扔掉,而该模式串还可以继续匹配 s [ j − 2 ] s[j-2] s[j2],因此 d p [ i ] [ j ] = d p [ i ] [ j − 1 ] dp[i][j] = dp[i][j-1] dp[i][j]=dp[i][j1]
    如果 p [ i − 2 ] p[i-2] p[i2] . . . ,那么 p [ i − 2 : i ] p[i-2:i] p[i2:i] 这个模式串就可以匹配任意字符串,同样也分为匹配空串和 s [ : j ] s[:j] s[:j] 从末尾开始的任意字串,参考前面的思路,前者状态转移为 d p [ i ] [ j ] = d p [ i − 2 ] [ j ] dp[i][j] = dp[i-2][j] dp[i][j]=dp[i2][j],后者状态转移为 d p [ i ] [ j ] = d p [ i ] [ j − 1 ] dp[i][j] = dp[i][j-1] dp[i][j]=dp[i][j1]
    于是得到状态转移方程:
    d p [ i ] [ j ] = { d p [ i − 1 ] [ j − 1 ] , i f   p [ i − 1 ] = = " . "   o r   s [ j − 1 ] = = p [ i − 1 ] d p [ i − 2 ] [ j ]   o r   d p [ i ] [ j − 1 ] , i f   p [ i − 1 ]   = = " ∗ "   a n d   ( p [ i − 2 ] = = " . "   o r   s [ j − 1 ] = = p [ i − 2 ] ) d p [ i − 2 ] [ j ] , i f   p [ i − 1 ]   = = " ∗ "   a n d   s [ j − 1 ] ! = p [ i − 2 ] F a l s e , i f   p [ i − 1 ]   ! = " ∗ "   a n d   p [ i − 1 ]   ! = " . "   a n d   s [ j − 1 ] ! = p [ i − 1 ] dp[i][j] = \begin{cases}dp[i - 1][j - 1], & \mathrm{if} \ p[i-1] == "." \ or \ s[j-1] == p[i-1]\\dp[i-2][j]\ or\ dp[i][j-1], & \mathrm{if} \ p[i-1] \ == "*" \ and \ (p[i-2] == "." \ or \ s[j-1] == p[i-2])\\ dp[i-2][j], & \mathrm{if} \ p[i-1] \ == "*" \ and \ s[j-1] != p[i-2]\\ False, & \mathrm{if}\ p[i-1] \ != "*" \ and \ p[i-1] \ != "." \ and \ s[j-1] != p[i-1]\end{cases} dp[i][j]=dp[i1][j1],dp[i2][j] or dp[i][j1],dp[i2][j],False,if p[i1]=="." or s[j1]==p[i1]if p[i1] =="" and (p[i2]=="." or s[j1]==p[i2])if p[i1] =="" and s[j1]!=p[i2]if p[i1] !="" and p[i1] !="." and s[j1]!=p[i1]
  • 边界条件:
    如果 p p p s s s 都是空的,那么我们可以认为 p p p 匹配了 s s s;当两者有一方非空时,如果是 p p p 空而 s s s 非空,那么 p p p 不可能匹配 s s s,因此 d p [ 0 ] [ j ] dp[0][j] dp[0][j] 只有当 j j j 等于0时才为 True,其他情况都是False;如果是 p p p 非空而 s s s 空,那么 p p p 只有在所有字符都匹配0次时才为True,也就是说所有非 ∗ * 字符后面必须紧跟 ∗ *

可能有读者考虑第三种情况有下标越界的问题,其实题目有明确说明保证测试用例的语法是正确的,因此 ∗ * 既不会出现在 p p p 的开头,也不会连续出现。根据以上分析得到如下代码:

def isMatch(self, s: str, p: str) -> bool:
	# 初始化
    dp = [[False for _ in range(len(s)+1)] for _ in range(len(p)+1)]

    dp[0][0] = True

    for i in range(len(p)):
        if p[i] == '*':
            dp[i+1][0] = dp[i-1][0]
	
	# 状态转移
	for i in range(1, len(p)+1):
    	for j in range(1, len(s)+1):
            if p[i-1] == '.' or p[i-1] == s[j-1]:
                dp[i][j] = dp[i-1][j-1]
            elif p[i-1] == '*':
                if p[i-2] == '.' or s[j-1] == p[i-2]:
                    dp[i][j] = dp[i-2][j] or dp[i][j-1]
                elif s[j-1] != p[i-2]:
                    dp[i][j] = dp[i-2][j]
            else:
                dp[i][j] = False
        
    return dp[-1][-1]

我们还是先分析一下这道题的状态是如何转移的。显然当前状态不仅和上一轮的状态有关,还和当前轮的状态有关:
在这里插入图片描述
根据之前的经验,这种情况只要用一个额外的变量储存被覆盖的状态就行了,至于要储存哪个状态要看我们压缩的是哪个维度。那么我们压缩哪个维度呢?如果我们还是压缩外层循环对应的那个维度(即维度 i i i),在部分测试用例上会得到错误的结果,原因在于这里的初始状态在维度 i i i 上不是一致的(与题目五情况相同),这就导致沿压缩方向投影之后无法保留全部初始状态信息,例如 s = " a p p l e " , p = " a ∗ p p " s="apple", p="a*pp" s="apple",p="app",按照正确的初始化,初始的 d p dp dp 表格是这样的:
在这里插入图片描述

可以看到 d p [ i ] [ 0 ] dp[i][0] dp[i][0] 有的是 True,有的是 False,如果直接沿 i i i 方向投影到 d p [ 0 ] dp[0] dp[0] 显然会导致状态丢失;也许你会问,第一行 d p [ 0 ] [ j ] dp[0][j] dp[0][j] 也是既有 True 也有 False 啊,但是注意第一行只有 d p [ 0 ] [ 0 ] dp[0][0] dp[0][0] 是 True,其余位置都一定是 False,因此尽管沿 j j j 方向投影后 d p [ 0 ] [ 0 ] dp[0][0] dp[0][0] 被后面的 False 覆盖了,我们只要单独处理一下第一次状态转移就行了

根据以上分析我们应该压缩维度 j j j;那么额外的变量要保存哪个状态的值呢?我们任意取出一个状态转移过程就知道了:
在这里插入图片描述

从图中我们可以清楚地看到 d p [ i − 1 ] [ j − 1 ] dp[i-1][j-1] dp[i1][j1] 将被格子 a a a 覆盖,因此我们需要一个变量(暂且命名为 p r e pre pre )存储 d p [ i − 1 ] [ j − 1 ] dp[i-1][j-1] dp[i1][j1] ;当 d p [ i ] [ j ] dp[i][j] dp[i][j] 计算完成后接着用它存储 d p [ i ] [ j − 1 ] dp[i][j-1] dp[i][j1] 以便为下一个状态的计算做准备;和题目五一样,由于 d p [ i ] [ j ] dp[i][j] dp[i][j] 计算完成后会立即覆盖 d p [ i ] [ j − 1 ] dp[i][j-1] dp[i][j1] ,所以我们还需要一个临时变量在计算 d p [ i ] [ j ] dp[i][j] dp[i][j] 之前暂时存储 d p [ i ] [ j − 1 ] dp[i][j-1] dp[i][j1] 的值,当 d p [ i ] [ j ] dp[i][j] dp[i][j] 计算完成后再将它的值赋给 p r e pre pre

你也许会问 b b b 不是也被 d p [ i − 2 ] [ j ] dp[i-2][j] dp[i2][j] 覆盖了吗?确实是这样,但是那个状态和当前状态 d p [ i ] [ j ] dp[i][j] dp[i][j] 的计算无关,所以我们不管它

现在状态压缩的框架我们已经明确了,剩下的就是一些细节了:

首先,注意我们压缩的是维度 j j j,而它在原来的程序中在循环内层,所以为了避免同一轮计算结果依次覆盖我们需要将原程序中对 i i i j j j 的循环反过来,即先对 j j j 做循环再对 i i i 做循环;

其次,刚才的分析中说了 d p [ 0 ] [ 0 ] dp[0][0] dp[0][0] 这个初始状态特殊,需要单独处理;我们先看一下它在状态转移的哪个位置:
在这里插入图片描述
可以看到 d p [ 0 ] [ 0 ] dp[0][0] dp[0][0] 在第一次状态转移中处于 d p [ i − 1 ] [ j − 1 ] dp[i-1][j-1] dp[i1][j1] 的位置(如果当前状态是 d p [ i ] [ j ] dp[i][j] dp[i][j] ),也就是需要临时变量保存的那个位置,所以临时变量的初始值应为 True;而后面的状态都是 False,所以我们应该只在第一次计算状态时令临时变量为 True;另外前面已经说了题目保证测试用例的语法无误,因此这里没有考虑 d p [ − 1 ] [ j ] dp[-1][j] dp[1][j] 的问题(从状态转移方程可以看到 d p [ − 1 ] [ j ] dp[-1][j] dp[1][j] 对应的情况是 p [ 0 ] = " ∗ " p[0]="*" p[0]="",而根据题意语法正确的模式串是不会在开头出现 ∗ * 的);

最后,是初始状态的压缩。我们已经知道在第一次状态转移中 d p [ 0 ] [ 0 ] dp[0][0] dp[0][0] 的值由临时变量保存,那么压缩后的 d p dp dp 表的初始化是否就可以将 d p [ 0 ] dp[0] dp[0] 初始化为 False 呢?其实不可以,我们在边界条件的分析中已经说过,如果 p p p 非空而 s s s 空,并且 p p p 的所有非 ∗ * 字符都可以匹配0次,那么 d p [ i ] dp[i] dp[i] 也应该为 True,比如 s = " " ,   p = " q ∗ w ∗ . ∗ " s="", \ p="q*w*.*" s="", p="qw.",因此我们要用以下代码做初始化:

dp = [False for _ in range(len(p) + 1)]
dp[0] = True

for i in range(len(p)):
    if p[i] == '*':
        dp[i + 1] = dp[i - 1]

在完成初始化之后再将 d p [ 0 ] dp[0] dp[0] 重置为 False

将以上分析落实到代码上就得到了状态压缩后的动态规划了:

def isMatch(self, s: str, p: str) -> bool:
	if not s and not p:
        return True
	
	# 初始化
    dp = [False for _ in range(len(p) + 1)]
    dp[0] = True

    for i in range(len(p)):
        if p[i] == '*':
            dp[i + 1] = dp[i - 1]

    dp[0] = False
	
	# 状态转移,由于我们是沿j方向压缩,所以将j的循环换到外层
    for j in range(1, len(s) + 1):
        pre = j == 1
        for i in range(1, len(p) + 1):
            tmp = dp[i]
            if p[i - 1] == '.':
                dp[i] = pre
            elif p[i - 1] == '*':
                if p[i - 2] == '.' or s[j - 1] == p[i - 2]:
                    dp[i] = dp[i - 2] or dp[i]
                elif s[j - 1] != p[i - 2]:
                    dp[i] = dp[i - 2]
            else:
                dp[i] = pre and p[i - 1] == s[j - 1]
            pre = tmp

    return dp[-1]

题目七:三维 d p dp dp 表的压缩

在这里插入图片描述

  • 状态定义:
    d p [ s ] [ j ] [ i ] dp[s][j][i] dp[s][j][i] 表示在第 i i i 天结束、最多进行 j j j 笔交易,且此时持有股票( s s s = 1)或不持有股票 ( s s s = 0)时的最大利润
  • 状态转移方程:
    由于买卖一支股票只算一次交易,因此我们不妨将买入股票算做交易,而卖出股票不算做交易
    如果第 i i i 天结束时不持有股票( s s s = 0),那么可能是第 i − 1 i-1 i1 天就没有股票,第 i i i 天什么都没做,此时利润不变;也可能是第 i − 1 i-1 i1 天持有股票而第 i i i 天卖出,此时利润就是第 i − 1 i-1 i1 天的利润加第 i i i 天的股票价格;第 i i i 天的利润应该取这两者的最大值
    如果第 i i i 天结束时持有股票( s s s = 1),那么可能是第 i − 1 i-1 i1 天就持有股票,第 i i i 天什么都没做,此时利润不变;也可能是第 i − 1 i-1 i1 天没有股票而第 i i i 天卖入,此时利润就是第 i − 1 i-1 i1 天的利润减第 i i i 天的股票价格,同时用掉了一个交易次数;第 i i i 天的利润应该取这两者的最大值
    综上可得状态转移方程:
    d p [ 0 ] [ j ] [ i ] = m a x ( d p [ 0 ] [ j ] [ i − 1 ] ,   d p [ 1 ] [ j ] [ i − 1 ] + p r i c e s [ i − 1 ] ) d p [ 1 ] [ j ] [ i ] = m a x ( d p [ 1 ] [ j ] [ i − 1 ] ,   d p [ 0 ] [ j − 1 ] [ i − 1 ] − p r i c e s [ i − 1 ] ) dp[0][j][i] = max(dp[0][j][i-1], \ dp[1][j][i-1]+prices[i-1])\\ dp[1][j][i] = max(dp[1][j][i-1],\ dp[0][j-1][i-1]-prices[i-1]) dp[0][j][i]=max(dp[0][j][i1], dp[1][j][i1]+prices[i1])dp[1][j][i]=max(dp[1][j][i1], dp[0][j1][i1]prices[i1])
  • 边界条件:
    d p [ 0 ] [ j ] [ 0 ] dp[0][j][0] dp[0][j][0] 表示第0天不持有股票时的最大利润,由于此时交易未开始,所以利润应该是0; d p [ 1 ] [ j ] [ 0 ] dp[1][j][0] dp[1][j][0] 表示第0天持有股票时的最大利润,由于此时交易未开始,所以不可能持有股票,我们让这个状态的利润为 − ∞ -\infty d p [ 0 ] [ 0 ] [ i ] dp[0][0][i] dp[0][0][i] 表示第 i i i 天不持有股票且未进行交易时的最大利润,这个值显然应该是0; d p [ 1 ] [ i ] [ 0 ] dp[1][i][0] dp[1][i][0] 表示第 i i i 天持有股票且未进行交易时的最大利润,这种状态显然也是不可能的,我们让它等于 − ∞ -\infty
    综上可得代码:
def maxProfit(self, k: int, prices: List[int]) -> int:
	# 初始化
    dp = [[[0 for _ in range(len(prices)+1)] for _ in range(k+1)] for _ in range(2)]
        
    for i in range(len(prices)+1):
        dp[1][0][i] = -10**5
        
    for i in range(k+1):
        dp[1][i][0] = -10**5
        
    # 状态转移
    for i in range(1, len(prices)+1):
        for j in range(1, k+1):
            dp[0][j][i] = max(dp[0][j][i-1], dp[1][j][i-1]+prices[i-1])
            dp[1][j][i] = max(dp[1][j][i-1], dp[0][j-1][i-1]-prices[i-1])
        
    return dp[0][k][-1]

观察代码,可以很清楚地看到状态转移方程两边的 i i i 分别是个定值(左边只有 i i i ,右边只有 i − 1 i-1 i1),因此压缩这个维度是最简单的,类似第一题,我们直接将这个维度删除,同时这个维度的初始化也可以删除了,然后就得到了状态压缩后的代码:

def maxProfit(self, k: int, prices: List[int]) -> int:
	dp = [[0 for _ in range(k+1)] for _ in range(2)]
        
    for i in range(k+1):
        dp[1][i] = -10**5
        
    for i in range(1, len(prices)+1):
        for j in range(k, 0, -1):
            dp[0][j] = max(dp[0][j], dp[1][j]+prices[i-1])
            dp[1][j] = max(dp[1][j], dp[0][j-1]-prices[i-1])
        
    return dp[0][k]

可以看到三维 d p dp dp 表的状态压缩其实和二维没什么区别;如果你觉得不直观,也可以把 d p dp dp 表先画出来然后判断应该如何压缩,结果是一样的

以上就是这篇文章的全部内容了,希望对你有所帮助。虽然讲解的题目不多,但是其它状态压缩基本都是这几种题目交叉组合的产物,只要这篇文章你能够读懂,基本可以做到一通百通;最后你会发现其实动态规划的状态压缩并不难,只要想清楚投影的方向,以及状态之间的覆盖关系就可以很轻松地写出代码。

  • 7
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值