每日算法 — 使用java实现打地鼠:动态规划最优击打序列与贪心策略

引言

打地鼠是一款考验反应速度与决策的经典街机游戏。地鼠随机从洞中探出,玩家需在有限时间内击打尽可能多的地鼠。本文将用Java实现一个智能化的打地鼠AI,核心在于将地鼠出现序列建模为带时间窗口的序列决策问题,使用动态规划求解最优击打序列,并结合贪心策略处理实时约束。

问题建模

假设游戏区域有 $n$ 个地鼠洞,地鼠按时间戳出现,每个地鼠具有:
– 出现位置 $(x, y)$
– 出现时间 $t_{appear}$
– 停留时长 $duration$
– 分值 $score$

玩家移动锤子需要时间,锤子在时间 $t$ 可以击打位置 $(x, y)$ 的地鼠,当且仅当 $t_{appear} \le t \le t_{appear} + duration$。

目标:在总时间 $T$ 内,选择一组可执行的击打动作,使得总分最大。

动态规划最优解

将每个地鼠视为一个”任务”,其时间窗口为 $[t_{appear}, t_{appear} + duration]$。假设玩家在地鼠 $i$ 和地鼠 $j$ 之间移动需要耗时 $dist(i, j) / v$,其中 $v$ 为移动速度。

定义 $dp[i]$ 为以击打地鼠 $i$ 作为最后一个动作时能获得的最大分数。状态转移:

$$
dp[i] = score_i + \max_{j: t_j + move(j,i) \le t_i^{deadline}} { dp[j] }
$$

若无可行前驱,则 $dp[i] = score_i$。最终答案为 $\max_i dp[i]$。

Java实现

地鼠实体类

public class Mole {
    int id;
    int x, y;           // 位置坐标
    int appearTime;     // 出现时间(毫秒)
    int duration;       // 停留时长
    int score;          // 击打得分
    int deadline;       // 最晚可击打时间 = appearTime + duration

    public Mole(int id, int x, int y, int appearTime, int duration, int score) {
        this.id = id;
        this.x = x;
        this.y = y;
        this.appearTime = appearTime;
        this.duration = duration;
        this.score = score;
        this.deadline = appearTime + duration;
    }

    // 计算移动耗时(毫秒),假设速度为每单位距离1ms
    public int moveTimeTo(Mole other) {
        return Math.abs(this.x - other.x) + Math.abs(this.y - other.y);
    }

    @Override
    public String toString() {
        return String.format("Mole[%d]@(%d,%d) t=%d~%d score=%d", id, x, y, appearTime, deadline, score);
    }
}

动态规划求解器

import java.util.*;

public class MoleDPStrategy {
    private List<Mole> moles;
    private int startX, startY; // 锤子初始位置

    public MoleDPStrategy(List<Mole> moles, int startX, int startY) {
        this.moles = new ArrayList<>(moles);
        this.startX = startX;
        this.startY = startY;
        // 按最晚击打时间排序
        this.moles.sort(Comparator.comparingInt(m -> m.deadline));
    }

    /**
     * 动态规划求解最优击打序列
     * dp[i] = 以第i个地鼠结尾的最大得分
     */
    public Result solve() {
        int n = moles.size();
        if (n == 0) return new Result(0, new ArrayList<>());

        double[] dp = new double[n];
        int[] prev = new int[n];
        Arrays.fill(prev, -1);

        for (int i = 0; i < n; i++) {
            Mole curr = moles.get(i);
            // 从起点直接击打
            int distFromStart = Math.abs(curr.x - startX) + Math.abs(curr.y - startY);
            if (distFromStart <= curr.deadline) {
                dp[i] = curr.score;
            } else {
                dp[i] = Double.NEGATIVE_INFINITY;
            }

            // 尝试从之前的某个地鼠转移过来
            for (int j = 0; j < i; j++) {
                Mole prevMole = moles.get(j);
                int moveTime = prevMole.moveTimeTo(curr);
                int arrivalTime = prevMole.appearTime + moveTime; // 简化为从出现时刻出发

                // 更精确:需要知道击打j的实际时间,这里假设在deadline前到达j并立即出发
                int departureTime = Math.min(prevMole.deadline, prevMole.appearTime + 100); // 简化为出现后100ms击打
                arrivalTime = departureTime + moveTime;

                if (arrivalTime <= curr.deadline && dp[j] > Double.NEGATIVE_INFINITY) {
                    if (dp[j] + curr.score > dp[i]) {
                        dp[i] = dp[j] + curr.score;
                        prev[i] = j;
                    }
                }
            }
        }

        // 找最大值
        double maxScore = Double.NEGATIVE_INFINITY;
        int bestIdx = -1;
        for (int i = 0; i < n; i++) {
            if (dp[i] > maxScore) {
                maxScore = dp[i];
                bestIdx = i;
            }
        }

        // 回溯路径
        List<Mole> sequence = new ArrayList<>();
        int idx = bestIdx;
        while (idx != -1) {
            sequence.add(moles.get(idx));
            idx = prev[idx];
        }
        Collections.reverse(sequence);

        return new Result((int) maxScore, sequence);
    }

