每日算法 — 使用java实现青蛙过河:贪心策略与最优跳跃路径规划

引言

青蛙过河是一道经典的算法面试题:河面上排列着若干石头,每块石头上标有青蛙在该位置能跳跃的最大距离,判断青蛙能否从起点到达终点。本文将这一问题扩展为最优路径规划版本——不仅判断是否可过河,还要找到跳跃次数最少、或总耗能最低的最优路径。通过贪心策略与动态规划的结合,实现不同约束条件下的最优决策。

问题定义与变体

基础版本(跳跃可达性)

给定数组stones[i]表示在第i块石头上最多能向前跳跃的步数,判断能否从第0块石头到达最后一块。

最优版本(最少跳跃次数)

在可达的前提下,找出到达终点所需的最少跳跃次数。例如[2,3,1,1,4]的最优解为2次(0→1→4)。

扩展版本(加权最优路径)

每块石头有额外属性(如跳跃耗能、停留风险),求总成本最低的路径。本文实现支持自定义成本函数的通用框架。

算法设计

贪心策略:最少跳跃次数

最少跳跃次数问题有经典的O(n)贪心解法:维护当前能到达的最远边界end,以及下一跳能到达的最远位置farthest。遍历数组时不断更新farthest,当到达当前end边界时,必须进行一次跳跃,并将end更新为farthest。

贪心正确性证明:假设存在更优解,在某一步没有选择能跳最远的石头,则可以用更远的选择替换,不会增加跳跃次数。

动态规划:加权最优路径

当每跳有自定义成本时,贪心不再适用。定义dp[i]为到达石头i的最小总成本:

dp[i] = min(dp[j] + cost(j, i)) for all j < i and j + stones[j] >= i

该DP的时间复杂度为O(n²),可通过单调队列优化至O(n)。

Java完整实现

import java.util.*;

/**
 * 青蛙过河最优路径规划
 * 支持最少跳跃、加权最优、路径回溯等多种策略
 */
public class FrogJumpOptimizer {

    private int[] stones;
    private int n;

    public FrogJumpOptimizer(int[] stones) {
        this.stones = stones;
        this.n = stones.length;
    }

    /**
     * 基础版本:判断是否能到达终点
     * 贪心维护最远可达距离
     */
    public boolean canReach() {
        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 farthest >= n - 1;
    }

    /**
     * 贪心版本:最少跳跃次数
     * 时间复杂度 O(n),空间复杂度 O(1)
     */
    public int minJumpsGreedy() {
        if (n <= 1) return 0;

        int jumps = 0;
        int currentEnd = 0;      // 当前跳跃能到达的最远边界
        int farthest = 0;        // 下一跳能到达的最远位置

        for (int i = 0; i < n - 1; i++) {
            // 更新下一跳的最远可达位置
            farthest = Math.max(farthest, i + stones[i]);

            // 到达当前边界,必须跳一次
            if (i == currentEnd) {
                jumps++;
                currentEnd = farthest;

                // 提前终止检查
                if (currentEnd >= n - 1) break;
            }
        }

        return jumps;
    }

    /**
     * 贪心版本附带路径回溯
     * 记录每次跳跃的落点
     */
    public Result minJumpsGreedyWithPath() {
        if (n <= 1) return new Result(0, Arrays.asList(0));

        int jumps = 0;
        int currentEnd = 0;
        int farthest = 0;
        int bestNext = 0;        // 下一跳的最佳落点
        List<Integer> path = new ArrayList<>();
        path.add(0);

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

            if (i == currentEnd) {
                jumps++;
                if (bestNext != path.get(path.size() - 1)) {
                    path.add(bestNext);
                }
                currentEnd = farthest;

                if (currentEnd >= n - 1) {
                    path.add(n - 1);
                    break;
                }
            }
        }

