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 个必定是最大。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值