LeetCode学习-动态规划算法
上一章Leetcode学了分治算法,或者说递归算法,有没有发现,出现很多重叠子问题。举个例子说,求斐波那契额数列的时候,fei(6)=fei(5)+fei(4),然后你看,fei(5)里面是不是算过一次fei(4),fei(6)也算了一次fei(4)。发现问题没?有很多的重叠计算。当规模大的时候,重叠的更多了。所以我们来学学动态规划算法来处理 重复计算的问题。就是加个 状态 到算法中
什么是动态规划算法(1)?
其实若直接给一个定义,说明动态规划算法(dp算法)。感受并不会很深刻,甚至想睡觉 onz。如从一个例子出发,然后把代码看懂。看懂后好好感受一下他的思想。然后再提炼出一个通用的模板出来,最后再思考这个模板能解决什么样的问题。这才是学习一个新算法的基本步骤,话不多说,go on~
举个例子
- 我们以最经典的动态规划题目-Leetcode 300.最长上升子序列问题为例。
题目描述
输入: [10,9,2,5,3,7,101,18]
输出: 4
解释: 最长的上升子序列是 [2,3,7,101],它的长度是 4(就是从左往右数,从小打到的连贯起来算一小组)。
说明:可能会有多种最长上升子序列的组合,你只需要输出对应的长度即可。你算法的时间复杂度应该为 O(n2) 。
看到这个题目,脑海里第一个想法是什么?在没有学过算法,只学过简单的编程的我们来说,首先用循环,用循环怎么写呢?什么思路呢?抓耳挠腮好久也写不出来。写不出来才正常,这里我就直接给出动态规划思路来;
思路
- 学过数据结构的单源最短路径算法的同学应该有点印象,没学过也没关系。大致感觉就是,每个点到起点都有一个最短路径,每当新加入一个点,都要看最短路径有没有因为这个点的加入而变得更短……这个题目有点相似的思想(但不等同)。
- 我们将数组指针从左往右移动。每次只看指针和指针以前的数字(这个很重要,视线只盯着指针指到的位置,后面的不看);然后给数组中每个数都定义一个属性dp[i],这个属性记录的是 以这个点为递增子序列的最后一个数字 的 序列的最大长度,好比KMP算法的最大匹配字符串位置(学过数据结构的应该知道KMP算法,不会也没关系),这个时候就需要一个新的数组dp,里面每个值记录对应位置的num以自己为最后一个数字的递增序列的最大长度(最后max(dp)就是我们要的结果了,后面会讲为什么)。
举个例子:在[10,9,2,5,3,7,101,18]中,dp[0]指的是以10为最后一个数字的递增子串的最大长度,因为10 前面没有数字,10就是最大长度所以dp[10]=1;再来一个dp[5].就是以数字7为最后一个数字的最大递增子串的长度。7的前面的数是[10,9,2,5,3,7],最大长度的是1,5,7或者2,3,7.这两个都是以7为最后一个数字的最长递增子串。所以dp[5]=3;懂了没?dp[i]记录的数据是啥?还不懂的话就去学学KMP算法怎么求next数组……
- 现在假设你已经懂了dp[i]数组的含义了。现在问题来了,我们需要求出所有的dp[i]的值是不是,然后max(dp)就是我们最终的最大递增子串的长度了。那么怎么求呢?想想,[10,9,2,5,3,7]不会做就数学归纳法,说白了就是举出几个例子自己找规律。用肉眼观出这几个数的dp值:
i | 0 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|---|
num | 10 | 9 | 2 | 5 | 3 | 7 |
dp[i] | 1 | 1 | 1 | 2 | 2 | 3 |
- 仔细想想你脑海的思考过程:你是不是从左到右 视线 每盯到一个数(相当于指针指到一个数),你就往左找比他小的,找到了它的长度就+1了,是不是?那我再给你优化一下:指针每次指到一个num,我们就找到这个num前面比这个num小的数,每个小的数是不是有一个dp值呀(仔细想想这个dp是什么意思),重点来了:以求7点dp值 也就是求dp[5]的值为例,遍历7前面所有的数,找到所有比7小的,然后,把‘7’拼接到每个比7小的数的后面,是不是这样前面每个小7递增子串 长度+1了,也就是前面比7小的数的dp[i]+1就是7的dp[i]了。我们只要从中取出max作为dp[5]的值。 ‘7’,前面比7小的有2,5,3 ,分别的dp值为1,2,2 则7的dp值是 2,3,3中的最大值也就是3,懂了没?那么具体化到代码中:
for i in range(len(nums)):
for j in range(i):
if nums[i]>nums[j]:
dp[i]=max(dp[i],dp[j]+1)
- 最重要的过程我们已经弄懂了。是不是并没有那么难?
- 剩下的就是细枝末节了,dp数组的长度,肯定是跟num数组一样长啦。然后dp数组初始化肯定都是1,为啥?因为一个数字就是最小的递增子串了呀,所以dp最小不就是1了,初始化dp为1 的python代码如下不会的看我往期笔记Python列表的创建:
#三个都可以,自己想想1 2 的区别联系
1. dp=[1 for _ in range(n)]
2. dp=[1 for i in range(n)]
3. dp=[1]*n
- 还有一点,为什么max(dp)就是最后列表的最大递增子序列长度呢?举个例子就知道了,比如nums=[1,3,6,7,9,4,10,5,6],而最后dp=[1,2,3,4,5,3,6,4,5];不是最后一个数字必须在最大递增子序列中。明白吗?例子中最大递增子序列的最后一个数字是10,并不是最后的6;
- 到这里基本结束了思路部分,下面直接上代码:
def lengthOfLIS(self, nums: List[int]) -> int: #箭头是注视的作用,增加代码可阅读下
if not nums:return 0 #判断边界条件,非空就执行,nums为null就退出
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) #确定输出状态
其实这个过程还能优化,参考二分方法+动态规划详解
动态规划模板步骤
任何算法,你只看懂不会用并不代表你学会了,只有自己亲自使用了一次,才代表你学会了。怎么用?这个时候就需要模板来帮助记忆理解了。后面用的多了,就不需要再刻意去想模板了,动笔便是优秀算法(你还远着呢,多学,多写,熟能生巧……)
1,确定动态规划状态
2,写出状态转移方程(画出状态转移表)
3,考虑初始化条件
4,考虑输出状态
5,考虑对时间,空间复杂度的优化(Bonus)
来讲讲别总结的模板。加深自己的理解跟记忆。1,确定动态规划状态,这个就是要确定一个东西来记录每一次的最优的东西opt。最优解用什么来表示。上个例子的用的是dp值来描述这个状态。用大白话说就是,我们用 将每个数字作为当前位置最大递增子串最后一个数字的长度为状态量(这个状态描述有点长,思想就是每个数字都在一个最长递增子串的末尾,这个串的长度就是dp值了);2,状态转移是啥呢?你想像成我们刚刚指针移动的时候,是不是求指针指向的num的dp值,没移动一次,是不是要在找到dp[i]最大的那个值填进去。3,初始化就简单了,状态表最开始的状态是什么。这个题目是全是1就行了;4,输出状态:就是最后求出的dp表哪个才是最后的输出结果。这个具体问题具体分析,一般都是求最大要么最小的值;5复杂度分析。就是思考哪个步骤能不能优化。
实际应用动态规划算法
下面练习题参考DataWhale列举的LeetCode习题,此处做的搬运:
Leetcode516. 最长回文子序列
题目描述
给定一个字符串s,找到其中最长的回文子序列。可以假设s的最大长度为1000。
示例 1:
输入:
"bbbab"
输出:
4
解题思路
-
这个问题和上面的例题也非常相似,直接套用动态规划套路也可以很快解决出来:
-
第一步:确定动态规划状态 这里求的是最长子串的长度,所以我们可以直接定义一个二维的dp[i][j]来表示字符串第i个字符到第j个字符的长度,子问题也就是每个子回文字符串的长度。
-
第二步:写出状态转移方程 我们先来具体分析一下整个题目状态转移的规律。对于d[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])
但是问题来了,具体我们应该怎么求每个状态的值呢?这里介绍一种利用状态转移表法写出状态转移方程,我们通过把dp[i][j]的状态转移直接画成一张二维表格,我们所要做的也就是往这张表中填充所有的状态,进而得到我们想要的结果。如下图:
我们用字符串为"cbbd"作为输入来举例子,每次遍历就是求出右上角那些红色的值,通过上面的图我们会发现,按照一般的习惯都会先计算第一行的数值,但是当我们计算dp[0,2]的时候,我们会需要dp[1,2],按照这个逻辑,我们就可以很容易发现遍历从下往上遍历会很方便计算。
-
-
第三步:考虑初始化条件 很明显看出来的当只有一个字符的时候,最长回文子序列就是1,所以可以得到dp[i][j]=1(i=j) 接下来我们来看看 当i>j时,不符合题目要求,不存在子序列,所以直接初始化为0。 当i<j时,每次计算表中对应的值就会根据前一个状态的值来计算。
-
第四步:考虑输出状态,我们想要求最长子序列的时候,我们可以直接看出来dp[0][-1]是最大的值,直接返回这个值就是最后的答案。
-
第五步:考虑对时间,空间复杂度的优化 对于这个题目,同样可以考虑空间复杂度的优化,因为我们在计算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])
- 这里给出基本版的实现代码,如果需要优化后的可以看空间压缩优化解法
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] #返回右上角位置的状态就是最长
-
总结:对于二维的数组的动态规划,采用了画状态转移表的方法来得到输出的状态,这种方法更加直观能看出状态转移的具体过程,同时也不容易出错。当然具体选择哪种方法则需要根据具体题目来确定,如果状态转移方程比较复杂的利用这种方法就能简化很多。
-
模板总结:
for i in range(len(nums)):
for j in range(n):
if s[i]==s[j]:
dp[i][j]=dp[i][j]+...
else:
dp[i][j]=最值(...)
什么是动态规划算法(2)?
-
学到这儿,是否对动态规划有个比较具体的认识了呢?
-
动态规划常常适用于有重叠子问题和最优子结构性质的问题,动态规划方法所耗时间往往远少于朴素解法。
-
主要思想
若要解一个给定问题,我们需要解其不同部分(即子问题),再根据子问题的解以得出原问题的解。动态规划往往用于优化递归问题,例如斐波那契数列,如果运用递归的方式来求解会重复计算很多相同的子问题,利用动态规划的思想可以减少计算量。
动态规划法仅仅解决每个子问题一次,具有天然剪枝的功能,从而减少计算量,
一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。
动态规划是算法中比较难的类型,但是其实主要是掌握一种思维,有了这种思维,其实很难的问题都能一步一步解决好。所以需要大量的动手动脑练习才能不断巩固自己的思维方式,不能让思维模式停留在动不动就循环遍历解决问题上。要争取用计算机思维处理问题。此处奉上本片博客引用文章