每日算法 — 使用java实现打地鼠:线段树区间覆盖与最优击打调度

引言

打地鼠是一款考验反应速度与策略判断的经典街机游戏。地鼠随机从多个洞口冒出,玩家需要在有限时间内尽可能多地击中地鼠。当多个地鼠同时出现且时间窗口重叠时,如何规划击打顺序才能最大化得分?这正是区间调度问题的典型场景。本文将用Java实现一款带AI决策的打地鼠游戏,核心算法采用线段树(Segment Tree)维护区间覆盖信息,结合贪心策略求解最优击打序列。

问题建模

将打地鼠抽象为区间调度问题:
– 每个地鼠的冒出过程对应一个时间区间 [start, end]
– 每次击打消耗固定时间 hitDuration
– 若在某地鼠的存活区间内完成击打,则获得该地鼠的分数
– 目标:在总时间限制内,选择一组互不冲突(或允许有限重叠)的击打序列,使得总分最大

当击打间隔存在冷却时间或地鼠存在”连续出现奖励”时,我们需要在区间上快速查询最大得分,线段树便成为理想的数据结构。

核心算法:线段树区间最值与贪心调度

算法思路

  1. 区间离散化:将所有地鼠的 start 和 end 时间戳离散化为整数坐标
  2. 动态规划状态设计:设 dp[i] 表示处理完前 i 个时间单位能获得的最大分数
  3. 线段树优化转移:对于每个地鼠区间 [l, r],更新 dp[r] = max(dp[r], query_max(l, r) + score),其中 query_max 用线段树在 O(log n) 内完成
  4. 贪心选择:按结束时间排序,优先处理先结束的地鼠(经典区间调度贪心)

复杂度分析

  • 时间复杂度:O(n log n),其中 n 为地鼠数量,排序 O(n log n),每次线段树查询/更新 O(log n)
  • 空间复杂度:O(n),用于存储线段树和离散化坐标

Java完整实现

import java.util.*;

/**
 * 打地鼠游戏:线段树区间覆盖与最优击打调度
 * 
 * 核心思路:
 * 1. 将每只地鼠抽象为带权区间 [start, end, score]
 * 2. 按结束时间排序,采用贪心+线段树DP求解最大得分
 * 3. 线段树维护区间最大值,支持单点更新与区间查询
 */
public class WhackAMole {

    // ============ 线段树类 ============
    static class SegmentTree {
        private int[] tree;
        private int n;

        public SegmentTree(int size) {
            this.n = size;
            this.tree = new int[4 * size];
        }

        /** 单点更新:将位置 pos 的值更新为 max(原值, val) */
        public void update(int pos, int val) {
            update(1, 0, n - 1, pos, val);
        }

        private void update(int node, int l, int r, int pos, int val) {
            if (l == r) {
                tree[node] = Math.max(tree[node], val);
                return;
            }
            int mid = (l + r) >>> 1;
            if (pos <= mid) {
                update(node << 1, l, mid, pos, val);
            } else {
                update(node << 1 | 1, mid + 1, r, pos, val);
            }
            tree[node] = Math.max(tree[node << 1], tree[node << 1 | 1]);
        }

        /** 区间查询:查询 [ql, qr] 范围内的最大值 */
        public int query(int ql, int qr) {
            return query(1, 0, n - 1, ql, qr);
        }

        private int query(int node, int l, int r, int ql, int qr) {
            if (ql > r || qr < l) {
                return Integer.MIN_VALUE / 2; // 避免溢出
            }
            if (ql <= l && r <= qr) {
                return tree[node];
            }
            int mid = (l + r) >>> 1;
            return Math.max(
                query(node << 1, l, mid, ql, qr),
                query(node << 1 | 1, mid + 1, r, ql, qr)
            );
        }
    }

    // ============ 地鼠区间类 ============
    static class MoleInterval implements Comparable<MoleInterval> {
        int start;   // 出现时间(毫秒或帧数)
        int end;     // 消失时间
        int score;   // 击中得分
        int id;      // 地鼠编号

        public MoleInterval(int start, int end, int score, int id) {
            this.start = start;
            this.end = end;
            this.score = score;
            this.id = id;
        }

