代码随想录第三十八天 | 动态规划:理论基础;斐波那契数(509),递归的时间复杂度;爬楼梯(70);使用最小花费爬楼梯(746)

1、动态规划理论基础

动态规划刷题大纲

1.1 什么是动态规划

如果某一问题有很多重叠子问题,使用动态规划是最有效的,动态规划中每一个状态一定是由上一个状态推导出来的,这一点就区分于贪心贪心没有状态推导,而是从局部直接选最优

例如:有N件物品和一个最多能背重量为W 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大
动态规划中dp[j]是由dp[j-weight[i]]推导出来的,然后取max(dp[j], dp[j - weight[i]] + value[i])

但如果是贪心呢,每次拿物品选一个最大的或者最小的就完事了,和上一个状态没有关系,所以贪心解决不了动态规划的问题,动规是由前一个状态推导出来的,而贪心是局部直接选最优的,对于刷题来说就够用了

1.2 动态规划的解题步骤

状态转移公式(递推公式)是很重要,但动规不仅仅只有递推公式

对于动态规划问题,Carl将拆解为如下五步曲,这五步都搞清楚了,才能说把动态规划真的掌握了
1、确定dp数组(dp table)以及下标的含义
2、确定递推公式
3、dp数组如何初始化
4、确定遍历顺序
5、举例推导dp数组

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

1.3 动态规划应该如何debug

找问题的最好方式就是把dp数组打印出来,看看究竟是不是按照自己思路推导
做动规的题目,写代码之前一定要把状态转移在dp数组的上具体情况模拟一遍,心中有数,确定最后推出的是想要的结果

然后再写代码,如果代码没通过就打印dp数组,看看是不是和自己预先推导的哪里不一样,如果打印出来和自己预先模拟推导是一样的,那么就是自己的递归公式、初始化或者遍历顺序有问题了,如果和自己预先模拟推导的不一样,那么就是代码实现细节有问题

1.4 总结

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

2、leetcode 509:斐波那契数

第一遍代码,因为给出递推式了,只要用一个数组记录每个位置上的数值即可

class Solution {
public:
    int fib(int n) {
        int a[n+1];
        a[0] = 0;
        if(n >= 1) {
            a[1] = 1;//如果输入为0就不能整这句了
        }
        int i = 2;
        while(i <= n) {
            a[i] = a[i - 1] + a[i - 2];
            i++;
        }
        return a[n];
    }
};

2.1 动态规划

动规五部曲:
这里我们要用一个一维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

代码随想录C++代码如下:

class Solution {
public:
    int fib(int N) {
        if (N <= 1) return N;//N==1就不用往下整,没有dp[1]初值
        vector<int> dp(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];
    }
};

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

class Solution {
public:
    int fib(int N) {
        if (N <= 1) return N;
        int dp[2];
        dp[0] = 0;
        dp[1] = 1;
        for (int i = 2; i <= N; i++) {
            int sum = dp[0] + dp[1];
            dp[0] = dp[1];
            dp[1] = sum;//维护两个数值
        }
        return dp[1];
    }
};

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

2.2 递归解法

本题还可以使用递归解法来做
代码如下:

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

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

3、递归的时间复杂度

Carl通过一道简单的面试题,模拟面试的场景,来带大家逐步分析递归算法的时间复杂度,最后找出最优解,来看看同样是递归,怎么就写成了 O(n) 的代码

面试题:求x的n次方
想一下这么简单的一道题目,代码应该如何写呢。最直观的方式应该就是,一个for循环求出结果,代码如下:

int function1(int x, int n) {
    int result = 1;  // 注意 任何数的0次方等于1
    for (int i = 0; i < n; i++) {
        result = result * x;
    }
    return result;
}

为了降低时间复杂度考虑一下递归算法,可以写出了如下这样的一个递归的算法,使用递归解决了这个问题

int function2(int x, int n) {
    if (n == 0) {
        return 1; // return 1 同样是因为0次方是等于1的
    }
    return function2(x, n - 1) * x;
}

递归算法的时间复杂度本质上是要看: 递归的次数 * 每次递归中的操作次数

递归了n次时间复杂度是O(n)每次进行了一个乘法操作,乘法操作的时间复杂度一个常数项O(1),所以这份代码的时间复杂度n × 1 = O(n)
这个时间复杂度就没有达到面试官的预期。于是又写出了如下的递归算法的代码:

int function3(int x, int n) {
    if (n == 0) return 1;
    if (n == 1) return x;

    if (n % 2 == 1) {
        return function3(x, n / 2) * function3(x, n / 2)*x;
    }
    return function3(x, n / 2) * function3(x, n / 2);
}

来分析一下,首先看递归了多少次呢,可以把递归抽象出一棵满二叉树。刚刚同学写的这个算法,可以用一棵满二叉树来表示(为了方便表示,选择n为偶数16),如图:
代码随想录第三十八天 | 动态规划:理论基础;斐波那契数(509),递归的时间复杂度;爬楼梯(70);使用最小花费爬楼梯(746)_第1张图片
当前这棵二叉树就是求x的n次方,考虑n为16的情况,n为16的时候,进行了多少次乘法运算呢?
这棵树上每一个节点就代表着一次递归进行了一次相乘操作,所以进行了多少次递归的话,就是看这棵树上有多少个节点

熟悉二叉树话应该知道如何求满二叉树节点数量,这棵满二叉树的节点数量就是23 + 22 + 21 + 20 = 15,可以发现:这其实是等比数列的求和公式,这个结论在二叉树相关的面试题里也经常出现

