青蛙过河 图解题解
这道题到底在问什么
- 输入
- stones=[0,1,3,5,6,8,12,17]
- 输出
- true(存在一条可达路径)
- 输入
- stones=[0,1,2,3,4,8,9,11]
- 输出
- false(中间缺口过大)
最优解:为什么这么做
一句话答案:LeetCode 403 青蛙过河:判断能否按『上一跳 k、这跳只能 k−1/k/k+1』落到最后一块石子。用状态带上一跳步长的哈希记忆化 DP,每块石子记下能落到它的步长集合往后递推,时间空间都是 O(n²)。
青蛙过河这道题到底在判什么
给一排升序的石子位置 stones,青蛙从第一块出发,第一跳必须是 1 个单位。之后若上一跳跨了 k,这一跳只能跨 k−1、k 或 k+1(步长为正),且每跳都要正好落在石子上,问能不能跳到最后一块。以 stones=[0,1,3,5,6,8,12,17] 为例存在一条可达路径,答案 true。
为什么把所有跳法枚举一遍会炸
每落到一块石子,下一跳在 k−1、k、k+1 三个步长里分岔,一路乘下去是 3 的石子数次方级的路径,石子一多就枚举不完。慢还慢在同一种局面被反复重算:站在石子 6、上一跳步长同为 3,往后那截路是定死的,暴力递归每碰一次却从头再跳。把这类局面结果记下复用,指数级枚举才压得平。
站在同一块石子,为什么还得记住上一跳跳多远
只知道青蛙站在石子 6 不够——它能跳多远得看上一跳跨了几格:上一跳是 1,这跳只能是 1、2;上一跳是 3,这跳能是 2、3、4。同一块石子用不同步长跳上来,往后能走的路就不一样,所以完整的局面必须是『石子位置 + 上一跳步长』这一对。
定成这一对,就满足无后效性(未来只由这对状态决定):知道『站在 6、上一跳步长 3』,能不能过河就定死了。于是用哈希表(按键直接查值的表)jumps 记账,键是石子位置 x,值是能落到 x 时上一跳的所有可能步长。
每块石子的步长集合怎么往后推
起手把 jumps[0] 设成 {0}:从 0 枚举 0−1、0、0+1 只有 0+1=1 为正,逼出唯一合法的首跳步长 1。之后从左到右扫每块石子 x,取出 jumps[x] 里每个步长 k,试 nk=k−1、k、k+1:只要 nk 为正、落点 x+nk 是石子,就把 nk 记进 jumps[x+nk]。落点是终点就返回能过河,扫完没到就是过不去。
拿题面示例把步长集合一块块填出来
拿题面的 stones=[0,1,3,5,6,8,12,17] 走一遍,先 jumps[0]={0}。从 0 步长 1 到 1、1 步长 2 到 3(站在石子 1、上一跳步长 1,所以这跳能取 k+1=2)、3 步长 2 与 3 到 5 和 6、5 步长 1 到 6,jumps[6] 集齐 {3,1}(3 来自石子 3 步长 3、1 来自石子 5 步长 1)。再往前推,石子 5 步长 3 到 8、石子 6 步长 2 到 8,jumps[8] 凑成 {3,2};8 步长 4 到 12、12 步长 5 到终点 17,返回 true。路径 0→1→3→5→8→12→17。
青蛙从石子 4 死活跳不到 8,这串为什么只能判 false
换 stones=[0,1,2,3,4,8,9,11]:青蛙能蹭到石子 4,可此时步长最多才 2、这跳最远跨 3,隔着 4 格的石子 8 够不着,jumps[8] 始终空着,返回 last==0 即过不去。每块石子步长集合 O(n) 个、各试 3 向,时间空间都 O(n²)。最易写错处:jumps[0] 忘塞 0,首跳无从触发;漏掉 nk 为正,青蛙往回跳;[0,1] 直达算 true,而 [0,2] 落不到 2 判 false。
▶ 动画逐步走查(共 33 步)——想跟着动画一帧帧对照就展开
- 3记住「jumps[x] 记到 x 的步长、每步尝试 k−1/k/k+1、落在石子就记下、碰到终点即 true」,下面逐石子套它。
- 4初始化:jumps[0]={0}。这个「0」是技巧:它让第一跳的步长 nk=0+1=1 恰好满足「首跳为 1」的规则。
- 5处理石子 0(紫):它的步长集合是 {0}。对每个 k,试着往前跳 k−1、k、k+1。
- 6从 0 用步长 1 跳到石子 1(绿)。把步长 1 记进 jumps[1],以后从 1 还能继续往前。
- 7处理石子 1(紫):它的步长集合是 {1}。对每个 k,试着往前跳 k−1、k、k+1。
- 8从 1 用步长 1 会落到 2,但那里没有石子,这一跳不行。
- 9从 1 用步长 2 跳到石子 3(绿)。把步长 2 记进 jumps[3],以后从 3 还能继续往前。
- 10处理石子 3(紫):它的步长集合是 {2}。对每个 k,试着往前跳 k−1、k、k+1。
- 11从 3 用步长 1 会落到 4,但那里没有石子,这一跳不行。
- 12从 3 用步长 2 跳到石子 5(绿)。把步长 2 记进 jumps[5],以后从 5 还能继续往前。
- 13从 3 用步长 3 跳到石子 6(绿)。把步长 3 记进 jumps[6],以后从 6 还能继续往前。
- 14处理石子 5(紫):它的步长集合是 {2}。对每个 k,试着往前跳 k−1、k、k+1。
- 15从 5 用步长 1 跳到石子 6(绿)。把步长 1 记进 jumps[6],以后从 6 还能继续往前。
- 16从 5 用步长 2 会落到 7,但那里没有石子,这一跳不行。
- 17从 5 用步长 3 跳到石子 8(绿)。把步长 3 记进 jumps[8],以后从 8 还能继续往前。
- 18处理石子 6(紫):它的步长集合是 {1, 3}。对每个 k,试着往前跳 k−1、k、k+1。
- 19从 6 用步长 1 会落到 7,但那里没有石子,这一跳不行。
- 20从 6 用步长 2 跳到石子 8(绿)。把步长 2 记进 jumps[8],以后从 8 还能继续往前。
- 21从 6 用步长 2 跳到石子 8(绿)。它的集合里已有 2。
- 22从 6 用步长 3 会落到 9,但那里没有石子,这一跳不行。
- 23从 6 用步长 4 会落到 10,但那里没有石子,这一跳不行。
- 24处理石子 8(紫):它的步长集合是 {2, 3}。对每个 k,试着往前跳 k−1、k、k+1。
- 25从 8 用步长 1 会落到 9,但那里没有石子,这一跳不行。
- 26从 8 用步长 2 会落到 10,但那里没有石子,这一跳不行。
- 27从 8 用步长 3 会落到 11,但那里没有石子,这一跳不行。
- 28从 8 用步长 2 会落到 10,但那里没有石子,这一跳不行。
- 29从 8 用步长 3 会落到 11,但那里没有石子,这一跳不行。
- 30从 8 用步长 4 跳到石子 12(绿)。把步长 4 记进 jumps[12],以后从 12 还能继续往前。
- 31处理石子 12(紫):它的步长集合是 {4}。对每个 k,试着往前跳 k−1、k、k+1。
- 32从 12 用步长 3 会落到 15,但那里没有石子,这一跳不行。
- 33从 12 用步长 4 会落到 16,但那里没有石子,这一跳不行。
- 34从 12 用步长 5 正好跳到终点 17!青蛙过河成功,直接返回 true。
- 35回放一条可行路径:0→1→3→5→8→12→17,每一跳的步长都满足「上一跳 ±1」的规则,最终落在终点 17。答案 true。
⚠️ 容易写错的地方
✗ 错:把首跳也允许 0 或 k 任意
✓ 对:首跳固定 1,用 jumps[0]={0} 巧妙实现
jumps[0] 放步长 0,使首跳 nk∈{−1,0,1} 里只有 1 合法(nk>0),自然强制首跳为 1
✗ 错:nk 允许为 0 或负
✓ 对:只接受 nk>0 的跳
步长必须为正(青蛙得往前),k=1 时 k−1=0 不是合法跳,要过滤掉
✗ 错:边遍历 jumps[x] 边往里加
✓ 对:先对 jumps[x] 取快照再遍历
处理 x 时只会往 x 之后的石子加步长、不改 jumps[x] 本身;但稳妥起见拷快照可避免某些语言的并发修改异常
完整代码(Python / C++ / Java)
Python
from typing import List
from collections import defaultdict
class Solution:
def canCross(self, stones: List[int]) -> bool:
stone_set = set(stones)
jumps = defaultdict(set)
jumps[0].add(0)
last = stones[-1]
for x in stones:
for k in list(jumps[x]):
for nk in (k - 1, k, k + 1):
if nk > 0 and x + nk in stone_set:
if x + nk == last:
return True
jumps[x + nk].add(nk)
return last == 0C++
#include <unordered_map>
#include <unordered_set>
#include <vector>
using namespace std;
class Solution {
public:
bool canCross(vector<int>& stones) {
unordered_set<int> pos(stones.begin(), stones.end());
unordered_map<int, unordered_set<int>> jumps;
jumps[0].insert(0);
int last = stones.back();
for (int x : stones) {
auto cur = jumps[x];
for (int k : cur) for (int nk : {k - 1, k, k + 1}) if (nk > 0 && pos.count(x + nk)) {
if (x + nk == last) return true;
jumps[x + nk].insert(nk);
}
}
return false;
}
};Java
import java.util.*;
class Solution {
public boolean canCross(int[] stones) {
Set<Integer> pos = new HashSet<>();
for (int x : stones) pos.add(x);
Map<Integer, Set<Integer>> jumps = new HashMap<>();
jumps.computeIfAbsent(0, k -> new HashSet<>()).add(0);
int last = stones[stones.length - 1];
for (int x : stones) {
for (int k : new HashSet<>(jumps.getOrDefault(x, Collections.emptySet()))) {
for (int nk = k - 1; nk <= k + 1; nk++) if (nk > 0 && pos.contains(x + nk)) {
if (x + nk == last) return true;
jumps.computeIfAbsent(x + nk, t -> new HashSet<>()).add(nk);
}
}
}
return false;
}
}复杂度
时间
O(n²)
n 是石子数。每块石子的步长集合最多 O(n) 个,每个步长试 3 个方向 O(1) 判石子;总体 O(n²)
空间
O(n²)
jumps 表最坏每块石子存 O(n) 个步长,合计 O(n²);石子集合 O(n)
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 青蛙过河 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
为什么状态非得带『上一跳步长』,只记跳到哪块石子不行吗?+
因为下一跳能跳多远(k−1、k、k+1)完全由上一跳步长决定。同一块石子,用步长 1 跳上来和用步长 3 跳上来,往后能落的石子集合不一样。若状态只记位置、把不同步长混成一团,就会漏掉某些本可行的跳法或放行不合规的跳法。把步长并进去,(石子, 上一跳步长)这对状态才满足无后效性,未来只由它决定,递推才站得住。
jumps[0] 为什么初始化成 {0},设空或 {1} 行不行?+
设成 {0} 是为了逼出唯一合法的首跳。从步长 0 枚举 0−1、0、0+1,只有 0+1=1 为正,正好对上『第一跳必须是 1』。设成空集,石子 0 一个步长都取不出来,递推起不了步;设成 {1},会让首跳枚举出 0、1、2,等于放行了步长 2 这种不合规的首跳。
这个逐石子推集合的写法,和记忆化的深度优先搜索是一回事吗?+
等价。可以反过来写成递归 canReach(石子, 上一跳步长),对(石子, 步长)这对状态做记忆化(把算过的状态结果存住、再遇到直接返回),避免重算。它和从左到右填 jumps 集合探索的是同一批状态,复杂度同为 O(n²)。差别只是一个自顶向下递归、一个自底向上递推,状态里都少不了那个步长,所以这道题的动态规划天生不是一维的。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 青蛙过河 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
把这道题真正拿下
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。