7.12 LeetCode刷题记录(动态规划,简单x1,中等x2)
7.12 LeetCode刷题记录(动态规划,简单x1,中等x2)
1 动态规划概念
动态规划(英语:Dynamic programming,简称 DP),是一种在数学、管理科学、计算机科学、经济学和生物信息学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。动态规划常常适用于有重叠子问题和最优子结构性质的问题。
单来说,动态规划其实就是,给定一个问题,我们把它拆成一个个子问题,直到子问题可以直接解决。然后呢,把子问题答案保存起来,以减少重复计算。再根据子问题答案反推,得出原问题解的一种方法。
核心思想:拆分子问题,记住过往,减少重复计算。
2 基础动态规划问题——青蛙跳阶问题
题目:一只青蛙一次可以跳上1级台阶,也可以跳上2级台阶。求该青蛙跳上一个 n 级的台阶总共有多少种跳法。
答案需要取模 1e9+7(1000000007),如计算初始结果为:1000000008,请返回 1。
示例 1:
输入:n = 2
输出:2
示例 2:
输入:n = 7
输出:21
示例 3:
输入:n = 0
输出:1
way 1 数组备忘录法(自底向上):
读题:青蛙跳0阶阶梯时,只有1种可能,跳1阶阶梯,只有1种可能,跳2阶阶梯时,有1+1种可能。由此类推,青蛙跳n阶阶梯时,有\(f(n-1)+f(n-2)\)种可能。
代码如下:
class Solution(object):
def numWays(self, n):
dp = [1,1]
for i in range(2,n+1):
dp.append(dp[i-2]+dp[i-1])
return dp[n] % 1000000007
需要注意的是,range的范围以及数组的添加函数append()。
way 2 二数备忘录法(自底向上):
思路:因为每次只需要前两个值相加,可以进一步优化空间
class Solution(object):
def numWays(self, n):
x,y = 1,1
for i in range(n):
x , y = y , x+y
return x % 1000000007
需要注意的是,range的范围,以及x,y的对应赋值。
总结
在上述例子中,我们分别采用了两种方法,第一种是基础备忘录方法,即利用数组记录每一阶段的数值从而进行递归,在此之上的第二种则是优化了空间进行的备忘录方法。
但是在此还需要注意一点,最原始的方法——暴力递归法多具有的复杂与重复性,由下图我们可以很明显的看出在递归过程中的重复性,所以单纯的暴力递归法往往会呈现指数爆炸式的空间占用及耗时,所以在此我们采用备忘录法。
3 动态规划的解题套路
什么样的问题可以考虑使用动态规划解决呢?
★ 如果一个问题,可以把所有可能的答案穷举出来,并且穷举出来后,发现存在重叠子问题,就可以考虑使用动态规划。
比如一些求最值的场景,如最长递增子序列、最小编辑距离、背包问题、凑零钱问题等等,都是动态规划的经典应用场景。
动态规划的解题思路
动态规划的核心思想就是拆分子问题,记住过往,减少重复计算。 并且动态规划一般都是自底向上的,因此到这里,基于青蛙跳阶问题,我总结了一下我做动态规划的思路:
-
穷举分析
-
确定边界
-
找出规律,确定最优子结构
-
写出状态转移方程
(1)穷举分析
- 当台阶数是1的时候,有一种跳法, \(f(1)=1\)
- 当只有2级台阶时,有两种跳法,第一种是直接跳两级,第二种是先跳一级,然后再跳一级。即f(2) = 2;
- 当台阶是3级时,想跳到第3级台阶,要么是先跳到第2级,然后再跳1级台阶上去,要么是先跳到第 1级,然后一次迈 2 级台阶上去。所以 \(f(3) = f(2) + f(1) =3\)
- 当台阶是4级时,想跳到第3级台阶,要么是先跳到第3级,然后再跳1级台阶上去,要么是先跳到第 2级,然后一次迈 2 级台阶上去。所以\(f(4) = f(3) + f(2) =5\)
- 当台阶是5级时......
(2)确定边界
通过穷举分析,我们发现,当台阶数是1的时候或者2的时候,可以明确知道青蛙跳法。\(f(1) =1\),\(f(2) = 2\),当台阶 \(n>=3\) 时,已经呈现出规律 \(f(3) = f(2) + f(1) =3\) ,因此 \(f(1) =1\),\(f(2) = 2\) 就是青蛙跳阶的边界。
(3)找规律,确定最优子结构
\(n>=3\) 时,已经呈现出规律 \(f(n) = f(n-1) + f(n-2)\) ,因此,\(f(n-1)\) 和 \(f(n-2)\) 称为 \(f(n)\) 的最优子结构。什么是最优子结构?有这么一个解释:
★ 一道动态规划问题,其实就是一个递推问题。假设当前决策结果是f(n),则最优子结构就是要让 \(f(n-k)\) 最优,最优子结构性质就是能让转移到n的状态是最优的,并且与后面的决策没有关系,即让后面的决策安心地使用前面的局部最优解的一种性质
(4)写出状态转移方程
通过前面3步,穷举分析,确定边界,最优子结构,我们就可以得出状态转移方程啦:
(5)代码实现
我们实现代码的时候,一般注意从底往上遍历哈,然后关注下边界情况,空间复杂度,也就差不多啦。动态规划有个框架的,大家实现的时候,可以考虑适当参考一下:
dp[0][0][...] = 边界值
for(状态1 :所有状态1的值){
for(状态2 :所有状态2的值){
for(...){
//状态转移方程
dp[状态1][状态2][...] = 求最值
}
}
}
4 例题:最长递增子序列(中等难度)
题目
给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。
子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。
示例 1:
输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。
示例 2:
输入:nums = [0,1,0,3,2,3]
输出:4
示例 3:
输入:nums = [7,7,7,7,7,7,7]
输出:1
解答
我们按照以上动态规划的解题思路,
- 穷举分析
- 确定边界
- 找规律,确定最优子结构
- 状态转移方程
(1)穷举分析
因为动态规划,核心思想包括拆分子问题,记住过往,减少重复计算。 所以我们在思考原问题:数组num[i]的最长递增子序列长度时,可以思考下相关子问题,比如原问题是否跟子问题num[i-1]的最长递增子序列长度有关呢?
(2)自顶向上的穷举
这里观察规律,显然是有关系的,我们还是遵循动态规划自底向上的原则,基于示例1的数据,从数组只有一个元素开始分析。
- 当nums只有一个元素10时,最长递增子序列是[10],长度是1.
- 当nums需要加入一个元素9时,最长递增子序列是[10]或者[9],长度是1。
- 当nums再加入一个元素2时,最长递增子序列是[10]或者[9]或者[2],长度是1。
- 当nums再加入一个元素5时,最长递增子序列是[2,5],长度是2。
- 当nums再加入一个元素3时,最长递增子序列是[2,5]或者[2,3],长度是2。
- 当nums再加入一个元素7时,,最长递增子序列是[2,5,7]或者[2,3,7],长度是3。
- 当nums再加入一个元素101时,最长递增子序列是[2,5,7,101]或者[2,3,7,101],长度是4。
- 当nums再加入一个元素18时,最长递增子序列是[2,5,7,101]或者[2,3,7,101]或者[2,5,7,18]或者[2,3,7,18],长度是4。
- 当nums再加入一个元素7时,最长递增子序列是[2,5,7,101]或者[2,3,7,101]或者[2,5,7,18]或者[2,3,7,18],长度是4.
(3)分析找规律,拆分子问题
通过上面分析,我们可以发现一个规律:
如果新加入一个元素nums[i], 最长递增子序列要么是以nums[i]结尾的递增子序列,要么就是nums[i-1]的最长递增子序列。看到这个,是不是很开心,nums[i]的最长递增子序列已经跟子问题 nums[i-1]的最长递增子序列有关联了。
原问题数组nums[i]的最长递增子序列 = 子问题数组nums[i-1]的最长递增子序列/nums[i]结尾的最长递增子序列
是不是感觉成功了一半呢?但是如何把nums[i]结尾的递增子序列也转化为对应的子问题呢?要是nums[i]结尾的递增子序列也跟nums[i-1]的最长递增子序列有关就好了。又或者nums[i]结尾的最长递增子序列,跟前面子问题num[j](0=<j<i)结尾的最长递增子序列有关就好了,带着这个想法,我们又回头看看穷举的过程:
nums[i]的最长递增子序列,不就是从以数组num[i]每个元素结尾的最长子序列集合,取元素最多(也就是长度最长)那个嘛,所以原问题,我们转化成求出以数组nums每个元素结尾的最长子序列集合,再取最大值嘛。哈哈,想到这,我们就可以用dp[i]表示以num[i]这个数结尾的最长递增子序列的长度啦,然后再来看看其中的规律:
其实,nums[i]结尾的自增子序列,只要找到比nums[i]小的子序列,加上nums[i] 就可以啦。显然,可能形成多种新的子序列,我们选最长那个,就是dp[i]的值啦
- nums[3]=5,以
5
结尾的最长子序列就是[2,5]
,因为从数组下标0到3
遍历,只找到了子序列[2]
比5
小,所以就是[2]+[5]
啦,即dp[4]=2
- nums[4]=3,以
3
结尾的最长子序列就是[2,3]
,因为从数组下标0到4
遍历,只找到了子序列[2]
比3
小,所以就是[2]+[3]
啦,即dp[4]=2
- nums[5]=7,以
7
结尾的最长子序列就是[2,5,7]
和[2,3,7]
,因为从数组下标0到5
遍历,找到2,5和3
都比7小,所以就有[2,7],[5,7],[3,7],[2,5,7]和[2,3,7]
这些子序列,最长子序列就是[2,5,7]和[2,3,7]
,它俩不就是以5
结尾和3
结尾的最长递增子序列+[7]来的嘛!所以,**dp[5]=3 =dp[3]+1=dp[4]+1**
。
”
很显然有这个规律:一个以nums[i]结尾的数组nums
- 如果存在j属于区间[0,i-1],并且num[i]>num[j]的话,则有,dp(i) =max(dp(j))+1,
(3)最简单的边界情况
当nums数组只有一个元素时,最长递增子序列的长度dp(1)=1,当nums数组有两个元素时,dp(2) =2或者1, 因此边界就是dp(1)=1。
(4)确定最优子结构
从穷举分析,我们可以得出,以下的最优结构:
dp(i) =max(dp(j))+1,存在j属于区间[0,i-1],并且num[i]>num[j]。
max(dp(j)) 就是最优子结构。
(5)状态转移方程
通过前面分析,我们就可以得出状态转移方程啦:
所以数组num[i]的最长递增子序列就是:
最长递增子序列 =max(dp[i])
(6)代码实现
class Solution(object):
def lengthOfLIS(self, nums):
if len(nums) == 0:
return 0
dp = []
for i in range(len(nums)):
dp.append(1)
for j in range(i):
if nums[i] > nums[j]:
dp[i] = max(dp[i], dp[j]+1)
return max(dp)
需要注意的是两个循环变量 i,j 的对应内容以及两个数组的对应内容,最后返回的为 max(dp)而不是 dp[len(nums)] ,因为最后一个可能和其他数字形成一个较小的序列数,而不是最大的那个。
5 测验:解决智力问题(中等难度)
题目
给你一个下标从 0 开始的二维整数数组 questions ,其中 $questions[i] = [pointsi, brainpoweri] $。
这个数组表示一场考试里的一系列题目,你需要 按顺序 (也就是从问题 0 开始依次解决),针对每个问题选择 解决 或者 跳过 操作。解决问题 i 将让你 获得 pointsi 的分数,但是你将 无法 解决接下来的 brainpoweri 个问题(即只能跳过接下来的 brainpoweri 个问题)。如果你跳过问题 i ,你可以对下一个问题决定使用哪种操作。
比方说,给你 questions = [[3, 2], [4, 3], [4, 4], [2, 5]] :
如果问题 0 被解决了, 那么你可以获得 3 分,但你不能解决问题 1 和 2 。
如果你跳过问题 0 ,且解决问题 1 ,你将获得 4 分但是不能解决问题 2 和 3 。
请你返回这场考试里你能获得的 最高 分数。
示例 1:
输入:questions = [[3,2],[4,3],[4,4],[2,5]]
输出:5
解释:解决问题 0 和 3 得到最高分。
- 解决问题 0 :获得 3 分,但接下来 2 个问题都不能解决。
- 不能解决问题 1 和 2
- 解决问题 3 :获得 2 分
总得分为:3 + 2 = 5 。没有别的办法获得 5 分或者多于 5 分。
示例 2:
输入:questions = [[1,1],[2,2],[3,3],[4,4],[5,5]]
输出:7
解释:解决问题 1 和 4 得到最高分。
- 跳过问题 0
- 解决问题 1 :获得 2 分,但接下来 2 个问题都不能解决。
- 不能解决问题 2 和 3
- 解决问题 4 :获得 5 分
总得分为:2 + 5 = 7 。没有别的办法获得 7 分或者多于 7 分。
解答
我们用 \(\textit{dp}[i]\) 来表示解决第 \(i\) 道题目及以后的题目可以获得的最高分数。同时,我们从后往前遍历题目,并更新 \(\textit{dp}\) 数组。类似地,根据是否选择解决第 \(i\) 道题目,会有以下两种情况:
不解决第 \(i\) 道题目,此时 \(\textit{dp}[i] = \textit{dp}[i+1]\);
解决第 \(i\) 道题目,我们只能解决下标大于 \(i + \textit{brainpower}[i]\) 的题目,而此时根据 \(\textit{dp}\) 数组的定义,解决这些题目的最高分数为 \(dp[i + \textit{brainpower}[i] + 1]\)(当 \(i \ge n\) 的情况下,我们认为 \(dp[i] = 0\))。因此,我们有:
\(\textit{dp}[i] = \textit{points}[i] + dp[i + \textit{brainpower}[i] + 1]\).
综合上述两种情况,我们就得出了 \(\textit{dp}[i]dp[i]\) 的状态转移方程:
\(\textit{dp}[i] = \max(\textit{dp}[i+1], \textit{points}[i] + dp[i + \textit{brainpower}[i] + 1])\).
在实际计算中,考虑到 \(i \ge n\) 的边界条件,我们在定义 \(\textit{dp}\) 数组时,可以预留 \(dp[n] = 0\) 用来表示没有做任何题目的分数。那么上面的转移方程变为:
\(\textit{dp}[i] = \max(\textit{dp}[i+1], \textit{points}[i] + dp[\min(n, i + \textit{brainpower}[i] + 1)])\).
最终,\(dp[0]\) 即为考试中可以获得的最高分数,我们返回该数值作为答案。
class Solution(object):
def mostPoints(self, questions):
n = len(questions)
dp = [0] * (n + 1) # 解决每道题及以后题目的最高分数
for i in range(n):
dp[i] = max(dp[i + 1], questions[i][0] + dp[min(n, i + questions[i][1] + 1)])
return max(dp)
需要注意的是,dp[5] = 0 ,即表示全部跳过,dp得到的是第 i 个所能得到的最大分数,所以第 4 个为 2 ,由于同时判断了自身和下一个的大小,所以第 0 个必定是最大。