代码随想录day38|DP动态规划登场|理论基础|509. 斐波那契数|70. 爬楼梯|746. 使用最小花费爬楼梯|Golang

目录

DP主要题目图:

 理论基础:

动态规划的解题步骤

动态规划应该如何debug

总结

509. 斐波那契数

思路

动规五部曲:

总结

70. 爬楼梯

 思路

746. 使用最小花费爬楼梯

思路


DP主要题目图:

 理论基础:

        动态规划,英文:Dynamic Programming,简称DP,如果某一问题有很多重叠子问题,使用动态规划是最有效的。

        所以动态规划中每一个状态一定是由上一个状态推导出来的,这一点就区分于贪心,贪心没有状态推导,而是从局部直接选最优的,

        在关于贪心算法,你该了解这些!中我举了一个背包问题的例子。

        例如:有N件物品和一个最多能背重量为W 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。

        动态规划中dp[j]是由dp[j-weight[i]]推导出来的,然后取max(dp[j], dp[j - weight[i]] + value[i])。

        但如果是贪心呢,每次拿物品选一个最大的或者最小的就完事了,和上一个状态没有关系。所以贪心解决不了动态规划的问题。

        其实大家也不用死扣动规和贪心的理论区别,后面做做题目自然就知道了

        而且很多讲解动态规划的文章都会讲最优子结构啊和重叠子问题啊这些,这些东西都是教科书的上定义,晦涩难懂而且不实用。

        大家知道动规是由前一个状态推导出来的,而贪心是局部直接选最优的,对于刷题来说就够用了。

        上述提到的背包问题,后序会详细讲解。

动态规划的解题步骤

        做动规题目的时候,很多同学会陷入一个误区,就是以为把状态转移公式背下来,照葫芦画瓢改改,就开始写代码,甚至把题目AC之后,都不太清楚dp[i]表示的是什么。

        这就是一种朦胧的状态,然后就把题给过了,遇到稍稍难一点的,可能直接就不会了,然后看题解,然后继续照葫芦画瓢陷入这种恶性循环中

        状态转移公式(递推公式)是很重要,但动规不仅仅只有递推公式。对于动态规划问题,我将拆解为如下五步曲,这五步都搞清楚了,才能说把动态规划真的掌握了!

  1. 确定dp数组(dp table)以及下标的含义
  2. 确定递推公式
  3. dp数组如何初始化
  4. 确定遍历顺序
  5. 举例推导dp数组

        一些同学可能想为什么要先确定递推公式,然后在考虑初始化呢?因为一些情况是递推公式决定了dp数组要如何初始化!

        后面的讲解中我都是围绕着这五点来进行讲解。可能刷过动态规划题目的同学可能都知道递推公式的重要性,感觉确定了递推公式这道题目就解出来了。

        其实 确定递推公式 仅仅是解题里的一步而已!一些同学知道递推公式,但搞不清楚dp数组应该如何初始化,或者正确的遍历顺序,以至于记下来公式,但写的程序怎么改都通过不了。

        后序的讲解的大家就会慢慢感受到这五步的重要性了

动态规划应该如何debug

        相信动规的题目,很大部分同学都是这样做的。

        看一下题解,感觉看懂了,然后照葫芦画瓢,如果能正好画对了,万事大吉,一旦要是没通过,就怎么改都通过不了,对 dp数组的初始化,递推公式,遍历顺序,处于一种黑盒的理解状态。

        写动规题目,代码出问题很正常!

        找问题的最好方式就是把dp数组打印出来,看看究竟是不是按照自己思路推导的!

        一些同学对于dp的学习是黑盒的状态,就是不清楚dp数组的含义,不懂为什么这么初始化,递推公式背下来了,遍历顺序靠习惯就是这么写的,然后一鼓作气写出代码,如果代码能通过万事大吉,通过不了的话就凭感觉改一改。

        这是一个很不好的习惯!

        做动规的题目,写代码之前一定要把状态转移在dp数组的上具体情况模拟一遍,心中有数,确定最后推出的是想要的结果

        然后再写代码,如果代码没通过就打印dp数组,看看是不是和自己预先推导的哪里不一样。

        如果打印出来和自己预先模拟推导是一样的,那么就是自己的递归公式、初始化或者遍历顺序有问题了。

        如果和自己预先模拟推导的不一样,那么就是代码实现细节有问题。

        这样才是一个完整的思考过程,而不是一旦代码出问题,就毫无头绪的东改改西改改,最后过不了,或者说是稀里糊涂的过了

        这也是我为什么在动规五步曲里强调推导dp数组的重要性。

        举个例子哈:在「代码随想录」刷题小分队微信群里,一些录友可能代码通过不了,会把代码抛到讨论群里问:我这里代码都已经和题解一模一样了,为什么通过不了呢?

