每日算法 — 使用java实现推箱子:DFS回溯与死锁检测

游戏介绍

推箱子(Sokoban)是经典的逻辑益智游戏,玩家控制角色在仓库中推动箱子到指定目标位置。规则限制箱子只能推不能拉,且每次只能推动一个箱子。本文用Java实现推箱子的自动求解器。

核心算法

DFS回溯搜索

由于推箱子需要精确规划每一步,采用深度优先搜索(DFS)配合回溯策略。状态包括玩家位置和所有箱子位置。

死锁检测

推箱子最容易陷入死锁(箱子无法再移动),需要在搜索过程中实时检测:
1. 墙角死锁:箱子被推到墙角且该墙角不是目标点
2. 边缘死锁:箱子沿墙壁排列形成无法解开的阻挡
3. 简单死锁表:预处理所有非目标点的”死锁角落”

状态压缩

将玩家位置和箱子位置集合编码为字符串,使用HashSet去重。

Java实现

import java.util.*;

public class SokobanSolver {
    // 地图符号
    static final char WALL = '#';
    static final char FLOOR = ' ';
    static final char TARGET = '.';
    static final char BOX = '$';
    static final char PLAYER = '@';
    static final char BOX_ON_TARGET = '*';
    static final char PLAYER_ON_TARGET = '+';

    // 示例关卡(XSB格式)
    static String[] LEVEL = {
        "  ##### ",
        "###   # ",
        "#   $ # ",
        "#  .  # ",
        "###@### ",
        "  ###   "
    };

    int rows, cols;
    char[][] grid;        // 原始地图(墙壁和目标点不变)
    boolean[][] isTarget; // 标记目标点位置
    boolean[][] deadLock; // 预处理的死锁位置
    Set<String> visited;
    List<String> solution;

    // 方向
    static final int[][] DIRS = {{0, -1}, {0, 1}, {-1, 0}, {1, 0}};
    static final String[] DIR_NAMES = {"上", "下", "左", "右"};

    public SokobanSolver() {
        rows = LEVEL.length;
        cols = LEVEL[0].length();
        grid = new char[rows][cols];
        isTarget = new boolean[rows][cols];
        deadLock = new boolean[rows][cols];

        for (int y = 0; y < rows; y++) {
            String line = LEVEL[y];
            for (int x = 0; x < cols; x++) {
                char c = line.charAt(x);
                grid[y][x] = (c == BOX || c == PLAYER || c == BOX_ON_TARGET || c == PLAYER_ON_TARGET)
                        ? FLOOR : c;
                if (c == TARGET || c == BOX_ON_TARGET || c == PLAYER_ON_TARGET) {
                    isTarget[y][x] = true;
                }
            }
        }
        precomputeDeadLocks();
    }

    // 预处理死锁位置:非目标点的凹角
    void precomputeDeadLocks() {
        for (int y = 0; y < rows; y++) {
            for (int x = 0; x < cols; x++) {
                if (grid[y][x] == WALL || isTarget[y][x]) continue;
                // 检查四种类型的凹角(左上、右上、左下、右下)
                if ((isWall(x - 1, y) && isWall(x, y - 1)) || // 左上
                    (isWall(x + 1, y) && isWall(x, y - 1)) || // 右上
                    (isWall(x - 1, y) && isWall(x, y + 1)) || // 左下
                    (isWall(x + 1, y) && isWall(x, y + 1))) {  // 右下
                    deadLock[y][x] = true;
                }
            }
        }
    }

    boolean isWall(int x, int y) {
        return x < 0 || x >= cols || y < 0 || y >= rows || grid[y][x] == WALL;
    }

    // 解析初始状态
    State parseInitialState() {
        int px = 0, py = 0;
        Set<String> boxes = new HashSet<>();
        for (int y = 0; y < rows; y++) {
            String line = LEVEL[y];
            for (int x = 0; x < cols; x++) {
                char c = line.charAt(x);
                if (c == PLAYER || c == PLAYER_ON_TARGET) {
                    px = x; py = y;
                }
                if (c == BOX || c == BOX_ON_TARGET) {
                    boxes.add(x + "," + y);
                }
            }
        }
        return new State(px, py, boxes, new ArrayList<>());
    }

    // DFS求解
    public List<String> solve() {
        State init = parseInitialState();
        visited = new HashSet<>();
        solution = new ArrayList<>();

        if (dfs(init)) {
            return solution;
        }
        return null;
    }

