爬楼梯问题详解

时间:2022-07-23
本文章向大家介绍爬楼梯问题详解,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

说到动态规划啊,很多人都觉得是一生之敌。繁琐,耗脑子,变化多,有许多细节,不想看,不想懂。对我们这些不是专门研究算法的人来说,确实不容易把握住动态规划的所有细节。那,常见的题型我们还是可以摊开来分析分析的嘛。正好今天做题做到了爬楼梯的题目,那我们就借此来说道说道。

我们先来看一下题目: 假设你正在爬楼梯。需要 n 阶你才能到达楼顶。

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

示例 1:

输入:3 输出:4 解释:

  1. 1+1+1
  2. 1+2
  3. 2+1
  4. 3

示例 2:

输入:4 输出:7 解释:

  1. 1+1+1+1
  2. 1+1+2
  3. 1+2+1
  4. 2+1+1
  5. 2+2
  6. 1+3
  7. 3+1

老惯例,我们先尝试用暴力破解的方式去解题,无脑递归。

暴力解法

首先思路还是很明确的,我们每一步的时候,都有三个选择,跳一步,还是跳两步,还是跳三步。那我们可以把递归写成这样:

public int countWays(int n) {
    if (n == 0)
        return 1; // 到顶    if (n == 1)
        return 1; // 唯一选择,走一步
  if (n == 2)
        return 2; // 可以走一步,也可以走两步    // 如果走了一步,那就剩下n-1步
  int take1Step = countWays(n - 1);
  // 类似地,走了两步就剩下n-2步
  int take2Step = countWays(n - 2);
  // 走3步就剩下n-3步
  int take3Step = countWays(n - 3);
  //汇总齐活儿
  return take1Step + take2Step + take3Step; }

这时间复杂度到达了O(3^n),因为我们在函数里做了三次递归调用。空间复杂度就是很普通的O(n)。我们用countWay(4)来分析一下:

画个图我们就可以很轻易地发现,有重叠子问题,CountWays(2)CountWays(1)被调用了两次。说到这个那我可就会了啊,缓存结果啊!?

自上而下

之前在讲斐波那契数列得时候,我们就说过缓存结果。我们可以用一个数组之类的东西把已经计算过的结果缓存起来:

 public int countWays(int n) {
        int dp[] = new int[n + 1];
        return countWaysRecursive(dp, n);
    }

    public int countWaysRecursive(int[] dp, int n) {
        if (n == 0)
            return 1;

        if (n == 1)
            return 1;

        if (n == 2)
            return 2;

        if (dp[n] == 0) {
            int take1Step = countWaysRecursive(dp, n - 1);
            int take2Step = countWaysRecursive(dp, n - 2);
            int take3Step = countWaysRecursive(dp, n - 3);
            dp[n] = take1Step + take2Step + take3Step;
        }

        return dp[n];
    }

现在的一个复杂度是怎么样的?我们的缓存数组缓存所有子问题的计算结果,我们可以肯定的是不会有超过n+1个子问题, 所以时间复杂度就在O(n),空间复杂度还是没变,还是O(n)

自下而上

我们还可以用自下而上的方式来尝试优化。所谓的自下而上很直观,还是看上面那个树形图,可以这么理解,先计算上面的再计算下面的小子问题称之为自上而下。而直接计算下面的子问题从而引导到计算大问题称之为自下而上。我们来尝试填充这个dp数组,以自下而上的方式。通过上面的代码我们可以看出,每个countWaysRecursive(n)都是前面三个之和。我们可以利用这一点来填充数组。

 public int countWays(int n) {
        int dp[] = new int[n+1];
        dp[0] = 1;
        dp[1] = 1;
        dp[2] = 2;

        for(int i=3; i<=n; i++)
            dp[i] = dp[i-1] + dp[i-2] + dp[i-3];

        return dp[n];
    }

这边我们的代码精简了不少,时间复杂度跟空间复杂度都维持在了O(n)

还可以继续优化吗?

上面的这个代码跟斐波那契数列还是很像的,我们可以发现我们其实并不需要用一个数组来保存之前所有的值,我们只是需要前面三个结果来计算当前结果。那基于这个情况,我们可以进一步优化我们的代码:

public int countWays(int n) {
        if (n < 2) return 1;
        if (n == 2) return 2;
        int n1=1, n2=1, n3=2, temp;
        for(int i=3; i<=n; i++) {
            temp = n1 + n2 + n3;
            n1 = n2;
            n2 = n3;
            n3 = temp;
        }
        return n3;
    }

经过这一步,我们的时间复杂度没变,但是空间复杂度已经降到了O(1)。nice!

写在最后

哇,这一通分析下来真的很令人脱发。但看到我们就这么把一道动态规划题做出来了还是小有成就感的。动态规划的题目太多了,不过常见的题型也可以分为几类,太极端的题目我们可以不看了。它们也像之前讨论的二分啊,滑动窗口啊一样有对应的套路可循。最近我会继续分享一些动态规划相关的问题出来,感谢大家关注。Happy coding~