目录

题目描述

LCR 088. 使用最小花费爬楼梯

题意分析

给一个数组 costcost[i]站在第 i 级台阶上要支付的费用。付费之后可以往上爬一级或两级。可以从下标 0 或下标 1 出发(出发本身不额外收费,但站上去要付该级的费用),目标是走到楼梯顶部,也就是下标 n 这个「第 n 级」——它在数组之外,因此不需要付费。求最小总花费。

这里最容易读错的是费用的计费点。cost[i]离开第 i 级时支付的,或者说是「踩上第 i 级并从它继续往上」的代价。终点 n 不在数组里,所以永远不付费;反过来,最后一级 cost[n-1] 是否要付,取决于路线是否踩过它——从 n-2 一步跨到 n 就绕开了它。这解释了为什么答案常常小于数组末尾若干项之和。

每一步只有「爬一级」「爬两级」两种选择,说明到达某一级的方式只能来自它前面的两级,问题具有一维、局部、无后效性的递推结构。同时题目只要最小花费这一个数值,不要具体路线,因此不必枚举方案,直接沿台阶递推即可。

约束:2 ≤ cost.length ≤ 10000 ≤ cost[i] ≤ 999。数组至少两个元素,所以不存在「一步都不用走」的退化情况。费用非负这一点保证了不会出现「多绕几步反而更省」的反直觉解,最优路线一定是单向向上的。边界重点在两个起点都免费这条规则如何编码,以及答案取的是「到达 n」而不是「到达 n-1」。

解法:动态规划递推

核心思路

暴力做法是从两个起点出发做搜索,每级分岔成两条路,路线总数呈斐波那契级增长,n = 1000 时天文数字。瓶颈在于「到达第 i 级的最小花费」这个子问题被不同的前缀路线反复计算了无数遍。

关键观察是:站在第 i 级时,之前具体走过哪条路线对后续毫无影响,唯一有用的信息是「走到这里累计花了多少」。既然如此,对每一级只保留最小值即可,子问题数量从指数级塌缩到 n + 1 个。

于是定义状态:dp[i] 表示到达第 i 级台阶(尚未支付 cost[i])所需的最小总花费i 的取值范围是 0n。这个定义里「尚未支付 cost[i]」是精髓——它让终点 dp[n] 的语义天然正确(第 n 级不存在费用),也让转移写起来对称。

转移方程:到达第 i 级只能从第 i-1 级迈一步、或从第 i-2 级迈两步。从第 i-1 级出发要先付 cost[i-1],从第 i-2 级出发要先付 cost[i-2],于是

dp[i] = min(dp[i-1] + cost[i-1], dp[i-2] + cost[i-2])

基准情形直接由题意给出:dp[0] = dp[1] = 0,因为从下标 0 或下标 1 出发都不额外收费,站上去时还没付钱。这两个 0 恰好是数组的默认初值,所以代码里连显式赋值都省了。

答案是 dp[n],不是 dp[n-1],也不是 min(dp[n-1], dp[n-2])——后者是把「顶部」误解成最后一级台阶的典型错误。

解题步骤

  • 开长度为 n + 1 的数组:状态下标要覆盖到 n,因为终点是数组之外的那一级。开成 n 会直接漏掉答案所在的格子。
  • 基准情形依赖默认零值dp[0]dp[1] 都是 0,含义是「站在起点上、还没付钱」。Java 的 new int[] 与 Go 的 make([]int, ...) 都会零初始化,所以不需要额外写。理解它们为什么是 0,比背下来更重要。
  • i = 2 开始正序递推dp[i] 依赖下标更小的 dp[i-1]dp[i-2],所以必须正序,且起点是 2——i = 0i = 1 是基准,i 从 0 开始会访问 dp[-1] 越界。
  • 转移时把费用记在「出发的那一级」上dp[i-1] + cost[i-1] 而不是 dp[i-1] + cost[i]。费用属于被踩过的台阶,不属于目的地;写成 cost[i]i == n 时还会直接越界。
  • 返回 dp[n]:循环结束时 dp[n] 已经算好,直接返回。

cost = [10, 15, 20] 走一遍,n = 3dp 长度为 4。

初始 dp = [0, 0, 0, 0],其中前两个 0 是基准:从下标 0 出发花费 0,从下标 1 出发也花费 0。

i = 2dp[1] + cost[1] = 0 + 15 = 15,表示从下标 1 起步、付 15 后迈一级到达 2;dp[0] + cost[0] = 0 + 10 = 10,表示从下标 0 起步、付 10 后迈两级到达 2。取小得 dp[2] = 10

i = 3dp[2] + cost[2] = 10 + 20 = 30,表示走到第 2 级再付 20 迈一级到顶;dp[1] + cost[1] = 0 + 15 = 15,表示从下标 1 起步付 15 后直接迈两级跨到顶部,绕开了 cost[2]。取小得 dp[3] = 15

返回 dp[3] = 15,对应路线「从下标 1 出发 → 付 15 → 跨两级到顶」。这个用例很好地说明了为什么答案不是 dp[n-1]dp[2] = 10 看起来更小,但它只走到第 2 级,还没到顶,从那里到顶还要再付 20。

再以 cost = [1, 100, 1, 1, 1, 100, 1, 1, 100, 1] 快速核对:dp 依次为 0, 0, 1, 2, 2, 3, 3, 4, 4, 5, 6,答案 dp[10] = 6,对应路线是踩下标 0、2、3、4、6、7、9 各付 1,共 6,与预期一致。

代码实现

