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
方法一:动态规划(反向查找)
算法思路
- 状态定义:
dp[i]
表示从位置i
到达数组末尾的最小跳跃次数
。 - 初始化:末尾位置不需要跳跃(
dp[n-1] = 0
)。 - 状态转移:对于每个位置
i
,遍历它能跳到的所有位置j
(i+1
到i+nums[i]
),取dp[j] + 1
的最小值。 - 结果:返回
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 数组。
方法二:贪心算法(正向查找)
算法思路
- 核心变量:
steps
:记录当前跳跃次数。end
:当前步数能到达的最远边界。maxPos
:下一步能到达的最远位置。
- 遍历数组:
- 更新
maxPos = max(maxPos, i + nums[i])
。 - 当遍历到当前边界
end
时:- 跳跃次数
steps++
。 - 更新边界
end = maxPos
(表示进入下一步)。
- 跳跃次数
- 提前终止:如果
end
已覆盖终点,直接结束。
- 更新
- 结果:返回跳跃次数
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]
:
- 初始化:
steps=0, end=0, maxPos=0
。 - 遍历:
i=0
:maxPos = max(0, 0+2)=2
→ 到达边界end=0
→steps=1
,end=2
。i=1
:maxPos = max(2, 1+3)=4
(边界未更新)。i=2
:maxPos = max(4, 2+1)=4
→ 到达边界end=2
→steps=2
,end=4
。i=3
:maxPos
不变 → 边界end=4
已覆盖终点,循环结束。
- 结果:
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
}
关键点
- 动态规划:
- 反向计算最小跳跃次数。
- 适用于理解状态转移,但效率较低(O(n²))。
- 贪心算法:
核心思想
:每一步都跳到未来潜力最大的位置。- 边界更新:当遍历到当前边界时,意味着必须进行下一次跳跃。
- 提前终止:若边界已覆盖终点,直接结束。
- 最优选择:时间复杂度 O(n),空间复杂度 O(1),是本题最优解法。
常见问题
- 为什么贪心算法中循环条件是
i < n-1
?
终点不需要跳跃,且当end
覆盖终点时可提前终止。 - 贪心算法的正确性保证?
每一步都扩展了下一步的最大覆盖范围,确保跳跃次数最小。 - 动态规划为何效率低?
每个位置需遍历其后所有可达位置,最坏情况时间复杂度 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]
(起点到终点的解)。
无后效性要求
动态规划的核心要求之一是无后效性:一个子问题的解一旦确定,就不应受后续状态的影响:
- 状态定义应该包含确定问题解的所有必要信息
- 状态转移不应依赖未求解的状态
- 状态一旦计算完成,其值不应再改变