LeetCode 55. 跳跃游戏:两种高效解法深度解析

28 阅读7分钟

在 LeetCode 的数组类题目中,「跳跃游戏」是一道经典的贪心算法应用题型。它不仅考察我们对数组遍历的掌控,更核心的是考验对「局部最优推导全局最优」思想的理解。今天,我们就来详细拆解这道题,并深入分析两种不同的解法思路。

一、题目解读

题目描述很简洁:给定一个非负整数数组 nums,我们最初位于数组的第一个下标。数组中的每个元素代表在该位置可以跳跃的最大长度,判断是否能够到达最后一个下标。如果可以返回 true,否则返回 false

举两个简单例子帮助理解:

  • 示例 1:输入 [2,3,1,1,4],输出 true。解释:从下标 0 跳 1 步到 1,再从 1 跳 3 步直达最后一个下标。

  • 示例 2:输入 [3,2,1,0,4],输出 false。解释:无论从下标 0 怎么跳,最终都会停在下标 3(因为下标 3 的元素是 0,无法继续跳跃),无法到达最后一个下标。

核心问题:如何通过合理的跳跃策略,判断是否能覆盖到数组的最后一个位置。

二、解法一:贪心策略(局部最优选最远)

1. 思路分析

这是一种比较直观的贪心思路:每一步都选择「能让后续跳跃范围最远」的位置。具体来说,在当前位置cur 可跳跃的范围内(即 [cur+1, cur+nums[cur]]),找到一个位置 aft,使得从 aft 出发能到达的最远距离(aft + nums[aft])最大。然后将当前位置更新为 aft,重复这个过程,直到到达最后一个下标或无法继续跳跃。

2. 代码解析


function canJump_1(nums: number[]): boolean {
  const numsLen = nums.length;
  let cur = 0; // 当前所在位置
  while (cur < numsLen - 1) { // 只要没到最后一个位置,就继续尝试跳跃
    let count = 0; // 记录当前范围内能到达的最远距离增量
    let aft = cur; // 记录当前范围内最优的下一个跳跃位置
    // 遍历当前位置可跳跃的所有范围
    for (let j = cur + 1; j <= cur + nums[cur]; j++) {
      // 计算从j出发能到达的最远距离(相对于cur的增量)
      // 这里j - cur是从cur到j的步数,加上nums[j]就是j能再跳的距离
      if (j - cur + nums[j] > count) {
        count = j - cur + nums[j];
        aft = j; // 更新最优跳跃位置
      }
    }
    // 如果aft没有变化,说明当前位置无法跳跃到任何新位置,直接返回false
    if (aft === cur) return false;
    cur = aft; // 跳转到最优位置
  }
  return true; // 循环结束说明到达了最后一个位置
};

3. 关键细节说明

  • count 的含义:这里的 count 计算的是「从当前位置 curj 的步数(j - cur)加上 j 能跳跃的最大长度(nums[j])」,本质上等价于 j + nums[j](因为 j - cur + nums[j] + cur = j + nums[j]),只是计算时以 cur 为基准,逻辑更清晰。

  • 终止条件:当 aft === cur 时,说明在当前位置的跳跃范围内,没有任何一个位置能让我们跳得更远,即陷入了「无法前进」的困境,直接返回 false;当 cur 到达或超过最后一个下标时,返回 true

4. 时间复杂度与空间复杂度

  • 时间复杂度:O(n)。虽然看起来有两层循环,但每个位置最多被遍历一次(因为每次跳跃都会跳到新的范围,不会重复遍历之前的位置)。

  • 空间复杂度:O(1)。只使用了 curcountaft 等几个常量级变量,没有额外占用空间。

三、解法二:贪心策略(全局最远覆盖)

1. 思路分析