class Solution {
    public int minCostClimbingStairs(int[] cost) {
        int n = cost.length;
        // dp[i]:到达第 i 级(尚未支付 cost[i])的最小花费;下标要覆盖到 n。
        int[] dp = new int[n + 1];
        // dp[0] = dp[1] = 0:从下标 0 或 1 出发都不额外收费,默认零值即可。
        for (int i = 2; i <= n; ++i) {
            // 费用记在出发的那一级上,所以是 cost[i - 1] 与 cost[i - 2]。
            dp[i] = Math.min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);
        }
        return dp[n];
    }
}
func minCostClimbingStairs(cost []int) int {
    n := len(cost)
    // dp[i]:到达第 i 级(尚未支付 cost[i])的最小花费;下标要覆盖到 n。
    dp := make([]int, n+1)
    // dp[0] = dp[1] = 0:从下标 0 或 1 出发都不额外收费,默认零值即可。
    for i := 2; i <= n; i++ {
        // 费用记在出发的那一级上,所以是 cost[i-1] 与 cost[i-2]。
        dp[i] = min(dp[i-1]+cost[i-1], dp[i-2]+cost[i-2])
    }
    return dp[n]
}

复杂度分析

  • 时间复杂度:$O(n)$,从 2 到 n 只扫一遍,每格做一次加法和一次取最小,都是常数操作。
  • 空间复杂度:$O(n)$,用了长度为 n + 1 的数组。由于 dp[i] 只依赖前两格,可以用两个变量滚动把空间压到 $O(1)$;这里保留完整数组是为了让状态定义一目了然,面试中主动指出可优化到常数空间是加分项。

关键点总结

  • 状态定义里「费用算在谁头上」必须一次讲清楚:本题采用「到达第 i 级但尚未支付 cost[i]」,于是转移里出现 cost[i-1]cost[i-2],终点 dp[n] 自然免费。定义换成「已支付」也能做,但转移和边界要跟着全改——不要中途换定义,这是 DP 出错最常见的根源。
  • 答案取 dp[n] 而非 dp[n-1],因为楼梯顶部在数组之外。凡是题目里出现「顶部」「终点」这类数组之外的位置,都要先确认状态数组要不要多开一格。
  • 基准情形要能用题意解释,而不是试出来的:两个起点免费,所以 dp[0] = dp[1] = 0。面试时能说出「为什么是 0」比写对更重要。
  • 递推方向由依赖关系决定dp[i] 依赖更小的下标,因此正序;这条规则在所有线性 DP 中通用,也是后面滚动数组能否原地更新的判据。
  • 可迁移的识别信号:「每一步只能从固定的前几个位置转移过来,且只要最优值不要方案」,就是一维线性 DP 的典型形态,爬楼梯、打家劫舍、跳跃游戏都属于这一族。

易错点总结

  • 数组开成 new int[n]cost = [10,15,20] 时循环写到 i = n 会数组越界抛异常。
  • 返回 dp[n-1]cost = [10,15,20] 会返回 dp[2] = 10,但正确答案是 15,因为只走到了第 2 级还没到顶。
  • 返回 min(dp[n-1], dp[n-2])cost = [10,15,20] 返回 min(10, 0) = 0,把「站在起点」当成了到顶。
  • 转移写成 dp[i-1] + cost[i]i = ncost[n] 越界;即使把上界改小,cost = [10,15,20] 也会算出 dp[2] = min(0+20, 0+15) = 15 这种把费用记到目的地的错误值。
  • 循环从 i = 0i = 1 开始:会访问 dp[-1]cost[-1],直接越界。
  • 误把 dp[0] 设成 cost[0]dp[1] 设成 cost[1]cost = [10,15,20] 得到 dp[2] = min(15+15, 10+10) = 20dp[3] = min(20+20, 15+15) = 30,答案翻倍。
  • 把状态定义成「站在第 i 级且已付费」却仍写 cost[i-1]cost = [1,100] 会算出 0 而不是 1,两套定义混用是 DP 最隐蔽的错误。
  • 以为可以贪心地每步选较小的下一级cost = [0, 0, 100, 0, 0] 时贪心会在某一步被 100 挡住绕不开,而 DP 能选出跨越它的路线;贪心在这类「局部最小不等于全局最小」的题上必错。
  • 滚动数组优化时把两个变量的更新顺序写反cost = [10,15,20] 若先更新 pre 再更新 cur,第二轮会用到被覆盖的值,结果偏小。
  • 误以为费用非负就可以不比较、总走一步cost = [1,100,1,1] 时一步一步走要付 1+100+1 = 102,而跨过 100 只需 2,必须真的取最小值。

相似题目

题目 难度 考察点
746. 使用最小花费爬楼梯 简单 与本题完全同题,代码可原样提交
70. 爬楼梯 简单 没有费用,求方案数而非最小值,min 换成加法即成计数递推
509. 斐波那契数 简单 同一条递推式的纯数学形态,是本题去掉一切题面包装后的骨架
剑指 Offer 10- I. 斐波那契数列 简单 与 509 同题,额外要求对 1e9+7 取模,考察溢出处理
剑指 Offer 10- II. 青蛙跳台阶问题 简单 与 70 同题,基准值定义略有差异,容易在 n = 0 上翻车
面试题 08.01. 三步问题 简单 每步可跨 1、2、3 级,转移项从两项扩到三项,同样要注意取模
198. 打家劫舍 中等 同为一维线性 DP,但约束从「步长」变成「相邻不可同时选」
补充题 2. 圆环回原点问题 中等 状态多一维「当前所在位置」,转移在环上左右两个方向计数