1. 动态规划理论基础
动态规划刷题大纲
什么是动态规划
动态规划(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])。
但如果是贪心呢,每次拿物品选一个最大的或者最小的就完事了,和上一个状态没有关系。
所以贪心解决不了动态规划的问题。
动规的解题步骤
1、确定dp数组(dp table)以及下标的含义
2、确定递推公式
3、dp数组如何初始化
4、确定遍历顺序
5、举例推导dp数组
一些同学可能想为什么要先确定递推公式,然后在考虑初始化呢?
因为一些情况是递推公式决定了dp数组要如何初始化!
其实 确定递推公式 仅仅是解题里的一步而已!
只知道递推公式,但搞不清楚dp数组应该如何初始化,或者正确的遍历顺序,以至于记下来公式,但写的程序怎么改都通过不了。
动态规划如何debug
看一下题解,感觉看懂了,然后照葫芦画瓢,如果能正好画对了,万事大吉,一旦要是没通过,就怎么改都通过不了,对 dp数组的初始化,递推公式,遍历顺序,处于一种黑盒的理解状态。
写动规题目,代码出问题很正常!
找问题的最好方式就是把dp数组打印出来,看看究竟是不是按照自己思路推导的!
一些同学对于dp的学习是黑盒的状态,就是不清楚dp数组的含义,不懂为什么这么初始化,递推公式背下来了,遍历顺序靠习惯就是这么写的,然后一鼓作气写出代码,如果代码能通过万事大吉,通过不了的话就凭感觉改一改。
这是一个很不好的习惯!
做动规的题目,写代码之前一定要把状态转移在dp数组的上具体情况模拟一遍,心中有数,确定最后推出的是想要的结果。
然后再写代码,如果代码没通过就打印dp数组,看看是不是和自己预先推导的哪里不一样。
如果打印出来和自己预先模拟推导是一样的,那么就是自己的递归公式、初始化或者遍历顺序有问题了。
如果和自己预先模拟推导的不一样,那么就是代码实现细节有问题。
如果碰到背下代码但AC不过,自己先思考这三个问题:
- 这道题目我举例推导状态转移公式了么?
- 我打印dp数组的日志了么?
- 打印出来了dp数组和我想的一样么?
如果这灵魂三问自己都做到了,基本上这道题目也就解决了,或者更清晰的知道自己究竟是哪一点不明白,是状态转移不明白,还是实现代码不知道该怎么写,还是不理解遍历dp数组的顺序。
总结
动态规划五步骤、如何debug非常重要。
2. 斐波那契数
例题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[i]的定义为:第i个数的斐波那契数值是dp[i] -
确定递推公式
为什么这是一道非常简单的入门题目呢?
因为题目已经把递推公式直接给我们了:状态转移方程 dp[i] = dp[i - 1] + dp[i - 2]; -
dp数组如何初始化
题目中把如何初始化也直接给我们了,如下:
dp[0] = 0;
dp[1] = 1; -
确定遍历顺序
从递归公式dp[i] = dp[i - 1] + dp[i - 2];中可以看出,dp[i]是依赖 dp[i - 1] 和 dp[i - 2],那么遍历的顺序一定是从前到后遍历的。
举例推导dp数组 -
按照这个递推公式dp[i] = dp[i - 1] + dp[i - 2],我们来推导一下,当N为10的时候,dp数组应该是如下的数列:
0 1 1 2 3 5 8 13 21 34 55
//动规解法:时间复杂度O(n),空间复杂度O(n)
class Solution {
public int fib(int n) {
int[] dp=new int[31];
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(2^n),空间复杂度O(n)
class Solution {
public int fib(int n) {
if(n==0) return 0;
else if(n==1) return 1;
else return fib(n-1)+fib(n-2);
}
}
3. 爬楼梯
例题70:
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 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该怎么初始化呢?
再回顾一下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 也能说得通。这时就要从i=3开始递推。
需要注意的是:题目中说了n是一个正整数,题目根本就没说n有为0的情况。
所以本题其实就不应该讨论dp[0]的初始化!
class Solution {
public int climbStairs(int n) {
int[] dp=new int[46];
dp[0]=0;
dp[1]=1;
dp[2]=2;
for(int i=3;i<=n;i++){
dp[i]=dp[i-2]+dp[i-1];
}
return dp[n];
}
}
这道题从递推公式来看发现与斐波那契相同,但是对于这道题如果从2开始递推,dp[0]需要初始化未1,而从3开始递推,dp[0]=1。并且这道题没有给出递推公式,需要分析,难度更大。
拓展
这道题目还可以深化,可以在单纯爬楼梯中考察为什么dp[0]=1。以及可以一步迈1个、2个…m个台阶,那么跑到第n阶需要多少步?这道题在leetcode上没有原题,是完全背包问题,考察算法能力。
在后续背包问题中讲解。
4. 使用最小花费爬楼梯
例题746:
给你一个整数数组 cost ,其中 cost[i] 是从楼梯第 i 个台阶向上爬需要支付的费用。一旦你支付此费用,即可选择向上爬一个或者两个台阶。
你可以选择从下标为 0 或下标为 1 的台阶开始爬楼梯。
请你计算并返回达到楼梯顶部的最低花费。
从后往前不正确,从前往后怎么判断0和1两个位置?
题目中说 “你可以选择从下标为 0 或下标为 1 的台阶开始爬楼梯” 也就是相当于 跳到 下标 0 或者 下标 1 是不花费体力的, 从 下标 0 下标1 开始跳就要花费体力了。
1. 确定dp数组以及下标的含义(确定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]跳呢?
比较两者谁小就选谁!
- 如何初始化?
已经知道到下标0和1的位置花费为0。 - 递归方向?
后一步的最小代价取决于前两步的代价,所以是从前往后遍历。 - 根据递推公式推导
根据例子[10,15,20]可以推导出到达最高阶的最小花费是15,符合递推公式。
时间复杂度为O(n),空间复杂度为O(n)
class Solution {
public int minCostClimbingStairs(int[] cost) {
int[] dp=new int[cost.length+1];
dp[0]=0;
dp[1]=0;
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];
}
}
如果面试需要优化空间复杂度,可以知道第i位是由前两位推出的,所以不用dp数组,直接用两个变量记录。
class Solution {
public int minCostClimbingStairs(int[] cost) {
int a=0;
int b=0;
int t=0;
for(int i=2;i<=cost.length;i++){
t=Math.min((a+cost[i-2]),b+cost[i-1]);
a=b;
b=t;
}
return t;
}
}
5. 动态规划第一节总结
- 首先最重要的是动态规划的五步骤!
在做题的时候,先确定dp数组的含义与下标,然后推导递推公式,再决定初始化条件,再看遍历方向,最后举例验证推导出来的公式。
如果动规报错的话,可以将dp数组打印出来,看是否与推导的结果相同。
如果代码写出来了,一直不能AC的灵魂三问:
①这道题分析推导状态转移公式了吗?
②打印dp数组了吗?
③dp数组结果与推导的一样吗?
- 斐波那契数
这道题直接给出状态转移公式与初始化,是入手的简单题。 - 爬楼梯
这道题虽然递推公式与斐波那契相同,但是对于dp[0]的定义不同。并且需要自己推导递推公式,相较于上一题更难。 - 最小花费爬楼梯
这道题进一步提升难度,需要明白dp数组的含义,到前两步的花费为0。