发出这样的问题之前,其实可以自己先思考这三个问题:

  • 这道题目我举例推导状态转移公式了么?
  • 我打印dp数组的日志了么?
  • 打印出来了dp数组和我想的一样么?

        如果这灵魂三问自己都做到了,基本上这道题目也就解决了,或者更清晰的知道自己究竟是哪一点不明白,是状态转移不明白,还是实现代码不知道该怎么写,还是不理解遍历dp数组的顺序。

        然后在问问题,目的性就很强了,群里的小伙伴也可以快速知道提问者的疑惑了。

        注意这里不是说不让大家问问题哈, 而是说问问题之前要有自己的思考,问题要问到点子上!大家工作之后就会发现,特别是大厂,问问题是一个专业活,是的,问问题也要体现出专业!

        如果问同事很不专业的问题,同事们会懒的回答,领导也会认为你缺乏思考能力,这对职场发展是很不利的。

        所以大家在刷题的时候,就锻炼自己养成专业提问的好习惯

总结

        这一篇是动态规划的整体概述,讲解了什么是动态规划,动态规划的解题步骤,以及如何debug。

        动态规划是一个很大的领域,今天这一篇讲解的内容是整个动态规划系列中都会使用到的一些理论基础。

        在后序讲解中针对某一具体问题,还会讲解其对应的理论基础,例如背包问题中的01背包,leetcode上的题目都是01背包的应用,而没有纯01背包的问题,那么就需要在把对应的理论知识讲解一下。

        大家会发现,我讲解的理论基础并不是教科书上各种动态规划的定义,错综复杂的公式。

        这里理论基础篇已经是非常偏实用的了,每个知识点都是在解题实战中非常有用的内容,大家要重视起来哈。

        今天我们开始新的征程了,你准备好了么?

509. 斐波那契数

思路1:常规做法,for循环 / 递归

// 常规递推
func fib(n int) int {
    if n<=1 {
        return n    
    }
    n1,n2,n3 := 0, 1, 0
    for i:=0;i<n;i++{    
        n1,n2 = n2,n3    // 前一个数等于下一个数字
        n3 = n2 + n1    // 下一个数等于前两个数之和
    }
    return n3
}


// 常规递归
func fib(n int) int {
    // 1.递归终止条件
    if n<=1 {   
        return n    
    }
    // 2.递归逻辑
    return fib(n-2) + fib(n-1)
}




//记忆化递归,减少重复递归,降低复杂度
func fib(n int) int {
    if n < 2 {
        return n
    }
    memo := make([]int,n+1)
    return fib2(n, memo)
}

func fib2(n int, memo []int) int {
    if n < 2 {
        return n
    }
    if memo[n] == 0 {
        memo[n] = fib2(n-1, memo) + fib2(n-2,memo)
    }
    return memo[n]
}
    

思路2:动态规划

思路

        斐波那契数列大家应该非常熟悉不过了,非常适合作为动规第一道题目来练练手。

