每日算法 — 使用java实现青蛙过河:双指针贪心策略与最优跳跃序列

青蛙过河是经典的跳跃挑战问题:河面上排列着若干石块,每块石块标注了从该位置最远可以向前跳跃的步数。青蛙从第一块石头出发,目标是判断能否到达最后一块石头,并求出最少跳跃次数。本文将用 Java 实现基于双指针贪心策略的高效解法,分析覆盖区间合并与最远可达边界更新的核心思想。

一、问题定义与建模

1.1 游戏规则

给定一个非负整数数组 stones,其中 stones[i] 表示从第 i 块石头最多可以向前跳跃 stones[i] 步。青蛙初始站在第 0 块石头,每次跳跃可以选择 1 到 stones[i] 之间的任意步数。需要回答两个问题:

  1. 青蛙能否到达最后一块石头?
  2. 若能到达,最少需要跳跃几次?

1.2 贪心策略核心思想

该问题的最优解可以通过贪心算法在 O(n) 时间内求得。核心洞察是:在当前可达范围内,选择能够使下一步跳得最远的那个位置作为起跳点。

具体维护两个边界:

  • currentEnd:当前这一跳能够到达的最远位置
  • farthest:在当前边界内所有位置起跳,能够到达的最远位置

当遍历位置到达 currentEnd 时,必须进行下一次跳跃,并将 currentEnd 更新为 farthest。

二、Java 实现

2.1 基础贪心解法(判断可达性 + 最少跳跃次数)

/**
 * 青蛙过河问题:双指针贪心解法
 * 求最少跳跃次数,并判断是否能到达终点
 */
public class FrogJump {

    /**
     * 计算到达终点的最少跳跃次数
     * 若无法到达返回 -1
     * 
     * 算法思路:
     * 1. 维护当前跳跃的最远边界 currentEnd
     * 2. 维护从当前边界内任意位置起跳能到达的最远位置 farthest
     * 3. 当遍历到 currentEnd 时,跳跃次数 +1,并将边界扩展到 farthest
     * 
     * 时间复杂度: O(n)
     * 空间复杂度: O(1)
     */
    public int minJumps(int[] stones) {
        if (stones == null || stones.length <= 1) {
            return 0;
        }

        int n = stones.length;
        // 如果第一块石头就是0,且不是终点,则无法移动
        if (stones[0] == 0 && n > 1) {
            return -1;
        }

        int jumps = 0;          // 已执行的跳跃次数
        int currentEnd = 0;     // 当前这跳能覆盖的最远位置
        int farthest = 0;       // 下一步能到达的最远位置

        // 最后一个位置不需要起跳,所以遍历到 n-2 即可
        for (int i = 0; i < n - 1; i++) {
            // 更新从位置 i 起跳能到达的最远位置
            farthest = Math.max(farthest, i + stones[i]);

            // 如果最远位置已经超过或等于终点,直接返回
            if (farthest >= n - 1) {
                return jumps + 1;
            }

            // 当遍历到当前跳跃的边界时,必须进行下一次跳跃
            if (i == currentEnd) {
                jumps++;
                currentEnd = farthest;

                // 如果边界没有推进,说明被困住了
                if (currentEnd <= i) {
                    return -1;
                }
            }
        }

        return jumps;
    }

    /**
     * 判断是否能到达终点(简化版本)
     */
    public boolean canReach(int[] stones) {
        int n = stones.length;
        int farthest = 0;

        for (int i = 0; i < n; i++) {
            // 如果当前位置已经超出了最远可达范围,说明无法到达
            if (i > farthest) {
                return false;
            }
            // 更新最远可达位置
            farthest = Math.max(farthest, i + stones[i]);
            // 如果已经可以到达终点,提前返回
            if (farthest >= n - 1) {
                return true;
            }
        }

        return true;
    }

