动态规划
动态规划常常适用于有重叠子问题和最优子结构性质的问题,动态规划方法所耗时间往往远少于朴素解法。
主要思想
若要解一个给定问题,我们需要解其不同部分(即子问题),再根据子问题的解以得出原问题的解。动态规划往往用于优化递归问题,例如斐波那契数列,如果运用递归的方式来求解会重复计算很多相同的子问题,利用动态规划的思想可以减少计算量。
动态规划法仅仅解决每个子问题一次,具有天然剪枝的功能,从而减少计算量,
一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。
动态规划模板步骤:
-
确定动态规划状态
-
写出状态转移方程(画出状态转移表)
-
考虑初始化条件
-
考虑输出状态
-
考虑对时间,空间复杂度的优化(Bonus)
例题详解
接下来,我们对每个步骤进行详细的讲解,并给出不同题目中考虑的不同方式,争取让大家吃透动态规划的套路。
我们以最经典的动态规划题目——Leetcode 300.最长上升子序列 为例子。
题目描述
给定一个无序的整数数组,找到其中最长上升子序列的长度。
示例:
输入: [10,9,2,5,3,7,101,18]
输出: 4
解释: 最长的上升子序列是 [2,3,7,101],它的长度是 4。
说明:
可能会有多种最长上升子序列的组合,你只需要输出对应的长度即可。
你算法的时间复杂度应该为 O(n2) 。
解题思路
第一步:确定动态规划状态
-
是否存在状态转移?
-
什么样的状态比较好转移,找到对求解问题最方便的状态转移?
想清楚到底是直接用需要求的,比如长度作为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的数组:
1. dp=[1 for _ in range(n)] 2. dp=[1]*n
-
对于产生一个全为0,长度为m,宽度为n的二维矩阵:
1. dp=[[0 for _ in range(n)] for _ in range(m)] 2. 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(Nl