[Leetcode 55] 跳跃游戏

给你一个非负整数数组 nums ,你最初位于数组的 第一个下标 。数组中的每个元素代表你在该位置可以跳跃的最大长度。
判断你是否能够到达最后一个下标,如果可以,返回 true ;否则,返回 false 。
示例 1:
notion image
示例 2:
 

解法一:Top-down DP / 记忆化搜索

每个下标都是一个位置,每个位置都可以跳到右边的一些位置。问有没有一条路径能从 0 走到 n - 1
最直接的思路是从下标 0 开始,把所有能跳到的位置都试一遍。但这样暴力回溯的问题是重复计算太多。
对于某个位置 i,一旦我们确定 从 i 出发能到终点 或者 不能到终点,这个结论之后不会改变。无论我们是通过哪条路径来到 i,从 i 往后的数组都一样。
于是我们用一个 memo 数组缓存结果:
最后一个位置天然是 GOOD,因为它已经在终点。
复杂度分析
  • 时间复杂度是 O(n^2)
    • 每个位置最多被计算一次,但计算一个位置时,最坏可能扫描它右边的 O(n) 个位置。
  • 空间复杂度是 O(n)
    • memo 数组需要 O(n),递归栈最深也是 O(n)

解法二:Bottom-up DP

如果我能一步跳到某个已经确认能到终点的位置,那我自己也能到终点。
定义:dp[i] 表示 从下标 i 出发,能不能到达最后一个位置,
最后一个位置 dp[n - 1] = True
然后从右往左计算。对于位置 i,它能跳到的范围是: [ i + 1 , min(n - 1, i + nums[i]) ]
只要这些位置里有一个是 GOOD,那么 i 也是 GOOD
也就是说:dp[i] = any(dp[j] for j in reachable positions)
复杂度分析
  • 时间复杂度是 O(n^2)
    • 每个位置最多向右扫描 O(n) 个可达位置。
  • 空间复杂度是 O(n)
    • 需要一个 dp 数组。

解法三:贪心 (最优解)

我们可以从起点往终点搜索,也可以反过来从终点往起点思考。
我们从右往左看,把最后一个下标当成当前目标 ,goal = n - 1
我们从右往左遍历每个位置 i
由于 nums[i] 表示从位置 i 最多可以往右跳多少步,如果 i + nums[i] >= goal,则说明从 i 可以跳到当前目标位置。
既然当前目标已经能到达终点,那么 i 也能到达终点。
于是我们把目标更新为:goal = i
原来我们需要到达 goal,现在只要能到达 i 就够了,因为 i 可以到达原来的 goal。
不断这样往左推进。如果最后 goal == 0,说明起点也被证明是 GOOD,可以到达终点。
nums = [2, 3, 1, 1, 4] 为例:
我们从最后一个下标 4 开始把它当作 goal,从右往左看时,发现下标 3 可以到 4,下标 2 可以到 3,下标 1 可以覆盖到 2,下标 0 也可以覆盖到 1,所以 goal 会一路从 4 更新到 0。最终 goal == 0,说明起点本身可以到达终点,因此返回 True
再看一个例子:nums = [3, 2, 1, 0, 4]
初始 goal = 4。从右往左看,下标 3 最远只能到 3,下标 2 最远只能到 3,下标 1 最远也只能到 3,下标 0 最远还是只能到 3,所以没有任何位置能跨过下标 3 到达 goal = 4
因此 goal 一直停在 4,最终没有被更新到 0,返回 False
复杂度分析
  • 时间复杂度是 O(n)。只需要从右往左遍历一次数组。
  • 空间复杂度是 O(1)。只使用了一个变量 goal

总结

这道题可以先从可达性角度理解:每个下标是一种状态,nums[i] 表示从这里可以跳到右边的一段位置。
  • 暴力做法是从起点开始 DFS,尝试所有可跳位置;但同一个位置会被不同路径重复访问,所以会超时。
  • 对于每个位置,我们缓存它是 GOOD 还是 BADGOOD 表示从这里能到终点,BAD 表示不能。这样每个位置只会被计算一次,复杂度变成 O(n^2)
  • 然后可以把递归改成 bottom-up DP。因为每个位置只依赖它右边的位置,所以从右往左填表。dp[i] 表示从 i 能否到终点,只要它能跳到某个 dp[j] == True 的位置,dp[i] 就是 True
  • 最终的贪心解法来自对 DP 的进一步压缩。Bottom-up DP 需要知道右边哪些位置是 GOOD;贪心发现其实只需要维护最左边的 GOOD 位置。最后只要看 起点(0)是否能成为最左边的 GOOD 位置。
Buy Me a Coffee
上一篇
[Leetcode 46] 全排列
下一篇
[Leetcode 62] 不同路径