每日一题|2022-10-14|115.不同的子序列|940. 不同的子序列 II|Golang

        今天又是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. 斐波那契数

        斐波那契数 (通常用 F(n) 表示)形成的序列称为 斐波那契数列 。该数列由 0 和 1 开始,后面的每一项数字都是前面两项数字的和。也就是: F(0) = 0,F(1) = 1, F(n) = F(n - 1) + F(n - 2),其中 n > 1

        给定 n ,请计算 F(n) 。

思路:

        斐波那契数列大家应该非常熟悉不过了,非常适合作为动规第一道题目来练练手。因为这道题目比较简单,可能一些同学并不需要做什么分析,直接顺手一写就过了。

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

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

        所以我总结的动规五部曲,是要用来贯穿整个动态规划系列的,后面慢慢大家就会体会到,动规五部曲方法的重要性。

DP五部曲:

        这里我们要用一个一维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[10] = 55

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

以上用动态规划五部曲分析完了,下面给出Go的代码

/*
斐波那契-动态规划
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]我们来推导一下dp[4]
dp[4] = dp[4-1] + dp[4-2] 
			= dp[3] + dp[2]
			= dp[3-1] + dp[3-2] + dp[2-1] + dp[2-2]
			= dp[2] + dp[1] + dp[1] + dp[0]
			= dp[2-1] +dp[2-2] + dp[1] + dp[1] + dp[0]
      = dp[1] + dp[0] + dp[1] + do[1] + dp[0]
      = 1 + 1 + 1 + 0
      = 3 
如果N为4的话,那么dp数组应该是 0 1 1 2 3,可以看到dp[4] = 3.
*/
func fib(n int) int {
    if n < 2 {
        return n
    }
    dp := make([]int, n+1)
    dp[0] = 0
    dp[1] = 1
    for i:=2;i<=n;i++{
        dp[i] = dp[i-1] + dp[i-2]
    }
    return dp[n]
}
    时间复杂度:O(n)
    空间复杂度:O(1)

本题还可以使用递归解法来做

//递归解法
func fib(n int) {
  if n < 2 {
    return n
  }
  return fib(n-1) + fib(-2)
}
    时间复杂度:O(2^n)
    空间复杂度:O(n),算上了编程语言中实现递归的系统栈所占空间




// 另一种
func fib(n int) int {
    if n < 2 {
        return n
    }
    a,b,c := 0,1,0
    for i:=2;i<=n;i++{
        c = a + b
        a, b = b, c
    }
    return c
}
    时间复杂度:O(n)
    空间复杂度:O(1)

70、爬楼梯

        假设你正在爬楼梯。需要 n 阶你才能到达楼顶。每次你可以爬 12 个台阶。你有多少种不同的方法可以爬到楼顶呢?

示例 1:
输入:n = 2
输出:2
解释:有两种方法可以爬到楼顶。
1.     1 阶 + 1 阶
2.     2 阶

示例 2:
输入:n = 3
输出:3
解释:有三种方法可以爬到楼顶。
1.      1 阶 + 1 阶 + 1 阶
2.      1 阶 + 2 阶
3.      2 阶 + 1 阶

思路:

        本题大家如果没有接触过的话,会感觉比较难,多举几个例子,就可以发现其规律。

        爬到第一层楼梯有一种方法,爬到二层楼梯有两种方法。

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

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

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

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

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]的初始化!

        我相信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)
  	dp[1] = 1
  	dp[2] = 2
    for i:=3;i<=n;i++{
        dp[i] = dp[i-1] + dp[i-2]
    }
    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 

这道题目可以说是昨天70爬楼梯的花费版本

        注意题目描述:每当你爬上一个阶梯你都要花费对应的体力值,一旦支付了相应的体力值,你就可以选择向上爬一个阶梯或者爬两个阶梯

        所以示例1中只花费一个15 就可以到阶梯顶,最后一步可以理解为 不用花费。读完题大家应该知道指定需要动态规划的,贪心是不可能了。

