目录

题目描述

656. 成本最小路径

题意分析

要什么:从下标 0 出发跳到下标 n-1,每次最多向右跳 B 步,落在下标 i 要付出 A[i] 的代价,A[i] == -1 表示该位置不可落脚。求总代价最小的路径本身(用 1-based 下标序列表示),无法抵达返回空数组。若最小代价有多条路径达成,返回字典序最小的那条。
约束透露的信号:题目要的不是最小值而是路径,说明除了代价表还必须保留「下一步跳到哪」的决策记录,否则算完只有一个数字无法还原方案。「字典序最小」这个附加要求进一步说明:转移时不能随便挑一个最优前驱,必须在代价相等时按下标做确定性的取舍。每步跳跃范围是 B,转移的分支数就是 B,所以 $O(nB)$ 的朴素 DP 就是预期解。
边界:终点若是 -1 则直接无解;起点若是 -1 同样无解(代码中体现为 dp[0] 仍为无穷);跳跃上界要与 n-1 取较小值防越界;代价累加在最坏情况下可能超出 32 位,用 64 位更稳;无解时返回的是空数组而不是 null[-1]

解法:从后往前 DP + next 恢复路径

核心思路

暴力是从起点做深度优先搜索,枚举每一步跳 1 到 B 格,记录走过的路径和累计代价,最后在所有可行路径里取最优。分支数 $B^n$,指数爆炸。
瓶颈在于同一个下标会被无数条不同前缀重复展开,而「从下标 i 走到终点的最优代价」其实与「怎么走到 i 的」完全无关——这正是最优子结构,也是可以做记忆化 / DP 的信号。
于是状态定义为:dp[i] = 从下标 i 出发、最终抵达 n-1 所需的最小总代价(含 A[i] 自身,不可达记为无穷大);配套的决策记录 next[i] = 在取得 dp[i] 的最优方案中,从 i 迈出的第一步落在哪个下标(没有则为 -1)
转移是 dp[i] = A[i] + min{ dp[j] },其中 j 取遍 i+1 .. min(n-1, i+B)dp[j] 有限。边界是 dp[n-1] = A[n-1]。因为 dp[i] 依赖的全是更大的下标,所以必须从后往前推。
为什么这个「从后往前」的定义比「从前往后」更好?因为答案要的是从 0 出发的路径,next 指针天然形成一条从 0 开始的单链,顺着走一遍就是答案,不需要反转。
字典序最小怎么保证?路径以 1-based 下标序列比较,两条同代价路径从 i 出发,第一处差异就是第一跳的落点,落点下标越小字典序越小。所以dp[j] 相等时必须选最小的 j。代码里 j 从小到大枚举、只在严格更小时才更新 best,因此第一个取到最小值的 j 会被保留下来,恰好就是最小下标;再配合每个 next[j] 自身也是按同样规则选出的,整条链递归地保持字典序最小。

