跳跃游戏是数组贪心算法的经典入门问题,也是各大算法面试中的高频考点。问题描述简洁却蕴含深刻:给定一个非负整数数组,每个元素表示你在该位置可以跳跃的最大长度,判断你是否能够从数组第一个位置跳到最后一个位置。
这个问题的精妙之处在于,它存在从动态规划到贪心策略的优雅优化路径,完美展示了算法设计中”去冗余”的艺术。本文将用Java实现跳跃游戏系列问题,从基础的可达性判断,到最少跳跃次数求解,完整呈现算法演进过程。
问题一:跳跃游戏 I — 可达性判断
给定数组 nums,nums[i] 表示从位置i最多可以向右跳跃 nums[i] 步。判断是否能到达最后一个下标。
动态规划思路
最直观的想法是用动态规划:dp[i] 表示能否到达位置i。对于每个位置j < i,如果 dp[j] 为真且 j + nums[j] >= i,则 dp[i] = true。
/**
* 动态规划解法:判断是否能到达最后一个位置
* 时间复杂度 O(n^2),空间复杂度 O(n)
*/
public boolean canJumpDP(int[] nums) {
int n = nums.length;
boolean[] dp = new boolean[n];
dp[0] = true;
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
if (dp[j] && j + nums[j] >= i) {
dp[i] = true;
break;
}
}
}
return dp[n - 1];
}
贪心优化:维护最右可达边界
动态规划解法中,内层循环存在大量冗余判断。关键观察是:我们不需要知道每个位置是否可达,只需要知道当前能到达的最右边界。
设 maxReach 为当前能够到达的最远位置。遍历数组时,如果当前位置 i <= maxReach,说明位置i是可达的,此时更新 maxReach = max(maxReach, i + nums[i])。如果某时刻 maxReach >= n - 1,说明可以到达终点。
/**
* 贪心解法:维护最右可达边界
* 时间复杂度 O(n),空间复杂度 O(1)
*/
public boolean canJumpGreedy(int[] nums) {
int n = nums.length;
int maxReach = 0; // 当前能到达的最远位置
for (int i = 0; i < n; i++) {
// 如果当前位置已经超出可达范围,直接返回false
if (i > maxReach) {
return false;
}
// 更新最远可达位置
maxReach = Math.max(maxReach, i + nums[i]);
// 提前终止:已经可以到达终点
if (maxReach >= n - 1) {
return true;
}
}
return true;
}
贪心解法的优雅在于:它将时间复杂度从O(n²)降到了O(n),空间复杂度从O(n)降到了O(1)。这是通过丢弃不必要的中间状态信息实现的——我们只需要一个标量 maxReach,而非整个DP数组。
问题二:跳跃游戏 II — 最少跳跃次数
进阶版本:假设你一定可以到达最后一个位置,求到达终点的最少跳跃次数。
动态规划思路
dp[i] 表示到达位置i的最少跳跃次数。对于每个位置j < i,如果 j + nums[j] >= i,则 dp[i] = min(dp[i], dp[j] + 1)。
/**
* 动态规划解法:求最少跳跃次数
* 时间复杂度 O(n^2),空间复杂度 O(n)
*/
public int jumpDP(int[] nums) {
int n = nums.length;
int[] dp = new int[n];
Arrays.fill(dp, Integer.MAX_VALUE / 2);
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];
}
贪心优化:维护当前步边界与下一步边界
同样可以用贪心优化到O(n)。核心思想是:在当前步数能到达的范围内,找出下一步能到达的最远位置。
设 currentEnd 为当前步数能到达的最右边界,farthest 为下一步能到达的最远边界。当遍历到 currentEnd 时,必须再跳一步,将 currentEnd 更新为 farthest。
/**
* 贪心解法:维护当前步边界与下一步最远距离
* 时间复杂度 O(n),空间复杂度 O(1)
*/
public int jumpGreedy(int[] nums) {
int n = nums.length;
if (n <= 1) return 0;
int jumps = 0; // 已用跳跃次数
int currentEnd = 0; // 当前步数能到达的最右边界
int farthest = 0; // 下一步能到达的最远位置
for (int i = 0; i < n - 1; i++) {
// 更新下一步能到达的最远位置
farthest = Math.max(farthest, i + nums[i]);
// 到达当前步的边界,必须再跳一步
if (i == currentEnd) {
jumps++;
currentEnd = farthest;
// 提前终止
if (currentEnd >= n - 1) {
break;
}
}
}
return jumps;
}
完整可运行代码
import java.util.*;
/**
* 跳跃游戏求解器
* 包含两个经典问题:可达性判断与最少跳跃次数
*/
public class JumpGameSolver {
// ========== 问题一:跳跃游戏 I - 可达性判断 ==========
public boolean canJumpDP(int[] nums) {
int n = nums.length;
boolean[] dp = new boolean[n];
dp[0] = true;
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
if (dp[j] && j + nums[j] >= i) {
dp[i] = true;
break;
}
}
}
return dp[n - 1];
}
public boolean canJumpGreedy(int[] nums) {
int n = nums.length;
int maxReach = 0;
for (int i = 0; i < n; i++) {
if (i > maxReach) return false;
maxReach = Math.max(maxReach, i + nums[i]);
if (maxReach >= n - 1) return true;
}
return true;
}
// ========== 问题二:跳跃游戏 II - 最少跳跃次数 ==========
public int jumpDP(int[] nums) {
int n = nums.length;
int[] dp = new int[n];
Arrays.fill(dp, Integer.MAX_VALUE / 2);
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];
}
public int jumpGreedy(int[] nums) {
int n = nums.length;
if (n <= 1) return 0;
int jumps = 0, currentEnd = 0, farthest = 0;
for (int i = 0; i < n - 1; i++) {
farthest = Math.max(farthest, i + nums[i]);
if (i == currentEnd) {
jumps++;
currentEnd = farthest;
if (currentEnd >= n - 1) break;
}
}
return jumps;
}
// ========== 演示与测试 ==========
public void demonstrate() {
int[][] testCases = {
{2, 3, 1, 1, 4}, // 可达,最少2跳
{3, 2, 1, 0, 4}, // 不可达
{2, 0, 0}, // 可达,最少1跳
{1, 1, 1, 1}, // 可达,最少3跳
{5, 4, 3, 2, 1, 0}, // 可达,最少1跳
};
for (int[] nums : testCases) {
System.out.println("数组: " + Arrays.toString(nums));
boolean can = canJumpGreedy(nums);
System.out.println(" 是否可达: " + can);
if (can) {
System.out.println(" 最少跳跃次数: " + jumpGreedy(nums));
}
System.out.println();
}
}
public static void main(String[] args) {
new JumpGameSolver().demonstrate();
}
}
算法复杂度对比
| 问题 | 解法 | 时间复杂度 | 空间复杂度 | 核心思想 |
|---|---|---|---|---|
| 可达性 | 动态规划 | O(n²) | O(n) | 逐个位置判断 |
| 可达性 | 贪心 | O(n) | O(1) | 维护最右可达边界 |
| 最少次数 | 动态规划 | O(n²) | O(n) | 枚举所有前驱 |
| 最少次数 | 贪心 | O(n) | O(1) | 维护当前步边界与下一步最远 |
贪心正确性证明
跳跃游戏II的贪心策略为什么正确?关键在于:
当我们遍历到
currentEnd时,所有在[0, currentEnd]范围内的位置都已经在jumps步内可达。而[currentEnd+1, farthest]范围内的位置需要jumps+1步才能到达。选择farthest作为下一步边界,保证了我们在每一步都最大化了覆盖范围,因此跳跃次数最少。
这是典型的”贪心选择性质”——局部最优(每步跳到最远)导致全局最优(总步数最少)。
扩展思考
跳跃游戏的框架可以扩展到多维空间(如机器人网格路径规划)、带代价的版本(每次跳跃消耗不同能量)、以及障碍物版本(某些位置不可达)。核心的贪心思想——维护可达边界——在这些问题中依然适用。