《程序员面试金典(第6版)》面试题 08.01. 三步问题(动态规划,c++)

Posted 阿宋同学

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了《程序员面试金典(第6版)》面试题 08.01. 三步问题(动态规划,c++)相关的知识,希望对你有一定的参考价值。

题目描述

三步问题。有个小孩正在上楼梯,楼梯有n阶台阶,小孩一次可以上1阶、2阶或3阶。实现一种方法,计算小孩有多少种上楼梯的方式。结果可能很大,你需要对结果模1000000007。

示例1:

  • 输入:n = 3
    输出:4
    说明: 有四种走法

示例2:

  • 输入:n = 5
    输出:13

提示:

  • n范围在[1, 1000000]之间

解题思路与代码

这道题,我说实话对我而言没有什么太大的思路。然后我去网上看了看题解,主流的解法就是使用动态规划去解题,还有用矩阵快速幂去解题的,博主我大学数学学的不好,后悔大学没有好好学习高数,线代了,所以我这里先介绍动态规划的解法,到未来,如果我有一天数学基础又好了,我会回来将矩阵快速幂这个解法来补上的。

方法一,动态规划

这道题我们直接去套用Carl哥的动态规划五部曲,去解题分析。

第一步,确定dp数组以及下标的含义:

  • dp[i] : 爬到第i层楼,有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-3]代表的是,到达i-3层楼,一共有dp[i-3]种方法,那我再上三层楼,是不是就得到dp[i]了呢?
  • 所以dp[i]就是dp[i-1],dp[i-2],dp[i-3]它们的和,dp[i] = dp[i-1] + dp[i-2] + dp[i-3];

第三步,dp数组如何初始化?

  • 首先,我认为所谓初始化,就是如何从最底层向结果去推演的一个过程,因为我们之前知道,一共有三种方式可以得到dp[i],所以我们等下就要初始化3个值。
  • 那么由题意可值,n是一个正整数,最小值为1,这里讨论dp[0],就没有意义。
  • 所以我们要从dp[1]开始初始化,所以dp[1] = 1,dp[2] = 1,dp[2] = 2,dp[3] = 4

第四步,确定遍历顺序

  • 由递推公式:dp[i] = dp[i-1] + dp[i-2] + dp[i-3]; 我们可以看出,遍历顺序,一定是从前向后去遍历的。

第五步,举例推导dp数组

  • 当 n 为 6 时,
    • dp[1] = 1, dp[2] = 2, dp[3] = 4,
    • dp[4] = dp[1] + dp [2] + dp[3] = 7,
    • dp[5] = dp[2] + dp [3] + dp[4] = 13,
    • dp[6] = dp[3] + dp [4] + dp[5] = 24.

当这五部曲分析完成后,我们就可以去写代码啦~

代码如下:

class Solution 
public:
    int waysToStep(int n) 
        if(n <= 2) return n;
        if(n == 3) return 4;
        vector<int> dp (n+1,0);
        dp[1] = 1; dp[2] = 2; dp[3] = 4;
        for(int i = 4; i < n+1; ++i)
            dp[i] = ((dp[i-1] + dp[i-2])%1000000007 + dp[i-3])%1000000007; //这个公式是由下面公式合并同类项而来的
        	//dp[i] = ((dp[i-3]%1000000007 + dp[i-2])%1000000007 + dp[i-1]%1000000007)%1000000007;
        return dp[n];
    
;

复杂度分析

时间复杂度分析:

  • 初始化一个长度为 n+1 的 dp 数组,时间复杂度为 O(n)。
    遍历 dp 数组,计算 dp[i] 的值,时间复杂度为 O(n)。
    整个算法的时间复杂度为 O(n)。

空间复杂度分析:

  • 开辟一个长度为 n+1 的 dp 数组,空间复杂度为 O(n)。
    综上所述,该算法的时间复杂度为 O(n),空间复杂度为 O(n)。需要注意的是,由于取模操作的存在,实际运行时间可能会略微慢一些,但不会改变时间复杂度的量级。

方法二,使用滚动遍历,优化动态规划

和上题的思想一样,只不过用4个int变量,去替代了dp数组。
int one = 1,代替了原来的dp[1], int two = 2,代替了原来的dp[2], int three = 4,代替了原来的dp[3],
int result = ((three + two)%1000000007 + one)%1000000007,代替了原来的dp[n]

然后用one = two ,two = three,three = result,去不断更新dp[i-1],dp[i-2],dp[i-3]的值。

具体的代码实现如下:

class Solution 
public:
    int waysToStep(int n) 
        if(n <= 2) return n;
        if(n == 3) return 4;
        int one = 1; int two = 2; int three = 4; int result = 0;
        for(int i = 4; i < n+1; ++i)
            result = ((three + two)%1000000007 + one)%1000000007;
            one = two;
            two = three;
            three = result;
        
        return result;
    
;

复杂度分析

时间复杂度分析:

初始化三个变量 one, two, three,时间复杂度为 O(1)。
遍历 n 次,计算 result 的值,时间复杂度为 O(n)。
整个算法的时间复杂度为 O(n)。
空间复杂度分析:

