动态规划
什么是动态规划
用一句话解释动态规划就是“记住你之前得到的答案”。
动态规划常常适用于有重叠子问题和最优子结构性质的问题,动态规划方法所耗时间往往远少于朴素解法。
动态规划模板步骤
- 确定动态规划状态
- 写出状态转移方程(画出状态转移表)
- 考虑初始化条件
总结Python常用的初始化方法:
对于产生一个全为1,长度为n的数组:
dp=[1 for _ in range(n)]
dp=[1]*n
对于产生一个全为0,长度为m,宽度为n的二维矩阵:
dp=[[0 for _ in range(n)] for _ in range(m)]
dp=[[0]*n for _ in range(m)]
- 考虑输出状态
- 考虑对时间,空间复杂度的优化(Bonus)
Leetcode 300. 最长上升子序列
题目描述
给定一个无序的整数数组,找到其中最长上升子序列的长度。
示例:
输入: [10,9,2,5,3,7,101,18]
输出: 4
解释: 最长的上升子序列是 [2,3,7,101],它的长度是 4。
解题思路
-
确定动态规划状态
dp[i] 可以定义为以nums[i]这个数结尾的最长递增子序列的长度。
举个实际例子,比如在nums[10,9,2,5,3,7,101,18]中,dp[0]表示数字10的最长递增子序列长度,那就是本身,所以为1,对于dp[5]对应的数字7来说的最长递增子序列是[2,5,7](或者[2,3,7])所以dp[5]=3。 -
写出状态转移方程
比较当前dp[i]的长度和dp[i]对应产生新的子序列长度,我们用 j 来表示所有比 i 小的组数中的索引,可以用如下代码公式表示
for i in range(len(nums)):
for j in range(i):
if nums[i]>nums[j]:
dp[i]=max(dp[i],dp[j]+1)
-
考虑初始化条件
对于本问题,子序列最少也是自己,所以长度为1,这样我们就可以方便的把所有的dp初始化为1,所以用代码表示就是dp=[1]*len(nums)。 -
考虑输出状态
返回dp数组中最大的那个数字,一般对应记录最大值问题。 -
考虑对时间,空间复杂度的优化(Bonus)
遍历dp列表需要 O ( N ) O(N) O(N),计算每个dp[i]需要 O ( N ) O(N) O(N)的时间,所以总复杂度是 O ( N 2 ) O(N^2) O(N2)。前面遍历dp列表的时间复杂度肯定无法降低了,但是我们看后面在每轮遍历[0,i]的dp[i]元素的时间复杂度可以考虑设计状态定义,使得整个dp为一个排序列表,这样我们自然想到了可以利用二分法来把时间复杂度降到了 O ( N l o g N ) O(NlogN) O(NlogN)。
代码
class Solution:
def lengthOfLIS(self, nums: List[int]) -> int:
if not nums:return 0 #判断边界条件
dp=[1]*len(nums) #初始化dp数组状态
for i in range(len(nums)):
for j in range(i):
if nums[i]>nums[j]: #根据题目所求得到状态转移方程
dp[i]=max(dp[i],dp[j]+1)
return max(dp) #确定输出状态
模板总结
for i in range(len(nums)):
for j in range(i):
dp[i]=最值(dp[i],dp[j]+...)
Leetcode 674.最长连续递增序列
题目描述
给定一个未经排序的整数数组,找到最长且连续的的递增序列,并返回该序列的长度。
示例:
输入: [1,3,5,4,7]
输出: 3
解释: 最长连续递增序列是 [1,3,5], 长度为3。
尽管 [1,3,5,7] 也是升序的子序列, 但它不是连续的,因为5和7在原数组里被4隔开。
解题思路
- 确定动态规划状态
dp[i]是以nums[i]这个数结尾的最长递增子序列的长度。 - 写出状态转移方程(画出状态转移表)
第一种情况:不是连续的递增,那么这个数列最长连续递增序列就是他本身,也就是长度为1。
第二种情况:如果满足有递增序列,就意味着当前状态只和前一个状态有关,dp[i]只需要在前一个状态基础上加一就能得到当前最长连续递增序列的长度。总结起来,状态的转移方程可以写成 dp[i] = dp[i-1] + 1 。 - 考虑初始化条件
初始化状态就是一个一维的全为1的数组。 - 考虑输出状态
输出条件是求dp数组中最大的数。 - 考虑对时间,空间复杂度的优化(Bonus)
无优化余地。
代码
class Solution:
def findLengthOfLCIS(self, nums):
if not nums:return 0 #判断边界条件
dp=[1]*len(nums) #初始化dp数组状态
#注意需要得到前一个数,所以从1开始遍历,否则会超出范围
for i in range(1,len(nums)):
if nums[i]>nums[i-1]:#根据题目所求得到状态转移方程
dp[i]=dp[i-1]+1
else:
dp[i]=1
return max(dp) #确定输出状态
Leetcode 5. 最长回文子串
题目描述
给定一个字符串 s,找到 s 中最长的回文子串。你可以假设 s 的最大长度为 1000。
示例:
输入: "babad"
输出: "bab"
注意: "aba" 也是一个有效答案。
解题思路
- 确定动态规划状态
我们必须找到具体哪个部分符合回文子串的要求。这里我们定义 dp[i][j] 表示子串 s 从 i 到 j 是否为回文子串。 - 写出状态转移方程(画出状态转移表)
字符串首尾两个字符必须相等,否则肯定不是回文。
当字符串首尾两个字符相等时:如果子串是回文,整体就是回文,这里就有了动态规划的思想,出现了子问题;反之相反。
对于字符串 s , s[i,j] 的子串是 s[i+1,j-1] ,如果子串只有本身或者空串,那肯定是回文子串了,所以我们讨论的状态转移方程不是对于j-1-(i+1)+1<2的情况(整理得j-i<3),当 s[i] 和 s[j] 相等并且 j-i<3 时,我们可以直接得出 dp[i][j] 是True。
综上所述,可以得到状态转移方程
if s[i]==s[j]:
if j-i<3:
dp[i][j]=True
else:
dp[i][j]=dp[i+1][j-1]
- 考虑初始化条件
我们需要建立一个二维的初始状态是False的来保存状态的数组来表示dp,又因为考虑只有一个字符的时候肯定是回文串,所以dp表格的对角线dp[i][i]肯定是True。 - 考虑输出状态
dp表示的是从 i 到 j 是否是回文子串,但是由于我们需要找到最长的子串,所以我们优化一下可以只记录起始位置和当前长度。
if dp[i][j]: #只要dp[i][j]成立就表示是回文子串,然后我们记录位置,返回有效答案
cur_len=j-i+1
if cur_len>max_len:
max_len=cur_len
start=i
- 考虑对时间,空间复杂度的优化(Bonus)
对于空间方面的优化:这里采用一种叫中心扩散的方法来进行,而对于时间方面的优化,则是用了Manacher‘s Algorithm(马拉车算法)来进行优化。
代码
class Solution:
def longestPalindrome(self, s: str) -> str:
length=len(s)
if length<2: #判断边界条件
return s
dp=[[False for _ in range(length)]for _ in range(length)] #定义dp状态矩阵
max_len=1
start=0 #后续记录回文串初始位置
for j in range(1,length):
for i in range(j):
#矩阵中逐个遍历
if s[i]==s[j]:
if j-i<3:
dp[i][j]=True
else:
dp[i][j]=dp[i+1][j-1]
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]
Leetcode 516. 最长回文子序列
题目描述
给定一个字符串 s ,找到其中最长的回文子序列,并返回该序列的长度。可以假设 s 的最大长度为 1000 。
示例:
输入:"bbbab"
输出:4
一个可能的最长回文子序列为 "bbbb"。
提示:
1 <= s.length <= 1000
s 只包含小写英文字母
解题思路
- 确定动态规划状态
定义一个二维的dp[i][j]来表示字符串第i个字符到第j个字符的长度,子问题也就是每个子回文字符串的长度。 - 写出状态转移方程(画出状态转移表)
当s[i]和s[j]相等时,s[i+1…j-1]这个字符串加上2就是最长回文子序列;
当s[i]和s[j]不相等时,就说明可能只有其中一个出现在s[i,j]的最长回文子序列中,我们只需要取s[i-1,j-1]加上s[i]或者s[j]的数值中较大的;
综上所述,状态转移方程也就可以写成:`
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])
我们用字符串为"cbbd"作为输入来举例子,每次遍历就是求出右上角那些红色的值,通过上面的图我们会发现,按照一般的习惯都会先计算第一行的数值,但是当我们计算dp[0,2]的时候,我们会需要dp[1,2],按照这个逻辑,我们就可以很容易发现遍历从下往上遍历会很方便计算。
- 考虑初始化条件
当只有一个字符的时候,最长回文子序列就是1,所以可以得到dp[i][j]=1(i=j)。
当i>j时,不符合题目要求,不存在子序列,所以直接初始化为0。
当i<j时,每次计算表中对应的值就会根据前一个状态的值来计算。 - 考虑输出状态
我们可以直接看出来dp[0][-1]是最大的值,直接返回这个值就是最后的答案。 - 考虑对时间,空间复杂度的优化(Bonus)
可以考虑空间复杂度的优化,因为我们在计算dp[i][j]的时候,只用到左边和下边。如果改为用一维数组存储,那么左边和下边的信息也需要存在数组里,所以我们可以考虑在每次变化前用临时变量tmp记录会发生变化的左下边信息。所以状态转移方程就变成了:
if s[i] == s[j]:
tmp, dp[j] = dp[j], tmp + 2
else:
dp[j] =max(dp[j],dp[j-1])
代码
class Solution:
def longestPalindromeSubseq(self, s: str) -> int:
n=len(s)
dp=[[0]*n for _ in range(n)] #定义动态规划状态转移矩阵
for i in range(n): # 初始化对角线,单个字符子序列就是1
dp[i][i]=1
for i in range(n,-1,-1): #从右下角开始往上遍历
for j in range(i+1,n):
if s[i]==s[j]: #当两个字符相等时,直接子字符串加2
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] #返回右上角位置的状态就是最长
Leetcode 72. 编辑距离
题目描述
给你两个单词 word1 和 word2,请你计算出将 word1 转换成 word2 所使用的最少操作数 。
你可以对一个单词进行如下三种操作:
插入一个字符
删除一个字符
替换一个字符
示例:
输入: word1 = "horse", word2 = "ros"
输出: 3
解释:
horse -> rorse (将 'h' 替换为 'r')
rorse -> rose (删除 'r')
rose -> ros (删除 'e')
解题思路
- 确定动态规划状态
定义dp[i][j]为字符串word1长度为i和字符串word2长度为j时,word1转化成word2所执行的最少操作次数的值。 - 写出状态转移方程(画出状态转移表)
我们采用从末尾开始遍历word1和word2, 当word1[i]等于word2[j] 时,说明两者完全一样,所以i和j指针可以任何操作都不做,用状态转移式子表示就是 dp[i][j]=dp[i-1][j-1],也就是前一个状态和当前状态是一样的。
当word1[i]和word2[j]不相等时,就需要对三个操作进行递归了,这里就需要仔细思考状态转移方程的写法了。
对于 插入 操作,当我们在word1中插入一个和word2一样的字符,那么word2就被匹配了,所以可以直接表示为dp[i][j-1]+1 。
对于 删除 操作,直接表示为dp[i-1][j]+1 。
对于 替换 操作,直接表示为dp[i-1][j-1]+1 所以状态转移方程可以写成 min(dp[i][j-1]+1,dp[i-1][j]+1,dp[i-1][j-1]+1) 。 - 考虑初始化条件
dp[0][j] = j , dp[i][0] = i 。 - 考虑输出状态
最终的编辑距离就是最后一个状态的值,对应的就是dp[-1][-1]。 - 考虑对时间,空间复杂度的优化(Bonus)
由于dp[i][j]只和dp表中附近的三个状态(左边,右边和左上边)有关,所以同样可以进行压缩状态转移的空间存储
代码
class Solution:
def minDistance(self, word1, word2):
#m,n 表示两个字符串的长度
m=len(word1)
n=len(word2)
#构建二维数组来存储子问题
dp=[[0 for _ in range(n+1)] for _ in range(m+1)]
#考虑边界条件,第一行和第一列的条件
for i in range(n+1):
dp[0][i]=i #对于第一行,每次操作都是前一次操作基础上增加一个单位的操作
for j in range(m+1):
dp[j][0]=j #对于第一列也一样,所以应该是1,2,3,4,5...
for i in range(1,m+1): #对其他情况进行填充
for j in range(1,n+1):
if word1[i-1]==word2[j-1]: #当最后一个字符相等的时候,就不会产生任何操作代价,所以与dp[i-1][j-1]一样
dp[i][j]=dp[i-1][j-1]
else:
dp[i][j]=min(dp[i-1][j],dp[i][j-1],dp[i-1][j-1])+1 #分别对应删除,添加和替换操作
return dp[-1][-1] #返回最终状态就是所求最小的编辑距离
Leetcode 198. 打家劫舍
题目描述
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
示例:
输入:[1,2,3,1]
输出:4
解释:偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
偷窃到的最高金额 = 1 + 3 = 4 。
提示:
0 <= nums.length <= 100
0 <= nums[i] <= 400
解题思路
- 确定动态规划状态
dp[i]表示偷窃第i号房子能得到的最高金额。 - 写出状态转移方程(画出状态转移表)
如果抢了第i个房间,那么第i-1肯定是不能抢的,这个时候需要再往前一间,用第i-2间的金额加上当前房间的金额,得到的状态转移方程是dp[i]=dp[i-2]+nums[i]。
如果没有抢第i个房间,那么肯定抢了第i-1间的金额,所以直接有dp[i]=dp[i-1]。 - 考虑初始化条件
初始化条件需要考虑第一个房子和第二个房子,之后的房子都可以按照规律直接求解,当我们只有一个房子的时候,自然只抢那间房子,当有两间房的时候,就抢金额较大的那间。综合起来就是dp[0]=nums[0],dp[1]=max(nums[0],nums[1])。 - 考虑输出状态
直接返回状态转移数组的最后一个值就是所求的最大偷窃金额。 - 考虑对时间,空间复杂度的优化(Bonus)
时间复杂度为 O ( N ) O(N) O(N)不能再优化了,空间复杂度方面如果用动态规划是不能优化,但是如果用迭代的方法只存储临时变量来记录每一步计算结果,这样可以降到 O ( 1 ) O(1) O(1)。
代码
class Solution:
def rob(self, nums):
if(not nums): #特殊情况处理
return 0
if len(nums)==1:
return nums[0]
n=len(nums)
dp=[0]*n #初始化状态转移数组
dp[0]=nums[0] #第一个边界值处理
dp[1]=max(nums[0],nums[1])#第二个边界值处理
for i in range(2,n):
dp[i]=max(dp[i-2]+nums[i],dp[i-1]) #状态转移方程
return dp[-1]
Leetcode 213. 打家劫舍 II
题目描述
你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都围成一圈,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
示例:
给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。
输入: [2,3,2]
输出: 3
解释: 你不能先偷窃 1 号房屋(金额 = 2),然后偷窃 3 号房屋(金额 = 2), 因为他们是相邻的。
解题思路
-
确定动态规划状态
直接定义题目所求的偷窃的最高金额,所以dp[i]表示偷窃第i号房子能得到的最高金额。 -
写出状态转移方程(画出状态转移表)
相比于上个问题又增加了一个限制,这样一来第一个房子和最后一个房子只能选择其中一个偷窃了。所有我们把这个问题拆分成两个问题:偷窃了第一个房子,此时对应的是nums[1:],得到最大的金额value是v1。
偷窃了最后一个房子,此时对应的是nums[:n-1] (其中n是所有房子的数量),得到的最大金额value是v2。
最后的结果就是取这两种情况的最大值,即max(v1,v2)。每个子问题就和上题是一样的了,所以可以直接得到状态转移方程还是dp[i]=max(dp[i-2]+nums[i],dp[i-1])。
-
考虑初始化条件
dp[0]=nums[0],dp[1]=max(nums[0],nums[1])。 -
考虑输出状态
直接返回状态转移数组的最后一个值就是所求的最大偷窃金额。 -
考虑对时间,空间复杂度的优化(Bonus)
时间复杂度为 O ( N ) O(N) O(N)不能再优化了,空间复杂度方面如果用动态规划是不能优化,但是如果用迭代的方法只存储临时变量来记录每一步计算结果,这样可以降到 O ( 1 ) O(1) O(1)。
代码
class Solution:
def rob(self, nums: List[int]) -> int:
if not nums:
return 0
elif len(nums)<=2:
return max(nums)
def helper(nums):
if len(nums)<=2:
return max(nums)
dp=[0]*len(nums)
dp[0]=nums[0]
dp[1]=max(nums[0],nums[1])
for i in range(2,len(nums)):
dp[i]=max(dp[i-1],dp[i-2]+nums[i])
return dp[-1]
return max(helper(nums[1:]),helper(nums[:-1]))