    public static void main(String[] args) {
        FrogJump solver = new FrogJump();

        // 测试用例1:经典场景
        int[] stones1 = {2, 3, 1, 1, 4};
        System.out.println("测试1: " + java.util.Arrays.toString(stones1));
        System.out.println("能否到达: " + solver.canReach(stones1));
        System.out.println("最少跳跃: " + solver.minJumps(stones1));
        System.out.println();

        // 测试用例2:无法到达
        int[] stones2 = {3, 2, 1, 0, 4};
        System.out.println("测试2: " + java.util.Arrays.toString(stones2));
        System.out.println("能否到达: " + solver.canReach(stones2));
        System.out.println("最少跳跃: " + solver.minJumps(stones2));
        System.out.println();

        // 测试用例3:单步直达
        int[] stones3 = {5, 0, 0, 0, 0, 0};
        System.out.println("测试3: " + java.util.Arrays.toString(stones3));
        System.out.println("能否到达: " + solver.canReach(stones3));
        System.out.println("最少跳跃: " + solver.minJumps(stones3));
        System.out.println();

        // 测试用例4:大规模数据
        int[] stones4 = new int[100000];
        for (int i = 0; i < stones4.length; i++) {
            stones4[i] = (i % 10 == 0) ? 15 : 5;
        }
        long start = System.currentTimeMillis();
        int result = solver.minJumps(stones4);
        long end = System.currentTimeMillis();
        System.out.println("测试4 (n=100000): 最少跳跃 = " + result + ", 耗时 = " + (end - start) + "ms");
    }
}

2.2 扩展:记录具体跳跃路径

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

/**
 * 青蛙过河:不仅求最少跳跃次数,还记录具体路径
 */
public class FrogJumpWithPath {

    /**
     * 使用贪心策略记录最优跳跃路径
     * 维护一个数组记录每个位置由哪个前驱位置跳跃而来
     */
    public Result findOptimalPath(int[] stones) {
        int n = stones.length;
        if (n <= 1) {
            return new Result(0, Arrays.asList(0));
        }

        int jumps = 0;
        int currentEnd = 0;
        int farthest = 0;
        int bestNextPos = 0;  // 记录当前边界内最优的下一个起跳点

        // parent[i] 记录到达位置 i 的前一个起跳位置
        int[] parent = new int[n];
        Arrays.fill(parent, -1);

        for (int i = 0; i < n - 1; i++) {
            int reach = i + stones[i];
            if (reach > farthest) {
                farthest = reach;
                bestNextPos = i;
            }

            if (farthest >= n - 1) {
                // 记录最后一段路径
                for (int j = currentEnd + 1; j <= n - 1 && j <= farthest; j++) {
                    if (parent[j] == -1) {
                        parent[j] = bestNextPos;
                    }
                }
                jumps++;
                break;
            }

            if (i == currentEnd) {
                // 记录这一段路径中各位置的前驱
                for (int j = currentEnd + 1; j <= farthest; j++) {
                    if (parent[j] == -1) {
                        parent[j] = bestNextPos;
                    }
                }
                jumps++;
                currentEnd = farthest;
                if (currentEnd <= i) {
                    return new Result(-1, null); // 无法到达
                }
            }
        }

        // 回溯重建路径
        List<Integer> path = new ArrayList<>();
        int pos = n - 1;
        while (pos != -1) {
            path.add(pos);
            if (pos == 0) break;
            pos = parent[pos];
        }
        // 路径是倒序的,需要反转
        // 但这里我们记录的是从起点出发的跳跃点,简化处理

        return new Result(jumps, path);
    }

    static class Result {
        int jumps;
        List<Integer> path;

        Result(int jumps, List<Integer> path) {
            this.jumps = jumps;
            this.path = path;
        }

        @Override
        public String toString() {
            return "跳跃次数: " + jumps + ", 路径: " + path;
        }
    }

    public static void main(String[] args) {
        FrogJumpWithPath solver = new FrogJumpWithPath();
        int[] stones = {2, 3, 1, 1, 4, 2, 1, 2, 3, 1};
        System.out.println("石头数组: " + Arrays.toString(stones));
        Result result = solver.findOptimalPath(stones);
        System.out.println(result);
    }
}

2.3 进阶:带权重的青蛙过河(不同石头有通过代价)

import java.util.Arrays;