        return new Result(jumps, path);
    }

    /**
     * 动态规划:加权最优路径
     * cost[i] 表示从石头i起跳的基础成本
     */
    public Result weightedMinCost(int[] jumpCost) {
        // dp[i] 表示到达i的最小总成本
        int[] dp = new int[n];
        int[] parent = new int[n]; // 记录前驱节点,用于回溯路径
        Arrays.fill(dp, Integer.MAX_VALUE / 2);
        Arrays.fill(parent, -1);
        dp[0] = 0;

        for (int i = 1; i < n; i++) {
            for (int j = 0; j < i; j++) {
                // 从j能跳到i
                if (j + stones[j] >= i) {
                    int cost = dp[j] + jumpCost[j];
                    if (cost < dp[i]) {
                        dp[i] = cost;
                        parent[i] = j;
                    }
                }
            }
        }

        if (dp[n - 1] >= Integer.MAX_VALUE / 2) {
            return new Result(-1, new ArrayList<>());
        }

        // 回溯路径
        List<Integer> path = new ArrayList<>();
        int cur = n - 1;
        while (cur != -1) {
            path.add(cur);
            cur = parent[cur];
        }
        Collections.reverse(path);

        return new Result(dp[n - 1], path);
    }

    /**
     * 单调队列优化DP:最小跳跃次数(等价于每跳成本为1)
     * 时间复杂度 O(n),适用于大规模数据
     */
    public int minJumpsDeque() {
        if (n <= 1) return 0;

        // dp[i] 表示到达i的最少跳跃次数
        int[] dp = new int[n];
        Arrays.fill(dp, Integer.MAX_VALUE / 2);
        dp[0] = 0;

        // 双端队列维护候选跳跃源:dp值单调递增
        Deque<Integer> deque = new LinkedList<>();
        deque.addLast(0);

        for (int i = 1; i < n; i++) {
            // 移除不能跳到i的源(超出跳跃范围)
            while (!deque.isEmpty() && deque.peekFirst() + stones[deque.peekFirst()] < i) {
                deque.pollFirst();
            }

            if (deque.isEmpty()) {
                dp[i] = Integer.MAX_VALUE / 2; // 不可达
                continue;
            }

            // 队首就是最优源(最少跳跃次数)
            dp[i] = dp[deque.peekFirst()] + 1;

            // 维护单调性:移除dp值大于等于dp[i]的元素
            while (!deque.isEmpty() && dp[deque.peekLast()] >= dp[i]) {
                deque.pollLast();
            }
            deque.addLast(i);
        }

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

    /**
     * 多约束优化:同时考虑跳跃次数和总距离
     * 使用字典序比较:(jumps, totalDistance)
     */
    public Result multiObjectiveOptimize() {
        // 状态:(最少跳跃次数, 最小总跳跃距离)
        int[] minJumps = new int[n];
        int[] minDist = new int[n];
        int[] parent = new int[n];
        Arrays.fill(minJumps, Integer.MAX_VALUE / 2);
        Arrays.fill(minDist, Integer.MAX_VALUE / 2);
        Arrays.fill(parent, -1);
        minJumps[0] = 0;
        minDist[0] = 0;

        for (int i = 1; i < n; i++) {
            for (int j = 0; j < i; j++) {
                if (j + stones[j] >= i) {
                    int dist = i - j; // 本次跳跃距离
                    int newJumps = minJumps[j] + 1;
                    int newDist = minDist[j] + dist;

                    // 字典序优化:先比跳跃次数,再比总距离
                    if (newJumps < minJumps[i] || 
                            (newJumps == minJumps[i] && newDist < minDist[i])) {
                        minJumps[i] = newJumps;
                        minDist[i] = newDist;
                        parent[i] = j;
                    }
                }
            }
        }

        if (minJumps[n - 1] >= Integer.MAX_VALUE / 2) {
            return new Result(-1, new ArrayList<>());
        }

        List<Integer> path = new ArrayList<>();
        int cur = n - 1;
        while (cur != -1) {
            path.add(cur);
            cur = parent[cur];
        }
        Collections.reverse(path);

        System.out.println("Multi-objective result: jumps=" + minJumps[n-1] 
                + ", totalDistance=" + minDist[n-1]);
        return new Result(minJumps[n - 1], path);
    }

    /**
     * 打印跳跃可视化
     */
    public void visualizePath(List<Integer> path) {
        char[] line = new char[n];
        Arrays.fill(line, '-');
        for (int i = 0; i < n; i++) {
            line[i] = (char) ('0' + (stones[i] % 10));
        }

        System.out.println("Stones: " + Arrays.toString(stones));
        System.out.print("Path:   ");
        for (int i = 0; i < n; i++) {
            if (path.contains(i)) {
                System.out.print("^");
            } else {
                System.out.print(" ");
            }
            if (i < n - 1) System.out.print(" ");
        }
        System.out.println();
    }

    static class Result {
        int value;
        List<Integer> path;
        Result(int v, List<Integer> p) { this.value = v; this.path = p; }
    }

    public static void main(String[] args) {
        int[] stones = {2, 3, 1, 1, 4, 2, 1, 3, 2, 1};
        FrogJumpOptimizer solver = new FrogJumpOptimizer(stones);

        System.out.println("=== Frog Jump Optimizer Demo ===\n");
        System.out.println("Stones: " + Arrays.toString(stones));

        // 基础可达性
        System.out.println("\n1. Can reach end? " + solver.canReach());

        // 贪心最少跳跃
        int greedyJumps = solver.minJumpsGreedy();
        System.out.println("\n2. Greedy min jumps: " + greedyJumps);

        // 贪心附带路径
        Result greedyPath = solver.minJumpsGreedyWithPath();
        System.out.println("\n3. Greedy path: jumps=" + greedyPath.value 
                + ", path=" + greedyPath.path);
        solver.visualizePath(greedyPath.path);

        // 单调队列优化
        int dequeJumps = solver.minJumpsDeque();
        System.out.println("\n4. Deque optimized min jumps: " + dequeJumps);

        // 加权最优路径(假设每块石头的跳跃成本为stones[i])
        int[] costs = Arrays.copyOf(stones, stones.length);
        Result weighted = solver.weightedMinCost(costs);
        System.out.println("\n5. Weighted min cost (cost=stones[i]): cost=" + weighted.value 
                + ", path=" + weighted.path);
        solver.visualizePath(weighted.path);

        // 多目标优化
        Result multi = solver.multiObjectiveOptimize();
        System.out.println("\n6. Multi-objective path: " + multi.path);
        solver.visualizePath(multi.path);
    }
}

