动态规划Ⅰ(动态规划基础理论,什么是动态规划,动态规划的解题步骤,动态规划如何debug,509斐波那契数,70爬楼梯)

200 阅读4分钟

1 动态规划基础理论

1.1 什么是动态规划

动态规划:Dynamic Programming(简称DP)

某一问题有很多重叠子问题,使用动态规划是最有效的。

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

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

但如果是贪心呢,每次拿物品选一个最大的或者最小的就完事了,和上一个状态没有关系。

所以贪心解决不了动态规划的问题。

1.2 动态规划的解题步骤

动规五部曲

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

先确定递推公式,然后再考虑初始化,为什么? 递推公式决定了dp数组要如何初始化

1.3 动态规划如何debug

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

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

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

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

总结: 出现问题思考

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

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) 。

2.1 思路

用一个一维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数组打印出来看看和我们推导的数列是不是一致的。

2.2 动态规划

cpp代码

class Solution {
	public:
		int fib(int N) {
			if(N <= 1) return N;
			// 1 确定dp数组 
			vector<int> dp(N + 1);
			//  3 dp数组初始化 
			dp[0] = 0;
			dp[1] = 1;
		    // 4 确定遍历顺序 
			for(int i = 2; i <= N; i++){
				// 2 确定递推公式 
				dp[i] = dp[i-1] + dp[i-2];
			}
			return dp[N];
		}
		// 举例推导dp数组(纸上看) 
};
  • 时间复杂度:O(n)
  • 空间复杂度:O(n)

接着分析可以发现,只需要维护两个数值就可以了,不需要记录整个序列 代码如下:

class Solution {
	public:
		int fib(int N) {
			if(N <= 1) return N;
			// 1 确定dp数组 
			vector<int> dp(N+1);
			// 3 dp数组初始化 
			dp[0] = 0;
			dp[1] = 1;
			// 4 确定遍历顺序 
			for(int i = 2;i <= N;i++) {
				// 2 确定递推公式 
				int sum = dp[0] + dp[1];
				dp[0] = dp[1];
				dp[1] = sum;
			}
			// 5 举例推导dp数组 
			// dp[0] 0 1 1 2 4
			// dp[1] 1 1 2 4 8
			return dp[1];
		}
}; 
  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

2.3 递归解法

class Solution {
	public:
		int fib(int N) {
			if (N < 2) return N;
			return fib(N - 1) + fib(N - 2);
		}
}; 

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

2.4 JS 版本

解法一:

var fib = function(n) {
    let dp = [0, 1]
    for(let i = 2; i <= n; i++) {
        dp[i] = dp[i - 1] + dp[i - 2]
    }
    console.log(dp)
    return dp[n]
};

解法二:时间复杂度O(N),空间复杂度O(1)

/**
 * @param {number} n
 * @return {number}
 */
var fib = function(n) {
   let pre1 = 1;
   let pre2 = 0;
   let temp;
   if(n == 0) return 0;
   if(n == 1) return 1;
   for(let i = 2; i <= n; i++){
       temp = pre1;
       pre1 = pre1 + pre2;
       pre2 = temp;
   }
   return pre1;
};

3 70爬楼梯

力扣题目链接

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。

每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?

注意:给定n是个正整数

3.1 思路

就举几个例子,找规律: 爬楼梯(层) 方法 1 1 2 2 3 3 4 5 ...

可以分析出:第三层由第二层楼梯和到第一层楼梯推导出来,就可以联想到动态规划

动规五部曲:

  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] + dp[i - 2] 。

  1. dp数组如何初始化

dp[0] = ? 爬到第0层 题目说 n 正整数,所以没有 0 要是有呢?

例如强行安慰自己爬到第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 也能说得通。

初始化 dp[1] = 1, dp[2] = 2

不考虑dp[0],只初始化dp[1] = 1,dp[2] = 2,然后从i = 3开始递推,这样才符合dp[i]的定义。

  1. 确定遍历顺序

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

  1. 举例推导dp数组

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

20210105202546299.png

此时大家应该发现了,这不就是斐波那契数列么!

cpp代码

// 版本一
class Solution {
public:
    int climbStairs(int n) {
        if (n <= 1) return n; // 因为下面直接对dp[2]操作了,防止空指针
        vector<int> dp(n + 1);
        dp[1] = 1;
        dp[2] = 2;
        for (int i = 3; i <= n; i++) { // 注意i是从3开始的
            dp[i] = dp[i - 1] + dp[i - 2];
        }
        return dp[n];
    }
};
// 版本二
class Solution {
public:
    int climbStairs(int n) {
        if (n <= 1) return n;
        int dp[3];
        dp[1] = 1;
        dp[2] = 2;
        for (int i = 3; i <= n; i++) {
            int sum = dp[1] + dp[2];
            dp[1] = dp[2];
            dp[2] = sum;
        }
        return dp[2];
    }
};

3.2 拓展

这道题目还可以继续深化,就是一步一个台阶,两个台阶,三个台阶,直到 m个台阶,有多少种方法爬到n阶楼顶。

这其实是一个完全背包问题,后面会专门学习。

3.3 总结

这道题目和动态规划:斐波那契数 题目基本是一样的,但是会发现本题相比动态规划:斐波那契数难多了,为什么呢?

关键是 动态规划:斐波那契数 题目描述就已经把动规五部曲里的递归公式和如何初始化都给出来了,剩下几部曲也自然而然的推出来了。而本题,就需要逐个分析了。