这么如果是求x的n次方,这个递归树有多少个节点呢,如下图所示:(m为深度,从0开始)
代码随想录第三十八天 | 动态规划:理论基础;斐波那契数(509),递归的时间复杂度;爬楼梯(70);使用最小花费爬楼梯(746)_第2张图片
时间复杂度忽略掉常数项-1之后,这个递归算法的时间复杂度依然是O(n)。对,你没看错,依然是O(n)的时间复杂度

那么O(logn)的递归算法应该怎么写呢?
想一想刚刚给出的那份递归算法的代码,是不是有哪里比较冗余呢,其实有重复计算的部分
于是又写出如下递归算法的代码

int function4(int x, int n) {
    if (n == 0) return 1;
    if (n == 1) return x;
    int t = function4(x, n / 2);// 这里相对于function3,是把这个递归操作抽取出来
    if (n % 2 == 1) {
        return t * t * x;
    }
    return t * t;
}

再来看一下现在这份代码时间复杂度是多少呢?
依然还是看他递归了多少次,可以看到这里仅仅有一个递归调用,且每次都是n/2 ,所以这里我们一共调用了log以2为底n的对数次
每次递归了做都是一次乘法操作,这也是一个常数项的操作,那么这个递归算法的时间复杂度才是真正的O(logn)

4、leetcode 70:爬楼梯

第一遍代码
int climb[n+1]表示有多少种不同的方法可以爬到0~n阶
走一步的方法数加走两步的方法数递推式正好是斐波那契数列

class Solution {
public:
    int climbStairs(int n) {
        int climb[n+1];//有多少种不同的方法可以爬到0~n阶
        if(n == 1) {
            return 1;
        }
        climb[0] = 1;
        climb[1] = 1;
        for(int i = 2; i <= n; i++) {
            climb[i] = climb[i-1] + climb[i-2];
            //走一步的方法数加走两步的方法数,递推式正好是斐波那契数列
        }
        return climb[n];
    }
};

4.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[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,这个初始化大家应该都没有争议的,所以Carl的原则是:不考虑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数组)应该是这样的
代码随想录第三十八天 | 动态规划:理论基础;斐波那契数(509),递归的时间复杂度;爬楼梯(70);使用最小花费爬楼梯(746)_第3张图片
代码随想录 C++代码如下:

// 版本一
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];
    }
};

时间复杂度: O ( n ) O(n) O(n)
空间复杂度: O ( n ) O(n) O(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];
    }
};

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

4.2 拓展

这道题目还可以继续深化,就是一步一个台阶,两个台阶,三个台阶,直到 m个台阶,有多少种方法爬到n阶楼顶
这又有难度了,这其实是一个完全背包问题,但力扣上没有这种题目,所以后续Carl在讲解背包问题的时候,今天这道题还会从背包问题的角度上来再讲一遍,实现代码:

class Solution {
public:
    int climbStairs(int n) {
        vector<int> dp(n + 1, 0);
        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];
    }
};

5、leetcode 746:使用最小花费爬楼梯

第一遍代码,costN[n] 表示达到 0~n 阶楼梯的最低花费
对于递推式每到新的一级台阶,就两种方案,每种方案都依托之前的最优方案

class Solution {
public:
    int minCostClimbingStairs(vector<int>& cost) {
        int n = cost.size() + 1;
        int costN[n];//达到0~n阶楼梯的最低花费
        costN[0] = 0;
        costN[1] = 0;//初始值
        for(int i = 2; i < n; i++) {
            costN[i] = min(costN[i - 2] + cost[i - 2], costN[i - 1] + cost[i - 1]);
            //每到新的一级台阶,就两种方案,每种方案都依托之前的最优方案
        }
        return costN[cost.size()];
    }
};

5.1 代码随想录思路

题目中说 “你可以选择从下标为 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数组
拿示例2:cost = [1, 100, 1, 1, 1, 100, 1, 1, 100, 1] ,来模拟一下dp数组的状态变化,如下:
代码随想录第三十八天 | 动态规划:理论基础;斐波那契数(509),递归的时间复杂度;爬楼梯(70);使用最小花费爬楼梯(746)_第4张图片
如果代码写出来有问题,就把dp数组打印出来,看看和如上推导的是不是一样
可以优化空间复杂度,因为dp[i]就是由前两位推出来的,那么也不用dp数组了,C++代码如下:

class Solution {
public:
    int minCostClimbingStairs(vector<int>& cost) {
        int dp0 = 0;
        int dp1 = 0;
        for (int i = 2; i <= cost.size(); i++) {
            int dpi = min(dp1 + cost[i - 1], dp0 + cost[i - 2]);
            dp0 = dp1; // 记录一下前两位
            dp1 = dpi;
        }
        return dp1;
    }
};

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

5.2 拓展

旧力扣描述,如果按照 第一步是花费的,最后一步不花费,那么代码是这么写的,提交也可以通过

class Solution {
public:
    int minCostClimbingStairs(vector<int>& cost) {
        vector<int> dp(cost.size());
        dp[0] = cost[0]; // 第一步有花费
        dp[1] = cost[1];
        for (int i = 2; i < cost.size(); i++) {
            dp[i] = min(dp[i - 1], dp[i - 2]) + cost[i];
        }
        // 注意最后一步可以理解为不用花费,所以取倒数第一步,第二步的最少值
        return min(dp[cost.size() - 1], dp[cost.size() - 2]);
    }
};

你可能感兴趣的:(leetcode,c++,动态规划,算法,c++,leetcode)