    /**
     * 贪心策略:每次击打当前可到达且得分效率最高的地鼠
     */
    public Result greedySolve() {
        List<Mole> remaining = new ArrayList<>(moles);
        List<Mole> sequence = new ArrayList<>();
        int currX = startX, currY = startY;
        int currTime = 0;
        int totalScore = 0;

        while (!remaining.isEmpty()) {
            Mole best = null;
            double bestEfficiency = Double.NEGATIVE_INFINITY;

            for (Mole m : remaining) {
                int dist = Math.abs(m.x - currX) + Math.abs(m.y - currY);
                int arrival = currTime + dist;
                if (arrival <= m.deadline) {
                    // 效率 = 得分 / (移动时间 + 等待时间)
                    double efficiency = (double) m.score / (dist + Math.max(0, m.appearTime - currTime) + 1);
                    if (efficiency > bestEfficiency) {
                        bestEfficiency = efficiency;
                        best = m;
                    }
                }
            }

            if (best == null) break;

            int dist = Math.abs(best.x - currX) + Math.abs(best.y - currY);
            currTime = Math.max(currTime + dist, best.appearTime);
            currX = best.x;
            currY = best.y;
            totalScore += best.score;
            sequence.add(best);
            remaining.remove(best);
        }

        return new Result(totalScore, sequence);
    }

    public static class Result {
        public int totalScore;
        public List<Mole> sequence;

        public Result(int totalScore, List<Mole> sequence) {
            this.totalScore = totalScore;
            this.sequence = sequence;
        }
    }
}

游戏引擎与可视化

public class WhackAMoleGame {
    private static final int GRID_SIZE = 5;
    private static final int GAME_TIME = 30000; // 30秒
    private List<Mole> moles;
    private Random random;

    public WhackAMoleGame() {
        this.moles = new ArrayList<>();
        this.random = new Random();
    }

    /**
     * 生成随机地鼠序列
     */
    public void generateMoles(int count) {
        moles.clear();
        for (int i = 0; i < count; i++) {
            int x = random.nextInt(GRID_SIZE);
            int y = random.nextInt(GRID_SIZE);
            int appear = random.nextInt(GAME_TIME - 2000);
            int duration = 1000 + random.nextInt(2000);
            int score = 10 + random.nextInt(50);
            moles.add(new Mole(i, x, y, appear, duration, score));
        }
    }

    public void runAIComparison() {
        generateMoles(20);

        System.out.println("=== 地鼠序列 ===");
        for (Mole m : moles) {
            System.out.println(m);
        }

        // DP策略
        MoleDPStrategy dpStrategy = new MoleDPStrategy(moles, 0, 0);
        MoleDPStrategy.Result dpResult = dpStrategy.solve();
        System.out.println("\n=== DP最优策略 ===");
        System.out.println("总得分: " + dpResult.totalScore);
        System.out.println("击打序列:");
        for (Mole m : dpResult.sequence) {
            System.out.println("  " + m);
        }

        // 贪心策略
        MoleDPStrategy.Result greedyResult = dpStrategy.greedySolve();
        System.out.println("\n=== 贪心策略 ===");
        System.out.println("总得分: " + greedyResult.totalScore);
        System.out.println("击打序列:");
        for (Mole m : greedyResult.sequence) {
            System.out.println("  " + m);
        }

        System.out.println("\n得分提升: " + (dpResult.totalScore - greedyResult.totalScore));
    }

    public static void main(String[] args) {
        WhackAMoleGame game = new WhackAMoleGame();
        game.runAIComparison();
    }
}

带时间精度的状态DP(进阶)

public class MoleStateDP {
    /**
     * 状态压缩DP:将时间离散化为事件点
     * 对于小规模地鼠(n <= 15),可用位掩码表示已击打集合
     */
    public static Result bitmaskDP(List<Mole> moles, int startX, int startY) {
        int n = moles.size();
        if (n > 15) throw new IllegalArgumentException("地鼠数量过多,不适合状态压缩");

        double[] dp = new double[1 << n];
        int[] lastPos = new int[1 << n]; // 记录最后击打的地鼠
        Arrays.fill(lastPos, -1);

        // 预处理:计算从起点到每个地鼠的时间
        int[] startTime = new int[n];
        for (int i = 0; i < n; i++) {
            Mole m = moles.get(i);
            startTime[i] = Math.abs(m.x - startX) + Math.abs(m.y - startY);
        }

        double bestScore = 0;
        int bestMask = 0;

        for (int mask = 0; mask < (1 << n); mask++) {
            for (int i = 0; i < n; i++) {
                if ((mask & (1 << i)) != 0) continue;

                int prevMask = mask;
                int prevIdx = lastPos[prevMask];
                int arrivalTime;

                if (prevIdx == -1) {
                    arrivalTime = startTime[i];
                } else {
                    Mole prevMole = moles.get(prevIdx);
                    arrivalTime = /* 需要记录实际击打时间,此处简化 */ 0;
                }

                // 简化为检查可行性并更新得分
                // 实际实现需要维护每个状态的当前时间和位置
            }
        }

        return new Result((int) bestScore, new ArrayList<>());
    }
}

复杂度分析

  • 动态规划:时间 $O(n^2)$,空间 $O(n)$,其中 $n$ 为地鼠数量。需要对地鼠按截止时间排序。
  • 状态压缩DP:时间 $O(n \cdot 2^n)$,适用于 $n \le 20$ 的小规模场景,可求得全局最优。
  • 贪心策略:时间 $O(n^2)$,空间 $O(n)$,不保证最优但实时性更好。

总结

本文将打地鼠游戏建模为带时间窗口的序列决策问题,通过动态规划求解最优击打序列。DP策略在离线场景下能求得最优解,而贪心策略更适合在线实时决策。两种策略的结合可以在实际游戏中兼顾最优性与响应速度。