动态规划状态压缩详解

前言

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

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

在开始之前为了避免歧义先提前说明一下“沿某方向压缩”的含义,“沿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<
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值