一、游戏场景:青蛙过河的两种经典玩法
“青蛙过河”在两个方向上都是算法经典:
- 变体 A(最少跳跃数):一排石头,青蛙从岸边出发,每跳不超过
L,问最少几跳能到对岸; - 变体 B(最收益路径):青蛙每跳的距离必须落在
[D, D+K]之间(太近跳不过去、太远跳不动),每块石头有一个”通过代价/价值”,问走哪条路线收益最大。
变体 A 是贪心 + 双指针的教科书例子;变体 B 是一个典型的滑窗 DP:
dp[i] = value[i] + max{ dp[j] : D ≤ pos[i] - pos[j] ≤ D + K }
这个递推式里的 max 只在”一个随 i 右移的窗口”内取值,因此可以用单调队列把每次转移从 O(n) 压到摊还 O(1),整体从 O(n²) 降到 O(n)。本文把两种变体都实现一遍,重点讲变体 B 里最容易被写错的那个细节:窗口的上下界是两个方向不同的约束。
二、为什么必须是”窗口”而不是”前缀”
很多跳跃类 DP 的题解会写成 max{ dp[j] : j < i },这在青蛙过河里是错的,原因有两层:
- 下界约束:
pos[i] - pos[j] ≥ D意味着”离得太近的石头跳不过去”,所以不是所有j < i都合法,最近的几块反而不能用; - 上界约束:
pos[i] - pos[j] ≤ D + K意味着”离得太远的石头够不着”,所以更早的石头也会失效。
于是合法集合是 [lo(i), hi(i)]:
lo(i) = min{ j : pos[i] - pos[j] ≤ D + K } (再往前就太远)
hi(i) = max{ j : pos[i] - pos[j] ≥ D } (再往后就太近)
由于 pos 单调递增,lo(i) 与 hi(i) 都随 i 单调不减——这是能把 O(n²) 降到 O(n) 的唯一理由。如果坐标乱序,这套优化立刻失效(必须换成分块、线段树或 Li Chao 树)。
三、单调队列的机制
维护一个存”下标”的双端队列,保持两个不变量:
- 下标递增:因为按
j从前往后入队; dp值递减:入队时从队尾弹掉所有dp不大于新值的元素——它们永远不可能再成为窗口最大值。
三项操作:
- 入队:
while 队尾 dp ≤ dp[i]: 弹出队尾;然后push_back(i); - 出队(过期):从队首弹掉已不在窗口内的下标(按坐标判定,不按下标);
- 取最值:队首即窗口最大值。
因为队列里下标递增,所有”过期”(下标过小)的元素必然集中在队首,所以”弹队首”就足够——但前提是过期判定必须只用”太远”这一个方向。下界”太近”如何落地,是本文第四节的重点。
package com.algodaily.frog;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.function.IntPredicate;
/**
* 单调队列:维护滑动窗口最大值。
* 队列存下标,比较依据是外部传入的 value 数组。
* 不变量:队列内下标严格递增,value 严格递减。
*/
public final class MonotonicDeque {
private final Deque<Integer> deque = new ArrayDeque<>();
/** 入队:先把队尾所有 value <= 新值的元素弹掉,再把新下标放到队尾 */
public void push(int index, long[] value) {
while (!deque.isEmpty() && value[deque.peekLast()] <= value[index]) {
deque.pollLast();
}
deque.addLast(index);
}
/** 弹出队首所有满足条件(已过期)的元素 */
public void evictWhile(IntPredicate isExpired) {
while (!deque.isEmpty() && isExpired.test(deque.peekFirst())) {
deque.pollFirst();
}
}
/** 窗口最大值对应的下标;空队列返回 -1 */
public int bestIndex() { return deque.isEmpty() ? -1 : deque.peekFirst(); }
public boolean isEmpty() { return deque.isEmpty(); }
public int size() { return deque.size(); }
public void clear() { deque.clear(); }
}
把”过期判定”做成 IntPredicate 回调,是为了让同一个队列类既能服务”固定宽度窗口”,也能服务”由坐标区间决定的窗口”。这是本篇里最值得记住的一处抽象。
四、变体 B 的正确实现
4.1 数据结构
package com.algodaily.frog;
/** 一块石头:坐标与价值(收益 / 踩塌代价) */
public final class Stone {
public final long pos;
public final long weight;
public Stone(long pos, long weight) {
this.pos = pos;
this.weight = weight;
}
@Override
public String toString() { return "Stone(pos=" + pos + ", w=" + weight + ")"; }
}
4.2 求解结果
package com.algodaily.frog;
/** 求解结果:最大收益、最优路径(石头下标)、是否可达 */
public final class FrogResult {
public final long maxValue;
public final int[] path;
public final boolean reachable;
public FrogResult(long maxValue, int[] path, boolean reachable) {
this.maxValue = maxValue;
this.path = path;
this.reachable = reachable;
}
/** 路径对应的坐标序列,便于打印 */
public long[] positions(Stone[] stones) {
long[] out = new long[path.length];
for (int i = 0; i < path.length; i++) out[i] = stones[path[i]].pos;
return out;
}
}
4.3 主算法:双指针定窗口 + 单调队列取最值
职责分离是这版实现的核心:双指针负责”哪些下标在窗口内”,单调队列只负责”窗口里谁的 dp 最大”。
package com.algodaily.frog;
import java.util.Arrays;
/**
* 青蛙过河(变体 B)。
*
* 转移: dp[i] = weight[i] + max{ dp[j] : minJump <= pos[i]-pos[j] <= maxJump }
*
* 合法下标构成区间 [lo, hi],且 lo、hi 都随 i 单调不减 => 双指针 O(n)。
* 区间内取最大值用单调队列,摊还 O(1)。
*
* 前提: stones 按 pos 严格递增;stones[0] 是起点,stones[n-1] 是终点。
*/
public final class FrogWindowDp {
private static final long NEG = Long.MIN_VALUE / 4;
public static FrogResult solve(Stone[] stones, long minJump, long maxJump) {
if (minJump > maxJump) throw new IllegalArgumentException("minJump 不能大于 maxJump");
int n = stones.length;
long[] dp = new long[n];
int[] parent = new int[n];
Arrays.fill(dp, NEG);
Arrays.fill(parent, -1);
dp[0] = stones[0].weight; // 起点收益计入
MonotonicDeque window = new MonotonicDeque();
int lo = 0; // 窗口左端(可能已过期,但尚未弹出)
int hi = -1; // 窗口右端(已入队的最大下标)
for (int i = 1; i < n; i++) {
long posI = stones[i].pos;
// ① 推进右端点:把所有满足 pos[i] - pos[j] >= minJump 的 j 入队
// (下标递增入队,保持"队列内下标递增"不变量)
while (hi + 1 < i && posI - stones[hi + 1].pos >= minJump) {
hi++;
if (dp[hi] != NEG) window.push(hi, dp);
}
// ② 推进左端点:把所有满足 pos[i] - pos[j] > maxJump 的 j 移出窗口
while (lo <= hi && posI - stones[lo].pos > maxJump) {
lo++;
}
// ③ 让队列与窗口同步:弹出所有下标 < lo 的队首元素
// 由于队列内下标递增,这些元素必然都在队首连续段上
final int lowerBound = lo;
window.evictWhile(j -> j < lowerBound);
if (window.isEmpty()) {
dp[i] = NEG; // 当前石头不可达,不入队
continue;
}
int best = window.bestIndex();
dp[i] = dp[best] + stones[i].weight;
parent[i] = best;
}
int last = n - 1;
if (dp[last] == NEG) return new FrogResult(NEG, new int[0], false);
return new FrogResult(dp[last], restore(parent, last), true);
}
/** 回溯路径:从终点沿 parent 走到起点,再反转 */
private static int[] restore(int[] parent, int end) {
int len = 0;
for (int k = end; k >= 0; k = parent[k]) {
len++;
if (parent[k] == -1) break;
}
int[] path = new int[len];
int idx = len - 1;
int k = end;
while (idx >= 0) {
path[idx--] = k;
if (parent[k] == -1) break;
k = parent[k];
}
return path;
}
}
三个关键点,逐条解释:
(1)为什么先推进 hi 再推进 lo?
hi 只增不减,lo 只增不减,两个指针独立。先把右端推到位,才能保证入队的都是”够得着且跳得过去”的候选;左端随后清理”太远”的。顺序反过来的话,lo 会先越过还没入队的下标,导致数量对不上。
(2)为什么用 lo 的下标而非坐标来做弹出条件?
因为队列里存的就是下标。若用坐标判定(stones[j].pos 与阈值比较),在 pos 有重复值时会出现歧义;用下标做严格比较,语义最清晰:j < lo 就是”不在窗口内”。
(3)为什么 dp[i] == NEG 的石头不入队?
不可达的石头若进队,会让”不可达路径”以 NEG + w 的形式继续传播,最后 dp 里全是巨大的负数而不是恰好 NEG。这类 bug 不会崩溃,只会让”是否可达”的判定失效——比崩溃更难发现。
五、变体 A:最少跳跃数
package com.algodaily.frog;
import java.util.ArrayDeque;
import java.util.Arrays;
/** 青蛙过河(变体 A):从 0 跳到 >= target,每跳不超过 maxJump,求最少跳数 */
public final class MinJump {
/**
* 贪心 + 双指针,O(n)。
* reach = 当前跳数下能到达的最远坐标
* nextReach= 从 reach 范围内任一点再跳一次能到达的最远坐标
* 每一跳都把"可达最远处"推到极限,因此跳数最少。
*/
public static int greedy(long[] positions, long target, long maxJump) {
if (target <= maxJump) return 1;
int jumps = 0;
long reach = 0;
long nextReach = 0;
int idx = 0;
int n = positions.length;
while (reach < target) {
jumps++;
// 把所有落在 (reach, reach + maxJump] 的石头作为本跳的候选落点
while (idx < n && positions[idx] <= reach + maxJump) {
nextReach = Math.max(nextReach, positions[idx] + maxJump);
idx++;
}
if (nextReach <= reach) return -1; // 没有新落点,卡住了
reach = nextReach;
}
return jumps;
}
/** BFS 版本:正确性基准,便于对拍 */
public static int bfs(long[] positions, long target, long maxJump) {
int n = positions.length;
long[] nodes = new long[n + 2];
nodes[0] = 0;
System.arraycopy(positions, 0, nodes, 1, n);
nodes[n + 1] = target;
int total = n + 2;
int[] dist = new int[total];
Arrays.fill(dist, -1);
dist[0] = 0;
ArrayDeque<Integer> queue = new ArrayDeque<>();
queue.add(0);
while (!queue.isEmpty()) {
int u = queue.poll();
for (int v = u + 1; v < total; v++) {
if (nodes[v] - nodes[u] > maxJump) break; // 坐标递增,后面更远,直接断
if (dist[v] == -1) {
dist[v] = dist[u] + 1;
queue.add(v);
}
}
}
return dist[total - 1];
}
}
贪心版里还有一个细节:nextReach 跨轮次不清零是正确的(它代表”累积可达最远”),但 reach 必须在每轮用 nextReach 刷新——这两个变量混用会少算一跳,这类差一错误在跳跃问题上极常见,用 BFS 对拍是成本最低的保险。
六、演示与随机对拍
package com.algodaily.frog;
import java.util.Arrays;
import java.util.Random;
/** 入口:演示最优路径 + 对拍两套实现 */
public final class FrogApp {
public static void main(String[] args) {
demoMaxWeight();
demoMinJump();
stressMaxWeight();
}
private static void demoMaxWeight() {
// 起点 0,终点 30,中间若干石头;weight 代表"通过难度/收益"
Stone[] stones = {
new Stone(0, 0),
new Stone(5, 10),
new Stone(9, 30),
new Stone(12, 5),
new Stone(16, 50),
new Stone(20, 8),
new Stone(24, 60),
new Stone(27, 15),
new Stone(30, 0),
};
FrogResult r = FrogWindowDp.solve(stones, 3, 9);
System.out.println("可达 = " + r.reachable + ",最大收益 = " + r.maxValue);
System.out.println("下标路径 = " + Arrays.toString(r.path));
System.out.println("坐标路径 = " + Arrays.toString(r.positions(stones)));
}
private static void demoMinJump() {
long[] pos = {5, 8, 13, 19, 24, 29};
System.out.println("最少跳数(贪心)= " + MinJump.greedy(pos, 32, 7));
System.out.println("最少跳数(BFS) = " + MinJump.bfs(pos, 32, 7));
}
/** 对拍:单调队列版 vs 朴素 O(n²) 版 */
private static void stressMaxWeight() {
Random rnd = new Random(20261101L);
int mismatch = 0;
for (int iter = 0; iter < 500; iter++) {
int n = 2 + rnd.nextInt(25);
long[] pos = new long[n];
pos[0] = 0;
for (int i = 1; i < n; i++) pos[i] = pos[i - 1] + 1 + rnd.nextInt(6);
Stone[] stones = new Stone[n];
for (int i = 0; i < n; i++) {
stones[i] = new Stone(pos[i], (i == 0 || i == n - 1) ? 0 : rnd.nextInt(50));
}
long minJump = 1 + rnd.nextInt(5);
long maxJump = minJump + rnd.nextInt(10);
long fast = FrogWindowDp.solve(stones, minJump, maxJump).maxValue;
long slow = bruteForce(stones, minJump, maxJump);
if (fast != slow) {
mismatch++;
System.out.println("不一致 iter=" + iter + " fast=" + fast + " slow=" + slow);
}
}
System.out.println("对拍完成,不一致 = " + mismatch);
}
/** 朴素 O(n²) DP:逐对检查距离,作为正确性基准 */
private static long bruteForce(Stone[] stones, long minJump, long maxJump) {
int n = stones.length;
long NEG = Long.MIN_VALUE / 4;
long[] dp = new long[n];
Arrays.fill(dp, NEG);
dp[0] = stones[0].weight;
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
long d = stones[i].pos - stones[j].pos;
if (d < minJump || d > maxJump) continue;
if (dp[j] == NEG) continue;
dp[i] = Math.max(dp[i], dp[j] + stones[i].weight);
}
}
return dp[n - 1];
}
}
七、复杂度与四个易错点
| 方案 | 时间 | 空间 | 适用 |
|---|---|---|---|
| 朴素 DP | O(n²) |
O(n) |
n ≤ 5000 的验证 / 对拍 |
| 双指针 + 单调队列 | O(n) 摊还 |
O(n) |
坐标有序、窗口两端单调 |
| 贪心(最少跳数) | O(n) |
O(1) |
仅”每跳有上限”类问题 |
| BFS | O(n²) 最坏 |
O(n) |
通用基准 |
按踩坑频率排序的四条:
- 窗口用下标而不是坐标定义。约束是
pos[i] - pos[j] ∈ [D, D+K],若写成”最近W块石头”,在石头疏密不均时会立刻算错。 - 两个方向约束的处理顺序。左端(太远)对应”下标过小”,右端(太近)对应”下标过大但不大于
hi“。真正的技巧是用双指针把窗口两端都显式推到位,让单调队列只做”取最大值”这一件事——职责分离后,每一行都能单独验证。 dp == NEG不能入队。不可达状态进队会让”不可达”伪造成”可达但收益极低”,NEG语义被污染,”是否可达”的判断直接失效。- 贪心的差一错误。
reach与nextReach混用会少算一跳;务必用 BFS 对拍。
八、这道题真正的教学价值
青蛙过河是”窗口 DP“最干净的入口,它把三件事同时摊在你面前:
- 状态定义:
dp[i]只依赖窗口内的dp[j],窗口外的历史全部作废; - 转移优化:取窗口最大值的
max操作,正是单调队列存在的唯一理由; - 约束方向:上界(太远)与下界(太近)是两类不同方向的约束,落地方式不同。
一旦理解”窗口两端由坐标决定、两端都单调右移”这个结构,你会发现一批题目其实是同一道题:滑动窗口最大值、股票含冷冻期的买卖、限流器的令牌桶、时序数据库的滑窗聚合——它们都能被同一个单调队列模板吃掉。这才是学这道题真正划得来的地方。