1306. 跳跃游戏 III — 图搜索思路拆解

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> 来记录已访问节点,功能完全正确。但这里有一个值得思考的点:这道题的下标是什么特点?

  • 下标从 0n-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. 真实系统中的图遍历 & 识别清单

真实工程场景

  1. 网页爬虫:从种子 URL 出发,沿超链接爬取,已爬过的 URL 不重复爬------完全一样的隐式图遍历问题
  2. 社交网络可达性:判断 A 能否通过好友关系触达 B(六度理论)------同样是图的连通性判断
  3. 迷宫求解:判断能否从起点走到终点------经典 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------因为你理解了为什么,不只是记住了怎么写。

相关推荐
喵了几个咪3 小时前
Kratos WebRTC 传输中间件:H5游戏P2P实时音视频与数据通信实战
游戏·微服务·中间件·golang·webrtc·实时音视频·kratos
如竟没有火炬18 小时前
字符串相乘——int数组转字符串
开发语言·数据结构·python·算法·leetcode·深度优先
草木深雨纷纷19 小时前
骑马与砍杀2mod整合包下载(动作优化+自动驻军+外交等)2026最新版分享
游戏·游戏程序
Kurisu57519 小时前
深海迷航2修改器 2026.5.16最新破解版加修改器免费下载 一键转存 永久更新 (看到速转存 资源随时走丢)
游戏·游戏引擎·游戏程序·修改器·关卡设计
157092511341 天前
回溯算法基础分享
算法·深度优先
草木深雨纷纷1 天前
泰拉瑞亚风灵月影修改器下载分享2026最新版(增强工具使用指南)
游戏·游戏程序
jushi89991 天前
23款PC剧情真人恋爱游戏合集 全DLC+预购特典+免安装中文
游戏·美女
mmz12071 天前
深度优先搜索DFS3(c++)
c++·算法·深度优先
故事和你911 天前
洛谷-【图论2-1】树6
开发语言·数据结构·c++·算法·深度优先·动态规划·图论