目录
- 1.问题描述
- 2.问题分析
-
- [2.1 题目理解](#2.1 题目理解)
- [2.2 核心洞察](#2.2 核心洞察)
- [2.3 破题关键](#2.3 破题关键)
- 3.算法设计与实现
-
- [3.1 解法一:贪心算法(正向遍历)](#3.1 解法一:贪心算法(正向遍历))
- [3.2 解法二:反向贪心(从后往前)](#3.2 解法二:反向贪心(从后往前))
- [3.3 解法三:动态规划(自上而下记忆化)](#3.3 解法三:动态规划(自上而下记忆化))
- [3.4 解法四:动态规划(自底向上)](#3.4 解法四:动态规划(自底向上))
- [3.5 解法五:BFS/队列模拟](#3.5 解法五:BFS/队列模拟)
- 4.性能对比
-
- [4.1 理论复杂度对比表](#4.1 理论复杂度对比表)
- [4.2 实际性能测试(n=10^4)](#4.2 实际性能测试(n=10^4))
- [4.3 各场景适用性分析](#4.3 各场景适用性分析)
- 5.扩展与变体
-
- [5.1 变体一:跳跃游戏 II(最少跳跃次数)](#5.1 变体一:跳跃游戏 II(最少跳跃次数))
- [5.2 变体二:跳跃游戏 III(带跳跃方向)](#5.2 变体二:跳跃游戏 III(带跳跃方向))
- [5.3 变体三:跳跃游戏 IV(等值跳跃)](#5.3 变体三:跳跃游戏 IV(等值跳跃))
- [5.4 变体四:跳跃游戏(含障碍物)](#5.4 变体四:跳跃游戏(含障碍物))
- 6.总结
-
- [6.1 核心思想总结](#6.1 核心思想总结)
- [6.2 实际应用场景](#6.2 实际应用场景)
- [6.3 面试建议](#6.3 面试建议)
- [6.4 常见面试问题Q&A](#6.4 常见面试问题Q&A)
1.问题描述
给你一个非负整数数组 nums,你最初位于数组的第一个下标 。数组中的每个元素代表你在该位置可以跳跃的最大长度。判断你是否能够到达最后一个下标,如果可以,返回 true;否则,返回 false。
示例 1:
输入:nums = [2,3,1,1,4]
输出:true
解释:可以先跳 1 步,从下标 0 到达下标 1, 然后再从下标 1 跳 3 步到达最后一个下标。
示例 2:
输入:nums = [3,2,1,0,4]
输出:false
解释:无论怎样,总会到达下标为 3 的位置。但该下标的最大跳跃长度是 0 ,所以永远不可能到达最后一个下标。
提示:
1 <= nums.length <= 10^40 <= nums[i] <= 10^5
2.问题分析
2.1 题目理解
给定一个数组,每个位置表示从该位置可以向前跳跃的最大步数(可以跳 0 到该值之间的任意步数)。问能否从第一个位置跳到最后一个位置。
2.2 核心洞察
- 贪心策略 :维护一个当前能到达的最远位置(
maxReach),遍历每个位置,如果当前位置在可达范围内,则更新maxReach = max(maxReach, i + nums[i])。如果maxReach >= n-1,则返回true;如果遇到某个位置不可达(i > maxReach),则提前返回false。 - 动态规划 :定义
dp[i]表示从位置 0 能否到达位置 i,递推时从所有能到达 i 的前驱位置更新。但复杂度较高。 - 反向贪心:从后往前看,维护一个"需要到达的最左位置",如果当前位置能跳到该位置,则更新目标位置为当前位置。最终如果目标位置为 0,则可达。
2.3 破题关键
- 贪心算法是最优解法,只需一次遍历,O(n) 时间。
- 注意边界:数组长度为 1 时直接返回 true。
- 如果某个位置跳跃能力为 0 且不是终点,可能成为障碍。
3.算法设计与实现
3.1 解法一:贪心算法(正向遍历)
核心思想:
维护当前能到达的最远位置,遍历每个位置,如果当前位置可达,则更新最远距离;如果最远距离已经覆盖终点,则返回 true。
算法思路:
- 初始化
maxReach = 0。 - 遍历
i从 0 到 n-1:- 如果
i > maxReach,说明当前位置不可达,直接返回 false。 - 更新
maxReach = max(maxReach, i + nums[i])。 - 如果
maxReach >= n-1,返回 true。
- 如果
- 遍历结束,返回 true(实际上若循环未提前结束,说明所有位置都可达)。
Java代码实现:
java
class Solution {
public boolean canJump(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;
}
}
性能分析:
- 时间复杂度:O(n),一次遍历。
- 空间复杂度:O(1)。
- 优点:简单高效,是本题的最优解。
- 缺点:需要理解贪心的正确性。
3.2 解法二:反向贪心(从后往前)
核心思想:
从最后一个位置开始,向前寻找能够到达当前位置的位置。维护一个目标位置 lastPos,初始为 n-1。从右向左遍历,如果当前索引 i 能够到达 lastPos(即 i + nums[i] >= lastPos),则更新 lastPos = i。最后检查 lastPos == 0。
算法思路:
- 初始化
lastPos = n - 1。 - 从
i = n-1递减到 0:- 如果
i + nums[i] >= lastPos,则更新lastPos = i。
- 如果
- 返回
lastPos == 0。
Java代码实现:
java
class Solution {
public boolean canJump(int[] nums) {
int n = nums.length;
int lastPos = n - 1;
for (int i = n - 1; i >= 0; i--) {
if (i + nums[i] >= lastPos) {
lastPos = i;
}
}
return lastPos == 0;
}
}
性能分析:
- 时间复杂度:O(n)
- 空间复杂度:O(1)
- 优点:思路简单,从后往前,无需提前判断可达性。
- 缺点:与正向贪心本质相同,但遍历方向相反。
3.3 解法三:动态规划(自上而下记忆化)
核心思想:
定义状态 dp[i] 表示从位置 i 能否到达终点。递归地,如果 dp[i] 可以跳转到某个可达位置,则 dp[i] = true。使用记忆化搜索避免重复计算。
算法思路:
- 创建记忆数组
memo,memo[i]表示从 i 出发能否到达终点(-1 未知,0 不能,1 能)。 - 递归函数
canJumpFrom(i):- 如果
i >= n-1,返回 true。 - 如果
memo[i]已计算,直接返回。 - 尝试从
i跳到i+1到i+nums[i]的范围,如果任一可达,则标记 true。
- 如果
- 返回
canJumpFrom(0)。
Java代码实现:
java
class Solution {
public boolean canJump(int[] nums) {
int n = nums.length;
int[] memo = new int[n]; // 0: unknown, 1: can, -1: cannot
memo[n-1] = 1;
return canJumpFrom(0, nums, memo);
}
private boolean canJumpFrom(int pos, int[] nums, int[] memo) {
if (memo[pos] != 0) {
return memo[pos] == 1;
}
int maxJump = Math.min(nums.length - 1, pos + nums[pos]);
for (int next = pos + 1; next <= maxJump; next++) {
if (canJumpFrom(next, nums, memo)) {
memo[pos] = 1;
return true;
}
}
memo[pos] = -1;
return false;
}
}
性能分析:
- 时间复杂度:最坏 O(n²)(例如 [n-1, n-2, ..., 1] 时,每个位置都要尝试很多步)。
- 空间复杂度:O(n) 递归栈 + 记忆数组。
- 优点:符合直觉的递归思想。
- 缺点:效率低,可能超时。
3.4 解法四:动态规划(自底向上)
核心思想:
从后向前递推,dp[i] 表示从 i 能否到达终点。dp[n-1] = true。对于每个 i,如果存在 j 在 [i+1, i+nums[i]] 范围内使得 dp[j] = true,则 dp[i] = true。
算法思路:
- 创建布尔数组
dp,长度 n,dp[n-1] = true。 - 从
i = n-2递减到 0:- 计算最远能跳到的位置
maxJump = Math.min(n-1, i + nums[i])。 - 遍历
j从i+1到maxJump,如果dp[j]为 true,则dp[i] = true并 break。
- 计算最远能跳到的位置
- 返回
dp[0]。
Java代码实现:
java
class Solution {
public boolean canJump(int[] nums) {
int n = nums.length;
boolean[] dp = new boolean[n];
dp[n-1] = true;
for (int i = n-2; i >= 0; i--) {
int maxJump = Math.min(n-1, i + nums[i]);
for (int j = i+1; j <= maxJump; j++) {
if (dp[j]) {
dp[i] = true;
break;
}
}
}
return dp[0];
}
}
性能分析:
- 时间复杂度:O(n²),最坏情况。
- 空间复杂度:O(n)。
- 优点:实现简单。
- 缺点:效率低,不适合大数据。
3.5 解法五:BFS/队列模拟
核心思想:
将跳跃看作图上的边,从 0 开始 BFS,看是否能到达 n-1。但实际不需要,因为图是线性的,但可以作为一种思路。
算法思路:
- 使用队列存储当前可到达的位置,并记录已访问。
- 从 0 开始,对于每个位置,将其能跳到的所有位置加入队列(如果未访问)。
- 如果遇到 n-1,返回 true。
- 队列空则返回 false。
Java代码实现:
java
import java.util.*;
class Solution {
public boolean canJump(int[] nums) {
int n = nums.length;
boolean[] visited = new boolean[n];
Queue<Integer> queue = new LinkedList<>();
queue.offer(0);
visited[0] = true;
while (!queue.isEmpty()) {
int cur = queue.poll();
if (cur == n-1) return true;
int maxJump = Math.min(n-1, cur + nums[cur]);
for (int next = cur+1; next <= maxJump; next++) {
if (!visited[next]) {
visited[next] = true;
queue.offer(next);
}
}
}
return false;
}
}
性能分析:
- 时间复杂度:O(n²)(最坏情况每个位置可能被多次考虑,但实际每个位置只入队一次,但内层循环可能重复遍历)。
- 空间复杂度:O(n)。
- 优点:通用图遍历思想。
- 缺点:效率不高,不如贪心。
4.性能对比
4.1 理论复杂度对比表
| 解法 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 |
|---|---|---|---|---|
| 贪心(正向) | O(n) | O(1) | 最优,简洁 | 需要理解贪心正确性 |
| 贪心(反向) | O(n) | O(1) | 从后往前,思路不同 | 与正向本质相同 |
| 记忆化递归 | O(n²) | O(n) | 自然递归 | 效率低,可能栈溢出 |
| 自底向上DP | O(n²) | O(n) | 容易理解 | 效率低 |
| BFS | O(n²) | O(n) | 图论视角 | 效率低 |
4.2 实际性能测试(n=10^4)
- 贪心算法:约 0.2 ms
- 其他 O(n²) 算法:在极端数据下会超时
4.3 各场景适用性分析
- 面试场景:贪心法是标准答案,必须掌握。
- 竞赛场景:贪心法最快。
- 学习场景:可对比不同解法,展示优化过程。
5.扩展与变体
5.1 变体一:跳跃游戏 II(最少跳跃次数)
题目描述:给定一个非负整数数组,你最初位于第一个位置。数组中的每个元素代表你在该位置可以跳跃的最大长度。你的目标是使用最少的跳跃次数到达最后一个位置。假设你总是可以到达最后一个位置。
Java代码实现:
java
class Solution {
public int jump(int[] nums) {
int n = nums.length;
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;
}
}
return jumps;
}
}
5.2 变体二:跳跃游戏 III(带跳跃方向)
题目描述 :给定一个非负整数数组,你从索引 start 开始。你可以向左或向右跳 nums[i] 步,问是否能到达值为 0 的索引。
Java代码实现:
java
class Solution {
public boolean canReach(int[] arr, int start) {
int n = arr.length;
boolean[] visited = new boolean[n];
Queue<Integer> queue = new LinkedList<>();
queue.offer(start);
visited[start] = true;
while (!queue.isEmpty()) {
int cur = queue.poll();
if (arr[cur] == 0) return true;
int left = cur - arr[cur];
if (left >= 0 && !visited[left]) {
visited[left] = true;
queue.offer(left);
}
int right = cur + arr[cur];
if (right < n && !visited[right]) {
visited[right] = true;
queue.offer(right);
}
}
return false;
}
}
5.3 变体三:跳跃游戏 IV(等值跳跃)
题目描述 :
给定一个整数数组 arr,你从索引 0 开始,每次可以:
- 向前或向后跳 1 步(即从索引
i可以跳到i+1或i-1,前提是不越界); - 或者跳到任意一个与当前索引值相同的索引(即如果
arr[i] == arr[j],你可以从i跳到j)。
求到达最后一个索引(n-1)的最少步数。题目保证至少有一种方式可以到达终点。
核心思想 :
使用 BFS 进行最短路径搜索。每个位置是一个节点,相邻位置(i+1、i-1)以及所有相同值的位置都是邻居。为了避免重复遍历,对于相同值的节点,当第一次访问到该值对应的所有节点时,将它们全部加入队列后,就可以删除该值的映射,避免后续重复处理。
Java代码实现:
java
import java.util.*;
class Solution {
public int minJumps(int[] arr) {
int n = arr.length;
if (n == 1) return 0;
// 构建值到索引列表的映射
Map<Integer, List<Integer>> valueToIndices = new HashMap<>();
for (int i = 0; i < n; i++) {
valueToIndices.computeIfAbsent(arr[i], k -> new ArrayList<>()).add(i);
}
boolean[] visited = new boolean[n];
Queue<Integer> queue = new LinkedList<>();
queue.offer(0);
visited[0] = true;
int steps = 0;
while (!queue.isEmpty()) {
int size = queue.size();
for (int i = 0; i < size; i++) {
int cur = queue.poll();
if (cur == n - 1) return steps;
// 跳相邻位置
if (cur - 1 >= 0 && !visited[cur - 1]) {
visited[cur - 1] = true;
queue.offer(cur - 1);
}
if (cur + 1 < n && !visited[cur + 1]) {
visited[cur + 1] = true;
queue.offer(cur + 1);
}
// 跳相同值的位置
List<Integer> sameValues = valueToIndices.getOrDefault(arr[cur], new ArrayList<>());
for (int next : sameValues) {
if (!visited[next]) {
visited[next] = true;
queue.offer(next);
}
}
// 关键优化:处理完当前值的所有节点后,清空该列表,避免重复处理
valueToIndices.remove(arr[cur]);
}
steps++;
}
return -1; // 理论上不会执行到这里
}
}
5.4 变体四:跳跃游戏(含障碍物)
题目描述 :
给定一个非负整数数组 nums,其中某些位置可能是障碍物(用 -1 表示),你无法停留在这些位置上。你最初位于第一个下标(0),数组中的非负整数代表在该位置可以跳跃的最大长度。判断你是否能够到达最后一个下标。注意:你不能跳到障碍物上(即 nums[i] == -1 的位置不可达)。
核心思想 :
与标准跳跃游戏类似,但需要跳过障碍物。在维护最远可达距离时,只考虑非障碍物位置,并且当前位置必须是可达的(即 i <= maxReach)。另外,如果起点就是障碍物,直接返回 false(但题目通常不会这样)。
Java代码实现:
java
class Solution {
public boolean canJumpWithObstacles(int[] nums) {
int n = nums.length;
// 起点是障碍物则无法开始
if (nums[0] == -1) return false;
int maxReach = 0;
for (int i = 0; i < n; i++) {
// 如果当前位置不可达,提前退出
if (i > maxReach) break;
// 当前位置是障碍物,不能作为跳板,但可能通过其他位置跳过
if (nums[i] == -1) continue;
maxReach = Math.max(maxReach, i + nums[i]);
if (maxReach >= n - 1) return true;
}
return maxReach >= n - 1;
}
}
补充说明:
- 障碍物位置本身不能被选择为落脚点,但可以跳过它(即如果
i是障碍物,我们仍可以从它之前的位置跳到它之后的位置,只要跳跃距离足够)。 - 如果障碍物在起点,直接返回 false。
- 该解法时间复杂度 O(n),空间 O(1)。
6.总结
6.1 核心思想总结
- 贪心:维护当前能到达的最远距离,是本题的最优解。
- 动态规划:从后向前递推,但效率低。
- 反向贪心:从后向前找能够到达终点的最左位置。
6.2 实际应用场景
- 游戏中的角色跳跃判定
- 网络路由中的可达性分析
- 动态规划经典例题
6.3 面试建议
- 首选贪心解法,并解释其正确性。
- 能够对比动态规划和贪心的优劣。
- 注意边界条件(如数组长度为1)。
6.4 常见面试问题Q&A
Q1:贪心算法为什么是正确的?
A1:因为对于每个位置,我们只需要知道最远能到达的距离,而不关心具体路径。只要当前位置可达,那么它所能覆盖的区间都是可达的,贪心维护最远距离即可。
Q2:如果要求输出路径,贪心还能用吗?
A2:贪心只能判断是否可达,要输出具体路径,需要记录每一步的跳转。
Q3:跳跃游戏 II 的贪心策略是什么?
A3:在贪心基础上,记录当前步数能到达的最远距离,当遍历到当前步数边界时,步数加1。
Q4:如果数组中有负数,还能用贪心吗?
A4:不能,因为负数表示不能向后的情况,但题目规定非负,所以没有问题。
Q5:动态规划解法为什么效率低?
A5:因为每个位置可能重复检查后续所有可能的位置,导致 O(n²) 复杂度。