引言
在上一篇文章中,我们学习了贪心算法的经典问题——区间调度问题。今天我们将探讨另一个有趣的贪心问题:跳跃游戏。
跳跃游戏问题不仅在算法面试中频繁出现,而且能够很好地展示贪心算法从暴力解法到优化解法的思维过程。通过这个问题,我们将学习如何逐步优化算法,找到最优的贪心策略。
问题描述
跳跃游戏 I (LeetCode 55)
给定一个非负整数数组 nums,你最初位于数组的第一个下标。数组中的每个元素代表你在该位置可以跳跃的最大长度。判断你是否能够到达最后一个下标。
示例 1:
1 2 3
| 输入:nums = [2,3,1,1,4] 输出:true 解释:可以先跳 1 步,从下标 0 到达下标 1, 然后再从下标 1 跳 3 步到达最后一个下标。
|
示例 2:
1 2 3 4
| 输入:nums = [3,2,1,0,4] 输出:false 解释:无论怎样,总会到达下标为 3 的位置。但该下标的最大跳跃长度是 0, 所以永远不可能到达最后一个下标。
|
跳跃游戏 II (LeetCode 45)
给定一个非负整数数组 nums,你最初位于数组的第一个位置。数组中的每个元素代表你在该位置可以跳跃的最大长度。你的目标是使用最少的跳跃次数到达数组的最后一个位置。假设你总是可以到达数组的最后一个位置。
示例:
1 2 3 4
| 输入:nums = [2,3,1,1,4] 输出:2 解释:跳到最后一个位置的最小跳跃数是 2。 从下标 0 跳到下标 1, 跳 1 步, 然后跳 3 步到达数组的最后一个位置。
|
解法一:跳跃游戏 I - 能否到达终点
思路分析
我们需要判断是否能到达最后一个位置。关键思想:维护一个能够到达的最远位置。
贪心策略:
- 遍历数组,不断更新能够到达的最远位置
- 如果当前位置超过了最远位置,说明无法继续前进
- 如果最远位置 >= 数组长度-1,说明可以到达终点
代码实现 - 跳跃游戏 I
Java 代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| public class JumpGame {
public static boolean canJump(int[] nums) { if (nums == null || nums.length == 0) { return false; } int maxReach = 0; for (int i = 0; i < nums.length; i++) { if (i > maxReach) { return false; } maxReach = Math.max(maxReach, i + nums[i]); if (maxReach >= nums.length - 1) { return true; } } return maxReach >= nums.length - 1; }
public static boolean canJumpBackward(int[] nums) { int lastPos = nums.length - 1; for (int i = nums.length - 2; i >= 0; i--) { if (i + nums[i] >= lastPos) { lastPos = i; } } return lastPos == 0; } }
|
Python 代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| def can_jump(nums): """ 跳跃游戏 I - 判断能否到达最后一个位置 :param nums: List[int] 非负整数数组 :return: bool 能否到达最后一个位置 """ if not nums: return False max_reach = 0 for i in range(len(nums)): if i > max_reach: return False max_reach = max(max_reach, i + nums[i]) if max_reach >= len(nums) - 1: return True return max_reach >= len(nums) - 1
def can_jump_backward(nums): """ 优化版本:倒序遍历 从后往前找,看能否一步步推到起点 """ last_pos = len(nums) - 1 for i in range(len(nums) - 2, -1, -1): if i + nums[i] >= last_pos: last_pos = i return last_pos == 0
|
复杂度分析
- 时间复杂度:O(n),只需遍历一遍数组
- 空间复杂度:O(1),只使用常数额外空间
解法二:跳跃游戏 II - 最少跳跃次数
贪心策略与算法流程
这个问题要求找到最少的跳跃次数。我们可以用贪心策略:在每一步中选择能够跳到最远位置的方案。
关键概念:
currentEnd:当前跳跃能到达的边界
farthest:下一跳能到达的最远位置
jumps:跳跃次数
算法流程:
- 遍历数组(不包括最后一个元素)
- 不断更新下一跳能到达的最远位置
- 当到达当前跳跃边界时,必须进行下一跳
代码实现 - 跳跃游戏 II
Java 代码 - 跳跃游戏 II
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
| public class JumpGameII {
public static int jump(int[] nums) { if (nums == null || nums.length <= 1) { return 0; } int jumps = 0; int currentEnd = 0; int farthest = 0; for (int i = 0; i < nums.length - 1; i++) { farthest = Math.max(farthest, i + nums[i]); if (i == currentEnd) { jumps++; currentEnd = farthest; if (currentEnd >= nums.length - 1) { break; } } } return jumps; }
public static int jumpDP(int[] nums) { int n = nums.length; int[] dp = new int[n]; Arrays.fill(dp, Integer.MAX_VALUE); dp[0] = 0; for (int i = 0; i < n; i++) { for (int j = 1; j <= nums[i] && i + j < n; j++) { dp[i + j] = Math.min(dp[i + j], dp[i] + 1); } } return dp[n - 1]; } }
|
Python 代码 - 跳跃游戏 II
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| def jump(nums): """ 跳跃游戏 II - 最少跳跃次数(贪心算法) :param nums: List[int] 非负整数数组 :return: int 最少跳跃次数 """ if not nums or len(nums) <= 1: return 0 jumps = 0 current_end = 0 farthest = 0 for i in range(len(nums) - 1): farthest = max(farthest, i + nums[i]) if i == current_end: jumps += 1 current_end = farthest if current_end >= len(nums) - 1: break return jumps
def jump_dp(nums): """ 动态规划解法(用于对比) 时间复杂度:O(n²),空间复杂度:O(n) """ n = len(nums) dp = [float('inf')] * n dp[0] = 0 for i in range(n): for j in range(1, nums[i] + 1): if i + j < n: dp[i + j] = min(dp[i + j], dp[i] + 1) return dp[n - 1]
|
算法图解
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| nums = [2, 3, 1, 1, 4] 索引: 0 1 2 3 4
第1跳:从索引0出发,可以跳到索引1或2 选择跳到索引1(因为从1出发能跳得更远) currentEnd = 2, farthest = 4
第2跳:在索引1,可以跳到索引2、3、4 直接到达索引4(终点) jumps = 2
过程可视化: [2, 3, 1, 1, 4] ^ ^ ^ ^ ^ | |__| | | |_____|__|__| 第1跳 第2跳
|
时间空间复杂度
- 时间复杂度:O(n),只需遍历一遍数组
- 空间复杂度:O(1),只使用常数额外空间
测试用例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| public class JumpGameTest { public static void main(String[] args) { System.out.println("=== 跳跃游戏 I ==="); int[] test1 = {2, 3, 1, 1, 4}; System.out.println("输入: " + Arrays.toString(test1)); System.out.println("能否到达: " + JumpGame.canJump(test1)); int[] test2 = {3, 2, 1, 0, 4}; System.out.println("输入: " + Arrays.toString(test2)); System.out.println("能否到达: " + JumpGame.canJump(test2)); System.out.println("\n=== 跳跃游戏 II ==="); int[] test3 = {2, 3, 1, 1, 4}; System.out.println("输入: " + Arrays.toString(test3)); System.out.println("最少跳跃次数: " + JumpGameII.jump(test3)); int[] test4 = {2, 3, 0, 1, 4}; System.out.println("输入: " + Arrays.toString(test4)); System.out.println("最少跳跃次数: " + JumpGameII.jump(test4)); int[] test5 = {1, 1, 1, 1, 1}; System.out.println("输入: " + Arrays.toString(test5)); System.out.println("最少跳跃次数: " + JumpGameII.jump(test5)); } }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| if __name__ == "__main__": print("=== 跳跃游戏 I ===") test1 = [2, 3, 1, 1, 4] print(f"输入: {test1}") print(f"能否到达: {can_jump(test1)}") test2 = [3, 2, 1, 0, 4] print(f"输入: {test2}") print(f"能否到达: {can_jump(test2)}") print("\n=== 跳跃游戏 II ===") test3 = [2, 3, 1, 1, 4] print(f"输入: {test3}") print(f"最少跳跃次数: {jump(test3)}") test4 = [2, 3, 0, 1, 4] print(f"输入: {test4}") print(f"最少跳跃次数: {jump(test4)}") test5 = [1, 1, 1, 1, 1] print(f"输入: {test5}") print(f"最少跳跃次数: {jump(test5)}")
|
相关LeetCode题目
55. 跳跃游戏
45. 跳跃游戏 II
1306. 跳跃游戏 III
1345. 跳跃游戏 IV
算法对比
| 方法 |
时间复杂度 |
空间复杂度 |
适用场景 |
| 贪心算法 |
O(n) |
O(1) |
跳跃游戏 I 和 II 的最优解 |
| 动态规划 |
O(n²) |
O(n) |
更复杂的变种问题 |
| BFS |
O(n²) |
O(n) |
需要记录路径的情况 |
关键思路总结
跳跃游戏 I 的贪心策略
核心思想:维护能够到达的最远位置
- 如果当前位置 > 最远位置,返回 false
- 不断更新最远位置
- 如果最远位置 >= 终点,返回 true
跳跃游戏 II 的贪心策略
核心思想:在必须跳跃时,选择能跳到最远的位置
- 维护两个变量:当前边界和下一跳最远位置
- 在遍历过程中不断更新下一跳最远位置
- 到达当前边界时,执行跳跃,更新边界
思维扩展
跳跃游戏问题的本质是区间覆盖问题:
- 每个位置
i 可以覆盖的区间是 [i, i + nums[i]]
- 问题转化为:这些区间能否覆盖整个数组
- 贪心策略:始终选择能够延伸覆盖范围的最优方案
总结
通过跳跃游戏问题,我们学到了:
- 贪心算法的优化思路:从动态规划的 O(n²) 优化到贪心的 O(n)
- 问题抽象能力:将跳跃问题抽象为区间覆盖问题
- 边界条件处理:注意遍历范围和提前终止条件
- 多种解法对比:理解不同算法的适用场景
在下一篇文章中,我们将探讨加油站问题,这是另一个经典的贪心算法应用。
💡 思考题:
- 如果允许向左跳跃,算法需要如何修改?
- 如果数组中有负数(表示必须后退),问题会变成什么样?
📚 扩展阅读:
- LeetCode 跳跃游戏系列题目解析
- 贪心算法与动态规划的选择策略
- 区间覆盖问题的通用解法