只开辟了三个变量 one, two, three,空间复杂度为 O(1)。
综上所述,该算法的时间复杂度为 O(n),空间复杂度为 O(1)。

总结

这道题,是一道动态规划非常好的练手题。我们可以拿它来做动态规划的入门。很好很不错!

《程序员面试金典(第6版)》面试题 08.04. 幂集(回溯算法,位运算,C++)不断更新

题目描述

  • 幂集。编写一种方法,返回某集合的所有子集。集合中不包含重复的元素。

  • 说明:解集不能包含重复的子集。

  • 示例:
    输入: nums = [1,2,3]
    输出:
    [
    [3],
    [1],
    [2],
    [1,2,3],
    [1,3],
    [2,3],
    [1,2],
    []
    ]

解题思路与代码

  • 其实这道题,一看就是属于子集问题,让你在一个N个数的集合里有多少符合条件的子集。
  • 回溯算法是一种试探性的搜索算法,它在解决某些组合问题,字节问题,排列问题等时非常有效,所以呢,这道题,我们就可以去用回溯法去解决。

方法一 : 回溯法

这里就用我最崇拜的carl哥的回溯三部曲模版,来带大家解这道题。

第一步,找出回溯函数模板返回值
第二步,确定回溯函数终止条件
第三步,回溯搜索的遍历过程

关于回溯算法的参数,一般是在写回溯逻辑的时候,发现缺哪个参数就加哪个参数的。不存在与在哪一个步骤就一定要确定好参数是啥。

这个是回溯法的大致模版

void backtracking(参数) 
    if (终止条件) 
        存放结果;
        return;
    

    for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) 
        处理节点;
        backtracking(路径,选择列表); // 递归
        回溯,撤销处理结果
    

这道题具体的代码如下:

class Solution 
public:
    vector<vector<int>> result;
    vector<vector<int>> subsets(vector<int>& nums) 
        if(nums.empty()) return ;
        int begin = 0;
        int end = nums.size();
        vector<int> vec;
        result.push_back();
        backtracking(nums,vec,begin,end);
        return result;
    
    void backtracking(vector<int>& nums,vector<int>& vec,int begin,int end)
        if(begin >= end) 
            return;
        for(int i = begin; i < end; ++i)
            vec.push_back(nums[i]);
            result.push_back(vec);
            backtracking(nums,vec,++begin,end);
            vec.pop_back();
        
    
;
  • 需要特别注意的是,在backtracking(nums,vec,++begin,end);这一行代码中,需要特别注意第二个传入参数

  • 这里可以写 ++ begini + 1 但是不能去写 begin + 1,这是因为,当我们使用++begin作为递归调用的参数时,begin的值在循环迭代中会被改变,而使用begin + 1作为参数时,begin的值在循环迭代中保持不变。这是它们之间的关键区别。

  • 使用++begin能够得到正确的结果,因为它确保了begin在每次递归调用之后递增。这样,当递归返回到当前层次时,begin的值已经递增了,从而避免了重复子集的产生。

  • 而使用begin + 1作为参数,在递归调用时虽然传递了正确的下一个值,但在循环迭代中,begin的值保持不变。这导致递归返回到当前层次时,重复使用了相同的begin值,从而产生了重复的子集。

  • i + 1 也能达到与++begin同样的效果,这是因为,它们之间都可以保证每次递归调用都是从当前元素的下一个元素开始,所以是对的。而不是从下一个元素开始,这将导致生成重复的子集。

复杂度分析

  • 时间复杂度:
    对于给定长度为n的nums数组,这段代码会生成所有可能的子集。子集的个数是2n,因为每个元素都有两种选择:包含在子集中或不包含。在这个实现中,我们使用回溯法遍历所有可能的子集。在最坏的情况下,我们需要遍历所有子集并将它们添加到结果集合中。因此,时间复杂度为O(2n * n),其中O(2^n)是遍历所有子集的时间,O(n)是在每次递归调用中复制子集到结果集合的时间。

  • 空间复杂度:
    递归栈空间:在最坏的情况下,递归栈的深度等于数组的长度n,因此递归栈空间复杂度为O(n)。
    结果集合空间:有2^n 个子集。由于子集中的元素总数是固定的,即n个元素,所以实际上我们不需要将每个子集的大小计入空间复杂度。因此,结果集合的空间复杂度应为O(2^n)。

综上所述,这段代码的空间复杂度应为O(n + 2^n)。递归栈空间为O(n),结果集合空间为O( 2^ n) 。

总结

这道题是一道很好的拿回溯模版练手的好题。可以更好的去理解回溯算法。

以上是关于《程序员面试金典(第6版)》面试题 08.01. 三步问题(动态规划,c++)的主要内容,如果未能解决你的问题,请参考以下文章

《程序员面试金典(第6版)》面试题 08.08. 有重复字符串的排列组合(回溯算法,全排列问题)C++

《程序员面试金典(第6版)》 面试题 08.13. 堆箱子(动态规划,与最长上升子序列问题相关的组合问题,C++)

程序员面试金典面试题 08.01. 三步问题

程序员面试金典-面试题 08.01. 三步问题

算法面试题 02.02. 返回倒数第 k 个节点

Leetcode程序员面试金典面试题04.06.后继者