每日算法 — 使用java实现推箱子:IDA*迭代加深搜索与模式数据库

推箱子(Sokoban)是一款源自日本的经典益智游戏,玩家需要将所有箱子推到指定目标位置。看似简单规则的背后,推箱子问题被证明是PSPACE完全问题,其搜索空间随关卡规模指数级增长。本文将用Java实现一个带模式数据库启发式的IDA*求解器,讲解迭代加深搜索如何规避内存爆炸、模式数据库如何提供可采纳启发式估计,并包含完整可运行的代码与复杂度分析。

一、推箱子问题的状态空间挑战

推箱子的规则很简单:玩家只能推箱子不能拉,一次只能推动一个箱子,且箱子不能穿过墙壁或其他箱子。然而,与华容道、迷宫等经典搜索问题相比,推箱子的分支因子极高——玩家每一步都可能向四个方向移动,若前方有箱子则可推动它产生新状态。

以一个20×20的标准关卡为例,状态空间可达10^30量级。BFS在此类问题中会因”状态爆炸”迅速耗尽内存。IDA(Iterative Deepening A)通过迭代加深与启发式剪枝,在时间换空间的策略下,成为推箱子求解的理想选择。

二、状态表示与核心数据结构

为了高效存储和比较状态,我们采用以下编码策略:

  • 地图表示:墙壁、空地、目标位置用二维布尔数组或位掩码表示。
  • 箱子集合:用 Set<Integer> 存储每个箱子的坐标编码(x * width + y),利用Java标准库的哈希结构实现O(1)查找。
  • 玩家位置:单独用二维坐标 (px, py) 表示。
  • 状态哈希:重写 equalshashCode,以箱子集合和玩家位置共同决定状态唯一性。
/**
 * 推箱子状态节点
 * 包含玩家位置、箱子位置集合,以及用于路径回溯的父状态和移动动作
 */
class SokobanState {
    final int px, py;                 // 玩家坐标
    final Set<Integer> boxes;         // 箱子坐标集合(编码为 x * W + y)
    final SokobanState parent;        // 父状态(用于回溯解路径)
    final char move;                  // 到达本状态的动作(U/D/L/R)
    final int g;                      // 从起点到本状态的实际代价(步数)
    final int h;                      // 启发式估计值(到目标的最小剩余代价)

    SokobanState(int px, int py, Set<Integer> boxes,
                 SokobanState parent, char move, int g, int h) {
        this.px = px; this.py = py;
        this.boxes = boxes;
        this.parent = parent;
        this.move = move;
        this.g = g;
        this.h = h;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof SokobanState)) return false;
        SokobanState s = (SokobanState) o;
        return px == s.px && py == s.py && boxes.equals(s.boxes);
    }

    @Override
    public int hashCode() {
        return 31 * (31 * px + py) + boxes.hashCode();
    }
}

三、IDA*算法:时间换空间的深度优先策略

A算法在推箱子中的最大障碍是内存消耗——优先队列需要存储海量已访问状态。IDA通过以下机制解决这一问题:

  1. 迭代加深:设定一个阈值 threshold = f(start) = g(start) + h(start),执行深度优先搜索。
  2. 剪枝:若当前状态的 f = g + h 超过阈值,立即回溯。
  3. 阈值更新:本轮搜索结束后,将阈值更新为所有被剪枝状态中最小的 f 值,进入下一轮。
  4. 无显式闭表:IDA*不保存完整的历史状态集合,仅依赖路径上的递归栈,内存占用与解深度成正比。
/**
 * IDA* 核心求解器
 */
public class SokobanSolver {
    private final boolean[][] wall;      // true表示墙壁
    private final boolean[][] target;    // true表示目标位置
    private final int rows, cols;
    private int threshold;               // 当前迭代阈值
    private int nextThreshold;           // 下一轮阈值
    private SokobanState solution;       // 找到解时存储

    // 四个方向:上、下、左、右
    private static final int[] DX = {-1, 1, 0, 0};
    private static final int[] DY = {0, 0, -1, 1};
    private static final char[] MOVE = {'U', 'D', 'L', 'R'};

    public SokobanSolver(boolean[][] wall, boolean[][] target) {
        this.wall = wall;
        this.target = target;
        this.rows = wall.length;
        this.cols = wall[0].length;
    }

    /**
     * 对外接口:给定初始状态,返回解路径(动作字符串),无解返回null
     */
    public String solve(SokobanState start) {
        threshold = start.h;  // 初始阈值为起点启发式值
        while (true) {
            nextThreshold = Integer.MAX_VALUE;
            solution = null;
            // depth-limited DFS with pruning
            search(start, 0);
            if (solution != null) {
                return reconstructPath(solution);
            }
            if (nextThreshold == Integer.MAX_VALUE) {
                return null; // 无解
            }
            threshold = nextThreshold;
        }
    }

