引言
贪吃蛇(Snake)是电子游戏史上最经典的益智游戏之一。玩家控制一条不断移动的蛇,通过吃食物来增长身体,同时避免撞墙或撞到自己的身体。随着蛇身越来越长,寻找安全路径的难度呈指数级增长。本文将使用Java实现一个基于迭代深化A(IDA)搜索算法的贪吃蛇AI,在有限的状态空间内高效规划安全路径,确保蛇能持续存活并吃到食物。
算法核心思想
迭代深化A(IDA)
IDA是A搜索算法的内存优化版本。它通过迭代加深的方式,逐步增加搜索的阈值(f值上限),在每次迭代中执行深度优先搜索,只扩展f值不超过当前阈值的节点。当找到目标时立即返回,避免了A*需要维护大规模优先队列的内存开销。
f(n) = g(n) + h(n),其中:
– g(n):从起点到当前节点的实际代价(已走步数)
– h(n):启发式函数估计的剩余代价
安全路径规划策略
贪吃蛇AI需要解决两个核心问题:
1. 寻找食物路径:使用IDA搜索从蛇头到食物的最短路径
2. 尾部避让*:吃完食物后,蛇身会变长,必须确保新位置不会堵住后续退路
启发式函数采用曼哈顿距离,同时增加尾部空间评估,确保路径终点附近有足够的”逃生空间”。
完整Java实现
import java.util.*;
/**
* 贪吃蛇游戏与IDA* AI求解器
* 使用迭代深化A*搜索算法规划安全路径
*/
public class SnakeIDAStar {
// 游戏区域大小
private static final int WIDTH = 20;
private static final int HEIGHT = 20;
// 方向向量:上、右、下、左
private static final int[][] DIRS = {{-1, 0}, {0, 1}, {1, 0}, {0, -1}};
private static final char[] DIR_NAMES = {'U', 'R', 'D', 'L'};
// 游戏状态
private List<int[]> snake; // 蛇身坐标,头部在索引0
private int[] food; // 食物坐标
private boolean[][] board; // true表示被蛇身占据
public SnakeIDAStar() {
this.snake = new ArrayList<>();
this.board = new boolean[HEIGHT][WIDTH];
}
/**
* 初始化游戏状态
*/
public void initGame() {
snake.clear();
for (boolean[] row : board) Arrays.fill(row, false);
// 蛇初始位置在中心
int startX = HEIGHT / 2;
int startY = WIDTH / 2;
snake.add(new int[]{startX, startY});
board[startX][startY] = true;
// 生成食物
generateFood();
}
/**
* 随机生成食物,确保不在蛇身上
*/
private void generateFood() {
Random rand = new Random();
do {
food = new int[]{rand.nextInt(HEIGHT), rand.nextInt(WIDTH)};
} while (board[food[0]][food[1]]);
}
/**
* 曼哈顿距离启发式函数
*/
private int heuristic(int x, int y) {
return Math.abs(x - food[0]) + Math.abs(y - food[1]);
}
/**
* 检查坐标是否在边界内且不被占据
* @param tailIgnored 是否忽略蛇尾(蛇会移动,尾部将空出)
*/
private boolean isValid(int x, int y, boolean tailIgnored) {
if (x < 0 || x >= HEIGHT || y < 0 || y >= WIDTH) return false;
if (!board[x][y]) return true;
if (tailIgnored) {
int[] tail = snake.get(snake.size() - 1);
return x == tail[0] && y == tail[1];
}
return false;
}
// IDA*搜索全局状态
private int threshold;
private int minExceed;
private List<Integer> path;
private List<Integer> bestPath;
/**
* 使用IDA*搜索从蛇头到食物的路径
* @return 方向序列,若找不到则返回null
*/
public List<Integer> findPathToFood() {
int[] head = snake.get(0);
threshold = heuristic(head[0], head[1]);
// 模拟蛇移动后的占据状态(预测蛇尾会空出)
while (true) {
path = new ArrayList<>();
minExceed = Integer.MAX_VALUE;
// 执行深度受限搜索
boolean found = idaSearch(head[0], head[1], 0, -1);
if (found) {
bestPath = new ArrayList<>(path);
return bestPath;
}
// 无解或阈值溢出
if (minExceed == Integer.MAX_VALUE) return null;
threshold = minExceed;
}
}
/**
* IDA*深度优先搜索
* @param x, y 当前坐标
* @param g 已花费代价
* @param fromDir 来时的方向(避免立即回头)
*/
private boolean idaSearch(int x, int y, int g, int fromDir) {
int f = g + heuristic(x, y);
if (f > threshold) {
minExceed = Math.min(minExceed, f);
return false;
}
// 到达食物
if (x == food[0] && y == food[1]) {
return true;
}
// 评估可移动方向,按启发式排序优化搜索顺序
List<int[]> candidates = new ArrayList<>();
for (int d = 0; d < 4; d++) {
if (d == (fromDir ^ 2)) continue; // 不回头
int nx = x + DIRS[d][0];
int ny = y + DIRS[d][1];
// 搜索时假设蛇尾会移动(尾部空出),因为吃食物前蛇身不变
if (isValid(nx, ny, true)) {
candidates.add(new int[]{d, heuristic(nx, ny), nx, ny});
}
}
// 按启发式值升序排列,优先搜索更优方向
candidates.sort(Comparator.comparingInt(a -> a[1]));
for (int[] cand : candidates) {
int d = cand[0], nx = cand[2], ny = cand[3];
// 临时标记当前位置为已占据
boolean wasFree = !board[nx][ny];
board[nx][ny] = true;
path.add(d);
if (idaSearch(nx, ny, g + 1, d)) return true;
path.remove(path.size() - 1);
if (wasFree) board[nx][ny] = false;
}
return false;
}
/**
* 验证吃完食物后是否有安全路径通向尾部
* 这是确保蛇不会把自己困死的关键检查
*/
public boolean hasSafePathAfterEat() {
if (bestPath == null || bestPath.isEmpty()) return false;
// 模拟蛇吃完食物后的状态
int[] head = snake.get(0);
int firstDir = bestPath.get(0);
int nx = head[0] + DIRS[firstDir][0];
int ny = head[1] + DIRS[firstDir][1];
// 模拟移动一步后的棋盘
boolean[][] simBoard = new boolean[HEIGHT][WIDTH];
for (int i = 0; i < HEIGHT; i++) {
simBoard[i] = board[i].clone();
}
// 新头部
simBoard[nx][ny] = true;
// 若没吃到食物,尾部移除;吃到则保留
boolean willEat = (nx == food[0] && ny == food[1]);
if (!willEat && snake.size() > 1) {
int[] tail = snake.get(snake.size() - 1);
simBoard[tail[0]][tail[1]] = false;
}
// 使用BFS检查从新的头部能否到达尾部
return canReachTail(simBoard, nx, ny);
}
/**
* BFS检查从指定位置能否到达蛇尾区域
*/
private boolean canReachTail(boolean[][] simBoard, int sx, int sy) {
int[] tail = snake.get(snake.size() - 1);
boolean[][] vis = new boolean[HEIGHT][WIDTH];
Queue<int[]> q = new LinkedList<>();
q.offer(new int[]{sx, sy});
vis[sx][sy] = true;
while (!q.isEmpty()) {
int[] cur = q.poll();
if (cur[0] == tail[0] && cur[1] == tail[1]) return true;
for (int[] dir : DIRS) {
int nx = cur[0] + dir[0];
int ny = cur[1] + dir[1];
if (nx >= 0 && nx < HEIGHT && ny >= 0 && ny < WIDTH
&& !simBoard[nx][ny] && !vis[nx][ny]) {
vis[nx][ny] = true;
q.offer(new int[]{nx, ny});
}
}
}
return false;
}
/**
* 执行AI移动一步
* @return 是否成功移动
*/
public boolean aiMove() {
List<Integer> path = findPathToFood();
if (path == null || path.isEmpty()) {
// 找不到去食物的路径,尝试随机安全移动
return randomSafeMove();
}
// 检查吃完食物后是否安全
if (!hasSafePathAfterEat() && snake.size() > 5) {
// 不安全时尝试跟随尾巴策略
return followTailStrategy();
}
int dir = path.get(0);
return move(dir);
}
/**
* 按指定方向移动蛇
*/
private boolean move(int dir) {
int[] head = snake.get(0);
int nx = head[0] + DIRS[dir][0];
int ny = head[1] + DIRS[dir][1];
if (!isValid(nx, ny, false)) return false;
snake.add(0, new int[]{nx, ny});
board[nx][ny] = true;
if (nx == food[0] && ny == food[1]) {
generateFood(); // 吃到食物,不删除尾部,蛇变长
} else {
int[] tail = snake.remove(snake.size() - 1);
board[tail[0]][tail[1]] = false;
}
return true;
}
/**
* 跟随尾巴策略:当没有安全食物路径时,尝试移动到离尾巴最近的位置
*/
private boolean followTailStrategy() {
int[] head = snake.get(0);
int[] tail = snake.get(snake.size() - 1);
int bestDir = -1;
int bestDist = Integer.MAX_VALUE;
for (int d = 0; d < 4; d++) {
int nx = head[0] + DIRS[d][0];
int ny = head[1] + DIRS[d][1];
if (isValid(nx, ny, true)) {
int dist = Math.abs(nx - tail[0]) + Math.abs(ny - tail[1]);
if (dist < bestDist) {
bestDist = dist;
bestDir = d;
}
}
}
if (bestDir >= 0) return move(bestDir);
return false;
}
/**
* 随机安全移动(最后手段)
*/
private boolean randomSafeMove() {
int[] head = snake.get(0);
List<Integer> safe = new ArrayList<>();
for (int d = 0; d < 4; d++) {
int nx = head[0] + DIRS[d][0];
int ny = head[1] + DIRS[d][1];
if (isValid(nx, ny, true)) safe.add(d);
}
if (safe.isEmpty()) return false;
return move(safe.get(new Random().nextInt(safe.size())));
}
/**
* 打印当前游戏状态
*/
public void printBoard() {
System.out.println("Score: " + (snake.size() - 1));
for (int i = 0; i < HEIGHT; i++) {
for (int j = 0; j < WIDTH; j++) {
if (i == food[0] && j == food[1]) System.out.print("F ");
else if (i == snake.get(0)[0] && j == snake.get(0)[1]) System.out.print("H ");
else if (board[i][j]) System.out.print("S ");
else System.out.print(". ");
}
System.out.println();
}
System.out.println();
}
/**
* 运行完整游戏
*/
public void runGame() {
initGame();
printBoard();
int steps = 0;
while (steps < 1000) {
if (!aiMove()) {
System.out.println("Game Over! Final score: " + (snake.size() - 1));
break;
}
steps++;
if (steps % 50 == 0) printBoard();
}
if (steps >= 1000) {
System.out.println("Reached step limit. Final score: " + (snake.size() - 1));
}
}
public static void main(String[] args) {
SnakeIDAStar game = new SnakeIDAStar();
game.runGame();
}
}
关键算法解析
1. 迭代深化A*搜索
IDA通过逐步增加阈值来控制搜索深度。每次迭代中,所有f值超过阈值的节点被剪枝,同时记录最小的超限值作为下一轮阈值。这种策略兼具了深度优先搜索的低内存开销和A的最优性保证。
2. 尾部避让与BFS验证
贪吃蛇最大的挑战在于吃食物后可能被困住。代码中的hasSafePathAfterEat方法模拟吃食物后的棋盘状态,使用BFS验证新头部能否到达原蛇尾位置。这一检查确保蛇在进食后仍有足够的逃生空间。
3. 跟随尾巴策略
当IDA*找不到安全的食物路径时(通常发生在蛇身很长、空间很窄的情况下),AI会切换到跟随尾巴策略。通过移动到离尾巴曼哈顿距离最近的安全格子,蛇可以有效地”追逐自己的尾巴”,维持一个安全的循环路径。
复杂度分析
| 指标 | 复杂度 | 说明 |
|---|---|---|
| 时间复杂度 | O(b^d) | b为分支因子(最大4),d为最优解深度 |
| 空间复杂度 | O(d) | 仅需存储当前搜索路径,d为解深度 |
| 启发式评估 | O(1) | 曼哈顿距离常数时间计算 |
相比标准A需要O(b^d)的优先队列空间,IDA将空间复杂度降低到O(d),非常适合贪吃蛇这种状态空间随蛇身长度动态变化的问题。
总结
本文实现了基于IDA*搜索的贪吃蛇AI,通过迭代深化策略在有限内存内寻找最优路径,结合尾部避让检查和跟随尾巴策略确保长期存活。该算法在20×20棋盘上通常能存活到蛇身占满大部分空间,展示了启发式搜索在实时游戏AI中的强大能力。