因为这道题目比较简单,可能一些同学并不需要做什么分析,直接顺手一写就过了。

        但「代码随想录」的风格是:简单题目是用来加深对解题方法论的理解的。通过这道题目让大家可以初步认识到,按照动规五部曲是如何解题的。

        对于动规,如果没有方法论的话,可能简单题目可以顺手一写就过,难一点就不知道如何下手了。

        所以我总结的动规五部曲,是要用来贯穿整个动态规划系列的,就像之前讲过二叉树系列的递归三部曲回溯法系列的回溯三部曲 (opens new window)一样。后面慢慢大家就会体会到,动规五部曲方法的重要性。

动规五部曲:

这里我们要用一个一维dp数组来保存递归的结果

1.确定dp数组以及下标的含义

        dp[i]的定义为:第i个数的斐波那契数值是dp[i]

2.确定递推公式(状态转移公式)

        为什么这是一道非常简单的入门题目呢?因为题目已经把递推公式直接给我们了:状态转移方程 dp[i] = dp[i - 1] + dp[i - 2];

3.dp数组如何初始化

        题目中把如何初始化也直接给我们了,dp[0]=0,dp[1] = 1

4.确定遍历顺序

        从递归公式dp[i] = dp[i - 1] + dp[i - 2];中可以看出,dp[i]是依赖 dp[i - 1] 和 dp[i - 2],那么遍历的顺序一定是从前到后遍历的

5.举例推导dp数组

        按照这个递推公式dp[i] = dp[i - 1] + dp[i - 2],我们来推导一下,当N为10的时候,dp数组应该是如下的数列:0 1 1 2 3 5 8 13 21 34 55

        如果代码写出来,发现结果不对,就把dp数组打印出来看看和我们推导的数列是不是一致的。

        以上我们用动规的方法分析完了,Go代码如下:

func fib(n int) int {
    // 特判
    if n<=1 {   
        return n    
    }
    
    dp := make([]int,n+1) //1.dp[i]数组及其下标的意义是啥?
    
    dp[0] = 0   // 3.dp数组如何初始化?
    dp[1] = 1
    
    for i:=2;i<=n;i++{  //  // 4. 确定遍历顺序是啥?
        dp[i] = dp[i-1] + dp[i-2]   // 2.递归公式(状态转移公式)是啥?
    }
    //5.举例推导dp数组,自己在草稿纸上推一推啊
    return dp[n]
}

    时间复杂度:O(n)
    空间复杂度:O(n)

总结

        斐波那契数列这道题目是非常基础的题目,我在后面的动态规划的讲解中将会多次提到斐波那契数列!

        这里我严格按照动规五部曲来分析了这道题目,一些分析步骤可能同学感觉没有必要搞的这么复杂,代码其实上来就可以撸出来。

        但我还是强调一下,简单题是用来掌握方法论的,动规五部曲将在接下来的动态规划讲解中发挥重要作用,敬请期待!

70. 爬楼梯

 思路

        本题大家如果没有接触过的话,会感觉比较难,多举几个例子,就可以发现其规律。爬到第一层楼梯有一种方法,爬到二层楼梯有两种方法。

        那么第一层楼梯再跨两步就到第三层 ,第二层楼梯再跨一步就到第三层。

        所以到第三层楼梯的状态可以由第二层楼梯 和 到第一层楼梯状态推导出来,那么就可以想到动态规划了

        我们来分析一下,动规五部曲:

定义一个一维数组来记录不同楼层的状态

1、确定dp数组以及下标的含义

        dp[i]: 爬到第i层楼梯,有dp[i]种方法

2、确定递推公式(状态转移公式)

如何可以推出dp[i]呢?从dp[i]的定义可以看出,dp[i] 可以有两个方向推出来。

        首先是dp[i - 1],上i-1层楼梯,有dp[i - 1]种方法,那么再一步跳一个台阶不就是dp[i]了么。

        还有就是dp[i - 2],上i-2层楼梯,有dp[i - 2]种方法,那么再一步跳两个台阶不就是dp[i]了么。

        那么dp[i]就是 dp[i - 1]与dp[i - 2]之和!

        所以dp[i] = dp[i - 1] + dp[i - 2] 。

        例如dp[3] = dp[2] + dp[1]         //d[2]是上2层,dp[1]是上1层。

        在推导dp[i]的时候,一定要时刻想着dp[i]的定义,否则容易跑偏。这体现出确定dp数组以及下标的含义的重要性!

