每日算法 — 使用java实现捉迷藏:视线追踪与状态空间搜索策略

捉迷藏是最古老的博弈游戏之一,躲藏者需要利用环境遮蔽,寻找者需要通过推理和搜索定位躲藏者。本文用Java实现捉迷藏的AI决策系统,重点讲解射线投射视线检测、状态空间搜索与贝叶斯推断在环境探索中的应用。

问题建模

将游戏场地建模为二维网格地图,每个格子有明确的遮挡属性:
– 空地标(.):可通行,不遮挡视线
– 障碍物(#):不可通行,完全遮挡视线
– 躲藏者(H):需避开寻找者的视线范围
– 寻找者(S):通过移动和观察缩小躲藏者可能位置集合

视线规则:寻找者只能看到同一行或列中、中间无障碍物相连的格子。对角线不可见。

核心算法

1. 射线投射视线检测(Ray Casting)

从寻找者位置向四个正方向发射射线,遇到障碍物即停止,标记所有可见格子。

2. 贝叶斯状态推断

寻找者维护一个概率分布,表示躲藏者在每个格子的概率。每次观察到某区域”无人”时,将该区域概率归零并重新归一化。

3. 最优搜索策略

寻找者选择能最大化信息增益(期望消除概率质量)的移动方向。

完整Java实现

import java.util.*;

/**
 * 捉迷藏 — 视线追踪与状态空间搜索策略
 * 包含射线投射、贝叶斯推断、信息增益最优搜索
 */
public class HideAndSeekGame {

    private final int rows;
    private final int cols;
    private final char[][] map;

    // 四个正方向
    private static final int[][] DIRS = {{-1,0}, {1,0}, {0,-1}, {0,1}};

    public HideAndSeekGame(char[][] map) {
        this.map = map;
        this.rows = map.length;
        this.cols = map[0].length;
    }

    /**
     * 射线投射:计算从(sr, sc)位置能看到的所有格子
     * 向四个方向发射射线,遇到障碍物停止
     */
    public boolean[][] computeVisibility(int sr, int sc) {
        boolean[][] visible = new boolean[rows][cols];
        if (!inBounds(sr, sc) || map[sr][sc] == '#') return visible;

        // 自身位置总是可见的
        visible[sr][sc] = true;

        // 四个方向射线投射
        for (int[] d : DIRS) {
            int r = sr + d[0];
            int c = sc + d[1];
            while (inBounds(r, c) && map[r][c] != '#') {
                visible[r][c] = true;
                r += d[0];
                c += d[1];
            }
        }

        return visible;
    }

    /**
     * 计算视线被遮挡的格子(从该位置不可见的格子)
     */
    public boolean[][] computeHiddenZones(int sr, int sc) {
        boolean[][] visible = computeVisibility(sr, sc);
        boolean[][] hidden = new boolean[rows][cols];
        for (int r = 0; r < rows; r++) {
            for (int c = 0; c < cols; c++) {
                hidden[r][c] = inBounds(r, c) && map[r][c] != '#' && !visible[r][c];
            }
        }
        return hidden;
    }

    /**
     * 贝叶斯状态推断:维护躲藏者的位置概率分布
     */
    public static class BeliefState {
        double[][] prob;      // 每个格子的概率
        int rows, cols;

        public BeliefState(int rows, int cols) {
            this.rows = rows;
            this.cols = cols;
            this.prob = new double[rows][cols];
        }

        /**
         * 均匀初始化:所有空地等概率
         */
        public void uniformInit(char[][] map) {
            int emptyCount = 0;
            for (int r = 0; r < rows; r++) {
                for (int c = 0; c < cols; c++) {
                    if (map[r][c] != '#') emptyCount++;
                }
            }
            double p = 1.0 / emptyCount;
            for (int r = 0; r < rows; r++) {
                for (int c = 0; c < cols; c++) {
                    prob[r][c] = (map[r][c] != '#') ? p : 0;
                }
            }
        }

        /**
         * 更新:观察到某区域"无人",将该区域概率归零并重新归一化
         */
        public void updateObservation(boolean[][] observed, boolean foundNothing) {
            double total = 0;
            for (int r = 0; r < rows; r++) {
                for (int c = 0; c < cols; c++) {
                    if (observed[r][c] && foundNothing) {
                        prob[r][c] = 0; // 观察到无人,概率归零
                    }
                    total += prob[r][c];
                }
            }
            // 重新归一化
            if (total > 0) {
                for (int r = 0; r < rows; r++) {
                    for (int c = 0; c < cols; c++) {
                        prob[r][c] /= total;
                    }
                }
            }
        }

        /**
         * 获取概率最高的位置
         */
        public int[] getMostLikelyPosition() {
            double maxP = -1;
            int[] best = null;
            for (int r = 0; r < rows; r++) {
                for (int c = 0; c < cols; c++) {
                    if (prob[r][c] > maxP) {
                        maxP = prob[r][c];
                        best = new int[]{r, c};
                    }
                }
            }
            return best;
        }

        /**
         * 计算熵(不确定性度量)
         */
        public double entropy() {
            double e = 0;
            for (int r = 0; r < rows; r++) {
                for (int c = 0; c < cols; c++) {
                    if (prob[r][c] > 0) {
                        e -= prob[r][c] * Math.log(prob[r][c]) / Math.log(2);
                    }
                }
            }
            return e;
        }
    }

    /**
     * 寻找者最优移动策略:选择能最大化期望信息增益的方向
     * 信息增益 = 当前熵 - 期望观察后的熵
     */
    public int[] selectBestMove(int sr, int sc, BeliefState belief) {
        int[] bestMove = null;
        double maxInfoGain = -1;

        for (int[] d : DIRS) {
            int nr = sr + d[0];
            int nc = sc + d[1];
            if (!inBounds(nr, nc) || map[nr][nc] == '#') continue;

            // 模拟移动到(nr, nc)后的观察
            boolean[][] visible = computeVisibility(nr, nc);

            // 计算期望信息增益
            double infoGain = computeExpectedInfoGain(belief, visible);

            // 额外奖励:如果高概率区域在视野内
            int[] likelyPos = belief.getMostLikelyPosition();
            if (likelyPos != null && visible[likelyPos[0]][likelyPos[1]]) {
                infoGain += 0.5;
            }

            if (infoGain > maxInfoGain) {
                maxInfoGain = infoGain;
                bestMove = new int[]{nr, nc};
            }
        }

        return bestMove != null ? bestMove : new int[]{sr, sc};
    }

    /**
     * 计算期望信息增益
     * E[IG] = P(发现) × IG(发现) + P(未发现) × IG(未发现)
     */
    private double computeExpectedInfoGain(BeliefState belief, boolean[][] visible) {
        double currentEntropy = belief.entropy();

        // 计算在可见区域内发现躲藏者的概率
        double pFound = 0;
        for (int r = 0; r < rows; r++) {
            for (int c = 0; c < cols; c++) {
                if (visible[r][c]) {
                    pFound += belief.prob[r][c];
                }
            }
        }

        // 场景1:发现了(概率pFound)
        // 此时熵变为0(确定位置),但具体哪个位置不确定,需要条件期望
        double entropyIfFound = 0;
        if (pFound > 0) {
            for (int r = 0; r < rows; r++) {
                for (int c = 0; c < cols; c++) {
                    if (visible[r][c] && belief.prob[r][c] > 0) {
                        double condProb = belief.prob[r][c] / pFound;
                        entropyIfFound -= condProb * Math.log(condProb) / Math.log(2);
                    }
                }
            }
        }

        // 场景2:没发现(概率1-pFound)
        // 躲藏者在不可见区域,重新归一化后的熵
        double entropyIfNotFound = currentEntropy; // 简化为当前熵(上界估计)

        double expectedEntropy = pFound * entropyIfFound + (1 - pFound) * entropyIfNotFound;
        return currentEntropy - expectedEntropy;
    }

    /**
     * 躲藏者最优移动策略:移动到最不可见的区域
     */
    public int[] selectBestHideMove(int hr, int hc, int sr, int sc) {
        int[] bestMove = null;
        double bestScore = Double.NEGATIVE_INFINITY;

        // 评估每个可能移动到的位置
        for (int[] d : DIRS) {
            int nr = hr + d[0];
            int nc = hc + d[1];
            if (!inBounds(nr, nc) || map[nr][nc] == '#') continue;

            // 计算从寻找者视角,该位置是否可见
            boolean[][] visible = computeVisibility(sr, sc);
            boolean isVisible = visible[nr][nc];

            // 计算该位置被多少其他位置看到("暴露度")
            int exposure = computeExposure(nr, nc);

            // 评分:不可见 + 暴露度低 + 远离寻找者
            int distToSeeker = Math.abs(nr - sr) + Math.abs(nc - sc);
            double score = (isVisible ? -10 : 5) - exposure * 0.3 + distToSeeker * 0.1;

            if (score > bestScore) {
                bestScore = score;
                bestMove = new int[]{nr, nc};
            }
        }

        return bestMove != null ? bestMove : new int[]{hr, hc};
    }

    /**
     * 计算某位置的"暴露度":从多少个其他空位能看到该位置
     */
    private int computeExposure(int r, int c) {
        int exposure = 0;
        for (int sr = 0; sr < rows; sr++) {
            for (int sc = 0; sc < cols; sc++) {
                if (map[sr][sc] == '#') continue;
                boolean[][] visible = computeVisibility(sr, sc);
                if (visible[r][c]) exposure++;
            }
        }
        return exposure;
    }

    /**
     * BFS最短路径:寻找者移动到目标位置的最短路径
     */
    public List<int[]> bfsPath(int sr, int sc, int tr, int tc) {
        boolean[][] visited = new boolean[rows][cols];
        Map<String, String> parent = new HashMap<>();
        Queue<int[]> queue = new LinkedList<>();

        queue.add(new int[]{sr, sc});
        visited[sr][sc] = true;

        while (!queue.isEmpty()) {
            int[] cur = queue.poll();
            if (cur[0] == tr && cur[1] == tc) {
                return reconstructPath(parent, sr, sc, tr, tc);
            }

            for (int[] d : DIRS) {
                int nr = cur[0] + d[0];
                int nc = cur[1] + d[1];
                if (!inBounds(nr, nc) || visited[nr][nc] || map[nr][nc] == '#') continue;

                visited[nr][nc] = true;
                parent.put(key(nr, nc), key(cur[0], cur[1]));
                queue.add(new int[]{nr, nc});
            }
        }

        return Collections.emptyList();
    }

    private List<int[]> reconstructPath(Map<String, String> parent,
                                         int sr, int sc, int tr, int tc) {
        LinkedList<int[]> path = new LinkedList<>();
        String cur = key(tr, tc);
        String start = key(sr, sc);
        while (!cur.equals(start)) {
            String[] p = cur.split(",");
            path.addFirst(new int[]{Integer.parseInt(p[0]), Integer.parseInt(p[1])});
            cur = parent.get(cur);
            if (cur == null) return Collections.emptyList();
        }
        path.addFirst(new int[]{sr, sc});
        return path;
    }

    /**
     * 完整游戏模拟
     */
    public void simulateGame(int[] seekerStart, int[] hiderStart, int maxTurns) {
        int[] seeker = seekerStart.clone();
        int[] hider = hiderStart.clone();

        BeliefState belief = new BeliefState(rows, cols);
        belief.uniformInit(map);

        System.out.println("===== 捉迷藏模拟 =====");
        System.out.println("S=寻找者 H=躲藏者 #=障碍物 .=空地");

        for (int turn = 1; turn <= maxTurns; turn++) {
            // 寻找者观察
            boolean[][] visible = computeVisibility(seeker[0], seeker[1]);
            boolean found = visible[hider[0]][hider[1]];

            if (found) {
                System.out.println("\n第" + turn + "回合:寻找者发现了躲藏者!");
                System.out.println("躲藏者位置: (" + hider[0] + "," + hider[1] + ")");
                printState(seeker, hider);
                return;
            }

            // 更新信念状态
            belief.updateObservation(visible, true);

            // 寻找者移动
            int[] seekerMove = selectBestMove(seeker[0], seeker[1], belief);
            if (seekerMove != null) {
                seeker[0] = seekerMove[0];
                seeker[1] = seekerMove[1];
            }

            // 躲藏者移动(若未被看到)
            int[] hiderMove = selectBestHideMove(hider[0], hider[1], seeker[0], seeker[1]);
            hider[0] = hiderMove[0];
            hider[1] = hiderMove[1];

            System.out.println("\n第" + turn + "回合");
            System.out.println("寻找者: (" + seeker[0] + "," + seeker[1] + ")");
            System.out.println("躲藏者: (" + hider[0] + "," + hider[1] + ")");
            System.out.println("信念熵: " + String.format("%.3f", belief.entropy()));
            int[] likely = belief.getMostLikelyPosition();
            System.out.println("最可能位置: (" + likely[0] + "," + likely[1] + ")" +
                              " 概率=" + String.format("%.3f", belief.prob[likely[0]][likely[1]]));
        }

        System.out.println("\n游戏结束,未能在" + maxTurns + "回合内找到躲藏者");
    }

    private void printState(int[] seeker, int[] hider) {
        for (int r = 0; r < rows; r++) {
            for (int c = 0; c < cols; c++) {
                if (r == seeker[0] && c == seeker[1]) {
                    System.out.print('S');
                } else if (r == hider[0] && c == hider[1]) {
                    System.out.print('H');
                } else {
                    System.out.print(map[r][c]);
                }
            }
            System.out.println();
        }
    }

    private boolean inBounds(int r, int c) {
        return r >= 0 && r < rows && c >= 0 && c < cols;
    }

    private String key(int r, int c) {
        return r + "," + c;
    }

    public static void main(String[] args) {
        char[][] map = {
            "#############".toCharArray(),
            "#...........#".toCharArray(),
            "#.#.#####.#.#".toCharArray(),
            "#.#.......#.#".toCharArray(),
            "#...#...#...#".toCharArray(),
            "###.#.#.#.###".toCharArray(),
            "#...#...#...#".toCharArray(),
            "#.#.......#.#".toCharArray(),
            "#.#.#####.#.#".toCharArray(),
            "#...........#".toCharArray(),
            "#############".toCharArray()
        };

        HideAndSeekGame game = new HideAndSeekGame(map);
        game.simulateGame(new int[]{1, 1}, new int[]{9, 11}, 30);
    }
}

算法核心要点

射线投射视线检测

本实现采用四方向射线投射而非全方向扫描,基于经典捉迷藏的”直线视野”规则。相比360度扇形扫描,这种设计更符合实际游戏直觉,且计算复杂度从O(360×r)降至O(4×r)。

贝叶斯信念更新

寻找者通过不断排除”已观察且无人”的区域来缩小搜索空间。每次观察后的概率重归一化是贝叶斯推断的核心步骤,确保信念分布始终满足概率公理。

信息增益驱动搜索

寻找者不再盲目追逐最可能位置,而是选择能”最大程度消除不确定性”的移动方向。这种基于信息论的决策策略在机器人搜索与救援、雷达追踪等领域有广泛应用。

复杂度分析

模块 时间复杂度 空间复杂度
射线投射 O(max(rows, cols)) O(rows×cols)
信念更新 O(rows×cols) O(rows×cols)
最优移动选择 O(4 × (射线代价 + 熵计算)) O(rows×cols)
暴露度预计算 O((rows×cols)²) O(rows×cols)
完整BFS路径 O(rows×cols) O(rows×cols)

扩展方向

  1. 多躲藏者场景:维护联合概率分布或使用独立假设近似
  2. 动态障碍物:引入时间维度,处理移动障碍
  3. 声音线索:扩展观测模型,加入”脚步声”等不完美信息
  4. 学习对手模式:用马尔可夫链建模躲藏者的移动偏好

总结

捉迷藏AI将经典的”搜索-隐藏”对抗转化为信息论问题。通过射线投射实现精确的视线判定、贝叶斯推断更新信念状态、信息增益指导探索策略,我们构建了一个能自主推理的寻找者AI。这种”感知-推断-决策”的闭环框架是智能体设计的核心范式,广泛应用于自动驾驶感知、军事目标追踪和机器人探索任务。