示例1:

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

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

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

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

        dp[i]的定义:到达第i个台阶所花费的最少体力为dp[i]。(注意这里认为是第一步一定是要花费)对于dp数组的定义,大家一定要清晰!

2、确定递推公式

        可以有两个途径得到dp[i],一个是dp[i-1] 一个是dp[i-2]。那么究竟是选dp[i-1]还是dp[i-2]呢?

        一定是选最小的,所以dp[i] = min(dp[i - 1], dp[i - 2]) + cost[i];

        注意这里为什么是加cost[i],而不是cost[i-1],cost[i-2]之类的,因为题目中说了:每当你爬上一个阶梯你都要花费对应的体力值

3、dp数组如何初始化

        根据dp数组的定义,dp数组初始化其实是比较难的,因为不可能初始化为第i台阶所花费的最少体力。

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

所以初始化代码为:

dp := make([]int,n+1)
dp[0] = cost[0];
dp[1] = cost[1];

4、确定遍历顺序

        最后一步,递归公式有了,初始化有了,如何遍历呢?

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

        但是稍稍有点难度的动态规划,其遍历顺序并不容易确定下来

        例如:01背包,都知道两个for循环,一个for遍历物品嵌套一个for遍历背包容量,那么为什么不是一个for遍历背包容量嵌套一个for遍历物品呢? 以及在使用一维dp数组的时候遍历背包容量为什么要倒序呢?

        这些都是遍历顺序息息相关。当然背包问题后续「代码随想录」都会重点讲解的!

5、举例推导dp数组

拿示例2:cost = [1, 100, 1, 1, 1, 100, 1, 1, 100, 1] ,来模拟一下dp数组的状态变化,如下:

        就是从前面到了最后,肯定是在最后两位中选最成本最低的呀。

如果大家代码写出来有问题,就把dp数组打印出来,看看和如上推导的是不是一样的。

/*
1、确定dp数组以及下标的含义
使用动态规划,就要有一个数组来记录状态,本题只需要一个一维数组dp[i]就可以了。
dp[i]的定义:到达第i个台阶所花费的最少体力为dp[i]。(注意这里认为第一步一定是要花费)
2、确定递推公式
可以有两个途径得到dp[i],一个是dp[i-1],一个是dp[i-2]
那么选哪个呢?选最小的 -> 即dp[i] = min(dp[i-1],dp[i-2]) + cost[i]
3、dp数组如何初始化
看一下递推公式,dp[i]由dp[i-1],dp[i-2]推出,那么只初始化dp[0]和dp[1]就可以了,其它的由这两个推出。
初始化代码:
dp[0] = cost[0]、 dp[1] = cost[1]
4、确定遍历顺序
因为是模拟台阶,而且dp[i]又是由dp[i-1]和dp[i-2]推出,所以是从前面到后面遍历cost数组就可以了。
5、举例推导dp数组
	略
*/
func minCostClimbingStairs(cost []int) int {
	dp := make([]int, len(cost))
	dp[0], dp[1] = cost[0], cost[1]
	for i := 2; i < len(cost); i++ {
		dp[i] = min(dp[i-1], dp[i-2]) + cost[i]
	}
	return min(dp[len(cost)-1], dp[len(cost)-2])
}
func min(a, b int) int {
	if a < b {
		return a
	}
	return b
}
    时间复杂度:O(n)
    空间复杂度:O(n)

真正的题目来了,今天的每日一题。(不会)

940. 不同的子序列 II

思路:

 

 

 基本思路就是这样了,具体代码实现,请参照如下部分。

func distinctSubseqII(s string) (total int) {
	const mod int = 1e9 + 7
	f := [26]int{}
	for _, c := range s {
		c -= 'a'
		others := total - f[c] // total 中不含 f[c] 的部分(由于取模的原因,这里的减法可能会产生负数)
		f[c] = 1 + total // 更新 f[c]
		total = ((f[c]+others)%mod + mod) % mod // 更新 total,并保证 total 非负
	}
	return
}

看不懂,后面补吧。

 

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值