3、dp数组如何初始化:

        再回顾一下dp[i]的定义:爬到第i层楼梯,有dp[i]中方法。

        那么i为0,dp[i]应该是多少呢,这个可以有很多解释,但都基本是直接奔着答案去解释的。例如强行安慰自己爬到第0层,也有一种方法,什么都不做也就是一种方法即:dp[0] = 1,相当于直接站在楼顶。但总有点牵强的成分。

        那还这么理解呢:我就认为跑到第0层,方法就是0啊,一步只能走一个台阶或者两个台阶,然而楼层是0,直接站楼顶上了,就是不用方法,dp[0]就应该是0.

        其实这么争论下去没有意义,大部分解释说dp[0]应该为1的理由其实是因为dp[0]=1的话在递推的过程中i从2开始遍历本题就能过,然后就往结果上靠去解释dp[0] = 1。

        从dp数组定义的角度上来说,dp[0] = 0 也能说得通。

        需要注意的是:题目中说了n是一个正整数,题目根本就没说n有为0的情况。所以本题其实就不应该讨论dp[0]的初始化!        

        力扣现在这道题有提示:1 <= n <= 45

        我相信dp[1] = 1,dp[2] = 2,这个初始化大家应该都没有争议的。

        所以我的原则是:不考虑dp[0]如果初始化,只初始化dp[1] = 1,dp[2] = 2,然后从i = 3开始递推,这样才符合dp[i]的定义。

4、确定遍历顺序:

        从递推公式dp[i] = dp[i - 1] + dp[i - 2];中可以看出,遍历顺序一定是从前向后遍历的

5、举例推导dp数组:

        举例当n为5的时候,dp table(dp数组)应该是这样的

如果代码出问题了,就把dp table 打印出来,看看究竟是不是和自己推导的一样。

        此时大家应该发现了,这不就是斐波那契数列么!唯一的区别是,没有讨论dp[0]应该是什么,因为dp[0]在本题没有意义!以上五部分析完之后,Go代码如下:

/*
1、确定dp数组以及下标的含义
dp[i]:爬到第i层楼梯,有dp[i]种方法
2、确定递推公式
上i层楼梯,就要吧能上第i-1次楼梯的方法加上能上第i-2层楼梯的方法加起来就是了
dp[i] = dp[i-1] + dp[i-2]
3、dp数组如何初始化
从dp[1]开始初始化
dp[1] = 1; dp[2] = 2,然后从3开始递推。
4、确定遍历顺序
从递推公式dp[i] = dp[i-1] + dp[i-2]看出顺序是从前往后递推的
5、举例推导dp数组
举例当n为5的时候,dp数组应该是这样的
下标: 1  2. 3. 4. 5
dp[i] 1  2  3. 5. 8
*/

func climbStairs(n int) int {   
    // 特判
    if n <= 2 {
        return n
    }
    
    dp := make([]int,n+1)   // 1.dp[i]数组及其下标的意义是啥?
    dp[1] = 1       // 3.dp数组如何初始化?
    dp[2] = 2 
    for i:=3;i<=n;i++{  // 4. 确定遍历顺序是啥?
        dp[i] = dp[i-1]+dp[i-2] // 2.递推公式(状态转移方程)是啥?
    }
    //5.举例推导dp数组,自己在草稿纸上推一推啊
    return dp[n]
}

    时间复杂度:O(n)
    空间复杂度:O(n)

746. 使用最小花费爬楼梯

        给你一个整数数组 cost ,其中 cost[i] 是从楼梯第 i 个台阶向上爬需要支付的费用。一旦你支付此费用,即可选择向上爬一个或者两个台阶。你可以选择从下标为 0 或下标为 1 的台阶开始爬楼梯。请你计算并返回达到楼梯顶部的最低花费。

 //参考视频
