引言
推箱子(Sokoban)是经典的NP-hard益智游戏,玩家控制角色将箱子推到指定目标位置。尽管BFS、A和IDA等算法已在该领域广泛应用,但当问题规模增大时,这些精确算法往往面临状态空间爆炸的困境。本文将用Java实现基于禁忌搜索(Tabu Search)的推箱子求解器,讲解如何通过局部搜索与记忆机制规避局部最优,在可接受时间内获得高质量近似解。
问题分析
推箱子的NP-hard特性
- 状态空间随箱子和空格数量指数增长
- 存在大量”死锁”状态(箱子无法到达任何目标)
- 传统精确算法在复杂关卡(超过20个箱子)时往往超时
禁忌搜索的优势
禁忌搜索是一种元启发式算法,核心思想:
1. 从初始解出发进行局部邻域搜索
2. 维护一个”禁忌表”记录近期访问过的解或移动
3. 允许暂时接受劣解,以跳出局部最优
4. 通过多样化策略和集中化策略平衡探索与利用
核心算法设计
状态表示
- 玩家位置
(px, py) - 每个箱子的位置集合
- 静态地图:墙、目标点、空地
邻域操作
从当前玩家位置出发,执行一次有效推箱动作,得到新状态。
评估函数
综合考虑以下因素:
– 每个箱子到最近目标点的曼哈顿距离之和
– 已到达目标的箱子数量(奖励)
– 死锁惩罚(箱子贴墙且无目标)
– 步数惩罚
禁忌表设计
记录近期执行过的”推箱动作”(箱子ID + 推动方向),在一定迭代次数内禁止重复执行相同的推动动作。
完整Java实现
package sokoban.tabu;
import java.util.*;
/**
* 推箱子 - 禁忌搜索与局部最优规避求解器
*
* 核心功能:
* 1. 推箱子游戏的状态建模与快速哈希
* 2. 基于曼哈顿距离与死锁检测的启发式评估
* 3. 禁忌搜索邻域生成与禁忌表管理
* 4. 自适应迭代策略与多样化重启机制
*/
public class SokobanTabuSearch {
// 地图元素
public static final char WALL = '#';
public static final char EMPTY = ' ';
public static final char TARGET = '.';
public static final char BOX = '$';
public static final char BOX_ON_TARGET = '*';
public static final char PLAYER = '@';
public static final char PLAYER_ON_TARGET = '+';
// 方向
private static final int[][] DIRS = {{-1,0},{1,0},{0,-1},{0,1}};
private static final String[] DIR_NAMES = {"UP","DOWN","LEFT","RIGHT"};
/**
* 游戏状态(不可变,便于哈希)
*/
public static class State {
public final int playerRow, playerCol;
public final Set<Long> boxPositions; // 用long编码位置: row * 1000 + col
public final State parent;
public final String move; // 到达此状态的移动描述
public final int depth;
public State(int pr, int pc, Set<Long> boxes, State parent, String move, int depth) {
this.playerRow = pr;
this.playerCol = pc;
this.boxPositions = Collections.unmodifiableSet(new HashSet<>(boxes));
this.parent = parent;
this.move = move;
this.depth = depth;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof State)) return false;
State s = (State) o;
return playerRow == s.playerRow && playerCol == s.playerCol
&& boxPositions.equals(s.boxPositions);
}
@Override
public int hashCode() {
return Objects.hash(playerRow, playerCol, boxPositions);
}
}
/**
* 一次推箱动作
*/
public static class Push {
public final long boxPos; // 箱子的位置编码
public final int dir; // 推动方向索引
public final int boxRow, boxCol; // 箱子坐标
public final int newRow, newCol; // 箱子新坐标
public Push(long boxPos, int dir, int br, int bc, int nr, int nc) {
this.boxPos = boxPos;
this.dir = dir;
this.boxRow = br;
this.boxCol = bc;
this.newRow = nr;
this.newCol = nc;
}
/**
* 禁忌表键:箱子原始位置 + 方向
*/
public String tabuKey() {
return boxRow + "," + boxCol + "_" + DIR_NAMES[dir];
}
}
private final char[][] grid;
private final int rows, cols;
private final Set<Long> targetPositions;
private final int totalTargets;
public SokobanTabuSearch(char[][] grid) {
this.grid = grid;
this.rows = grid.length;
this.cols = grid[0].length;
this.targetPositions = new HashSet<>();
// 收集所有目标点位置
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
if (grid[r][c] == TARGET || grid[r][c] == PLAYER_ON_TARGET
|| grid[r][c] == BOX_ON_TARGET) {
targetPositions.add(encode(r, c));
}
}
}
this.totalTargets = targetPositions.size();
}
private static long encode(int r, int c) {
return (long) r * 1000L + c;
}
private static int[] decode(long pos) {
return new int[]{(int)(pos / 1000), (int)(pos % 1000)};
}
/**
* 从地图解析初始状态
*/
public State parseInitialState() {
Set<Long> boxes = new HashSet<>();
int pr = -1, pc = -1;
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
char ch = grid[r][c];
if (ch == BOX || ch == BOX_ON_TARGET) {
boxes.add(encode(r, c));
}
if (ch == PLAYER || ch == PLAYER_ON_TARGET) {
pr = r;
pc = c;
}
}
}
return new State(pr, pc, boxes, null, null, 0);
}
/**
* 启发式评估函数
*
* 值越小越好。综合考虑:
* - 箱子到目标的最小距离和
* - 死锁惩罚
* - 步数代价
*/
public int evaluate(State state) {
int distanceSum = 0;
int onTarget = 0;
int deadlockPenalty = 0;
for (long boxPos : state.boxPositions) {
int[] pos = decode(boxPos);
int br = pos[0], bc = pos[1];
// 已到达目标
if (targetPositions.contains(boxPos)) {
onTarget++;
continue;
}
// 计算到最近目标的曼哈顿距离
int minDist = Integer.MAX_VALUE;
for (long targetPos : targetPositions) {
int[] tp = decode(targetPos);
int dist = Math.abs(br - tp[0]) + Math.abs(bc - tp[1]);
minDist = Math.min(minDist, dist);
}
distanceSum += minDist;
// 死锁检测:箱子贴墙且不在目标上
if (isCornerDeadlock(br, bc, state)) {
deadlockPenalty += 10000;
}
}
// 未覆盖的目标惩罚
int uncoveredTargets = totalTargets - onTarget;
return distanceSum * 10 + uncoveredTargets * 500 + deadlockPenalty + state.depth;
}
/**
* 角死锁检测:箱子位于角落且角落不是目标点
*/
private boolean isCornerDeadlock(int br, int bc, State state) {
if (targetPositions.contains(encode(br, bc))) return false;
boolean upWall = isWall(br - 1, bc);
boolean downWall = isWall(br + 1, bc);
boolean leftWall = isWall(br, bc - 1);
boolean rightWall = isWall(br, bc + 1);
// 箱子卡在角落:上下有墙且左右至少一侧有墙,或左右有墙且上下至少一侧有墙
return (upWall && (leftWall || rightWall)) || (downWall && (leftWall || rightWall));
}
private boolean isWall(int r, int c) {
return r < 0 || r >= rows || c < 0 || c >= cols || grid[r][c] == WALL;
}
/**
* 判断某个位置是否可通行(不是墙,且没有箱子)
*/
private boolean isFree(int r, int c, State state) {
return !isWall(r, c) && !state.boxPositions.contains(encode(r, c));
}
/**
* 生成当前状态的所有合法推箱邻域
*/
public List<Push> generatePushes(State state) {
List<Push> pushes = new ArrayList<>();
for (long boxPos : state.boxPositions) {
int[] pos = decode(boxPos);
int br = pos[0], bc = pos[1];
for (int d = 0; d < 4; d++) {
int dr = DIRS[d][0], dc = DIRS[d][1];
// 玩家需要在箱子的反方向
int playerNeedR = br - dr;
int playerNeedC = bc - dc;
// 箱子被推动后的新位置
int newR = br + dr;
int newC = bc + dc;
// 检查:玩家能否站到推动位置,且箱子新位置有效
if (isFree(playerNeedR, playerNeedC, state)
&& !isWall(newR, newC)
&& !state.boxPositions.contains(encode(newR, newC))) {
pushes.add(new Push(boxPos, d, br, bc, newR, newC));
}
}
}
return pushes;
}
/**
* 应用推箱动作,生成新状态
*/
public State applyPush(State state, Push push) {
Set<Long> newBoxes = new HashSet<>(state.boxPositions);
newBoxes.remove(push.boxPos);
newBoxes.add(encode(push.newRow, push.newCol));
// 玩家移动到箱子原来的位置
int newPlayerR = push.boxRow;
int newPlayerC = push.boxCol;
String moveDesc = String.format("Push box at (%d,%d) %s",
push.boxRow, push.boxCol, DIR_NAMES[push.dir]);
return new State(newPlayerR, newPlayerC, newBoxes, state, moveDesc, state.depth + 1);
}
/**
* 检查是否达到目标状态
*/
public boolean isGoal(State state) {
return state.boxPositions.equals(targetPositions);
}
/**
* 禁忌搜索主算法
*
* @param initial 初始状态
* @param maxIter 最大迭代次数
* @param tabuTenure 禁忌长度(迭代次数)
* @return 找到的最佳状态
*/
public State tabuSearch(State initial, int maxIter, int tabuTenure) {
State bestState = initial;
int bestEval = evaluate(initial);
State current = initial;
int currentEval = bestEval;
// 禁忌表:记录最近被禁止的动作及其剩余禁忌长度
Map<String, Integer> tabuList = new HashMap<>();
// 多样化重启计数
int noImproveCount = 0;
final int RESTART_THRESHOLD = 500;
for (int iter = 0; iter < maxIter; iter++) {
// 衰减禁忌表
tabuList.replaceAll((k, v) -> v - 1);
tabuList.values().removeIf(v -> v <= 0);
List<Push> pushes = generatePushes(current);
if (pushes.isEmpty()) {
// 无合法移动,强制重启
current = initial;
currentEval = evaluate(initial);
noImproveCount = 0;
continue;
}
// 评估所有邻域
Push bestPush = null;
State bestNeighbor = null;
int bestNeighborEval = Integer.MAX_VALUE;
for (Push push : pushes) {
State neighbor = applyPush(current, push);
int eval = evaluate(neighbor);
String key = push.tabuKey();
boolean isTabu = tabuList.containsKey(key);
boolean aspiration = eval < bestEval; // 藐视准则:优于历史最佳则突破禁忌
if ((!isTabu || aspiration) && eval < bestNeighborEval) {
bestNeighborEval = eval;
bestNeighbor = neighbor;
bestPush = push;
}
}
if (bestNeighbor == null) {
// 所有邻域都被禁忌且不满足藐视准则,随机选一个非禁忌移动
for (Push push : pushes) {
if (!tabuList.containsKey(push.tabuKey())) {
bestNeighbor = applyPush(current, push);
bestPush = push;
break;
}
}
if (bestNeighbor == null) {
current = initial;
continue;
}
}
// 更新当前状态
current = bestNeighbor;
currentEval = bestNeighborEval;
// 加入禁忌表
if (bestPush != null) {
tabuList.put(bestPush.tabuKey(), tabuTenure);
}
// 更新历史最佳
if (currentEval < bestEval) {
bestState = current;
bestEval = currentEval;
noImproveCount = 0;
// 检查是否达到目标
if (isGoal(bestState)) {
System.out.println("找到目标解!迭代次数: " + iter);
return bestState;
}
} else {
noImproveCount++;
}
// 多样化重启:长期无改进时,随机扰动当前解
if (noImproveCount >= RESTART_THRESHOLD) {
current = perturbState(current);
currentEval = evaluate(current);
noImproveCount = 0;
tabuList.clear();
}
}
return bestState;
}
/**
* 扰动操作:随机执行若干次推箱,跳出局部最优
*/
private State perturbState(State state) {
State perturbed = state;
Random rand = new Random();
for (int i = 0; i < 5; i++) {
List<Push> pushes = generatePushes(perturbed);
if (pushes.isEmpty()) break;
perturbed = applyPush(perturbed, pushes.get(rand.nextInt(pushes.size())));
}
return perturbed;
}
/**
* 回溯获取移动路径
*/
public List<String> extractPath(State goal) {
List<String> path = new ArrayList<>();
State cur = goal;
while (cur.parent != null) {
path.add(cur.move);
cur = cur.parent;
}
Collections.reverse(path);
return path;
}
// ==================== 测试入口 ====================
public static void main(String[] args) {
// 经典推箱子关卡 (XSB格式简化)
char[][] level = {
"########".toCharArray(),
"#......#".toCharArray(),
"#.@.$..#".toCharArray(),
"#..$...#".toCharArray(),
"#...$..#".toCharArray(),
"#......#".toCharArray(),
"#..###.#".toCharArray(),
"#......#".toCharArray(),
"########".toCharArray()
};
// 将三个目标点放在地图中
level[1][1] = TARGET;
level[1][2] = TARGET;
level[1][3] = TARGET;
System.out.println("===== 推箱子 禁忌搜索求解 =====\n");
SokobanTabuSearch solver = new SokobanTabuSearch(level);
State initial = solver.parseInitialState();
System.out.printf("初始评估值: %d%n", solver.evaluate(initial));
System.out.printf("目标点数量: %d%n\n", solver.totalTargets);
long start = System.currentTimeMillis();
State best = solver.tabuSearch(initial, 10000, 15);
long elapsed = System.currentTimeMillis() - start;
System.out.printf("\n搜索耗时: %dms%n", elapsed);
System.out.printf("最终评估值: %d%n", solver.evaluate(best));
System.out.printf("是否达成目标: %s%n", solver.isGoal(best) ? "是" : "否");
System.out.printf("搜索深度: %d%n", best.depth);
if (solver.isGoal(best)) {
List<String> path = solver.extractPath(best);
System.out.println("\n===== 推箱步骤 =====");
for (int i = 0; i < path.size(); i++) {
System.out.printf("%3d. %s%n", i + 1, path.get(i));
}
}
}
}
算法原理深入
禁忌搜索的核心机制
- 禁忌表(Tabu List)
- 记录近期执行过的移动(箱子+方向)
- 防止算法在短期内在相同区域来回震荡
-
禁忌长度(Tenure)决定记忆深度,通常设为
√N或固定经验值 -
藐视准则(Aspiration Criteria)
- 如果某个被禁忌的移动能产生优于历史最佳的解,则允许执行
-
这是禁忌搜索跳出局部最优的关键
-
多样化策略(Diversification)
- 长期无改进时,对当前解进行随机扰动
-
重置禁忌表,探索新的区域
-
集中化策略(Intensification)
- 在优质解附近加强搜索
- 本文通过评估函数引导,优先选择距离目标更近的移动
评估函数设计
推箱子的评估函数至关重要。本文采用多层设计:
| 因素 | 权重 | 说明 |
|---|---|---|
| 曼哈顿距离和 | ×10 | 箱子到最近目标的距离 |
| 未覆盖目标 | ×500 | 未放置箱子的目标点 |
| 死锁惩罚 | +10000 | 角死锁的额外惩罚 |
| 步数代价 | ×1 | 避免无限循环 |
与精确算法的对比
| 算法 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|
| BFS | 保证最优解 | 状态爆炸 | 简单关卡(<10箱子) |
| A/IDA | 有启发引导 | 死锁检测复杂 | 中等关卡 |
| 禁忌搜索 | 可处理大规模 | 不保证最优 | 复杂关卡、实时需求 |
扩展优化
- 更精细的死锁检测:检测”墙边双箱死锁”、”房间死锁”等复杂模式
- 目标分配优化:使用匈牙利算法为箱子分配目标点,而非简单的最近目标
- 并行禁忌搜索:多线程运行多个禁忌搜索实例,共享最佳解信息
- 学习评估函数:通过强化学习训练更精确的评估网络
总结
本文用Java实现了基于禁忌搜索的推箱子求解器,展示了:
– 推箱子状态的高效表示与哈希
– 死锁检测与启发式评估函数设计
– 禁忌搜索的核心机制:禁忌表、藐视准则、多样化重启
– 元启发式算法在NP-hard组合优化问题中的应用
禁忌搜索作为局部搜索的增强版本,在调度问题、路径规划、资源分配等领域均有出色表现。理解其”记忆-规避-突破”的思想,对解决实际工程中的复杂优化问题具有重要价值。