746.使用最小花费爬楼梯

给你一个整数数组 cost ,其中 cost[i] 是从楼梯第 i 个台阶向上爬需要支付的费用。一旦你支付此费用,即可选择向上爬一个或者两个台阶。

你可以选择从下标为 0 或下标为 1 的台阶开始爬楼梯。

请你计算并返回达到楼梯顶部的最低花费。

示例 1:

输入:cost = [10,15,20]
输出:15
解释:你将从下标为 1 的台阶开始。
- 支付 15 ,向上爬两个台阶,到达楼梯顶部。
总花费为 15 。

示例 2:

输入:cost = [1,100,1,1,1,100,1,1,100,1]
输出:6
解释:你将从下标为 0 的台阶开始。
- 支付 1 ,向上爬两个台阶,到达下标为 2 的台阶。
- 支付 1 ,向上爬两个台阶,到达下标为 4 的台阶。
- 支付 1 ,向上爬两个台阶,到达下标为 6 的台阶。
- 支付 1 ,向上爬一个台阶,到达下标为 7 的台阶。
- 支付 1 ,向上爬两个台阶,到达下标为 9 的台阶。
- 支付 1 ,向上爬一个台阶,到达楼梯顶部。
总花费为 6 。

提示:

  • 2 <= cost.length <= 1000
  • 0 <= cost[i] <= 999

思路:
设数组的长度为 n,一共有 n+1 个台阶,编号从 0 到 n。你需要从编号为 0 或 1 的台阶开始,向上爬到编号为 n 的台阶,并且每次只能爬一个或者两个台阶。从编号为 i 的台阶向上爬,需要支付 cost[i] 的花费。求爬到 n 的花费之和的最小值。

例如 cost=[10,15,20],表示有 0,1,2,3 四个台阶,起点为 0 或 1,终点为 3。从编号为 1 的台阶往上爬两个台阶就可以到达终点 3 了,对应的花费也最小,所以答案是 15。

一、启发思考:寻找子问题
 

假设数组长度 n=9。

我们要解决的问题是从 0 或 1 爬到 9 的最小花费。注意楼梯顶部是 n,不是 n−1。

枚举最后一步爬了几个台阶,分类讨论:

如果最后一步爬了 1 个台阶,那么我们得先爬到 8,要解决的问题缩小成:从 0 或 1 爬到 8 的最小花费。
如果最后一步爬了 2 个台阶,那么我们得先爬到 7,要解决的问题缩小成:从 0 或 1 爬到 7 的最小花费。
由于这两种情况都会把原问题变成一个和原问题相似的、规模更小的子问题,所以可以用递归解决。

注 1:从大往小思考,主要是为了方便把递归翻译成递推。从小往大思考也是可以的。

注 2:动态规划有「选或不选」和「枚举选哪个」两种基本思考方式。在做题时,可根据题目要求,选择适合题目的一种来思考。本题用到的是「枚举选哪个」。

二、递归怎么写:状态定义与状态转移方程

        因为要解决的问题都是「从 0 或 1 爬到 i」,所以定义 dfs(i) 表示从 0 或 1 爬到 i 的最小花费。

枚举最后一步爬了几个台阶,分类讨论:

        如果最后一步爬了 1 个台阶,那么我们得先爬到 i−1,要解决的问题缩小成:从 0 或 1 爬到 i−1 的最小花费。把这个最小花费加上 cost[i−1],就得到了 dfs(i),即 dfs(i)=dfs(i−1)+cost[i−1]。
        如果最后一步爬了 2 个台阶,那么我们得先爬到 i−2,要解决的问题缩小成:从 0 或 1 爬到 i−2 的最小花费。把这个最小花费加上 cost[i−2],就得到了 dfs(i),即 dfs(i)=dfs(i−2)+cost[i−2]。
这两种情况取最小值,就得到了从 0 或 1 爬到 i 的最小花费,即

                                dfs(i)=min(dfs(i−1)+cost[i−1],dfs(i−2)+cost[i−2])
递归边界:dfs(0)=0, dfs(1)=0。爬到 0 或 1 无需花费,因为我们一开始在 0 或 1。

递归入口:dfs(n),也就是答案。

// 会超时的递归代码
class Solution {
public:
    int minCostClimbingStairs(vector<int> &cost) {
        int n = cost.size();
        function<int(int)> dfs = [&](int i) -> int {
            if (i <= 1) { // 递归边界
                return 0;
            }
            return min(dfs(i - 1) + cost[i - 1], dfs(i - 2) + cost[i - 2]);
        };
        return dfs(n);
    }
};


三、递归 + 记录返回值 = 记忆化搜索

上面的做法太慢了,怎么优化呢?

        注意到「先爬 1 个台阶,再爬 2 个台阶」和「先爬 2 个台阶,再爬 1 个台阶」,都相当于爬 3 个台阶,都会从 dfs(i) 递归到 dfs(i−3)。

        一叶知秋,整个递归中有大量重复递归调用(递归入参相同)。由于递归函数没有副作用,同样的入参无论计算多少次,算出来的结果都是一样的,因此可以用记忆化搜索来优化:

        如果一个状态(递归入参)是第一次遇到,那么可以在返回前,把状态及其结果记到一个 memo 数组中。
        如果一个状态不是第一次遇到(memo 中保存的结果不等于 memo 的初始值),那么可以直接返回 memo 中保存的结果。
        注意:memo 数组的初始值一定不能等于要记忆化的值!例如初始值设置为 0,并且要记忆化的 dfs(i) 也等于 0,那就没法判断 0 到底表示第一次遇到这个状态,还是表示之前遇到过了,从而导致记忆化失效。一般把初始值设置为 −1。

四、1:1 翻译成递推

        我们可以去掉递归中的「递」,只保留「归」的部分,即自底向上计算。

具体来说,f[i] 的定义和 dfs(i) 的定义是一样的,都表示从 0 或 1 爬到 i 的最小花费。

相应的递推式(状态转移方程)也和 dfs 一样:

                                        f[i]=min(f[i−1]+cost[i−1],f[i−2]+cost[i−2])
相当于之前是用递归去计算每个状态,现在是枚举并计算每个状态。

初始值 f[0]=0, f[1]=0,翻译自递归边界 dfs(0)=0, dfs(1)=0。

答案为 f[n],翻译自递归入口 dfs(n)。

class Solution {
public:
    int minCostClimbingStairs(vector<int> &cost) {
        int n = cost.size();
        vector<int> memo(n + 1, -1); // -1 表示没有计算过
        function<int(int)> dfs = [&](int i) -> int {
            if (i <= 1) { // 递归边界
                return 0;
            }
            int &res = memo[i]; // 注意这里是引用
            if (res != -1) { // 之前计算过
                return res;
            }
            return res = min(dfs(i - 1) + cost[i - 1], dfs(i - 2) + cost[i - 2]); // 记忆化
        };
        return dfs(n);
    }
};

class Solution {
public:
    int minCostClimbingStairs(vector<int> &cost) {
        int f0 = 0, f1 = 0;
        for (int i = 1; i < cost.size(); i++) {
            int new_f = min(f1 + cost[i], f0 + cost[i - 1]);
            f0 = f1;
            f1 = new_f;
        }
        return f1;
    }
};

参考链接:https://leetcode.cn/problems/min-cost-climbing-stairs/solutions/2569116/jiao-ni-yi-bu-bu-si-kao-dong-tai-gui-hua-j99e/ 灵茶山艾府