> 技术文档 > 动态规划 跳跃游戏 II

动态规划 跳跃游戏 II


LeetCode 45. 跳跃游戏 II

问题描述

给定一个非负整数数组 nums,初始位于数组的第一个位置。数组中的每个元素代表在该位置可以跳跃的最大长度。目标是使用最少的跳跃次数到达数组的最后一个位置(假设总是可以到达)。

示例

示例 1:输入: nums = [2,3,1,1,4]输出: 2解释: 跳到最后一个位置的最小跳跃数是 2。 从下标为 0 跳到下标为 1 的位置,跳 1 步,然后跳 3 步到达数组的最后一个位置。 示例 2:输入: nums = [2,3,0,1,4]输出: 2

方法一:动态规划(反向查找)

算法思路

  1. 状态定义dp[i] 表示从位置 i 到达数组末尾的最小跳跃次数
  2. 初始化:末尾位置不需要跳跃(dp[n-1] = 0)。
  3. 状态转移:对于每个位置 i遍历它能跳到的所有位置 ji+1i+nums[i]),取 dp[j] + 1 的最小值。
  4. 结果:返回 dp[0](从起点到终点的最小跳跃次数)。

代码实现

class Solution { public int jump(int[] nums) { int n = nums.length; // dp[i] 表示从位置 i 到达末尾的最小跳跃次数 int[] dp = new int[n]; // 初始化:最后一个位置不需要跳跃 dp[n - 1] = 0; // 从后向前计算每个位置的最小跳跃次数 for (int i = n - 2; i >= 0; i--) { // 如果当前位置可以直接跳到末尾 if (i + nums[i] >= n - 1) { dp[i] = 1; } else { // 初始化为一个较大值(表示暂时不可达) dp[i] = Integer.MAX_VALUE - 1; // 防止后续 +1 溢出 // 遍历当前位置能跳到的所有位置 for (int j = i + 1; j <= i + nums[i] && j < n; j++) {  // 如果位置 j 可达,则更新 dp[i]  if (dp[j] != Integer.MAX_VALUE) { dp[i] = Math.min(dp[i], dp[j] + 1);  } } } } return dp[0]; }}

复杂度分析

  • 时间复杂度:O(n²),其中 n 是数组长度。每个位置可能需要遍历最多 n 个位置。
  • 空间复杂度:O(n),用于存储 dp 数组。

方法二:贪心算法(正向查找)

算法思路

  1. 核心变量
    • steps:记录当前跳跃次数。
    • end:当前步数能到达的最远边界。
    • maxPos:下一步能到达的最远位置。
  2. 遍历数组
    • 更新 maxPos = max(maxPos, i + nums[i])
    • 当遍历到当前边界 end 时:
      • 跳跃次数 steps++
      • 更新边界 end = maxPos(表示进入下一步)。
    • 提前终止:如果 end 已覆盖终点,直接结束。
  3. 结果:返回跳跃次数 steps

代码实现

class Solution { public int jump(int[] nums) { int n = nums.length; int steps = 0; // 跳跃次数 int end = 0; // 当前步数能到达的边界 int maxPos = 0; // 下一步能到达的最远位置 // 遍历数组(注意终点不需要再跳) for (int i = 0; i < n - 1; i++) { // 更新下一步能跳到的最远位置 maxPos = Math.max(maxPos, i + nums[i]); // 到达当前步数的边界,必须进行下一次跳跃 if (i == end) { steps++; // 增加跳跃次数 end = maxPos; // 更新边界 // 如果新边界已覆盖终点,提前结束 if (end >= n - 1) break; } } return steps; }}

复杂度分析

  • 时间复杂度:O(n),只需遍历数组一次。
  • 空间复杂度:O(1),仅使用常数空间。

算法过程(贪心法)

nums = [2,3,1,1,4]