https://www.bilibili.com/video/BV1SL4y1p7yV?spm_id_from=333.337.search-card.all.click&vd_source=fae32e86fd767a816309a0828b5dba8b

思路

示例1:

输入:cost = [10, 15, 20] 输出:15

比如我一开始爬2到了cost为15所在的台阶。此时我要付该台阶所对应的cost15然后我才可以继续爬,当我付完cost=15之后我选择爬2,那不就爬完了吗?所以就才付款了15是最少的。

在力扣修改了题目描述下,我又重新修改了题解

        修改之后的题意就比较明确了,题目中说 “你可以选择从下标为 0 或下标为 1 的台阶开始爬楼梯” 也就是相当于 跳到 下标 0 或者 下标 1 是不花费体力的, 从 下标 0 下标1 开始跳就要花费体力了。

1、确定dp数组以及下标的含义:

        使用动态规划,就要有一个数组来记录状态,本题只需要一个一维数组dp[i]就可以了。

dp[i]的定义:到达第i台阶所花费的最少体力为dp[i]对于dp数组的定义,大家一定要清晰!

2、确定递推公式:

        可以有两个途径得到dp[i],一个是dp[i-1] 一个是dp[i-2]

dp[i - 1] 跳到 dp[i] 需要花费 dp[i - 1] + cost[i - 1]。

dp[i - 2] 跳到 dp[i] 需要花费 dp[i - 2] + cost[i - 2]。

        那么究竟是选从dp[i - 1]跳还是从dp[i - 2]跳呢?一定是选最小的,所以dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);

3、dp数组如何初始化:

        看一下递归公式,dp[i]由dp[i - 1],dp[i - 2]推出,既然初始化所有的dp[i]是不可能的,那么只初始化dp[0]和dp[1]就够了,其他的最终都是dp[0]dp[1]推出。

        那么 dp[0] 应该是多少呢? 根据dp数组的定义,到达第0台阶所花费的最小体力为dp[0],那么有同学可能想,那dp[0] 应该是 cost[0],例如 cost = [1, 100, 1, 1, 1, 100, 1, 1, 100, 1] 的话,dp[0] 就是 cost[0] 应该是1。

        这里就要说明了,本题力扣为什么改题意了,而且修改题意之后 就清晰很多的原因了。

        新题目描述中明确说了 “你可以选择从下标为 0 或下标为 1 的台阶开始爬楼梯。” 也就是说 从 到达 第 0 个台阶是不花费的,但从 第0 个台阶 往上跳的话,需要花费 cost[0]。

所以初始化 dp[0] = 0,dp[1] = 0;

4、确定遍历顺序        

        最后一步,递归公式有了,初始化有了,如何遍历呢?本题的遍历顺序其实比较简单,简单到很多同学都忽略了思考这一步直接就把代码写出来了。因为是模拟台阶,而且dp[i]由dp[i-1]dp[i-2]推出,所以是从前到后遍历cost数组就可以了。

         但是稍稍有点难度的动态规划,其遍历顺序并不容易确定下来。 例如:01背包,都知道两个for循环,一个for遍历物品嵌套一个for遍历背包容量,那么为什么不是一个for遍历背包容量嵌套一个for遍历物品呢? 以及在使用一维dp数组的时候遍历背包容量为什么要倒序呢?

5、举例推导dp数组:

 

// 1. 确定dp数组以及含义
// dp[i]是爬上去楼梯顶的最小花费
// 2. 确定递推公式
// dp[i] = min(dp[i-1]+cost[i-1], dp[i-2]+cost[i-2)
// 3. 确定dp数组初始化
// dp[0]=0, dp[1]=0
// 4. 确定遍历顺序
// 从前到后
// 5. 打印dp数组

func minCostClimbingStairs(cost []int) int {
    n := len(cost)
    dp := make([]int, n+1)
    for i := 2; i <= n; i++ {
        dp[i] = min(dp[i-1]+cost[i-1], dp[i-2]+cost[i-2])
    }
    return dp[n]
}

func min(a, b int) int {
    if a < b {
        return a
    }
    return b
}

无了

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值