1306. 跳跃游戏 III --- 图搜索思路拆解
1. 题目概述与建模
题目 :给定一个非负整数数组 arr 和起始位置 start,当位于下标 i 时,可以跳到 i + arr[i] 或 i - arr[i](不能越界)。判断能否跳到任意一个值为 0 的下标处。
🌏 为什么要学这道题? 这道题的模式在真实系统中无处不在:网页爬虫从种子 URL 出发,沿着链接爬向新页面,已爬过的不重复爬------这就是图的遍历。理解了这道题,你就理解了爬虫的核心逻辑,也是所有"从 A 出发能否到达 B"问题的通用解法。
思考过程还原 :我第一次看到这道题,脑子里先闪过"贪心"------Jump Game I 不是贪心吗?但很快注意到一个关键细节:这题可以往左跳。一旦能往左跳,就可能出现环(比如 0→3→0)。有环的图遍历和树不一样,需要 visited 记录"走过的路",否则会陷入死循环。这一步很关键,是整道题的转折点。
问题建模
这道题在问什么?从 start 出发,能不能到达值为 0 的位置------这是图可达性问题。
关键建模步骤:把数组的每个下标看成一个节点,把合法的跳跃看做边。
节点: arr 的每个下标 i (0~n-1)
边: i → i + arr[i] (向右跳)
i → i - arr[i] (向左跳)
目标: 能否到达任意 arr[i] == 0 的节点
以上面的示例 arr = [4,2,3,0,3,1,2], start = 5 为例,建模出来的图:
邻接表:
0 → 4 (0+4) 0是目标节点
1 → 3 (1+2), -1(越界) 3是目标节点
2 → 5 (2+3), -1(越界)
3 → 7(越界), 0 (3-3) 3是目标节点
4 → 8(越界), 1 (4-3)
5 → 6 (5+1), 4 (5-1)
6 → 8(越界), 4 (6-2)
为什么这是图而不是树? 因为双向跳跃会造出环。例如:5→4→1→3,又从 3 的 3-3=0 可以跳回不到达。所以必须有 visited 记录走过的节点,否则会死循环。
✅ 到这里,你应该能回答:为什么这道题是图遍历问题,而不是树遍历?如果还说不清楚------树只有往下走的分支,不会绕回来;图可能有环,所以需要 visited。
2. 第一个思路:DFS 递归
2.1 为什么会想到递归?
💡 想象一下:你在商场找出口,每到一个路口有两条路可选------往左走 arr[i] 步,往右走 arr[i] 步。你会怎么做?随便选一条走到底,走不通就退回来换一条------这就是 DFS(深度优先搜索)。
再来看为什么这题适合递归:站在任何一个位置,你做的事和下一步做的事完全一样------检查自己是不是 0,然后往两个方向跳。这个"自己做的事和子问题做的事相同"的特征,就是递归的味道。
2.2 标准递归写法
cpp
class Solution {
unordered_set<int> paths; // 记录走过的节点,防止重复访问(防环)
public:
bool canReach(vector<int>& arr, int start) {
return dfs(arr, start);
}
bool dfs(vector<int>& nums, int i) {
// 1. 越界检查:无法跳到数组之外
if (i < 0 || i >= nums.size()) return false;
// 2. 已访问检查:双向跳跃会成环,走过的路不再走
if (paths.count(i)) return false;
// 3. 标记为已访问
paths.insert(i);
// 4. 目标检查:值为 0 → 到达!
if (nums[i] == 0) return true;
// 5. 往两个方向跳 ------ 短路求值:左边能到就不用看右边了
bool ret = dfs(nums, i + nums[i]) || dfs(nums, i - nums[i]);
// 6. 回溯:离开当前节点时取消标记(erase 操作)
paths.erase(i);
return ret;
}
};
2.3 实现要点
① 终止条件的顺序------为什么越界先于 visited?
cpp
if (i < 0 || i >= nums.size()) return false; // 先检查越界
if (paths.count(i)) return false; // 再检查已访问
类比:就像走迷宫------先看墙(越界),再看是不是走过的路口(visited),最后看是不是出口(target)。顺序很重要,因为越界访问下标会导致段错误,必须优先排除。
② visited 为什么不能省?
最自然的写法是不加 visited------凭什么不能再走一遍?但双向跳跃会造出回路:
5 → 4 → 1 → 3 → 0 (从3可以跳回不到0,但跳到了0附近)
不加 visited 的代码会在有环的图里死循环(栈溢出或无限递归)。
一个很自然的想法 :如果能"回溯",岂不是所有路径都能探索到?这个想法在枚举所有路径时是合理的(比如输出所有解),但这道题只问"能不能到",不需要回溯。
③ erase 操作在可达性问题中是多余的
看上面代码第 6 行:
cpp
paths.erase(i); // 回溯时取消标记
这个 erase 没有错,不会导致程序出错。但它做了不需要的事------因为这道题只问"能不能到",只要找到一条路就够了,不需要恢复状态去尝试其他路径。
| 问题类型 | erase 操作 | 原因 |
|---|---|---|
| 能否到达(本题) | 不需要 erase | 找到一条路就够了 |
| 所有可达路径 | 必须 erase | 需要恢复状态,尝试另一条路 |
④ 数据结构选择:还能更好吗?
上面代码用 unordered_set<int> 来记录已访问节点,功能完全正确。但这里有一个值得思考的点:这道题的下标是什么特点?
- 下标从
0到n-1,是连续的、非负的整数 - 哈希表的优势是应对稀疏、非连续的键;可这道题的键恰好是连续的
到这里,我们已经解决了核心问题。还有没有可以优化的地方?
如果确定下标范围连续且非负,可以用数组替代哈希表------访问是 O(1),没有哈希计算开销:
cpp
// 替代写法:用 vector<char> 替代 unordered_set
vector<char> visited(arr.size(), 0);
visited[i] = 1; // 标记
visited[i] == 1; // 检查
两种写法时间复杂度都是 O(n),都能 AC。数组版本在连续下标场景下更快,代码也更直观。
⑤ 短路求值:|| 为什么比 | 更安全?
cpp
// 正确写法(短路求值)
return dfs(nums, i + nums[i]) || dfs(nums, i - nums[i]);
// 不推荐的写法(不是bug,但不安全)
return dfs(nums, i + nums[i]) | dfs(nums, i - nums[i]);
类比 :面试时,第一题答对了就不用看第二题了------短路求值就是这样,左边能到就返回,右边不执行。
|则会两边都执行,在有副作用的函数里可能出问题。
2.4 完整优化版
综合上面两个优化点(去掉 erase + 用 vector),得到完整优化版:
cpp
class Solution {
vector<char> visited; // 用 vector<char> 替代 unordered_set,下标连续场景更快
public:
bool canReach(vector<int>& arr, int start) {
visited.assign(arr.size(), 0);
return dfs(arr, start);
}
bool dfs(const vector<int>& nums, int i) {
// 越界 → 已访问 → 目标,三件套一步到位
if (i < 0 || i >= nums.size() || visited[i]) return false;
visited[i] = 1; // 标记已访问
if (nums[i] == 0) return true; // 找到目标
// 不需要 erase:可达性判断找到一条路就够了
return dfs(nums, i + nums[i]) || dfs(nums, i - nums[i]);
}
};
相比最初的版本:少了 unordered_set 的引入,少了 erase 回溯,逻辑更干净。
✅ 到这里,你应该能回答:为什么这道题的 DFS 必须加 visited,而二叉树的遍历不需要?如果还说不清楚------树只有一条路往下走(无环),图可以绕回来(有环),所以需要 visited 来"打勾"。
3. 第二个思路:BFS(队列)
3.1 什么时候该想到 BFS?
💡 想象一下:往水里扔一颗石子,涟漪一圈圈扩散开------最先碰到边缘的那圈涟漪,走的步数最少。这就是 BFS(广度优先搜索)。
触发 BFS 的信号:
- 题目问"最少步数" → 必须用 BFS,因为 DFS 不知道走了多远
- 担心递归深度太大(栈溢出)→ BFS 是纯迭代,安全
- 这道题只问"能否到达",DFS 和 BFS 都能做,但 BFS 是 DFS 的等效替代
3.2 BFS 写法
cpp
class Solution {
public:
bool canReach(vector<int>& arr, int start) {
queue<int> q;
vector<char> visited(arr.size(), 0);
q.push(start);
visited[start] = 1;
while (!q.empty()) {
int i = q.front(); q.pop();
if (arr[i] == 0) return true; // 找到目标
// 扩展邻居
int left = i - arr[i];
int right = i + arr[i];
if (left >= 0 && !visited[left]) {
visited[left] = 1;
q.push(left);
}
if (right < arr.size() && !visited[right]) {
visited[right] = 1;
q.push(right);
}
}
return false;
}
};
3.3 实现要点
① 为什么用队列(FIFO)?
类比:排队买奶茶------先到的先做,后到的后做。队列就是这样"先入先出"的数据结构,保证了我们按层次一圈一圈往外搜。
② visited 何时标记------入队时,不是出队时
cpp
// 正确:在入队时立即标记 ------ 防止重复入队
visited[left] = 1;
q.push(left);
// ❌ 错误写法:在出队时才标记(可能导致同一个节点被多次入队)
类比:一进门就拿号,不是叫到号才拿号------否则同一个人可能拿两个号,排两次队。入队时标记保证每个节点只进队一次。
③ 边界检查顺序
cpp
if (left >= 0 && !visited[left]) // 先检查越界,再检查 visited
为什么?因为 !visited[left] 如果 left 是负数,C++ 负数下标访问可能不会立即报错(undefined behavior),所以越界检查必须放在前面。
3.4 DFS vs BFS 对比
| DFS(递归) | BFS(队列) | |
|---|---|---|
| 天然适合 | 判断能否到达 / 枚举所有路径 | 求最短路径 / 最少步数 |
| 实现方式 | 递归(系统栈) | 迭代(显式队列) |
| 最坏空间 | O(n)(递归深度) | O(n)(队列宽度) |
| 栈溢出风险 | 有(n 很大时,如 n=100000) | 无 |
| 找到目标的顺序 | 不确定(深度优先) | 逐层扩散(可自然得到最短步数) |
| 代码量 | 较少 | 稍多 |
| 生活类比 | 走迷宫,一条路走到底再换 | 涟漪扩散,逐层搜索 |
🎉 到这里你已经掌握了两种图的遍历方式------DFS 和 BFS。这比很多人第一次学的时候快多了。
✅ 到这里,你应该能一眼判断:看到"最少步数"就想到 BFS,看到"能否到达"就想到 DFS/BFS 都行。如果看到"所有路径"呢?那就要 DFS + 回溯(加 erase)。
4. DFS 递归核心解析
综合优化后的完整代码(来自 2.4 节):
cpp
class Solution {
vector<char> visited; // vector<char> 替代 unordered_set,下标连续场景更快
public:
bool canReach(vector<int>& arr, int start) {
visited.assign(arr.size(), 0);
return dfs(arr, start);
}
bool dfs(const vector<int>& nums, int i) {
if (i < 0 || i >= nums.size() || visited[i]) return false;
visited[i] = 1;
if (nums[i] == 0) return true;
return dfs(nums, i + nums[i]) || dfs(nums, i - nums[i]);
}
};
关键设计点回顾:
| 设计点 | 选择 | 原因 |
|---|---|---|
| visited 数据结构 | vector<char> |
下标连续且非负,O(1) 访问 |
| erase 操作 | 不需要 | 可达性判断,找到一条路就够了 |
| visited 位置 | 类成员 | 递归栈上所有调用共享 |
| 短路求值 | ` |
时间复杂度 :每个节点最多访问一次 → O(n)
空间复杂度 :visited O(n) + 递归栈 O(n) → O(n)
5. 常见坑点与失败替代方案
坑点一:visited erase 在可达性问题中是多余的
如果写了 erase,代码在逻辑上仍然正确(能 AC),但这是多了一步不需要的操作。记住:
| 问题类型 | erase 操作 | 原因 |
|---|---|---|
| 能否到达(本题) | 不需要 erase | 找到一条路就够了 |
| 所有可达路径 | 必须 erase | 需要恢复状态,尝试另一条路 |
坑点二:BFS visited 必须在入队时标记
cpp
// ❌ 错误:在出队时标记 ------ 可能导致重复入队
q.push(left);
int cur = q.front(); q.pop(); // 此时 left 还没标记!
visited[left] = 1;
坑点三:越界检查和 visited 检查的顺序
cpp
// ❌ 不安全:先检查 visited 再越界(负数下标会 undefined behavior)
if (paths.count(i) || i < 0 || i >= n) return false;
// ✅ 正确:越界优先
if (i < 0 || i >= n || paths.count(i)) return false;
6. 真实系统中的图遍历 & 识别清单
真实工程场景
- 网页爬虫:从种子 URL 出发,沿超链接爬取,已爬过的 URL 不重复爬------完全一样的隐式图遍历问题
- 社交网络可达性:判断 A 能否通过好友关系触达 B(六度理论)------同样是图的连通性判断
- 迷宫求解:判断能否从起点走到终点------经典 DFS/BFS 应用
核心认知
这道题的核心不是选 DFS 还是 BFS,而是认清这是图遍历问题------双向跳跃必成环,visited 不可省。
识别清单
拿到一道新题,看到这些信号,要条件反射想到图遍历 + visited:
- 可以"双向"移动(左右、上下、前后)
- 移动规则由数组/矩阵的值决定(隐式图)
- 问"能否从 A 到达 B"
- 图可能有环(双向移动 = 可能有回路)
- 数组/矩阵很大,递归深度可能溢出
模式迁移表
| 题目 | 问题类型 | 关键区别 |
|---|---|---|
| 1306 跳跃游戏 III | 图可达性 | 找目标节点 |
| 130. 被围绕的区域 | 图遍历(染色) | 找所有边界连通区域 |
| 133. 克隆图 | 图遍历(BFS/DFS) | 需要克隆节点 |
| 200. 岛屿数量 | 图遍历(连通分量) | 找连通分量个数 |
| 695. 岛屿的最大面积 | 图遍历 | 找最大连通区域面积 |
| 279. 完全平方数 | 图最短路 | BFS 求最少步数 |
7. 小结
两种方法核心一致:都是图的遍历,时间 O(n),空间 O(n)。
- DFS 递归:代码简洁,天然适合"判断能否到达",但有栈溢出风险
- BFS 迭代:绝对安全,还能顺便算出最短步数,但代码稍长
这道题教会我们的最重要的一件事 :双向跳跃 = 可能有环 = visited 不可省。一旦认清"这是图遍历",剩下的就是套模板了。
🎉 图遍历的两把刷子你已经握在手里了。下次看到"从 A 出发能不能到 B"的题,你会条件反射想到 visited + DFS/BFS------因为你理解了为什么,不只是记住了怎么写。