华容道是中国传统滑块拼图游戏的经典代表,其状态空间规模超过 (2.6 \times 10^{13}),远超普通BFS或A搜索在内存与时间上的可承受范围。本文用Java实现一套基于IDA(Iterative Deepening A*)的求解器,并引入模式数据库(Pattern Database)作为高阶启发式函数,在仅使用线性内存的前提下,将平均求解步数缩短至最优解附近。
一、问题建模:状态空间与合法移动
华容道的棋盘为 (4 \times 5) 网格,包含1个 (2 \times 2) 的”曹操”块、5个 (1 \times 2) 或 (2 \times 1) 的”五虎将”块、4个 (1 \times 1) 的”兵”块以及2个空格。目标是将曹操移动到棋盘正下方的出口位置。
/**
* 华容道棋盘状态表示
* 使用4x5的二维数组,每个格子记录占据它的方块ID
* 0表示空格,1~10分别表示不同方块
*/
public class KlotskiState {
public static final int ROWS = 5;
public static final int COLS = 4;
// 棋盘格子: board[row][col] = 方块ID
private final byte[][] board = new byte[ROWS][COLS];
// 曹操块的位置(左上角坐标)
private int caoRow, caoCol;
// 两个空格的位置
private int empty1Row, empty1Col, empty2Row, empty2Col;
/**
* 从初始布局构造状态
* layout: 5行4列的二维数组,每个元素为方块ID
*/
public KlotskiState(byte[][] layout) {
for (int r = 0; r < ROWS; r++) {
System.arraycopy(layout[r], 0, board[r], 0, COLS);
}
locatePieces();
}
/**
* 定位曹操和空格的位置
*/
private void locatePieces() {
int emptyCount = 0;
for (int r = 0; r < ROWS; r++) {
for (int c = 0; c < COLS; c++) {
if (board[r][c] == 1) { // 曹操块ID为1
if (c + 1 < COLS && board[r][c + 1] == 1 &&
r + 1 < ROWS && board[r + 1][c] == 1) {
caoRow = r;
caoCol = c;
}
} else if (board[r][c] == 0) {
if (emptyCount == 0) {
empty1Row = r; empty1Col = c;
} else {
empty2Row = r; empty2Col = c;
}
emptyCount++;
}
}
}
}
/**
* 检查是否达到目标状态:曹操位于出口(3,1)即row=3,col=1
*/
public boolean isGoal() {
return caoRow == 3 && caoCol == 1;
}
/**
* 生成所有合法的下一步状态
* 规则:方块可以向空格方向滑动,前提是移动方向上有足够的连续空格
*/
public List<Move> generateMoves() {
List<Move> moves = new ArrayList<>();
// 尝试移动曹操块(2x2)
addCaoMoves(moves);
// 尝试移动其他所有方块
boolean[] visited = new boolean[11];
for (int r = 0; r < ROWS; r++) {
for (int c = 0; c < COLS; c++) {
int id = board[r][c];
if (id > 1 && !visited[id]) {
visited[id] = true;
addPieceMoves(moves, id, r, c);
}
}
}
return moves;
}
/**
* 曹操(2x2)的移动:需要2个连续空格在同一侧
*/
private void addCaoMoves(List<Move> moves) {
// 上移:上方两格必须都是空格
if (caoRow > 0 && board[caoRow - 1][caoCol] == 0 && board[caoRow - 1][caoCol + 1] == 0) {
moves.add(new Move(1, Direction.UP));
}
// 下移
if (caoRow + 2 < ROWS && board[caoRow + 2][caoCol] == 0 && board[caoRow + 2][caoCol + 1] == 0) {
moves.add(new Move(1, Direction.DOWN));
}
// 左移
if (caoCol > 0 && board[caoRow][caoCol - 1] == 0 && board[caoRow + 1][caoCol - 1] == 0) {
moves.add(new Move(1, Direction.LEFT));
}
// 右移
if (caoCol + 2 < COLS && board[caoRow][caoCol + 2] == 0 && board[caoRow + 1][caoCol + 2] == 0) {
moves.add(new Move(1, Direction.RIGHT));
}
}
/**
* 添加单个方块的合法移动
*/
private void addPieceMoves(List<Move> moves, int pieceId, int r, int c) {
// 判断方块形状:1x2横条、2x1竖条或1x1小兵
boolean horizontal = (c + 1 < COLS && board[r][c + 1] == pieceId);
boolean vertical = (r + 1 < ROWS && board[r + 1][c] == pieceId);
if (horizontal) {
// 1x2横条
if (r > 0 && board[r - 1][c] == 0 && board[r - 1][c + 1] == 0)
moves.add(new Move(pieceId, Direction.UP));
if (r + 1 < ROWS && board[r + 1][c] == 0 && board[r + 1][c + 1] == 0)
moves.add(new Move(pieceId, Direction.DOWN));
if (c > 0 && board[r][c - 1] == 0)
moves.add(new Move(pieceId, Direction.LEFT));
if (c + 2 < COLS && board[r][c + 2] == 0)
moves.add(new Move(pieceId, Direction.RIGHT));
} else if (vertical) {
// 2x1竖条
if (r > 0 && board[r - 1][c] == 0)
moves.add(new Move(pieceId, Direction.UP));
if (r + 2 < ROWS && board[r + 2][c] == 0)
moves.add(new Move(pieceId, Direction.DOWN));
if (c > 0 && board[r][c - 1] == 0 && board[r + 1][c - 1] == 0)
moves.add(new Move(pieceId, Direction.LEFT));
if (c + 1 < COLS && board[r][c + 1] == 0 && board[r + 1][c + 1] == 0)
moves.add(new Move(pieceId, Direction.RIGHT));
} else {
// 1x1小兵
if (r > 0 && board[r - 1][c] == 0)
moves.add(new Move(pieceId, Direction.UP));
if (r + 1 < ROWS && board[r + 1][c] == 0)
moves.add(new Move(pieceId, Direction.DOWN));
if (c > 0 && board[r][c - 1] == 0)
moves.add(new Move(pieceId, Direction.LEFT));
if (c + 1 < COLS && board[r][c + 1] == 0)
moves.add(new Move(pieceId, Direction.RIGHT));
}
}
/**
* 执行移动,返回新状态(深拷贝)
*/
public KlotskiState applyMove(Move move) {
KlotskiState next = new KlotskiState(this.board);
// ... 具体移动逻辑省略,包含边界检查与状态更新
next.locatePieces();
return next;
}
}
/**
* 移动方向枚举
*/
enum Direction { UP, DOWN, LEFT, RIGHT }
/**
* 一次移动操作
*/
record Move(int pieceId, Direction dir) {}
二、IDA*核心算法:内存受限下的最优搜索
普通A在最坏情况下需要存储整个前沿集合,华容道的状态空间规模使其不可行。IDA通过迭代加深外层循环与深度优先内层搜索的结合,将内存复杂度降至 (O(d))((d) 为解深度),同时保留A*的最优性保证。
/**
* IDA*求解器
* 核心思想:外层循环不断增加f值阈值threshold,
* 内层DFS只探索f(s) = g(s) + h(s) <= threshold的节点
*/
public class IDAStarSolver {
private final Heuristic heuristic;
private int nodesExpanded = 0;
private int maxDepthReached = 0;
// 当前阈值,每次迭代后更新为超过阈值的最小f值
private int threshold;
// 记录路径,避免循环
private final List<Move> path = new ArrayList<>();
public IDAStarSolver(Heuristic heuristic) {
this.heuristic = heuristic;
}
/**
* 启动IDA*搜索,返回从初始状态到目标状态的最短移动序列
*/
public List<Move> solve(KlotskiState initial) {
// 初始阈值设为启发式估计值
threshold = heuristic.estimate(initial);
while (true) {
nodesExpanded = 0;
int nextThreshold = Integer.MAX_VALUE;
int result = search(initial, 0, nextThreshold);
if (result == FOUND) {
return new ArrayList<>(path);
}
if (result == Integer.MAX_VALUE) {
return null; // 无解
}
threshold = result; // 下次迭代使用新的阈值
System.out.println("阈值提升至: " + threshold + ", 本轮扩展节点: " + nodesExpanded);
}
}
private static final int FOUND = -1;
/**
* 深度优先搜索,返回下一个阈值或FOUND
* @param state 当前状态
* @param g 已走步数(实际代价)
* @param nextThreshold 用于记录超过当前阈值的最小f值
*/
private int search(KlotskiState state, int g, int nextThreshold) {
int f = g + heuristic.estimate(state);
if (f > threshold) {
return f; // 超出当前阈值,返回此节点的f值供外层更新阈值
}
if (state.isGoal()) {
return FOUND; // 找到目标
}
nodesExpanded++;
maxDepthReached = Math.max(maxDepthReached, g);
int minOverThreshold = Integer.MAX_VALUE;
for (Move move : state.generateMoves()) {
// 剪枝:避免立即回退上一步(与上一步相反的方向移动同一方块)
if (!path.isEmpty() && isReverseMove(path.get(path.size() - 1), move)) {
continue;
}
KlotskiState next = state.applyMove(move);
path.add(move);
int result = search(next, g + 1, nextThreshold);
if (result == FOUND) {
return FOUND;
}
if (result < minOverThreshold) {
minOverThreshold = result;
}
path.remove(path.size() - 1); // 回溯
}
return minOverThreshold;
}
/**
* 判断两次移动是否互为逆操作
*/
private boolean isReverseMove(Move last, Move current) {
if (last.pieceId() != current.pieceId()) return false;
return (last.dir() == Direction.UP && current.dir() == Direction.DOWN) ||
(last.dir() == Direction.DOWN && current.dir() == Direction.UP) ||
(last.dir() == Direction.LEFT && current.dir() == Direction.RIGHT) ||
(last.dir() == Direction.RIGHT && current.dir() == Direction.LEFT);
}
public int getNodesExpanded() {
return nodesExpanded;
}
}
三、模式数据库启发式:打破曼哈顿距离的局限
简单的曼哈顿距离或”阻挡块数”启发式在华容道中往往严重低估真实代价,导致搜索效率低下。模式数据库(Pattern Database, PDB)的核心思想是:从目标状态反向BFS,预计算子问题的最优解代价,运行时通过查表获得不可低估的启发值。
对华容道而言,我们将曹操块与部分关键小兵组成一个”模式”,其余方块视为不可区分的障碍物,反向搜索该模式到达各位置的最短步数。
/**
* 模式数据库:预计算子问题的最优解代价
* 本实现以"曹操 + 2个关键小兵"为模式,其余方块视为障碍
*/
public class PatternDatabase implements Heuristic {
// 数据库:编码后的状态 -> 到目标的最短步数
private final Map<Long, Byte> database = new HashMap<>();
/**
* 构建模式数据库
* 从目标状态反向BFS,记录模式块到达每个布局的最短步数
*/
public void build() {
// 目标状态编码:曹操在出口(3,1),小兵在合理位置
long goal = encodeGoalState();
Queue<long[]> queue = new ArrayDeque<>(); // [编码, 步数]
queue.offer(new long[]{goal, 0});
database.put(goal, (byte) 0);
int built = 0;
while (!queue.isEmpty()) {
long[] curr = queue.poll();
long state = curr[0];
byte steps = (byte) curr[1];
// 生成所有反向移动(等价于正向移动)
for (long neighbor : generateNeighbors(state)) {
if (!database.containsKey(neighbor)) {
database.put(neighbor, (byte) (steps + 1));
queue.offer(new long[]{neighbor, steps + 1});
}
}
built++;
if (built % 100000 == 0) {
System.out.println("PDB已构建: " + built + " 个状态");
}
}
System.out.println("模式数据库构建完成,共 " + database.size() + " 个状态");
}
/**
* 从完整棋盘状态中提取模式并查表
*/
@Override
public int estimate(KlotskiState state) {
long key = encodeState(state);
Byte value = database.get(key);
return value != null ? value : 0; // 若不在数据库中,返回0(保证可采纳)
}
/**
* 状态编码:将曹操(2x2)位置 + 两个小兵(1x1)位置压缩为long
* 曹操位置: 4 bits for row, 4 bits for col
* 小兵位置: 各5 bits for cell index (0~19)
*/
private long encodeState(KlotskiState state) {
// 具体编码逻辑:提取曹操和特定小兵的位置
// 返回紧凑的64位编码
long key = 0;
// ... 编码实现
return key;
}
private long encodeGoalState() {
// 曹操在(3,1),小兵在目标附近的编码
return encodeState(new KlotskiState(createGoalLayout()));
}
private byte[][] createGoalLayout() {
byte[][] layout = new byte[5][4];
// 曹操在出口位置
layout[3][1] = 1; layout[3][2] = 1;
layout[4][1] = 1; layout[4][2] = 1;
return layout;
}
/**
* 生成相邻状态(仅移动模式内的块)
*/
private List<Long> generateNeighbors(long state) {
List<Long> neighbors = new ArrayList<>();
// 解码状态,生成所有合法移动,再编码返回
// ...
return neighbors;
}
}
/**
* 启发式接口
*/
interface Heuristic {
int estimate(KlotskiState state);
}
四、组合启发式:多个模式数据库的取大策略
单模式数据库的覆盖范围有限。实践中常构建多个不重叠的模式(如”曹操+左侧小兵”、”曹操+右侧小兵”),运行时对多个PDB分别查表,取最大值作为最终启发值。这仍然保证可采纳性,且更接近真实代价。
/**
* 组合启发式:聚合多个模式数据库,取最大值
*/
public class CombinedHeuristic implements Heuristic {
private final List<PatternDatabase> databases;
public CombinedHeuristic(PatternDatabase... dbs) {
this.databases = Arrays.asList(dbs);
}
@Override
public int estimate(KlotskiState state) {
int maxH = 0;
for (PatternDatabase pdb : databases) {
maxH = Math.max(maxH, pdb.estimate(state));
}
return maxH;
}
}
五、主程序与运行演示
public class KlotskiSolver {
/**
* "横刀立马"经典开局布局
* 1=曹操(2x2), 2~6=五虎将(1x2或2x1), 7~10=小兵(1x1), 0=空格
*/
public static byte[][] HENG_DAO_LI_MA = {
{2, 1, 1, 3},
{2, 1, 1, 3},
{4, 5, 5, 6},
{4, 0, 0, 6},
{7, 8, 9, 10}
};
public static void main(String[] args) {
System.out.println("=== 华容道IDA*求解器 ===\n");
// 构建模式数据库(只需执行一次,可序列化保存)
PatternDatabase pdb1 = new PatternDatabase();
PatternDatabase pdb2 = new PatternDatabase();
System.out.println("正在构建模式数据库...");
pdb1.build(); // 曹操+左侧小兵
pdb2.build(); // 曹操+右侧小兵
Heuristic heuristic = new CombinedHeuristic(pdb1, pdb2);
// 创建初始状态
KlotskiState initial = new KlotskiState(HENG_DAO_LI_MA);
System.out.println("初始启发值: " + heuristic.estimate(initial));
// 执行IDA*
IDAStarSolver solver = new IDAStarSolver(heuristic);
long start = System.currentTimeMillis();
List<Move> solution = solver.solve(initial);
long elapsed = System.currentTimeMillis() - start;
if (solution != null) {
System.out.println("\n求解成功!");
System.out.println("最优步数: " + solution.size());
System.out.println("总扩展节点: " + solver.getNodesExpanded());
System.out.println("耗时: " + elapsed + "ms");
System.out.println("\n移动序列:");
for (int i = 0; i < solution.size(); i++) {
System.out.printf("%2d: 方块%d %s%n", i + 1,
solution.get(i).pieceId(), solution.get(i).dir());
}
} else {
System.out.println("无解");
}
}
}
六、复杂度分析
| 指标 | 复杂度 | 说明 |
|---|---|---|
| 状态空间 | (O(2.6 \times 10^{13})) | 华容道横刀立马开局所有可达状态 |
| IDA*时间 | (O(b^d)) | (b)为分支因子(约5~10),(d)为最优解深度(经典开局81步) |
| IDA*空间 | (O(d)) | 仅存储当前DFS路径,线性内存 |
| PDB构建 | (O(n)) | (n)为子问题状态数,一次预计算可重复使用 |
| PDB查询 | (O(1)) | 哈希表查表,常数时间 |
七、关键技巧与工程细节
1. 增量阈值更新
IDA外层的阈值更新不是简单加1,而是取本轮搜索中所有超出阈值节点的最小f值*。这保证了每次迭代至少有一个新节点被探索,避免冗余。
2. 移动对称性剪枝
华容道中同一方块向同一方向连续移动两次等价于直接移动两格(如果空间允许),但更关键的是禁止立即回退:如果上一步将方块A向上移动,下一步立即将A向下移动必然导致循环,应在生成阶段直接剪除。
3. 模式数据库的持久化
PDB构建可能需要数分钟到数小时,构建完成后应通过Java序列化或自定义二进制格式保存到磁盘,避免每次求解重复构建。
4. 状态编码优化
使用紧凑的long类型而非字符串或对象作为HashMap键,可显著降低PDB的内存占用。曹操位置(16种)× 小兵位置组合经优化编码后,单个PDB通常只需几十MB内存。
八、扩展方向
- ** Disjoint Pattern Databases**:将方块划分为互不重叠的组,各组独立建库,运行时启发值相加而非取大,可提供更紧的下界。
- 动态模式数据库:求解过程中在线学习新的模式代价,逐步提升启发式精度。
- 双向IDA*:从初始状态和目标状态同时迭代加深搜索,在中间相遇,可进一步降低搜索深度。
- 并行搜索:IDA*的每次迭代内部可并行化,现代多核CPU可显著加速大规模PDB查询。