task-02 动态规划
1 理解动态规划
首先看定义:
- 动态规划常常适用于有重叠子问题和最优子结构性质的问题,动态规划方法所耗时间往往远少于朴素解法。
从定义中至少可以看出来三点要点:
- 1.动态规划其实是分治算法的一种
- 2.动态规划中动态的意思是要解决的问题,其规模不确定,而问题的解却依赖于问题规模
- 3.能以较快速度(动态规划是一种泛用性算法,而泛用性算法与特定算法相比往往存在性能差距)将结果正确计算出来
动态规划效率高的原因,是以空间换取时间。将前一步的结果储存起来,下一步可以直接调用,加速了计算速度。
引用其他文章中的理解:动态规划这一思想的实质其实是以下两点:
1.分析问题,构造状态转移方程
2.以空间换时间
说下自己的理解:
动态规划是求数列的题目,数列有三个重要元素,初始值,递推式(通项公式),求和式
高中数学题目中,有数列的内容,数列的推导式有两种,一种类似于An=3*n-1,这个是公式法,即通项公式,当知道明确规模n时,可以直接求出An的值
第二种是递推式,即An=An-1+3,这个是递推式,描述前后两项关系,及一种规律,通过这个递推式,同样能解决关于数列的所有信息。
但数学的数列问题,都是欠缺实际含义的,会给出一些已存在的项,及项之间的关系,基本就是幼儿园水平,拿来直接找规律就行。
动态规划的数列问题,则是数列问题的实际运用,至少大学水平(哈哈,调侃一下)。需要1.自己确定数列的含义(确定动态规划状态),即你的数列的值,表示什么意思,存的是啥东西。2.写出状态转移方程,就是递推公式(这个递推公式一般带条件判断)。3.考虑初始化条件(求A0,任何数列问题都需要确定初始值)
这部分基本就是数列问题和动态规划的相似之处了,如何忘记数列知识,可以回去翻高中数学教材。
所以动态规划,可以说是数列思想的计算算法实现,后面还涉及算法优化,难度是很大的。
有了数列,那么下一步就是从数列取要的答案了,这里有几种情况,需要根据题意选择,应该是比较简单的,要么是特定规模的值,要么是数列中的某个极值。
严谨一点表达,就是
-
返回dp数组中最后一个值作为输出,一般对应二维dp问题。
-
返回dp数组中最大的那个数字,一般对应记录最大值问题。
-
返回保存的最大值,一般是Maxval=max(Maxval,dp[i])这样的形式。
最后一步,则是计算机算法都会有的一点东西,算法优化,涉及存储优化。
2 动态规划解决问题的步骤
有了以上理解,那么看一道具体问题的求解过程
- 第一步:确定动态规划状态
是否存在状态转移?
什么样的状态比较好转移,找到对求解问题最方便的状态转移?
想清楚到底是直接用需要求的,比如长度作为dp保存的变量还是用某个判断问题的状态比如是否是回文子串来作为方便求解的状态
该题目可以直接用一个一维数组dp来存储转移状态,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。
- 第二步:写出一个好的状态转移方程
使用数学归纳法思维,写出准确的状态方程
比如还是用刚刚那个nums数组,我们思考一下是如何得到dp[5]=3的:既然是递增的子序列,我们只要找到nums[5] (也就是7)前面那些结尾比7小的子序列,然后把7接到最后,就可以形成一个新的递增的子序列,也就是这个新的子序列也就是在找到的前面那些数后面加上7,相当长度加1。当然可能会找到很多不同的子序列,比如刚刚在上面列举的,但是只需要找到长度最长的作为dp[5]的值就行。总结来说就是比较当前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)
Tips: 在实际问题中,如果不能很快得出这个递推公式,可以先尝试一步一步把前面几步写出来,如果还是不行很可能就是 dp 数组的定义不够恰当,需要回到第一步重新定义 dp 数组的含义;或者可能是 dp 数组存储的信息还不够,不足以推出下一步的答案,需要把 dp 数组扩大成二维数组甚至三维数组。
- 第三步:考虑初始条件
这是决定整个程序能否跑通的重要步骤,当我们确定好状态转移方程,我们就需要考虑一下边界值,边界值考虑主要又分为三个地方:
dp数组整体的初始值
dp数组(二维)i=0和j=0的地方
dp存放状态的长度,是整个数组的长度还是数组长度加一,这点需要特别注意。
对于本问题,子序列最少也是自己,所以长度为1,这样我们就可以方便的把所有的dp初始化为1,再考虑长度问题,由于dp[i]代表的是nums[i]的最长子序列长度,所以并不需要加一。 所以用代码表示就是dp=[1]*len(nums)
**Tips:**还有一点需要注意,找到一个方便的状态转移会使问题变得非常简单。举个例子,对于Leetcode120.三角形最小路径和问题,大多数人刚开始想到的应该是自顶向下的定义状态转移的思路,也就是从最上面的数开始定义状态转移,但是这题优化的解法则是通过定义由下到上的状态转移方程会大大简化问题,同样的对于Leetcode53.最大子序和也是采用从下往上遍历,保证每个子问题都是已经算好的。这个具体我们在题目中会讲到。
这里额外总结几种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)]
- 第四步:考虑输出状态
主要有以下三种形式,对于具体问题,我们一定要想清楚到底dp数组里存储的是哪些值,最后我们需要的是数组中的哪些值:
返回dp数组中最后一个值作为输出,一般对应二维dp问题。
返回dp数组中最大的那个数字,一般对应记录最大值问题。
返回保存的最大值,一般是Maxval=max(Maxval,dp[i])这样的形式。
**Tips:**这个公式必须是在满足递增的条件下,也就是nums[i]>nums[j]的时候才能成立,并不是nums[i]前面所有数字都满足这个条件的,理解好这个条件就很容易懂接下来在输出时候应该是max(dp)而不是dp[-1],原因就是dp数组由于计算递增的子序列长度,所以dp数组里中间可能有值会是比最后遍历的数值大的情况,每次遍历nums[j]所对应的位置都是比nums[i]小的那个数。举个例子,比如nums=[1,3,6,7,9,4,10,5,6],而最后dp=[1,2,3,4,5,3,6,4,5]。 总结一下,最后的结果应该返回dp数组中值最大的数。
最后加上考虑数组是否为空的判断条件,下面是该问题完整的代码:
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) #确定输出状态
- 第五步:考虑对时间,空间复杂度的优化(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)。这里由于篇幅原因,如果大家感兴趣的话详细的解题步骤可以看好心人写的二分方法+动态规划详解
模板总结:
for i in range(len(nums)):
for j in range(i):
dp[i]=最值(dp[i],dp[j]+...)
对于子序列问题,很多也都是用这个模板来进行解题,比如Leetcode53.最大子序和。此外,其他情况的子序列问题可能需要二维的dp数组来记录状态,比如:Leetcode5. 最长回文子串(下面会讲到) 、 Leetcode1143. 最长公共子序列 (当涉及到两个字符串/数组时) 如果你觉得刚刚那题有点难的话,不如我们从简单一点的题目开始理解一下这类子序列问题。接下来所有题目我们都按照那五个步骤考虑
算法应用
Leetcode 674.最长连续递增序列
题目描述
给定一个未经排序的整数数组,找到最长且连续的的递增序列。
示例 1:
输入: [1,3,5,4,7]
输出: 3
解释: 最长连续递增序列是 [1,3,5], 长度为3。
尽管 [1,3,5,7] 也是升序的子序列, 但它不是连续的,因为5和7在原数组里被4隔开。
解题思路
这道题是不是一眼看过去和上题非常的像,没错了,这个题目最大的不同就是连续两个字,这样就让这个问题简单很多了,因为如果要求连续的话,那么就不需要和上题一样遍历两遍数组,只需要比较前后的值是不是符合递增的关系。
第一步:确定动态规划状态 对于这个问题,我们的状态dp[i]也是以nums[i]这个数结尾的最长递增子序列的长度
第二步:写出状态转移方程 这个问题,我们需要分两种情况考虑,第一种情况是如果遍历到的数nums[i]后面一个数不是比他大或者前一个数不是比他小,也就是所谓的不是连续的递增,那么这个数列最长连续递增序列就是他本身,也就是长度为1。 第二种情况就是如果满足有递增序列,就意味着当前状态只和前一个状态有关,dp[i]只需要在前一个状态基础上加一就能得到当前最长连续递增序列的长度。总结起来,状态的转移方程可以写成 dp[i]=dp[i-1]+1
第三步:考虑初始化条件 和上面最长子序列相似,这个题目的初始化状态就是一个一维的全为1的数组。
第四步:考虑输出状态 与上题相似,这个问题输出条件也是求dp数组中最大的数。
第五步:考虑是否可以优化 这个题目只需要一次遍历就能求出连续的序列,所以在时间上已经没有可以优化的余地了,空间上来看的话也是一维数组,并没有优化余地。
综上所述,可以很容易得到最后的代码:
def findLengthOfLCIS(self, nums: List[int]) -> int:
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) #确定输出状态
总结: 通过这个题目和例题的比较,我们需要理清子序列和子数组(连续序列)的差别,前者明显比后者要复杂一点,因为前者是不连续的序列,后者是连续的序列,从复杂度来看也很清楚能看到即使穷举子序列也比穷举子数组要复杂很多。
3 Leetcode题目实战
3.1 最长回文子串
Leetcode5. 最长回文子串
解题分析:
动态规划思路,前项:An-1为True,为回文子串,那么An=An-1 且( An-1前一项字母等于An-1后一项字母 )
原来我的思路,就是写一个扩展回文子串的函数,然后对整个字符串,调用这个函数,结果好像还不错
class Solution:
def longestPalindrome(self, s: str) -> str:
n = len(s)
if n == 0:
return ""
res = s[0]
def extend(i, j, s):
while(i >= 0 and j < len(s) and s[i] == s[j]):
i -= 1
j += 1
return s[i + 1:j]
for i in range(n - 1):
e1 = extend(i, i, s)
e2 = extend(i, i + 1, s)
if max(len(e1), len(e2)) > len(res):
res = e1 if len(e1) > len(e2) else e2
return res
class Solution:
def longestPalindrome(self, s: str) -> str:
n = len(s)
dp = [[False] * n for _ in range(n)]
ans = ""
# 枚举子串的长度 l+1
for l in range(n):
# 枚举子串的起始位置 i,这样可以通过 j=i+l 得到子串的结束位置
for i in range(n):
j = i + l
if j >= len(s):
break
if l == 0:
dp[i][j] = True
elif l == 1:
dp[i][j] = (s[i] == s[j])
else:
dp[i][j] = (dp[i + 1][j - 1] and s[i] == s[j])
if dp[i][j] and l + 1 > len(ans):
ans = s[i:j+1]
return ans
这个嘛,嗯,我觉得动态规划还有很大的优化空间。(从结果来看,这种题目不太适合动态规划)
3.2 最长回文子序列
做这道题,要注意子序列和子串的差别,子序列可以不连续,但要求相对位置不变。
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] #返回右上角位置的状态就是最长
这道题就很适合动态规划。
3.3 72. 编辑距离
#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] #返回最终状态就是所求最小的编辑距离
3.4 198. 打家劫舍
class Solution:
def rob(self, nums: List[int]) -> int:
if len(nums)>2:
dp=[0]*len(nums)
dp[0]=nums[0]
for i in range(1,len(nums)):
dp[i]=max(dp[i-1],dp[i-2]+nums[i])
return dp[-1]
elif len(nums)>1:
return max(nums)
elif len(nums)>0:
return nums[0]
else:
return 0
这道题很适合作为练手题,状态转移方程很好写出。
4 小结
动态规划是算法中比较难的类型,但是其实主要是掌握一种思维,有了这种思维,其实很难的问题都能一步一步解决好。最后再推荐一些比较优质的动态规划文章。
掌握动态规划,助你成为优秀的算法工程师
参考
「算法」动态规划通俗解说
本次学习资料,由Datawhale 提供
本次学习耗时:2小时