解题步骤

  • A[n-1] == -1 直接返回空数组。为什么要提前拦:终点不可落脚意味着任何路径都不合法,而后续的 dp[n-1] = A[n-1] 会把 -1 当成一个「代价为 -1」的合法边界,污染整张表。
  • dp 全部初始化为一个足够大的哨兵值、next 全部初始化为 -1,然后设 dp[n-1] = A[n-1]为什么哨兵要用 64 位的大数而不是 Integer.MAX_VALUE:转移里有 best + A[i] 的加法,用 32 位最大值会溢出成负数,反而被误判为「更优」;用 1e18 级别的 long 既不会溢出也远大于任何真实代价。
  • i = n-2 倒序遍历到 0,遇到 A[i] == -1 直接跳过。为什么倒序dp[i] 依赖 dp[i+1 .. i+B],只有先算完右边才能算左边;正序会读到尚未计算的哨兵值,全表作废。为什么跳过而不是设无穷dp[i] 初值本就是无穷,跳过等价于宣告该位置不可落脚,语义一致。
  • 内层在 j = i+1 .. min(n-1, i+B) 中找最小的 dp[j],跳过 dp[j] 仍为哨兵的下标。为什么必须跳过不可达的 j:哨兵值参与 best + A[i] 会算出一个巨大但有限的数,让本该不可达的位置伪装成可达。为什么上界要和 n-1 取小i + B 可能越过数组末尾,直接索引会越界。
  • 找到 bestJ 后写入 dp[i] = best + A[i]next[i] = bestJ;没找到则保持无穷、next 保持 -1。为什么 next 的默认值必须是 -1:它同时充当「链表终止标记」,路径恢复时靠 cur != -1 退出循环,dp[n-1] 对应的 next[n-1] 天然是 -1,正好在终点停下。
  • dp[0] 仍是哨兵,说明起点不可达终点,返回空数组。
  • 否则从 cur = 0 沿 next 链一路走,每步把 cur + 1 追加进结果。为什么要 +1:题目要求 1-based 下标。
  • A = [1, 2, 4, -1, 2]B = 2 走一遍。n = 5,终点 A[4] = 2 合法,故 dp[4] = 2、其余为无穷、next 全 -1。i = 3A[3] == -1,跳过,dp[3] 保持无穷。i = 2:上界 min(4, 4) = 4,枚举 j = 3dp[3] 无穷,跳过)、j = 4dp[4] = 2 有限,best = 2bestJ = 4),于是 dp[2] = 2 + 4 = 6next[2] = 4i = 1:上界 min(4, 3) = 3,枚举 j = 2dp[2] = 6best = 6bestJ = 2)、j = 3(无穷,跳过),于是 dp[1] = 6 + 2 = 8next[1] = 2i = 0:上界 min(4, 2) = 2,枚举 j = 1dp[1] = 8,先记 best = 8bestJ = 1)、j = 2dp[2] = 6 < 8,更新 best = 6bestJ = 2),于是 dp[0] = 6 + 1 = 7next[0] = 2dp[0] = 7 有限,从 cur = 0 恢复路径:记录 1,跳到 next[0] = 2;记录 3,跳到 next[2] = 4;记录 5,跳到 next[4] = -1 结束。返回 [1, 3, 5],总代价 1 + 4 + 2 = 7,与 dp[0] 吻合,且中途绕开了不可落脚的下标 3。

代码实现

// 核心实现:从后往前 DP + next 恢复路径,维护必要状态并避免重复处理。
class Solution {
    public List<Integer> cheapestJump(int[] A, int B) {
        int n = A.length;
        if (A[n - 1] == -1) {
            return new ArrayList<>();
        }

        long INF = (long) 1e18;
        long[] dp = new long[n];
        int[] next = new int[n];
        Arrays.fill(dp, INF);
        Arrays.fill(next, -1);

        dp[n - 1] = A[n - 1];
        for (int i = n - 2; i >= 0; i--) {
            if (A[i] == -1) {
                continue;
            }

            long best = INF;
            int bestJ = -1;
            int upper = Math.min(n - 1, i + B);
            for (int j = i + 1; j <= upper; j++) {
                if (dp[j] == INF) {
                    continue;
                }
                if (dp[j] < best || (dp[j] == best && j < bestJ)) {
                    best = dp[j];
                    bestJ = j;
                }
            }

            if (bestJ != -1) {
                dp[i] = best + A[i];
                next[i] = bestJ;
            }
        }

        if (dp[0] == INF) {
            return new ArrayList<>();
        }

        List<Integer> path = new ArrayList<>();
        int cur = 0;
        while (cur != -1) {
            path.add(cur + 1);
            cur = next[cur];
        }
        return path;
    }
}
// 核心实现:从后往前 DP + next 恢复路径,维护必要状态并避免重复处理。
func cheapestJump(A []int, B int) []int {
    n := len(A)
    if A[n-1] == -1 {
        return []int{}
    }

    const INF int64 = 1<<62
    dp := make([]int64, n)
    next := make([]int, n)
    for i := range dp {
        dp[i] = INF
        next[i] = -1
    }

    dp[n-1] = int64(A[n-1])
    for i := n - 2; i >= 0; i-- {
        if A[i] == -1 {
            continue
        }

        best := INF
        bestJ := -1
        upper := i + B
        if upper > n-1 {
            upper = n - 1
        }
        for j := i + 1; j <= upper; j++ {
            if dp[j] == INF {
                continue
            }
            if dp[j] < best || (dp[j] == best && j < bestJ) {
                best = dp[j]
                bestJ = j
            }
        }
        if bestJ != -1 {
            dp[i] = best + int64(A[i])
            next[i] = bestJ
        }
    }

    if dp[0] == INF {
        return []int{}
    }

    path := make([]int, 0)
    cur := 0
    for cur != -1 {
        path = append(path, cur+1)
        cur = next[cur]
    }
    return path
}

