问题:Leetcode 746. 使用最小花费爬楼梯
给你一个整数数组 cost
,其中 cost[i]
是从楼梯第 i
个台阶向上爬需要支付的费用。一旦你支付此费用,即可选择向上爬一个或者两个台阶。
你可以选择从下标为 0
或下标为 1
的台阶开始爬楼梯。
请你计算并返回达到楼梯顶部的最低花费。
算法1:递归
因为要解决的问题都是「从 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(cost[i - 1] + dfs(i - 1),cost[i - 2] + dfs(i - 2));
};
return dfs(n);
}
};
算法2:递归 + 记录返回值 = 记忆化搜索
注意到「先爬 1 个台阶,再爬 2 个台阶」和「先爬 2 个台阶,再爬 1 个台阶」,都相当于爬 3 个台阶,都会从 dfs ( i ) 递归到 dfs ( i − 3 ) 。
一叶知秋,整个递归中有大量重复递归调用(递归入参相同)。由于递归函数没有副作用,同样的入参无论计算多少次,算出来的结果都是一样的,因此可以用记忆化搜索来优化:
如果一个状态(递归入参)是第一次遇到,那么可以在返回前,把状态及其结果记到一个 memo 数组中。
如果一个状态不是第一次遇到(memo 中保存的结果不等于 memo 的初始值),那么可以直接返回 memo 中保存的结果。
注意:memo 数组的初始值一定不能等于要记忆化的值!例如初始值设置为 0,并且要记忆化的 dfs ( i ) 也等于 0,那就没法判断 0 到底表示第一次遇到这个状态,还是表示之前遇到过了,从而导致记忆化失效。一般把初始值设置为 −1。
代码:
class Solution {
public:
int minCostClimbingStairs(vector<int>& cost) {
int n = cost.size();
vector<int> memo(n + 1,-1);
function<int(int)>dfs = [&](int i)->int{
if(i <= 1) return 0;
int& res = memo[i];
if(res != -1) return memo[i];
return res = min(cost[i - 1] + dfs(i - 1),cost[i - 2] + dfs(i - 2));
};
return dfs(n);
}
};
算法3:1:1 翻译成递推
我们可以去掉递归中的「递」,只保留「归」的部分,即自底向上计算。
具体来说,dp [ i ] 的定义和 dfs ( i ) 的定义是一样的,都表示从 0 或 1 爬到 i 的最小花费。
相应的递推式(状态转移方程)也和 dfs 一样:dp [ i ] = min ( dp [ i − 1 ] + cost [ i − 1 ] , dp [ i − 2 ] + cost [ i − 2 ] )
相当于之前是用递归去计算每个状态,现在是枚举并计算每个状态。
初始值 dp [ 0 ] = 0, dp [ 1 ] = 0 ,翻译自递归边界 dfs ( 0 ) = 0 , dfs ( 1 ) = 0 。
答案为 dp [ n ] ,翻译自递归入口 dfs ( n ) 。
代码:
class Solution {
public:
int minCostClimbingStairs(vector<int>& cost) {
int n = cost.size();
vector<int> dp(n + 1);
for(int i = 2;i <= n;i++){
dp[i] = min(dp[i - 1] + cost[i - 1],dp[i - 2] + cost[i - 2]);
}
return dp[n];
}
};
算法4:空间优化
观察状态转移方程,发现一旦算出 dp [ i ] ,那么 dp [ i − 2 ] 及其左边的状态就永远不会用到了。
这意味着每次循环,只需要知道「上一个状态」和「上上一个状态」的 f 值是多少,分别记作dp1 和 dp0 。它俩的初始值均为 0,对应着 dp [ 1 ] 和 dp [ 0 ] 。
每次循环,计算出新的状态 newdp = min ( dp1 +cost [ i − 1 ] , dp0 + cost [ i − 2 ] ) ,那么对于下一轮循环来说:「上上一个状态」就是 dp1 更新 dp0 = dp1 。「上一个状态」就是 newdp ,更新 dp1 = newdp 。
最后答案为 dp1 ,因为最后一轮循环算出的 newdp 赋给了 dp1 。代码实现时,可以把 i 改成从 1 遍历到 n−1,这样 newdp = min ( dp1 + cost [ i ] , dp0 + cost [ i − 1 ] ) ,可以简化一点代码。
代码:
class Solution {
public:
int minCostClimbingStairs(vector<int>& cost) {
int n = cost.size();
int dp0 = 0,dp1 = 0;
for(int i = 2;i <= n;i++){
int newdp = min(dp1 + cost[i - 1],dp0 + cost[i - 2]);
dp0 = dp1;
dp1 = newdp;
}
return dp1;
}
};