羊羊刷题笔记Day38/60 | 第九章 动态规划P1 | 动态规划理论基础、509. 斐波那契数、70. 爬楼梯、746. 使用最小花费爬楼梯

160 阅读14分钟

动态规划理论基础

大纲

动态规划-总结大纲1.jpg

什么是动态规划

动态规划,英文: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数组的顺序。

总结

这一篇是动态规划的整体概述什么是动态规划,动态规划的解题步骤,以及如何debug
动态规划是一个很大的领域,这部分的内容是整个动态规划系列中都会使用到的一些理论基础。
在后序讲解中针对某一具体问题,还会补充其对应的理论基础,例如背包问题中的01背包,leetcode上的题目都是01背包的应用,而没有纯01背包的问题,那么就需要在把对应的理论知识讲解一下。
总的来说,这部分理论基础篇对比教科书偏实用,每个知识点都是在解题实战中非常有用的内容。

509 斐波那契数

理论成立,开始做题~先用简单题练练方法论

思路

通过这道题目让大家可以初步认识到,按照动规五部曲是如何解题的。
对于动规,如果没有方法论的话,可能简单题目可以顺手一写就过,难一点就不知道如何下手了。
后面慢慢就会体会到,动规五部曲方法的重要性。

动态规划

动规五部曲:
这里我们要用一个一维dp数组来保存递归的结果

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

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

  1. 确定递推公式

这题简单的原因就是斐波拉契数的定义就是我们的递归公式:dp[i] = dp[i - 1] + dp[i - 2];

  1. dp数组如何初始化

同样,题目也给了前两个数我们

dp[0] = 0;
dp[1] = 1;
  1. 确定遍历顺序

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

  1. 举例推导dp数组

按照这个递推公式dp[i] = dp[i - 1] + dp[i - 2],我们来推导一下,当N为10的时候,dp数组应该是如下的数列:
0 1 1 2 3 5 8 13 21 34 55
如果代码写出来,发现结果不对,就把dp数组打印出来看看和我们推导的数列是不是一致的。
以上我们用动规的方法分析完了,整体代码如下:

public int fib(int n) {


    // 动态规划 - 数组递归
    if (n <= 1) return n;

	// 根据题意f(0) = 0;f(1) = 1;
	int[] dp = new int[n + 1];
	dp[0] = 0;
	dp[1] = 1;
	for (int i = 2; i <= n;i++){
	    dp[i] = dp[i - 1] + dp[i - 2];
	}
	
	return dp[n];


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

当然可以发现,我们只需要维护两个数值就可以了,不需要记录整个序列。
代码如下:

public int fib(int n) {

    // 动态规划 - 优化空间 - 只维护三个变量
    if (n <= 1) return n;

	// 从1开始
	int sum = 0,a = 0,b = 1;
	
	for (int i = 1; i <= n - 1;i++){
	    sum = a + b;
	    // sum与b往前腾一位
	    a = b;
	    b = sum;
	}
	
	return sum;
}
  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

递归解法

本题还可以使用递归解法来做,但是...你懂的,效率很低
代码如下:

public int fib(int n) {
    // 常规递归法
    if (n == 1) return 1;
	if (n == 0) return 0;
	return fib(n - 1) + fib(n - 2);

}
  • 时间复杂度:O(2^n)
  • 空间复杂度:O(n),算上了编程语言中实现递归的系统栈所占空间

这个递归的时间复杂度画一下树形图就知道了:详细看👉通过一道面试题目,讲一讲递归算法的时间复杂度!

总结

斐波那契数列这道题目是非常基础的题目,我在后面的动态规划的讲解中将会多次提到斐波那契数列!
这里我严格按照动规五部曲来分析了这道题目,一些分析步骤可能同学感觉没有必要搞的这么复杂,代码其实上来就可以撸出来。
但还是强调一下,简单题是用来掌握方法论的,动规五部曲将在接下来的动态规划题目中发挥重要作用

70 爬楼梯

理解后,和斐波拉契数列大差不差。

思路

本题如果没有接触过的话,会感觉比较难,多举几个例子,就可以发现其规律。
爬到第一层楼梯有一种方法,爬到二层楼梯有两种方法。
那么第一层楼梯再跨两步就到第三层(两种方法) ,第二层楼梯再跨一步就到第三层(两种方法)。
所以到第三层楼梯的状态可以由第二层楼梯 和 到第一层楼梯状态推导出来,那么就可以想到动态规划了。
我们来分析一下,动规五部曲:
定义一个一维数组来记录不同楼层的状态

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

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

  1. 确定递推公式

如何可以推出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] * 1 + dp[i - 2] * 1。(这里乘1是因为在i - 1和i -2 层都只有一种方法到i层
在推导dp[i]的时候,一定要时刻想着dp[i]的定义,否则容易跑偏。
这体现出确定dp数组以及下标的含义的重要性!

  1. dp数组如何初始化

再回顾一下dp[i]的定义:爬到第i层楼梯,有dp[i]种方法。
因此dp[0] = 0 - 因为本身就在第0级
dp[1] = 1 - 只有一种方法到第一层
dp[2] = 2 - 只有两种方法到第二层
(因为这里第三层才开始递归,所以第二层不要想着dp[0] + dp[1])

  1. 确定遍历顺序

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

  1. 举例推导dp数组

举例当n为5的时候,dp table(dp数组)应该是这样的 (这里省略了下标0了)
image.png
如果代码出问题了,就把dp table 打印出来,看看究竟是不是和自己推导的一样。
此时应该发现了,这不就是斐波那契数列么!
唯一的区别是,没有讨论dp[0]应该是什么,因为dp[0]在本题没有意义!
以上五部分析完之后,整体代码如下:

public int climbStairs(int n) {
    if (n <= 2) return n;
	int [] dp = new int[n + 1];
	dp[0] = 0;
	dp[1] = 1;
	dp[2] = 2;
	
	for (int i = 3;i <= n;i++){
	    dp[i] = dp[i - 1] + dp[i - 2];
	}
}
  • 时间复杂度:O(n)
  • 空间复杂度:O(n)

当然依然也可以,优化一下空间复杂度,只用三个变量维护,代码如下:

public int climbStairs(int n) {
    // 优化效率 - 三个变量维护
    if (n <= 2) return n;
	int sum = 0,a = 1,b = 2;
	for (int i = 3;i <= n;i++){
	    sum = a + b;
	    a = b;
	    b = sum;
	}
	return sum;


}
  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

后面将刷到很多动规的题目其实都是当前状态依赖前两个,或者前三个状态,都可以做空间上的优化,但面试中能写出版本一就够了,清晰明了,如果面试官要求进一步优化空间的话,我们再去优化
因为版本一才能体现出动规的思想精髓,递推的状态变化。

拓展

面试通常是从一道简单题出发,然后逐步添加难度,但本质不变。
基于斐波拉契数列就很合适。比如本题,如果一步一个台阶,两个台阶,三个台阶,直到 m个台阶,有多少种方法爬到n阶楼顶。
这又有难度了,这其实是一个完全背包问题,但力扣上没有这种题目,所以后续在刷背包问题的时候,今天这道题还会从背包问题的角度上来再讲一遍。
先来尝尝鲜,代码如下:

class Solution {
    public int climbStairs(int n) {
        int[] dp = new int[n + 1];
        dp[0] = 1;
        for (int i = 1; i <= n; i++) {
            for (int j = 1; j <= m; j++) { // 把m换成2,就可以AC爬楼梯这道题
                if (i - j >= 0) dp[i] += dp[i - j];
            }
        }
        return dp[n];
    }
};

代码中m表示最多可以爬m个台阶。
以上代码不能运行哈,我主要是为了体现只要把m换成2,粘过去,就可以AC爬楼梯这道题,不信你就粘一下试试
此时我就发现一个绝佳的大厂面试题,第一道题就是单纯的爬楼梯,然后看候选人的代码实现,如果把dp[0]的定义成1了,就可以发难了,为什么dp[0]一定要初始化为1,此时可能候选人就要强行给dp[0]应该是1找各种理由。那这就是一个考察点了,对dp[i]的定义理解的不深入。
然后可以继续发难,如果一步一个台阶,两个台阶,三个台阶,直到 m个台阶,有多少种方法爬到n阶楼顶。这道题目leetcode上并没有原题,绝对是考察候选人算法能力的绝佳好题。
这一连套问下来,候选人算法能力如何,面试官心里就有数了。
其实大厂面试最喜欢的问题就是这种简单题,然后慢慢变化,在小细节上考察候选人

总结

这道题目和斐波拉契数列基本是一样的,但难度上升了一点,
原因是斐波拉契数列题目描述就已经把动规五部曲里的递归公式和如何初始化都给出来了,剩下几部曲也自然而然的推出来了。
而本题,就需要逐个分析了,现在应该初步感受出动规五部曲了。
简单题是用来掌握方法论的,例如斐波那契的题目够简单了吧,但两道题都可以用五部曲想出来,这就是方法论!
所以不要轻视简单题,那种凭感觉就刷过去了,其实和没掌握区别不大,只有掌握方法论并说清一二三,才能触类旁通,举一反三!

746 使用最小花费爬楼梯

体现了动规五部曲的重要性

思路

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

使用动态规划,就要有一个数组来记录状态,本题只需要一个一维数组dp[i]就可以了。
dp[i]的定义:到达第i台阶所花费的最少体力为dp[i]

  1. 确定递推公式

可以有两个途径得到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] = Math.min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);

  1. dp数组如何初始化

