目录
前言
这篇文章将详细介绍动态规划算法的状态压缩技巧
所谓动态规划,本质上就是带有备忘录的递归:在计算出子问题的解之后先将它存储到备忘录中;在求解子问题之前先查看备忘录中有没有现成的答案,如果有,直接返回这个答案,从而避免了很多重复的计算,降低了时间复杂度;另一方面,我们经常发现子问题的答案在使用一次之后便不再使用了,因此动态规划的备忘录也是可以做优化以降低空间复杂度的,这就是状态压缩。状态压缩的工作可以分为两部分:对状态转移过程的压缩和对初始状态的压缩,而这两部分的本质其实都是两个字:投影;具体来说就是沿着压缩方向用最新的状态覆盖原来的状态,然后根据具体情况对计算过程做一些调整,比如增加临时变量或修改循环方向,这一点相信读者在阅读完本文后会有更深的理解
在开始之前为了避免歧义先提前说明一下“沿某方向压缩”的含义,“沿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[i−1]] ) 时有两种情况可以使 d p [ i ] [ j ] dp[i][j] dp[i][j] 为 True:不使用 n u m s [ i − 1 ] nums[i-1] nums[i−1],并且在前 i − 1 i - 1 i−1 个数中已经可以选出一部分正整数使它们的和为 j j j;或者使用 n u m s [ i − 1 ] nums[i-1] nums[i−1],并且在前 i − 1 i - 1 i−1 个数中可以选出一部分正整数,使得它们的和为 j − n u m s [ i − 1 ] j - nums[i-1] j−nums[i−1],综上可得状态转移方程:
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[i−1][j] or dp[i−1][j−nums[i−1]] - 边界条件:
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] j−nums[i−1] 作为数组的下标,必须大于等于 0 ,因此只有当 n u m s [ i − 1 ] < = j nums[i-1] <= j nums[i−1]<=j 时我们才能用上面的状态转移方程;那其他情况呢?思考一下就会发现 d p [ i ] [ j ] dp[i][j] dp[i][j] 在其他情况下至少应该和 d p [ i − 1 ] [ j ] dp[i-1][j] dp[i−1][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 i−1 (注意“上一轮”是相对循环顺序而言的,这里循环是从小到大计算,因此“上一轮”指 i − 1 i-1 i−1;如果是从大到小计算,则“上一轮”指 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[i−1][j] ,而状态压缩后这两个值将被压缩到同一个位置,即后来计算的 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 ] [ 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[i−1:j+1] 是回文串的充要条件是 s [ i − 1 ] = s [ j ] s[i-1] = s[j] s[i−1]=s[j] (注意这里是Python语言的表示方法, s [ i : j ] s[i:j] s[i:j] 表示下标从 i i i 到 j − 1 j-1 j−1 的子串, s [ i − 1 : j + 1 ] s[i-1:j+1] s[i−1:j+1] 表示下标从 i − 1 i-1 i−1 到 j j j 的子串);如果 s [ i − 1 ] s[i-1] s[i−1] 不等于 s [ j ] s[j] s[j] ,那么 s [ i − 1 : j + 1 ] s[i-1:j+1] s[i−1:j+1] 一定不是回文串,即 d p [ i − 1 ] [ j ] dp[i-1][j] dp[i−1][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][j−1],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<