推箱子(Sokoban)是一款源自日本的经典益智游戏,玩家需要将所有箱子推到指定目标位置。看似简单规则的背后,推箱子问题被证明是PSPACE完全问题,其搜索空间随关卡规模指数级增长。本文将用Java实现一个带模式数据库启发式的IDA*求解器,讲解迭代加深搜索如何规避内存爆炸、模式数据库如何提供可采纳启发式估计,并包含完整可运行的代码与复杂度分析。
一、推箱子问题的状态空间挑战
推箱子的规则很简单:玩家只能推箱子不能拉,一次只能推动一个箱子,且箱子不能穿过墙壁或其他箱子。然而,与华容道、迷宫等经典搜索问题相比,推箱子的分支因子极高——玩家每一步都可能向四个方向移动,若前方有箱子则可推动它产生新状态。
以一个20×20的标准关卡为例,状态空间可达10^30量级。BFS在此类问题中会因”状态爆炸”迅速耗尽内存。IDA(Iterative Deepening A)通过迭代加深与启发式剪枝,在时间换空间的策略下,成为推箱子求解的理想选择。
二、状态表示与核心数据结构
为了高效存储和比较状态,我们采用以下编码策略:
- 地图表示:墙壁、空地、目标位置用二维布尔数组或位掩码表示。
- 箱子集合:用
Set<Integer>存储每个箱子的坐标编码(x * width + y),利用Java标准库的哈希结构实现O(1)查找。 - 玩家位置:单独用二维坐标
(px, py)表示。 - 状态哈希:重写
equals和hashCode,以箱子集合和玩家位置共同决定状态唯一性。
/**
* 推箱子状态节点
* 包含玩家位置、箱子位置集合,以及用于路径回溯的父状态和移动动作
*/
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通过以下机制解决这一问题:
- 迭代加深:设定一个阈值
threshold = f(start) = g(start) + h(start),执行深度优先搜索。 - 剪枝:若当前状态的
f = g + h超过阈值,立即回溯。 - 阈值更新:本轮搜索结束后,将阈值更新为所有被剪枝状态中最小的
f值,进入下一轮。 - 无显式闭表: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~3个箱子作为”模式”(通常选2个箱子以平衡空间与时间)。
- 预计算:对每种箱子位置+玩家位置的配置,使用反向BFS计算将这组箱子推到目标的最小步数(忽略其他箱子的阻挡,视其为墙壁)。
- 查询加速:将预计算结果存入哈希表,运行时用查表代替实时计算。
- 可采纳性:由于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;
}
}
五、死锁检测:提前剪枝无效分支
推箱子中存在大量”死锁”状态——一旦进入,永远无法达成目标。快速识别死锁是提升求解速度的关键:
- 简单死角死锁:若箱子被推到墙壁角落且该角落不是目标,则永久死锁。
- 墙边死锁:沿墙边的连续非目标位置,若箱子被推到其中且两侧被堵,形成死锁。
- 冻结死锁:所有可推动方向都被其他箱子或墙壁阻挡。
/**
* 快速死锁检测
* 仅检测最常见的"角落死锁"与"墙边死锁"
*/
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)将多个子问题结果相加而非取最大值,获得更强的可采纳启发式。