        @Override
        public int compareTo(MoleInterval o) {
            return Integer.compare(this.end, o.end); // 按结束时间升序
        }

        @Override
        public String toString() {
            return String.format("Mole[%d](%d~%d, score=%d)", id, start, end, score);
        }
    }

    // ============ 游戏核心逻辑 ============
    private List<MoleInterval> moles;
    private int totalTime;     // 游戏总时长
    private int hitDuration;   // 每次击打耗时

    public WhackAMole(int totalTime, int hitDuration) {
        this.moles = new ArrayList<>();
        this.totalTime = totalTime;
        this.hitDuration = hitDuration;
    }

    /** 添加一只地鼠 */
    public void addMole(int start, int end, int score, int id) {
        if (start >= 0 && end <= totalTime && start < end) {
            moles.add(new MoleInterval(start, end, score, id));
        }
    }

    /**
     * 求解最优击打序列
     * 算法:按结束时间排序后,使用线段树优化的动态规划
     * dp[i] 表示在 sortedMoles[i].end 时刻之前能获得的最大分数
     */
    public Result solveOptimalSchedule() {
        if (moles.isEmpty()) {
            return new Result(0, Collections.emptyList());
        }

        // 1. 按结束时间排序(贪心基础)
        List<MoleInterval> sorted = new ArrayList<>(moles);
        Collections.sort(sorted);

        // 2. 坐标离散化:收集所有 start 和 end
        Set<Integer> coordSet = new TreeSet<>();
        for (MoleInterval m : sorted) {
            coordSet.add(m.start);
            coordSet.add(m.end);
        }
        List<Integer> coords = new ArrayList<>(coordSet);
        Map<Integer, Integer> indexMap = new HashMap<>();
        for (int i = 0; i < coords.size(); i++) {
            indexMap.put(coords.get(i), i);
        }
        int m = coords.size();

        // 3. 初始化线段树和DP数组
        SegmentTree segTree = new SegmentTree(m);
        int[] dp = new int[sorted.size()];
        int[] prev = new int[sorted.size()]; // 记录前驱,用于回溯方案
        Arrays.fill(prev, -1);

        // dp[i] = max(不选i, 选i)
        // 选i时:需要找到最后一个满足 end <= start_i 的区间j,dp[i] = dp[j] + score_i
        // 用线段树维护以某个坐标结尾的最大dp值
        int globalMax = 0;
        int bestIdx = -1;

        for (int i = 0; i < sorted.size(); i++) {
            MoleInterval cur = sorted.get(i);
            int si = indexMap.get(cur.start);
            int ei = indexMap.get(cur.end);

            // 查询在 cur.start 之前(含)的最大dp值
            int bestBefore = segTree.query(0, si); // 允许击打时间刚好在start时刻开始
            if (bestBefore < 0) bestBefore = 0;

            dp[i] = bestBefore + cur.score;

            // 同时考虑不选当前地鼠的情况(线段树已包含之前的最优值)
            if (i > 0 && dp[i - 1] > dp[i]) {
                // 这种情况在线段树查询中已经自然处理,因为不选i时不需要更新ei
            }

            // 记录前驱:找到使bestBefore最大的那个区间
            // 简化处理:只记录当前选择的前驱为"最近的兼容区间"
            for (int j = i - 1; j >= 0; j--) {
                if (sorted.get(j).end <= cur.start) {
                    if (dp[j] + cur.score == dp[i]) {
                        prev[i] = j;
                        break;
                    }
                }
            }
            if (prev[i] == -1 && bestBefore == 0) {
                prev[i] = -1; // 无前驱,独立区间
            }

            // 更新线段树:在结束位置记录最大dp值
            int currentMaxAtEnd = segTree.query(ei, ei);
            if (dp[i] > currentMaxAtEnd) {
                segTree.update(ei, dp[i]);
            }

            if (dp[i] > globalMax) {
                globalMax = dp[i];
                bestIdx = i;
            }
        }

        // 4. 回溯最优序列
        List<MoleInterval> schedule = new ArrayList<>();
        int idx = bestIdx;
        while (idx >= 0) {
            schedule.add(sorted.get(idx));
            idx = prev[idx];
        }
        Collections.reverse(schedule);

        return new Result(globalMax, schedule);
    }