    boolean dfs(State s) {
        String key = s.key();
        if (visited.contains(key)) return false;
        visited.add(key);

        // 检查是否全部到达目标
        if (s.allOnTarget(isTarget)) {
            solution = new ArrayList<>(s.moves);
            return true;
        }

        // 尝试四个方向
        for (int i = 0; i < DIRS.length; i++) {
            int nx = s.px + DIRS[i][0];
            int ny = s.py + DIRS[i][1];

            if (isWall(nx, ny)) continue;

            String nextPos = nx + "," + ny;
            if (s.boxes.contains(nextPos)) {
                // 前方是箱子,尝试推动
                int bx = nx + DIRS[i][0];
                int by = ny + DIRS[i][1];
                if (isWall(bx, by) || s.boxes.contains(bx + "," + by)) continue;
                if (deadLock[by][bx] && !isTarget[by][bx]) continue; // 死锁检测

                Set<String> newBoxes = new HashSet<>(s.boxes);
                newBoxes.remove(nextPos);
                newBoxes.add(bx + "," + by);

                List<String> newMoves = new ArrayList<>(s.moves);
                newMoves.add(DIR_NAMES[i]);

                State next = new State(nx, ny, newBoxes, newMoves);
                if (dfs(next)) return true;
            } else {
                // 前方是空地,直接移动
                List<String> newMoves = new ArrayList<>(s.moves);
                newMoves.add(DIR_NAMES[i]);
                State next = new State(nx, ny, s.boxes, newMoves);
                if (dfs(next)) return true;
            }
        }
        return false;
    }

    // 状态类
    static class State {
        int px, py;
        Set<String> boxes;
        List<String> moves;

        State(int px, int py, Set<String> boxes, List<String> moves) {
            this.px = px; this.py = py;
            this.boxes = boxes;
            this.moves = moves;
        }

        String key() {
            return px + "," + py + "|" + String.join(";", new TreeSet<>(boxes));
        }

        boolean allOnTarget(boolean[][] targets) {
            for (String b : boxes) {
                String[] p = b.split(",");
                int x = Integer.parseInt(p[0]), y = Integer.parseInt(p[1]);
                if (!targets[y][x]) return false;
            }
            return true;
        }
    }

    // 打印当前状态
    void printState(State s) {
        char[][] display = new char[rows][cols];
        for (int y = 0; y < rows; y++) {
            for (int x = 0; x < cols; x++) {
                display[y][x] = grid[y][x];
            }
        }
        // 标记目标点
        for (int y = 0; y < rows; y++) {
            for (int x = 0; x < cols; x++) {
                if (isTarget[y][x] && display[y][x] == FLOOR) display[y][x] = TARGET;
            }
        }
        // 放置箱子
        for (String b : s.boxes) {
            String[] p = b.split(",");
            int x = Integer.parseInt(p[0]), y = Integer.parseInt(p[1]);
            display[y][x] = isTarget[y][x] ? BOX_ON_TARGET : BOX;
        }
        // 放置玩家
        display[s.py][s.px] = isTarget[s.py][s.px] ? PLAYER_ON_TARGET : PLAYER;

        for (char[] row : display) {
            System.out.println(new String(row));
        }
        System.out.println();
    }

    public static void main(String[] args) {
        SokobanSolver solver = new SokobanSolver();
        System.out.println("死锁位置:");
        for (int y = 0; y < solver.rows; y++) {
            for (int x = 0; x < solver.cols; x++) {
                System.out.print(solver.deadLock[y][x] ? 'D' : ' ');
            }
            System.out.println();
        }

        long start = System.currentTimeMillis();
        List<String> solution = solver.solve();
        long end = System.currentTimeMillis();

        if (solution != null) {
            System.out.println("找到解!步数: " + solution.size());
            System.out.println("解法: " + String.join(" -> ", solution));
            System.out.println("耗时: " + (end - start) + "ms");
            System.out.println("访问状态数: " + solver.visited.size());
        } else {
            System.out.println("无解");
        }
    }
}

复杂度分析

  • DFS时间复杂度:最坏情况 $O(b^d)$,$b$为分支因子(最多4个方向),$d$为解深度
  • 死锁检测优化:通过预处理死锁角落,可剪枝大量无效分支,实际复杂度显著降低
  • 空间复杂度:$O(V)$,$V$为访问过的状态数,受HashSet存储限制

进阶优化

  1. BFS替代DFS:保证找到最短解
  2. 启发式评估:使用曼哈顿距离之和作为评估函数,优先搜索更优分支
  3. 模式死锁:检测更复杂的死锁模式(如两个箱子在墙边互相阻挡)

总结

本文通过DFS回溯实现了推箱子求解器,核心创新在于预处理的死锁检测机制。读者可以在此基础上加入更复杂的模式死锁检测和启发式搜索,挑战更困难的关卡。