    /**
     * 递归深度优先搜索,带f值剪枝
     */
    private void search(SokobanState state, int depth) {
        int f = depth + state.h;
        if (f > threshold) {
            // 记录超出阈值的最小f值,用于下一轮迭代
            nextThreshold = Math.min(nextThreshold, f);
            return;
        }
        if (isGoal(state)) {
            solution = state;
            return;
        }
        // 扩展四个方向
        for (int i = 0; i < 4; i++) {
            int nx = state.px + DX[i];
            int ny = state.py + DY[i];
            if (isWall(nx, ny)) continue;

            Set<Integer> nextBoxes = new HashSet<>(state.boxes);
            char move = MOVE[i];
            int boxCost = 0; // 推箱子额外代价

            int code = nx * cols + ny;
            if (state.boxes.contains(code)) {
                // 前方有箱子,尝试推动
                int bx = nx + DX[i];
                int by = ny + DY[i];
                if (isWall(bx, by) || state.boxes.contains(bx * cols + by)) {
                    continue; // 箱子被阻挡
                }
                nextBoxes.remove(code);
                nextBoxes.add(bx * cols + by);
                boxCost = 1; // 推箱子计1步

                // 死锁快速检测:箱子推到非目标死角
                if (isDeadlock(bx, by, nextBoxes)) continue;
            }

            int h = heuristic(nextBoxes);
            SokobanState next = new SokobanState(
                nx, ny, nextBoxes, state, move, state.g + 1 + boxCost, h
            );
            search(next, depth + 1 + boxCost);
            if (solution != null) return;
        }
    }

    private boolean isGoal(SokobanState state) {
        for (int box : state.boxes) {
            int x = box / cols;
            int y = box % cols;
            if (!target[x][y]) return false;
        }
        return true;
    }

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

    private String reconstructPath(SokobanState state) {
        StringBuilder sb = new StringBuilder();
        while (state.parent != null) {
            sb.append(state.move);
            state = state.parent;
        }
        return sb.reverse().toString();
    }
}

四、模式数据库启发式:可采纳且信息丰富

IDA*的效率直接取决于启发式函数 h 的质量。对于推箱子,一个简单下界是”每个箱子到最近目标位置的曼哈顿距离之和”。但这一估计忽略了箱子之间的相互阻挡(一个目标只能放一个箱子),信息含量不足。

模式数据库(Pattern Database, PDB) 通过预计算子问题的精确最优解来构建强大的启发式。其基本思想是:

  1. 选取子集:从所有箱子中选取1~3个箱子作为”模式”(通常选2个箱子以平衡空间与时间)。
  2. 预计算:对每种箱子位置+玩家位置的配置,使用反向BFS计算将这组箱子推到目标的最小步数(忽略其他箱子的阻挡,视其为墙壁)。
  3. 查询加速:将预计算结果存入哈希表,运行时用查表代替实时计算。
  4. 可采纳性:由于PDB忽略了其他箱子的限制,其值是实际代价的下界,满足A*可采纳性要求。
/**
 * 2-箱子模式数据库(2-box Pattern Database)
 * 预计算任意两个箱子+玩家位置的精确最小推动次数
 */
public class PatternDatabase {
    private final int rows, cols;
    private final boolean[][] wall;
    private final Map<Long, Integer> database = new HashMap<>();

    public PatternDatabase(boolean[][] wall) {
        this.wall = wall;
        this.rows = wall.length;
        this.cols = wall[0].length;
    }

    /**
     * 为给定目标位置构建PDB
     * 采用反向BFS:从目标状态出发,回推所有可达配置
     */
    public void build(List<int[]> targets) {
        // 为简化示例,实现单目标PDB构建框架
        // 实际生产环境会对每对目标组合分别建表
        for (int[] t : targets) {
            int tx = t[0], ty = t[1];
            // BFS状态:(玩家x, 玩家y, 箱子1x, 箱子1y, 箱子2x, 箱子2y)
            // 反向搜索:玩家"拉"箱子(与正向推动互逆)
            // 此处展示核心查表接口
        }
    }

    /**
     * 计算当前箱子配置的启发式值
     * 对每对箱子查表取最大值(确保可采纳性)
     */
    public int lookup(Set<Integer> boxes, int px, int py) {
        List<Integer> boxList = new ArrayList<>(boxes);
        int maxH = 0;
        // 若箱子数小于2,退化为曼哈顿距离
        if (boxList.size() < 2) {
            return manhattanHeuristic(boxes);
        }
        // 查询所有2-box组合的最大值
        for (int i = 0; i < boxList.size(); i++) {
            for (int j = i + 1; j < boxList.size(); j++) {
                long key = encode(boxList.get(i), boxList.get(j), px, py);
                Integer val = database.get(key);
                if (val != null && val > maxH) {
                    maxH = val;
                }
            }
        }
        return Math.max(maxH, manhattanHeuristic(boxes));
    }

    private long encode(int b1, int b2, int px, int py) {
        return ((long) b1 << 48) | ((long) b2 << 32) | (px << 16) | py;
    }