    /** 使用纯贪心策略(无线段树)作为对比 */
    public Result solveGreedy() {
        List<MoleInterval> sorted = new ArrayList<>(moles);
        Collections.sort(sorted);
        List<MoleInterval> schedule = new ArrayList<>();
        int currentTime = 0;
        int totalScore = 0;

        for (MoleInterval m : sorted) {
            if (m.start >= currentTime) {
                schedule.add(m);
                totalScore += m.score;
                currentTime = m.end + hitDuration; // 击打后需要冷却
            }
        }
        return new Result(totalScore, schedule);
    }

    // ============ 结果类 ============
    static class Result {
        int maxScore;
        List<MoleInterval> schedule;

        public Result(int maxScore, List<MoleInterval> schedule) {
            this.maxScore = maxScore;
            this.schedule = schedule;
        }

        @Override
        public String toString() {
            StringBuilder sb = new StringBuilder();
            sb.append("最大得分: ").append(maxScore).append("\n击打序列:\n");
            for (MoleInterval m : schedule) {
                sb.append("  ").append(m).append("\n");
            }
            return sb.toString();
        }
    }

    // ============ 主程序:演示与测试 ============
    public static void main(String[] args) {
        // 场景:10秒游戏,每次击打耗时1秒
        WhackAMole game = new WhackAMole(100, 10); // 时间单位:0.1秒

        // 随机生成地鼠(实际游戏中由随机数生成器控制)
        Random rand = new Random(42);
        int moleCount = 20;
        for (int i = 0; i < moleCount; i++) {
            int start = rand.nextInt(80);
            int duration = 5 + rand.nextInt(15); // 存活0.5~2秒
            int end = Math.min(start + duration, 100);
            int score = 10 + rand.nextInt(50);
            game.addMole(start, end, score, i + 1);
        }

        System.out.println("=== 打地鼠:线段树最优调度 vs 纯贪心 ===\n");

        Result optimal = game.solveOptimalSchedule();
        System.out.println("【线段树DP最优解】");
        System.out.println(optimal);

        Result greedy = game.solveGreedy();
        System.out.println("【纯贪心解】");
        System.out.println(greedy);

        int improvement = optimal.maxScore - greedy.maxScore;
        double percent = greedy.maxScore > 0 ? (improvement * 100.0 / greedy.maxScore) : 0;
        System.out.printf("\n优化提升: %d 分 (+%.1f%%)\n", improvement, percent);
    }
}

关键代码解析

1. 线段树设计

线段树在本算法中承担两个职责:
– 区间最大值查询:快速获取在某个时间戳之前能获得的最大累积分数
– 单点更新:在区间结束位置记录当前最优DP值

相比普通数组的 O(n) 扫描,线段树将每次状态转移优化到 O(log n),整体复杂度从 O(n²) 降至 O(n log n)。

2. 贪心与DP的结合

本算法首先按地鼠的结束时间升序排序,这是区间调度问题贪心的经典策略。排序后,每只地鼠只需考虑在它开始之前已经结束的地鼠,从而将二维冲突检测降为一维前缀查询,完美适配线段树的区间查询能力。

3. 前驱回溯

prev[] 数组记录每个最优状态的前一个地鼠索引,最终通过回溯链即可输出完整的击打序列,而非仅仅输出最大得分。

扩展思考

  1. 带权区间调度变体:若不同洞口的地鼠分值不同,上述算法天然支持,只需调整 score 参数
  2. 多玩家竞争:引入博弈论框架,将地鼠视为公共资源,两名玩家的击打策略形成纳什均衡
  3. 实时增量更新:使用动态线段树或树状数组,可在游戏进行中实时插入新出现的地鼠并重新计算最优策略

总结

本文以打地鼠游戏为载体,展示了线段树在区间最值查询中的强大能力。通过将游戏过程抽象为带权区间调度问题,结合贪心排序与线段树优化的动态规划,我们在 O(n log n) 时间内求解出最优击打序列。该框架可扩展至会议室预订、任务调度、资源分配等广泛的实际应用场景。