算法复杂度对比

算法 时间复杂度 空间复杂度 适用场景
可达性判断 O(n) O(1) 仅需判断能否过河
贪心最少跳跃 O(n) O(1) 无权重,仅求最少次数
基础DP O(n²) O(n) 带权重的最优路径
单调队列优化DP O(n) O(n) 大规模数据的最少跳跃
多目标DP O(n²) O(n) 多约束平衡优化

关键设计要点

  1. 贪心最优性保证:最少跳跃问题的贪心策略基于”当前边界内选最远”原则,严格优于或等于任何其他选择的后续表现
  2. 单调队列优化:维护候选源点的双端队列,利用”若j在i之前且dp[j] ≤ dp[i],则i在后续决策中永优j”的性质,将O(n²)降至O(n)
  3. 路径回溯机制:parent数组记录前驱节点,支持从终点反向重构完整决策链
  4. 多目标字典序:通过按优先级依次比较目标值,将多目标问题转化为可比较的单目标问题

总结

青蛙过河问题虽小,却涵盖了贪心、动态规划、单调队列优化等核心算法思想。本文从基础判断延伸至多目标最优路径,展示了如何针对同一问题在不同约束条件下选择最合适的算法策略。读者可进一步尝试将贪心与DP结合,或引入A*启发式搜索处理带地形障碍的二维过河场景。