看一下递归公式,dp[i]由dp[i - 1],dp[i - 2]推出,既然初始化所有的dp[i]是不可能的,那么只初始化dp[0]和dp[1]就够了,其他的最终都是dp[0]dp[1]推出。
那么 dp[0] 应该是多少呢?
新题目描述中明确说了 “你可以选择从下标为 0 或下标为 1 的台阶开始爬楼梯。” 也就是说 你本身就站在0 或 1下标上去上楼梯。站在 第 0 个台阶是不花费的,只有从 第0 个台阶 往上跳,才需要花费 cost[0]。
因此根据定义初始化 dp[0] = 0,dp[1] = 0;

  1. 确定遍历顺序

因为是模拟台阶,而且dp[i]由dp[i-1]dp[i-2]推出,所以是从前到后遍历cost数组

  1. 举例推导dp数组

拿示例2:cost = [1, 100, 1, 1, 1, 100, 1, 1, 100, 1] ,来模拟一下dp数组的状态变化,如下:
image.png
如果大家代码写出来有问题,就把dp数组打印出来,看看和如上推导的是不是一样的。
以上分析完毕,整体代码如下:

public int minCostClimbingStairs(int[] cost) {
    // 数组法
    int[] dp = new int[cost.length + 1];
    for (int i = 2; i<=cost.length;i++){
        dp[i] = Math.min(dp[i - 1] + cost[i - 1],dp[i - 2] + cost[i - 2]);
    }
    return dp[cost.length];
}
  • 时间复杂度:O(n)
  • 空间复杂度:O(n)

同样可以优化内存,三个变量维护,代码如下

public int minCostClimbingStairs(int[] cost) {
    // 优化内存 - 三变量
    int a = 0, b = 0, sum = 0;
    for (int i = 2; i <= cost.length; i++) {
        sum = Math.min(b + cost[i - 1], a + cost[i - 2]);
        System.out.println(sum);
        // 腾位
        a = b;
        b = sum;
    }

    return b;
}
  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

当然如果在面试中,能写出版本一就行,除非面试官额外要求 空间复杂度,那么再去思考版本二,因为版本二还是有点绕。版本一才是正常思路。

总结

对比 70 爬楼梯 又难了一点,今天题目呈现循序渐进。但整体思路还是动规五部曲是一样的。
因此,简单题就是练方法论,困难题则是用方法论,锻炼思维。

学习资料:

理论基础

509. 斐波那契数

70. 爬楼梯

746. 使用最小花费爬楼梯