复杂度分析

  • 时间复杂度:$O(nB)$。凭什么:外层遍历 n 个下标,内层最多枚举 B 个跳跃目标,每次做常数比较;末尾的路径恢复不超过 n 步,量级更小。
  • 空间复杂度:$O(n)$。凭什么:dpnext 各占一条长度 n 的数组,答案路径最长也是 n;没有二维表,也没有递归栈。

关键点总结

  • 要输出方案就必须存决策:DP 表只回答「最优值是多少」,想回答「怎么取到的」就得同步维护一个 next(或 from)指针数组。这是路径 DP 与普通 DP 的分水岭,也是本题被标为困难的主要原因。
  • 状态方向由输出需求决定:本题定义成「从 i 到终点」而不是「从起点到 i」,就是为了让 next 链从 0 开始顺着走,省掉反转。设计状态时先想清楚最后要怎么把答案读出来。
  • 字典序最小 = 在每个决策点上对并列项取最小下标,并且这个规则要在链条的每一环都成立。实现上只要「升序枚举 + 严格小于才更新」即可自然满足,不必额外写比较逻辑。
  • 哨兵值的选取是有讲究的:既要大到不可能被误认为最优,又要小到参与加法不会溢出。用 32 位最大值做无穷大再做加法,是这类题最经典的翻车方式。
  • 面试视角:先说状态定义和转移,再单独强调「不可达用哨兵、哨兵不参与转移」和「并列取小下标保证字典序」两个细节,最后演示路径恢复。被追问优化时可以提「内层的区间最小值可以用单调队列压到 $O(n)$」,但要说明当前数据规模下 $O(nB)$ 已足够。

易错点总结

  • 错误写法:漏掉 A[n-1] == -1 的提前返回;用例 A = [1, 2, -1]B = 2dp[2] 被设为 -1,起点算出一条「代价 0」的伪路径 [1, 3],正确答案是空数组。
  • 错误写法:内层不跳过 dp[j] 为无穷的下标;用例 A = [1, -1, 2]B = 1 → 从 0 只能跳到不可达的 1,却把无穷加上 A[0] 得到一个有限大数,dp[0] 被误判为可达,返回一条经过 -1 位置的非法路径。
  • 错误写法:用 Integer.MAX_VALUE 当哨兵并直接做 best + A[i];用例 任意含不可达位置的输入 → 加法溢出成负数,负数比所有真实代价都小,DP 全表被这个「负无穷」污染。
  • 错误写法:正序遍历 i 从 0 到 n-2;用例 A = [1, 2, 3]B = 2 → 计算 dp[0]dp[1]dp[2] 还是初始哨兵,全部被跳过,dp[0] 保持无穷,返回空数组。
  • 错误写法:内层上界写成 i + B 不与 n-1 取小;用例 A = [1, 2]B = 5 → 访问 dp[5] 直接数组越界。
  • 错误写法:并列时更新条件写成 dp[j] <= best;用例 A = [0, 0, 0, 0]B = 3 → 所有候选代价相同,bestJ 一路被覆盖成最大的下标,返回 [1, 4] 而不是字典序更小的 [1, 2, 3, 4]
  • 错误写法:next 初始化为 0 而不是 -1;用例 任意输入 → 路径恢复的 while (cur != -1) 永远遇不到终止标记,cur 在终点又跳回 0,死循环或路径无限增长。
  • 错误写法:状态定义成「从起点到 i 的最小代价」并记录前驱,却忘记最后把路径反转;用例 A = [1, 2, 4, -1, 2]B = 2 → 返回 [5, 3, 1],顺序颠倒判错。
  • 错误写法:把 dp[i] 的含义写成「不含 A[i] 的后续代价」,转移时却又加上了 A[i];用例 A = [1, 1]B = 1 → 起点或终点的代价被重复计入或整段漏计,总代价与真实路径对不上。
  • 错误写法:起点是 -1 时不做处理,直接从 dp[0] 恢复路径;用例 A = [-1, 2]B = 1 → 循环里 i = 0continue 跳过使 dp[0] 保持无穷,若忘记检查 dp[0] 是否为哨兵就会顺着 next[0] = -1 返回 [1] 这条非法路径。

相似题目

题目 难度 考察点
45. 跳跃游戏 II 中等 每步代价恒为 1 且无障碍,可用贪心把 $O(nB)$ 压成 $O(n)$,无需记路径
55. 跳跃游戏 中等 只问可达性不问代价,维护一个最远可达边界即可,连 DP 表都不用开
64. 最小路径和 中等 转移方向固定为两种且状态是二维网格,同样可加前驱数组来还原具体路径