/**
 * 带代价的青蛙过河:每块石头有通过代价,求到达终点的最小总代价
 * 此时贪心策略失效,需要使用动态规划
 */
public class FrogJumpWithCost {

    /**
     * 动态规划解法
     * dp[i] 表示到达第 i 块石头的最小代价
     * dp[i] = min(dp[j] + cost[i]) for all j < i and j + stones[j] >= i
     * 
     * 时间复杂度: O(n^2)
     * 空间复杂度: O(n)
     */
    public int minCost(int[] stones, int[] cost) {
        int n = stones.length;
        int[] dp = new int[n];
        Arrays.fill(dp, Integer.MAX_VALUE);
        dp[0] = cost[0];

        for (int i = 1; i < n; i++) {
            for (int j = 0; j < i; j++) {
                // 检查能否从 j 跳到 i
                if (j + stones[j] >= i && dp[j] != Integer.MAX_VALUE) {
                    dp[i] = Math.min(dp[i], dp[j] + cost[i]);
                }
            }
        }

        return dp[n - 1] == Integer.MAX_VALUE ? -1 : dp[n - 1];
    }

    /**
     * 动态规划 + 单调队列优化
     * 当 stones[i] 的值较大时,可使用线段树或单调队列将复杂度降至 O(n log n)
     */
    public int minCostOptimized(int[] stones, int[] cost) {
        int n = stones.length;
        int[] dp = new int[n];
        Arrays.fill(dp, Integer.MAX_VALUE);
        dp[0] = cost[0];

        // 使用线段树或单调队列维护区间最小值
        // 这里展示一个简化的滑动窗口最小值优化
        // 适用于 stones[i] 变化范围有限的场景

        // 实际工程中可使用 java.util.TreeMap 或自定义线段树
        for (int i = 1; i < n; i++) {
            // 找到能跳到 i 的所有位置中的最小 dp 值
            int minPrev = Integer.MAX_VALUE;
            for (int j = Math.max(0, i - 100); j < i; j++) {
                if (j + stones[j] >= i && dp[j] < minPrev) {
                    minPrev = dp[j];
                }
            }
            if (minPrev != Integer.MAX_VALUE) {
                dp[i] = minPrev + cost[i];
            }
        }

        return dp[n - 1] == Integer.MAX_VALUE ? -1 : dp[n - 1];
    }

    public static void main(String[] args) {
        FrogJumpWithCost solver = new FrogJumpWithCost();

        int[] stones = {2, 3, 1, 1, 4};
        int[] cost = {1, 2, 3, 1, 2};

        System.out.println("石头: " + Arrays.toString(stones));
        System.out.println("代价: " + Arrays.toString(cost));
        System.out.println("最小代价: " + solver.minCost(stones, cost));
    }
}

三、算法正确性证明

贪心选择性质:假设存在一条从起点到终点的最优路径,其第一次跳跃到达的位置为 k。根据贪心策略,我们在 [0, currentEnd] 范围内选择能够跳得最远的点作为起跳点,设该点为 g。若 g 跳跃的最远位置 g + stones[g] 不小于 k + stones[k],则用 g 替换 k 不会使后续跳跃次数增加。因此贪心选择是安全的。

最优子结构:在第一次贪心跳跃后,问题转化为从新的边界 currentEnd 出发到达终点的子问题,该子问题的最优解与全局最优解一致。

四、复杂度分析

算法版本 时间复杂度 空间复杂度 适用场景
基础贪心 O(n) O(1) 只求最少跳跃次数
路径记录 O(n) O(n) 需要输出具体路径
带权DP O(n²) O(n) 每步有不同代价
单调队列优化 O(n log n) O(n) 大规模带权场景

五、总结

青蛙过河问题是贪心算法的经典应用场景。双指针技巧通过维护”当前边界”和”最远可达”两个变量,将看似需要回溯搜索的问题转化为线性扫描。本文从基础版本出发,逐步扩展到路径重建和带权代价场景,展示了算法设计从简单到复杂的演进过程。在实际游戏开发中,这种覆盖区间合并的思想还可用于技能范围计算、视野检测等多种场景。