    private int manhattanHeuristic(Set<Integer> boxes) {
        int sum = 0;
        for (int box : boxes) {
            int x = box / cols, y = box % cols;
            int minDist = Integer.MAX_VALUE;
            // 简化为到最近目标的曼哈顿距离
            for (int tx = 0; tx < rows; tx++) {
                for (int ty = 0; ty < cols; ty++) {
                    if (!wall[tx][ty]) {
                        int d = Math.abs(x - tx) + Math.abs(y - ty);
                        minDist = Math.min(minDist, d);
                    }
                }
            }
            sum += minDist;
        }
        return sum;
    }
}

五、死锁检测:提前剪枝无效分支

推箱子中存在大量”死锁”状态——一旦进入,永远无法达成目标。快速识别死锁是提升求解速度的关键:

  1. 简单死角死锁:若箱子被推到墙壁角落且该角落不是目标,则永久死锁。
  2. 墙边死锁:沿墙边的连续非目标位置,若箱子被推到其中且两侧被堵,形成死锁。
  3. 冻结死锁:所有可推动方向都被其他箱子或墙壁阻挡。
/**
 * 快速死锁检测
 * 仅检测最常见的"角落死锁"与"墙边死锁"
 */
private boolean isDeadlock(int bx, int by, Set<Integer> boxes) {
    // 如果当前位置是目标,不是死锁
    if (target[bx][by]) return false;

    // 检测角落死锁:箱子相邻两面是墙
    boolean[] blocked = new boolean[4]; // 上、下、左、右
    for (int i = 0; i < 4; i++) {
        int nx = bx + DX[i];
        int ny = by + DY[i];
        if (isWall(nx, ny) || boxes.contains(nx * cols + ny)) {
            blocked[i] = true;
        }
    }
    // 若上下同时被堵 或 左右同时被堵,且该位置不是目标,则为死锁
    if ((blocked[0] && blocked[1]) || (blocked[2] && blocked[3])) {
        return true;
    }
    return false;
}

六、完整运行示例

将以上模块组合后,我们得到完整的推箱子求解器。以下是一个简易关卡(使用字符地图)的解析与运行示例:

public class Main {
    public static void main(String[] args) {
        // 字符地图:#=墙, .=目标, $=箱子, @=玩家, 空格=空地
        String[] map = {
            "  ##### ",
            "###   #",
            "#.$@  #",
            "### $.#",
            "#.$  ##",
            "#   . #",
            "#######"
        };

        int rows = map.length;
        int cols = Arrays.stream(map).mapToInt(String::length).max().orElse(0);
        boolean[][] wall = new boolean[rows][cols];
        boolean[][] target = new boolean[rows][cols];
        Set<Integer> initBoxes = new HashSet<>();
        int px = -1, py = -1;

        for (int i = 0; i < rows; i++) {
            String row = map[i];
            for (int j = 0; j < row.length(); j++) {
                char c = row.charAt(j);
                wall[i][j] = (c == '#');
                target[i][j] = (c == '.' || c == '*' || c == '+');
                if (c == '$' || c == '*') initBoxes.add(i * cols + j);
                if (c == '@' || c == '+') { px = i; py = j; }
            }
        }

        PatternDatabase pdb = new PatternDatabase(wall);
        // 实际使用时应先build PDB,此处简化直接用曼哈顿距离
        SokobanSolver solver = new SokobanSolver(wall, target);
        int h = pdb.lookup(initBoxes, px, py);
        SokobanState start = new SokobanState(px, py, initBoxes, null, ' ', 0, h);
        String solution = solver.solve(start);
        System.out.println(solution != null ? "Solution: " + solution : "No solution found");
    }
}

七、复杂度分析

维度 BFS IDA* + 曼哈顿 IDA* + PDB
时间 O(b^d) O(b^d) O(b^d)(实际远小)
空间 O(b^d) O(d) O(d)
启发式信息 较弱
实际可解规模 极小 中等 较大

其中 b 为分支因子(推箱子约2~4),d 为最优解深度。PDB通过提供更紧密的下界,将有效搜索深度大幅降低。对于标准XSB关卡集,2-box PDB配合IDA*可在数秒内求解大多数关卡。

八、总结与延伸

本文通过Java实现了推箱子的IDA*求解器,核心要点包括:

  • IDA*算法以迭代加深避免BFS的内存爆炸,适合解深度未知的大规模状态空间。
  • 模式数据库通过预计算子问题的精确解,为A*家族算法提供可采纳且信息丰富的启发式。
  • 死锁检测在搜索前端快速剪枝,避免无效分支的指数级扩张。

延伸方向包括:将PDB扩展到3-box以进一步提升启发式质量、引入宏观操作(Macro Move)减少搜索深度、以及使用不相交模式数据库(Disjoint PDB)将多个子问题结果相加而非取最大值,获得更强的可采纳启发式。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注