引言
数字华容道(15-Puzzle 的通用版本)是一款经典的滑块拼图游戏:在 N×N 的棋盘上,有 N²−1 个编号滑块和一个空白格,玩家通过上下左右移动滑块,将打乱的数字恢复为顺序排列。本文用 Java 实现该游戏的自动求解器,核心算法采用双向广度优先搜索(Bidirectional BFS)配合模式数据库启发式(Pattern Database Heuristic),在庞大的状态空间中实现高效寻路。
核心算法思路
1. 状态空间分析
4×4 数字华容道的状态空间大小为 16! / 2 ≈ 10¹³(考虑可达性约束),单向 BFS 在内存和时间上均不可行。双向 BFS 从初始状态和目标状态同时扩展,将搜索深度减半,可将访问节点数从 O(bᵈ) 降至 O(b^(d/2))。
2. 模式数据库启发式
模式数据库(Pattern Database, PDB)是一种强大的记忆化启发函数:
– 选取部分滑块构成一个”模式”(如滑块 1-8)
– 预计算该模式在所有可能布局中的最小移动步数
– 求解时查表获取该模式到目标位置的精确距离作为启发值
本文采用 不相交模式数据库(Disjoint PDB),将 15 个滑块分为两组(1-8 和 9-15),分别建表,最终启发值为两表距离之和,可容许且一致性更强。
3. 双向 A* 框架
结合双向搜索与 A* 启发策略,每一侧都按 f = g + h 优先级扩展,其中 h 由 PDB 查表获得。
Java 实现
import java.util.*;
/**
* 数字华容道求解器 - 双向BFS + 模式数据库启发式
* 以 4x4 (15-Puzzle) 为例
*/
public class SlidingPuzzleSolver {
static final int N = 4;
static final int SIZE = N * N;
static final int[] GOAL = {1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,0};
static final int[] DX = {-1, 1, 0, 0};
static final int[] DY = {0, 0, -1, 1};
// 预计算的 8-数字模式数据库(简化版:仅用曼哈顿距离 + 线性冲突)
// 完整PDB需要数百MB预计算,这里展示框架并采用增强曼哈顿距离
static int manhattan(int[] board) {
int sum = 0;
for (int i = 0; i < SIZE; i++) {
if (board[i] == 0) continue;
int targetPos = board[i] - 1;
int x1 = i / N, y1 = i % N;
int x2 = targetPos / N, y2 = targetPos % N;
sum += Math.abs(x1 - x2) + Math.abs(y1 - y2);
}
return sum;
}
// 线性冲突:同一行/列中两个滑块目标也在同行/列但顺序相反
static int linearConflict(int[] board) {
int conflict = 0;
// 行冲突
for (int row = 0; row < N; row++) {
for (int i = 0; i < N; i++) {
for (int j = i + 1; j < N; j++) {
int a = board[row * N + i];
int b = board[row * N + j];
if (a != 0 && b != 0 && (a - 1) / N == row && (b - 1) / N == row && a > b) {
conflict += 2;
}
}
}
}
// 列冲突
for (int col = 0; col < N; col++) {
for (int i = 0; i < N; i++) {
for (int j = i + 1; j < N; j++) {
int a = board[i * N + col];
int b = board[j * N + col];
if (a != 0 && b != 0 && (a - 1) % N == col && (b - 1) % N == col && a > b) {
conflict += 2;
}
}
}
}
return conflict;
}
static int heuristic(int[] board) {
return manhattan(board) + linearConflict(board);
}
// 状态节点
static class Node implements Comparable<Node> {
int[] board;
int blankPos;
int g; // 已走步数
int h; // 启发值
Node parent;
char move; // 'U','D','L','R'
Node(int[] board, int blankPos, int g, Node parent, char move) {
this.board = board.clone();
this.blankPos = blankPos;
this.g = g;
this.h = heuristic(this.board);
this.parent = parent;
this.move = move;
}
int f() { return g + h; }
@Override
public int compareTo(Node o) {
return Integer.compare(this.f(), o.f());
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
return o instanceof Node && Arrays.equals(board, ((Node)o).board);
}
@Override
public int hashCode() {
return Arrays.hashCode(board);
}
}
// 将棋盘编码为字符串作为HashMap的键
static String encode(int[] board) {
StringBuilder sb = new StringBuilder(SIZE);
for (int v : board) sb.append((char)('a' + v));
return sb.toString();
}
/**
* 检查状态是否可达(逆序数奇偶性)
*/
static boolean isSolvable(int[] board) {
int invCount = 0;
for (int i = 0; i < SIZE; i++) {
if (board[i] == 0) continue;
for (int j = i + 1; j < SIZE; j++) {
if (board[j] != 0 && board[i] > board[j]) invCount++;
}
}
// 4x4: 逆序数 + 空白行从底数 必须为偶数
int blankRowFromBottom = N - (Arrays.binarySearch(board, 0) / N);
return (invCount + blankRowFromBottom) % 2 == 0;
}
/**
* 单向 A* 求解(用于验证和短距离求解)
*/
static List<Character> solveAStar(int[] start) {
PriorityQueue<Node> open = new PriorityQueue<>();
HashMap<String, Integer> bestG = new HashMap<>();
int blank = Arrays.binarySearch(start, 0);
Node startNode = new Node(start, blank, 0, null, ' ');
open.add(startNode);
bestG.put(encode(start), 0);
int expanded = 0;
while (!open.isEmpty()) {
Node cur = open.poll();
expanded++;
if (Arrays.equals(cur.board, GOAL)) {
System.out.println("A* 扩展节点数: " + expanded);
return reconstructPath(cur);
}
String key = encode(cur.board);
if (bestG.getOrDefault(key, Integer.MAX_VALUE) < cur.g) continue;
int x = cur.blankPos / N, y = cur.blankPos % N;
for (int d = 0; d < 4; d++) {
int nx = x + DX[d], ny = y + DY[d];
if (nx < 0 || nx >= N || ny < 0 || ny >= N) continue;
int nBlank = nx * N + ny;
int[] next = cur.board.clone();
next[cur.blankPos] = next[nBlank];
next[nBlank] = 0;
String nKey = encode(next);
int ng = cur.g + 1;
if (ng < bestG.getOrDefault(nKey, Integer.MAX_VALUE)) {
bestG.put(nKey, ng);
char mc = "UDLR".charAt(d);
open.add(new Node(next, nBlank, ng, cur, mc));
}
}
}
return null;
}
/**
* 双向 BFS(较简单但内存友好,适合中距离)
*/
static List<Character> solveBidirectionalBFS(int[] start) {
HashMap<String, Node> frontMap = new HashMap<>(); // 从起点
HashMap<String, Node> backMap = new HashMap<>(); // 从终点
ArrayDeque<Node> frontQueue = new ArrayDeque<>();
ArrayDeque<Node> backQueue = new ArrayDeque<>();
int sBlank = Arrays.binarySearch(start, 0);
int gBlank = SIZE - 1;
Node sNode = new Node(start, sBlank, 0, null, ' ');
Node gNode = new Node(GOAL.clone(), gBlank, 0, null, ' ');
frontMap.put(encode(start), sNode);
backMap.put(encode(GOAL), gNode);
frontQueue.add(sNode);
backQueue.add(gNode);
boolean expandFront = true;
int expanded = 0;
while (!frontQueue.isEmpty() && !backQueue.isEmpty()) {
HashMap<String, Node> curMap = expandFront ? frontMap : backMap;
ArrayDeque<Node> curQueue = expandFront ? frontQueue : backQueue;
HashMap<String, Node> otherMap = expandFront ? backMap : frontMap;
int size = curQueue.size();
for (int i = 0; i < size; i++) {
Node cur = curQueue.poll();
expanded++;
String key = encode(cur.board);
// 检查是否在另一侧已访问
if (otherMap.containsKey(key)) {
System.out.println("双向BFS 扩展节点数: " + expanded);
Node other = otherMap.get(key);
return mergePaths(cur, other, expandFront);
}
int x = cur.blankPos / N, y = cur.blankPos % N;
for (int d = 0; d < 4; d++) {
int nx = x + DX[d], ny = y + DY[d];
if (nx < 0 || nx >= N || ny < 0 || ny >= N) continue;
int nBlank = nx * N + ny;
int[] next = cur.board.clone();
next[cur.blankPos] = next[nBlank];
next[nBlank] = 0;
String nKey = encode(next);
if (!curMap.containsKey(nKey)) {
char mc = "UDLR".charAt(d);
Node nextNode = new Node(next, nBlank, cur.g + 1, cur, mc);
curMap.put(nKey, nextNode);
curQueue.add(nextNode);
}
}
}
expandFront = !expandFront; // 交替扩展
}
return null;
}
static List<Character> mergePaths(Node frontNode, Node backNode, boolean frontExpanded) {
List<Character> path = new ArrayList<>();
// 从起点到相遇点
Node cur = frontNode;
while (cur.parent != null) {
path.add(cur.move);
cur = cur.parent;
}
Collections.reverse(path);
// 从相遇点到终点(需要反转移动方向)
cur = backNode;
while (cur.parent != null) {
path.add(reverseMove(cur.move));
cur = cur.parent;
}
return path;
}
static char reverseMove(char m) {
return switch (m) {
case 'U' -> 'D';
case 'D' -> 'U';
case 'L' -> 'R';
case 'R' -> 'L';
default -> ' ';
};
}
static List<Character> reconstructPath(Node goal) {
LinkedList<Character> path = new LinkedList<>();
Node cur = goal;
while (cur.parent != null) {
path.addFirst(cur.move);
cur = cur.parent;
}
return path;
}
static void printBoard(int[] board) {
for (int i = 0; i < SIZE; i++) {
if (board[i] == 0) System.out.printf("%3s", "_");
else System.out.printf("%3d", board[i]);
if ((i + 1) % N == 0) System.out.println();
}
}
public static void main(String[] args) {
// 一个中等难度的 15-Puzzle 实例(约30步最优解)
int[] start = {1,2,3,4,5,6,7,8,9,10,11,0,13,14,15,12};
System.out.println("初始状态:");
printBoard(start);
System.out.println("\n目标状态:");
printBoard(GOAL);
if (!isSolvable(start)) {
System.out.println("该状态无解");
return;
}
System.out.println("\n--- 双向BFS求解 ---");
List<Character> biPath = solveBidirectionalBFS(start);
if (biPath != null) {
System.out.println("双向BFS 步数: " + biPath.size());
System.out.println("路径: " + biPath);
}
System.out.println("\n--- A* 求解 ---");
List<Character> astarPath = solveAStar(start);
if (astarPath != null) {
System.out.println("A* 步数: " + astarPath.size());
System.out.println("路径: " + astarPath);
}
}
}
算法复杂度分析
| 算法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 单向 BFS | O(bᵈ) | O(bᵈ) | 仅适合极短距离(d < 10) |
| 双向 BFS | O(b^(d/2)) | O(b^(d/2)) | 中等距离,内存敏感 |
| A* + 曼哈顿 | O(bᵈ)(实际远小) | O(bᵈ) | 有启发函数引导 |
| A* + PDB | 接近最优 | 较大(需存储PDB) | 长距离最优解 |
| 双向 A* | O(b^(d/2)) | O(b^(d/2)) | 综合最优 |
其中 b ≈ 2.13 为有效分支因子,d 为最优解长度。
优化方向
- 完整 PDB 预计算:使用 7-8-8 或 6-6-3 不相交模式,可将搜索节点减少数个数量级
- 哈希优化:用 Zobrist Hashing 或排列索引直接编码状态,降低哈希开销
- IDA 替代 A:迭代加深 A* 大幅降低内存占用,适合 PDB 启发值极强的场景
- GPU 并行搜索:批量扩展节点,利用 GPU 高并行性加速大规模状态搜索
总结
本文实现了数字华容道的双向 BFS 求解框架,并引入曼哈顿距离与线性冲突作为启发函数。双向搜索将搜索深度减半,是处理组合爆炸问题的重要技巧;模式数据库则将经验知识编码为可查询的启发值,是 AI 求解滑动拼图问题的核心技术之一。