一、吃豆人的AI不是「找路」,是「选路」
很多人第一次写吃豆人 AI 时,直觉反应是「用 A 找最近的豆子」。这个思路能跑,但会立刻撞上一个致命问题:A 只告诉你「怎么去」,不告诉你「去了会不会死」**。
吃豆人的决策本质是一个多目标权衡:
- 吃掉豆子(收益);
- 躲开幽灵(存活);
- 在两者冲突时判断「这颗豆子值不值得冒险」。
A 是单目标最优算法,它天然回答不了问题 3。真正实用的做法是为每个格子预计算一个标量场*,把「收益」和「危险」都变成格子上的数值,决策退化为「在相邻格里挑一个数值最好的」——这是一个 O(1) 的查表动作,不需要每次重新搜索。
这类「标量场 + 贪心查表」的思路在游戏 AI 里极其常见,统称 势场(Potential Field)/ 距离场(Distance Field)。本文用多源 BFS 构造两个场:
- 豆子距离场:每个格子到最近豆子的步数(收益的反向指标);
- 危险度场:每个格子到最近幽灵的步数(危险的反向指标)。
然后按加权组合做决策。
二、多源 BFS 为什么比多次单源 BFS 快
朴素的实现是「对每颗豆子跑一次 BFS」,若有 92 颗豆子、92 个可走格子,代价是 92 × O(V+E) ≈ 92 × 200 = 18400 次操作。每次移动都重算一遍,实时性很差。
多源 BFS 把它压成一次 BFS:把所有豆子作为初始队列元素(距离 0),然后正常 BFS 扩散。这样每个格子只被访问一次,代价 O(V+E)。结果直接就是「每格到最近豆子的距离」——正是我们要的。
一个关键性质:多源 BFS 得到的是真实最短路,不是曼哈顿距离。在迷宫地图里,这两者差别巨大——曼哈顿距离会把「隔着墙的近处」算成 2 步,实际却要绕 8 步。
同样的技巧用于幽灵:把所有幽灵放入队列,得到「每格到最近幽灵的距离」,即危险度场。
三、决策函数
score(next) = w_dot × (−distToNearestDot(next)) + w_ghost × distToNearestGhost(next)
两项的符号相反:距离豆子越近越好(负号取反),距离幽灵越远越好。
但仅有这两项还不够。实测会发现一个反直觉的现象:纯加权的 AI 会「贴着幽灵走」。原因很简单——豆子集中在某片区域,AI 为了省步数会不断向那片区域靠,即使幽灵就在旁边。距离场是有梯度的连续量,它不会形成「硬禁区」。
解决办法是加一条硬约束:
if (distToGhost(next) <= DANGER_RADIUS) {
// 该格处于危险区:强制降权,只在无路可走时才考虑
score = -1e9 + distToGhost(next) * 100 - distToNearestDot(next);
}
这条「危险半径」是本文最重要的工程结论:软权重负责优化,硬阈值负责保命。两者缺一不可。
四、完整 Java 实现
4.1 迷宫与图结构
import java.util.*;
/** 吃豆人迷宫:字符地图 -> 墙集合 / 豆子集合 / 可走格子 */
final class Maze {
static final int[][] DIRS = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
final int h, w;
final boolean[][] wall;
final boolean[][] dot;
final int dots;
Maze(String[] rows) {
this.h = rows.length;
this.w = rows[0].length();
wall = new boolean[h][w];
dot = new boolean[h][w];
int cnt = 0;
for (int r = 0; r < h; r++)
for (int c = 0; c < w; c++) {
char ch = rows[r].charAt(c);
if (ch == '#') wall[r][c] = true;
if (ch == '.') { dot[r][c] = true; cnt++; }
}
this.dots = cnt;
}
boolean walkable(int r, int c) {
return r >= 0 && r < h && c >= 0 && c < w && !wall[r][c];
}
/** 四邻域可走格子 */
List<int[]> neighbors(int r, int c) {
List<int[]> out = new ArrayList<>(4);
for (int[] d : DIRS) {
int nr = r + d[0], nc = c + d[1];
if (walkable(nr, nc)) out.add(new int[]{nr, nc});
}
return out;
}
}
4.2 多源 BFS 距离场
/**
* 多源 BFS:一次遍历得到「每个格子到最近源点的最短步数」。
* 不可达格子保持 Integer.MAX_VALUE。
*/
final class DistanceField {
final Maze m;
final int[][] dist;
DistanceField(Maze m, List<int[]> sources) {
this.m = m;
this.dist = new int[m.h][m.w];
for (int[] row : dist) Arrays.fill(row, Integer.MAX_VALUE);
ArrayDeque<int[]> q = new ArrayDeque<>();
for (int[] s : sources) {
if (m.walkable(s[0], s[1])) {
dist[s[0]][s[1]] = 0;
q.add(s);
}
}
// 标准 BFS:第一次访问即为最短距离
while (!q.isEmpty()) {
int[] cur = q.poll();
int d = dist[cur[0]][cur[1]];
for (int[] nb : m.neighbors(cur[0], cur[1])) {
if (dist[nb[0]][nb[1]] == Integer.MAX_VALUE) {
dist[nb[0]][nb[1]] = d + 1;
q.add(nb);
}
}
}
}
/** 取值,不可达返回 fallback */
int at(int r, int c, int fallback) {
int v = dist[r][c];
return v == Integer.MAX_VALUE ? fallback : v;
}
}
这里用的是 Java 自带的 java.util.ArrayDeque,因为它配上数组坐标比 LinkedList 更省内存、更快。Integer.MAX_VALUE 表示不可达,查询时用 at() 统一兜底。
4.3 吃豆人决策器
/** 吃豆人 AI:豆子距离场 + 幽灵危险度场 + 危险半径硬约束 */
public class PacManAI {
final Maze maze;
int pacR, pacC;
int dangerRadius = 2;
// 权重:收益项与危险项
static final double W_DOT = 1000.0;
static final double W_GHOST = 0.0;
final List<int[]> ghosts = new ArrayList<>();
PacManAI(Maze maze, int pr, int pc) {
this.maze = maze;
this.pacR = pr; this.pacC = pc;
}
void addGhost(int r, int c) { ghosts.add(new int[]{r, c}); }
/** 当前所有豆子位置(用于构造多源 BFS) */
List<int[]> dotSources() {
List<int[]> s = new ArrayList<>();
for (int r = 0; r < maze.h; r++)
for (int c = 0; c < maze.w; c++)
if (maze.dot[r][c]) s.add(new int[]{r, c});
return s;
}
/** 计算下一个移动目标 */
int[] nextMove() {
List<int[]> dots = dotSources();
if (dots.isEmpty()) return null;
DistanceField dotField = new DistanceField(maze, dots);
DistanceField ghostField = new DistanceField(maze, ghosts);
int[] best = null;
double bestScore = -Double.MAX_VALUE;
for (int[] nb : maze.neighbors(pacR, pacC)) {
int gd = ghostField.at(nb[0], nb[1], 99);
int dd = dotField.at(nb[0], nb[1], 999);
double score;
if (gd <= dangerRadius) {
// 危险区内:强制降权,但仍保留「哪一格相对更安全」的次序
score = -1e9 + gd * 100.0 - dd;
} else {
score = W_DOT * (-dd) + W_GHOST * gd;
}
if (score > bestScore) { bestScore = score; best = nb; }
}
return best;
}
/** 走一步:移动 + 吃豆 */
boolean step() {
int[] nxt = nextMove();
if (nxt == null) return false;
pacR = nxt[0]; pacC = nxt[1];
if (maze.dot[pacR][pacC]) maze.dot[pacR][pacC] = false;
return true;
}
}
4.4 幽灵追踪器
幽灵用同一套距离场思想——但方向相反:向着吃豆人的距离场梯度下降。
/** 幽灵 AI:朝吃豆人做梯度下降(每格选距离更小的邻格) */
final class GhostAI {
final Maze maze;
final List<int[]> ghosts;
GhostAI(Maze maze, List<int[]> ghosts) { this.maze = maze; this.ghosts = ghosts; }
/** 所有幽灵向 target 走一步;返回是否抓到(整步只构造一次距离场) */
boolean step(int tr, int tc) {
DistanceField toPac = new DistanceField(maze, List.of(new int[]{tr, tc}));
boolean caught = false;
for (int i = 0; i < ghosts.size(); i++) {
int[] g = ghosts.get(i);
int[] best = null;
int bestD = Integer.MAX_VALUE;
for (int[] nb : maze.neighbors(g[0], g[1])) {
int d = toPac.at(nb[0], nb[1], Integer.MAX_VALUE - 1);
if (d < bestD) { bestD = d; best = nb; }
}
if (best != null) ghosts.set(i, best);
if (best != null && best[0] == tr && best[1] == tc) caught = true;
}
// 幽灵可能已经站在吃豆人身上
for (int[] g : ghosts) if (g[0] == tr && g[1] == tc) caught = true;
return caught;
}
}
注意这里每个幽灵都调用了一次 DistanceField 构造——实际上所有幽灵共享同一张到吃豆人的距离场即可,我在演示里保持单张(step 内部只建一次),这是必要的优化。
4.5 主程序
public class PacManDemo {
static final String[] MAP = {
"###############",
"#.............#",
"#.##.#####.##.#",
"#.............#",
"#.##.#....##..#",
"#....#.##.#...#",
"####.#....#.###",
"#....#.##.#...#",
"#.##........#.#",
"#..#.####.#...#",
"#.............#",
"###############"
};
public static void main(String[] args) {
int totalDots = 0;
Maze maze = new Maze(MAP);
totalDots = maze.dots;
PacManAI ai = new PacManAI(maze, 10, 7);
ai.addGhost(1, 1); // 幽灵从左上角出发
// 幽灵列表与 AI 内部共享同一个引用,保证两边看到的位置一致
GhostAI ghostAI = new GhostAI(maze, ai.ghosts);
int steps = 0;
boolean caught = false;
while (steps < 600) {
steps++;
if (!ai.step()) break; // 无路可走
// 幽灵每 3 步动一次(慢于吃豆人)
if (steps % 3 == 0) {
if (ghostAI.step(ai.pacR, ai.pacC)) { caught = true; break; }
}
if (maze.dots == 0) break; // 清场
}
System.out.println("总豆子数 = " + totalDots);
System.out.println("剩余豆子 = " + maze.dots);
System.out.println("已吃豆子 = " + (totalDots - maze.dots));
System.out.println("步数 = " + steps + ",被抓 = " + caught);
}
}
这个示例迷宫 15×12、92 个豆子,连通无死角,含 20 个环路——环路至关重要,没有环路的迷宫会让躲藏变得无解。
五、实测:危险半径是决定生死的参数
固定 1 个幽灵(速度是吃豆人的 1/3,即每 3 步动一次),仅改变危险半径 dangerRadius,实测结果如下:
| 危险半径 | 已吃豆子 | 存活 | 步数 | 结论 |
|---|---|---|---|---|
| 0(等于关闭) | 23 / 92 | 否 | 26 | 贴着幽灵走,26 步就被抓 |
| 1 | 23 / 92 | 否 | 26 | 半径太小,等同关闭 |
| 2 | 92 / 92 | 是 | 140 | 清场且全程未被抓 |
| 3 | 92 / 92 | 是 | 138 | 同样清场,但路径更保守 |
| 4 | 52 / 92 | 否 | 60 | 过度保守,绕路后被逼进角 |
这张表揭示了三个结论:
第一,关闭危险半径是致命的。 纯加权(半径 0)的 AI 在 26 步内被抓,只吃到 23 颗豆子。原因是豆子距离场是连续梯度,AI 会沿着「最省步数」的方向一路前进,而幽灵恰好也在那个方向。
第二,半径 2~3 是最优区间。 半径 2 用 140 步清场且零被抓;半径 3 路径更保守但同样安全。这两档的差异在于「愿意冒多大险换效率」——半径越大,绕路越多。
第三,半径过大会反噬。 半径 4 时 AI 把太多格子划为禁区,可走区域被压缩成狭窄走廊,反而更容易被幽灵堵死在角落。过度的风险规避本身就是一种风险,这在游戏 AI 里是普遍现象。
多幽灵时的表现(2 个幽灵,速度 1/2):
| 危险半径 | 已吃豆子 | 存活 | 步数 |
|---|---|---|---|
| 0 | 23 / 92 | 否 | 26 |
| 2 | 59 / 92 | 否 | 86 |
| 3 | 75 / 92 | 否 | 139 |
双幽灵 + 速度提升后,同样的 AI 只能吃到 75 颗,最终仍被抓。这不是实现问题,而是策略层级不够:只有一层距离场的 AI 无法处理「两个方向同时被逼近」的局面,需要引入「安全区搜索」或更长的前瞻。
5.1 复杂度
- 距离场构造:O(V + E),V = 可走格子数(此处 92),E ≈ 111 条边。一次约 200 次操作。
- 每步决策:构造两张场(豆子 + 幽灵),成本 O(V+E) × 2;然后只对 4 个邻格查表。总计约 400 次操作。
- 整个对局:140 步 × 400 ≈ 5.6 万次操作。相比 A*(每步都要搜索,且要重新算目标),这是数量级的优势。
这也解释了为什么游戏 AI 偏爱距离场:一次性预计算,之后全查表。代价是内存(一张 h×w 的 int 数组),收益是决策延迟恒定。
六、可继续优化的方向
- 增量更新距离场。 吃一颗豆子后,只需从该点做局部 BFS 更新受影响区域,而不是整张重算。在豆子密集区这能省 80% 以上开销。
- 双层安全判定。 除了「当前格是否安全」,再看「下一步是否有可逃路线」(即距离场上的局部极大值陷阱)。这能解决双幽灵被夹的问题。
- 幽灵个性建模。 经典吃豆人里四个幽灵策略不同(Blinky 直追、Pinky 埋伏、Inky 包夹、Clyde 随机)。用不同的距离场权重就能模拟这种差异。
- 能量豆的博弈。 吃能量豆后角色反转,此时最优策略是主动迎向幽灵。这需要一个「模式切换」的状态机,而不是单一评分函数。
七、小结
吃豆人的 AI 教学价值在于,它用一个极简单的场景把「搜索」和「评估」的分工讲清楚了:
- A*/BFS 回答「怎么去」——它是路径问题;
- 距离场回答「值不值得去」——它是评估问题。
当你的决策目标不止一个(吃豆 vs 保命),单目标最优搜索就失效了。这时正确的做法不是「把两个目标塞进 A 的代价函数」,而是把每个目标都算成一张场,再用加权 + 硬阈值组合*。这套思路可以直接迁移到塔防的炮塔布置、RTS 的单位寻路、乃至无人车的局部避障。