这是一种更简洁的贪心思路:我们不关心每一步具体跳哪里,只关心「当前能到达的最远距离」。通过遍历数组,不断更新这个最远距离(maxReach)。如果在遍历过程中,maxReach 已经覆盖到最后一个下标,直接返回 true;如果遍历到了 maxReach 所在的位置,却还没覆盖到最后一个下标,说明无法继续前进,返回 false

核心逻辑:maxReach = Math.max(maxReach, i + nums[i]),即每遍历一个位置,就更新当前能到达的最远距离。

2. 代码解析


function canJump_2(nums: number[]): boolean {
  const numsLen = nums.length;
  if (numsLen === 1) return true; // 特殊情况:数组只有一个元素,本身就在终点
  let maxReach = 0; // 当前能到达的最远距离
  // 遍历范围:0 到 maxReach(超过maxReach的位置无法到达,无需遍历)
  for (let i = 0; i <= maxReach; i++) {
    // 更新能到达的最远距离
    maxReach = Math.max(maxReach, i + nums[i]);
    // 如果已经覆盖到最后一个下标,直接返回true
    if (maxReach >= numsLen - 1) return true;
    // 如果遍历到maxReach位置,却还没覆盖终点,说明无法前进
    if (i === maxReach) return false;
  }
  return false; // 理论上不会走到这里,因为循环内已覆盖所有情况
};

3. 关键细节说明

  • 特殊情况处理:当数组长度为 1 时,我们已经在最后一个下标,直接返回 true,避免不必要的遍历。

  • 遍历范围:i <= maxReach 是核心。因为超过 maxReach 的位置,我们当前无法到达,遍历这些位置没有意义。

  • 终止条件:maxReach >= numsLen - 1 表示已覆盖终点;i === maxReach 表示当前遍历到了能到达的最远距离,却还没到终点,此时无法继续前进,返回 false

4. 时间复杂度与空间复杂度

  • 时间复杂度:O(n)。最多遍历数组一次(遍历到 maxReach 覆盖终点或无法前进时就终止)。

  • 空间复杂度:O(1)。同样只使用了常量级变量,空间效率极高。

四、两种解法对比与总结

1. 解法对比

维度解法一(局部最优选最远)解法二(全局最远覆盖)
核心思路每步选能让后续跳得最远的位置只跟踪全局能到达的最远距离
代码复杂度稍高(两层循环,逻辑稍繁琐)极低(单层循环,逻辑简洁)
效率O(n),但实际遍历次数可能更少(跳跃跨度大)O(n),遍历次数更稳定
适用场景不仅要判断能否到达,还想知道具体跳跃路径(可扩展)仅需判断能否到达,追求代码简洁高效

2. 核心总结

两道解法的本质都是「贪心算法」,核心都是通过「局部最优选择」推导「全局最优结果」:

  • 解法一的局部最优是「当前步选能让后续跳得最远的位置」,全局最优是「最终能到达终点」。

  • 解法二的局部最优是「每一步都更新能到达的最远距离」,全局最优是「这个最远距离能覆盖终点」。

在实际面试或解题中,解法二 更推荐使用,因为它代码更简洁、逻辑更清晰,且空间和时间效率都处于最优水平。而解法一则适合在需要获取具体跳跃路径的场景下进行扩展(比如记录每一步的跳跃位置)。

五、常见误区提醒

  • 误区 1:暴力枚举所有跳跃路径。这种方法时间复杂度是 O(2^n),对于大数据量的数组(比如 n=10^4)会直接超时,绝对不推荐。

  • 误区 2:忽略「0」的影响。当数组中存在 0 时,需要判断这个 0 是否在「能到达的范围」内,且是否是最后一个元素。如果是中间的 0 且无法跳过,就会导致无法到达终点。

  • 误区 3:遍历范围过大。比如解法二中,如果遍历整个数组而不是 i <= maxReach,会导致遍历一些无法到达的位置,浪费时间。

希望通过这篇解析,大家能彻底理解跳跃游戏的贪心解法思路,并且能根据实际需求选择合适的解法。如果有疑问,欢迎在评论区交流~