  1. 初始化steps=0, end=0, maxPos=0
  2. 遍历
    • i=0maxPos = max(0, 0+2)=2 → 到达边界 end=0steps=1, end=2
    • i=1maxPos = max(2, 1+3)=4(边界未更新)。
    • i=2maxPos = max(4, 2+1)=4 → 到达边界 end=2steps=2, end=4
    • i=3maxPos 不变 → 边界 end=4 已覆盖终点,循环结束。
  3. 结果steps=2

测试用例

public static void main(String[] args) { Solution solution = new Solution(); // 测试用例1: 标准示例 int[] nums1 = {2,3,1,1,4}; System.out.println(\"Test 1: \" + solution.jump(nums1)); // 2 // 测试用例2: 起点即终点 int[] nums2 = {0}; System.out.println(\"Test 2: \" + solution.jump(nums2)); // 0 // 测试用例3: 需要连续跳跃 int[] nums3 = {1,2,1,1,1}; System.out.println(\"Test 3: \" + solution.jump(nums3)); // 3 // 测试用例4: 大跨度跳跃 int[] nums4 = {2,3,0,1,4}; System.out.println(\"Test 4: \" + solution.jump(nums4)); // 2 // 测试用例5: 递增序列 int[] nums5 = {1,2,3,4,5}; System.out.println(\"Test 5: \" + solution.jump(nums5)); // 3}

关键点

  1. 动态规划
    • 反向计算最小跳跃次数。
    • 适用于理解状态转移,但效率较低(O(n²))。
  2. 贪心算法
    • 核心思想:每一步都跳到未来潜力最大的位置。
    • 边界更新:当遍历到当前边界时,意味着必须进行下一次跳跃。
    • 提前终止:若边界已覆盖终点,直接结束。
    • 最优选择:时间复杂度 O(n),空间复杂度 O(1),是本题最优解法。

常见问题

  1. 为什么贪心算法中循环条件是 i < n-1
    终点不需要跳跃,且当 end 覆盖终点时可提前终止。
  2. 贪心算法的正确性保证
    每一步都扩展了下一步的最大覆盖范围,确保跳跃次数最小。
  3. 动态规划为何效率低
    每个位置需遍历其后所有可达位置,最坏情况时间复杂度 O(n²)。

为什么动态规划使用反向查找

1. 问题依赖关系的方向性

  • 跳跃方向:从位置 i 只能向后跳跃(i → i+1, i+2, ..., i+nums[i])。
  • 状态依赖:要计算 dp[i](从 i 到终点的最小步数),必须知道 i 后方位置的状态(dp[i+1], dp[i+2], …)。
  • 反向计算:从终点开始倒序计算,确保计算 dp[i] 时,其依赖的所有 dp[j]j > i)都已计算完成。

2. 正向查找的缺陷

若尝试正向计算(从起点向终点):

public int jump(int[] nums) { int n = nums.length; int[] dp = new int[n]; Arrays.fill(dp, Integer.MAX_VALUE); dp[0] = 0; for (int i = 1; i < n; i++) { for (int j = 0; j < i; j++) { if (j + nums[j] >= i) { dp[i] = Math.min(dp[i], dp[j] + 1); } } } return dp[n-1];}
  • 问题 1:计算 dp[i] 时,dp[j] 可能未达到最优解(动态规划要求无后效性)。
  • 问题 2:需要遍历所有 j < i,最坏时间复杂度 O(n²),且无法利用跳跃特性优化。

3. 反向查找的优势

(1) 自然符合状态依赖
for (int i = n - 2; i >= 0; i--) { for (int j = i + 1; j <= i + nums[i]; j++) { dp[i] = Math.min(dp[i], dp[j] + 1); // 依赖后方状态 }}
  • 倒序计算确保 dp[j]dp[i] 之前已求解。
(2) 提前终止优化
if (i + nums[i] >= n - 1) { dp[i] = 1; // 直接跳到终点}
  • 若从 i 可直接跳到终点,无需遍历后续位置。
(3) 逻辑清晰
  • 状态定义dp[i] = \"从 i 到终点的最小步数\" 符合直觉。
  • 目标明确:直接返回 dp[0](起点到终点的解)。

无后效性要求

动态规划的核心要求之一是无后效性:一个子问题的解一旦确定,就不应受后续状态的影响:

  1. 状态定义应该包含确定问题解的所有必要信息
  2. 状态转移不应依赖未求解的状态
  3. 状态一旦计算完成,其值不应再改变