游戏简介
15数码(15-puzzle)是Sam Loyd在19世纪末推广的经典滑块拼图游戏。游戏在一个4×4的格子中进行,包含15个编号方块和1个空白格。玩家通过上下左右移动空白格 adjacent 的数字方块,目标是将打乱的数字按顺序排列。本文将用Java实现完整的求解系统,重点讲解逆序数判定可解性、IDA*迭代加深搜索算法,以及曼哈顿距离启发式函数的设计。
核心规则与数据结构
游戏规则
- 4×4棋盘,包含数字1-15和一个空白格(用0表示)
- 每次移动将空白格相邻的数字滑入空白位置
- 目标状态:数字按1-15顺序排列,空白格在右下角
数据结构
使用一维数组int[16]表示棋盘状态,索引0-15对应棋盘位置。这种表示便于计算逆序数和曼哈顿距离。
public class Puzzle15 {
private int[] board;
private int blankPos; // 空白格位置(0-15)
private static final int SIZE = 4;
private static final int[] GOAL = {1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,0};
public Puzzle15(int[] initial) {
this.board = initial.clone();
for (int i = 0; i < board.length; i++) {
if (board[i] == 0) {
this.blankPos = i;
break;
}
}
}
/**
* 将2D坐标转为一维索引
*/
private static int index(int row, int col) {
return row * SIZE + col;
}
/**
* 将一维索引转为2D坐标
*/
private static int[] coord(int idx) {
return new int[]{idx / SIZE, idx % SIZE};
}
}
逆序数可解性判定
并非所有初始状态都有解。判定15数码可解性的经典方法是计算逆序数(inversion count)。
逆序数定义
将棋盘状态按行优先展开为一维序列(去掉空白格),逆序数指序列中前面数字大于后面数字的数对个数。
可解性定理
对于4×4的15数码:
– 计算逆序数invCount
– 计算空白格所在行号blankRow(从底部数,第1-4行)
– 若空白格在底部行(从底部数为第1行)或第3行:逆序数为偶数时可解
– 若空白格在底部第2行或第4行:逆序数为奇数时可解
等价表述:设blankRowFromBottom为空白格从底部数的行号(1-based),则状态可解当且仅当 (invCount + blankRowFromBottom) % 2 == 1。
public class Puzzle15 {
// ... 其他代码
/**
* 计算逆序数并判定当前状态是否可解
*/
public boolean isSolvable() {
int inversions = 0;
// 按行优先展开,去掉空白格
for (int i = 0; i < board.length; i++) {
if (board[i] == 0) continue;
for (int j = i + 1; j < board.length; j++) {
if (board[j] == 0) continue;
if (board[i] > board[j]) {
inversions++;
}
}
}
// 空白格从底部数的行号(1-based)
int blankRowFromBottom = SIZE - (blankPos / SIZE);
// 可解性判定:(invCount + blankRowFromBottom) % 2 == 1
return (inversions + blankRowFromBottom) % 2 == 1;
}
/**
* 生成一个保证可解的随机初始状态
*/
public static Puzzle15 generateSolvable() {
Random rand = new Random();
int[] board;
Puzzle15 puzzle;
do {
board = new int[16];
List<Integer> nums = new ArrayList<>();
for (int i = 0; i <= 15; i++) nums.add(i);
Collections.shuffle(nums, rand);
for (int i = 0; i < 16; i++) board[i] = nums.get(i);
puzzle = new Puzzle15(board);
} while (!puzzle.isSolvable());
return puzzle;
}
}
曼哈顿距离启发式
A搜索需要启发式函数估计从当前状态到目标状态的代价。对于滑块拼图,曼哈顿距离*(Manhattan Distance)是最常用且有效的启发式。
定义
每个数字块到其目标位置的行距离与列距离之和:
h(n) = Σ |current_row - goal_row| + |current_col - goal_col|
曼哈顿距离满足可采纳性(admissible):它永远不会高估实际代价,因此A*搜索保证能找到最优解。
public class Puzzle15 {
// ... 其他代码
/**
* 计算曼哈顿距离启发值
*/
public int manhattanDistance() {
int distance = 0;
for (int i = 0; i < board.length; i++) {
if (board[i] == 0) continue;
int targetPos = board[i] - 1; // 数字k的目标位置是k-1
distance += Math.abs(i / SIZE - targetPos / SIZE)
+ Math.abs(i % SIZE - targetPos % SIZE);
}
return distance;
}
/**
* 线性冲突(Linear Conflict)增强启发式
* 当同一行/列的两个块目标也在同一行/列,且互相阻挡时,额外+2
*/
public int linearConflict() {
int conflict = 0;
// 行冲突
for (int row = 0; row < SIZE; row++) {
for (int i = 0; i < SIZE; i++) {
int pos1 = index(row, i);
if (board[pos1] == 0) continue;
int targetRow1 = (board[pos1] - 1) / SIZE;
if (targetRow1 != row) continue;
for (int j = i + 1; j < SIZE; j++) {
int pos2 = index(row, j);
if (board[pos2] == 0) continue;
int targetRow2 = (board[pos2] - 1) / SIZE;
if (targetRow2 != row) continue;
// 如果目标列顺序与当前相反,则产生冲突
if ((board[pos1] - 1) % SIZE > (board[pos2] - 1) % SIZE) {
conflict += 2;
}
}
}
}
// 列冲突(类似逻辑)
for (int col = 0; col < SIZE; col++) {
for (int i = 0; i < SIZE; i++) {
int pos1 = index(i, col);
if (board[pos1] == 0) continue;
int targetCol1 = (board[pos1] - 1) % SIZE;
if (targetCol1 != col) continue;
for (int j = i + 1; j < SIZE; j++) {
int pos2 = index(j, col);
if (board[pos2] == 0) continue;
int targetCol2 = (board[pos2] - 1) % SIZE;
if (targetCol2 != col) continue;
if ((board[pos1] - 1) / SIZE > (board[pos2] - 1) / SIZE) {
conflict += 2;
}
}
}
}
return manhattanDistance() + conflict;
}
}
IDA*迭代加深搜索
A搜索在15数码中可能面临内存爆炸问题。IDA(Iterative Deepening A*)通过迭代加深的方式控制内存使用,同时利用启发式函数剪枝,是解决15数码最优解的高效算法。
算法思想
- 设置初始阈值
threshold = h(start) - 执行深度优先搜索,只扩展
f = g + h ≤ threshold的节点 - 若未找到解,将阈值更新为本次搜索中超出阈值的最小
f值 - 重复直到找到解
public class IDAStarSolver {
private static final int[] DR = {-1, 1, 0, 0}; // 上、下、左、右
private static final int[] DC = {0, 0, -1, 1};
private static final String[] DIR_NAME = {"UP", "DOWN", "LEFT", "RIGHT"};
private int nodesExpanded;
private int threshold;
private boolean found;
private List<String> solution;
/**
* IDA*主入口
*/
public List<String> solve(Puzzle15 puzzle) {
if (!puzzle.isSolvable()) {
return null; // 不可解
}
if (puzzle.isGoal()) {
return new ArrayList<>();
}
nodesExpanded = 0;
threshold = puzzle.linearConflict();
solution = new ArrayList<>();
while (true) {
found = false;
int nextThreshold = Integer.MAX_VALUE;
int result = search(puzzle, 0, -1, nextThreshold);
if (found) {
return solution;
}
if (result == Integer.MAX_VALUE) {
return null; // 无解(理论上不会发生,因为已判定可解)
}
threshold = result;
}
}
/**
* 深度优先搜索
* @param puzzle 当前状态
* @param g 已走步数
* @param lastDir 上次移动方向(避免回退)
* @param nextThreshold 下一次迭代的阈值候选
* @return 下一次迭代的阈值
*/
private int search(Puzzle15 puzzle, int g, int lastDir, int nextThreshold) {
int h = puzzle.linearConflict();
int f = g + h;
if (f > threshold) {
return f; // 超出当前阈值,返回f作为候选
}
if (puzzle.isGoal()) {
found = true;
return f;
}
int minExceed = nextThreshold;
nodesExpanded++;
int blankRow = puzzle.getBlankPos() / 4;
int blankCol = puzzle.getBlankPos() % 4;
for (int dir = 0; dir < 4; dir++) {
// 避免回退到上一步
if (lastDir != -1 && isOpposite(dir, lastDir)) continue;
int newRow = blankRow + DR[dir];
int newCol = blankCol + DC[dir];
if (newRow < 0 || newRow >= 4 || newCol < 0 || newCol >= 4) continue;
// 执行移动
puzzle.moveBlank(dir);
solution.add(DIR_NAME[dir]);
int result = search(puzzle, g + 1, dir, minExceed);
if (found) {
return result;
}
if (result < minExceed) {
minExceed = result;
}
// 回溯
solution.remove(solution.size() - 1);
puzzle.undoMove(dir);
}
return minExceed;
}
private boolean isOpposite(int dir1, int dir2) {
return (dir1 == 0 && dir2 == 1) || (dir1 == 1 && dir2 == 0) ||
(dir1 == 2 && dir2 == 3) || (dir1 == 3 && dir2 == 2);
}
public int getNodesExpanded() {
return nodesExpanded;
}
}
完整可运行代码
import java.util.*;
public class Puzzle15 {
private int[] board;
private int blankPos;
private static final int SIZE = 4;
public Puzzle15(int[] initial) {
this.board = initial.clone();
for (int i = 0; i < board.length; i++) {
if (board[i] == 0) {
this.blankPos = i;
break;
}
}
}
public boolean isGoal() {
for (int i = 0; i < 15; i++) {
if (board[i] != i + 1) return false;
}
return board[15] == 0;
}
public int getBlankPos() {
return blankPos;
}
/**
* 将空白格向指定方向移动
*/
public void moveBlank(int dir) {
int[] DR = {-1, 1, 0, 0};
int[] DC = {0, 0, -1, 1};
int row = blankPos / SIZE;
int col = blankPos % SIZE;
int newRow = row + DR[dir];
int newCol = col + DC[dir];
int newPos = newRow * SIZE + newCol;
// 交换
board[blankPos] = board[newPos];
board[newPos] = 0;
blankPos = newPos;
}
/**
* 撤销移动
*/
public void undoMove(int dir) {
// 撤销方向即原方向的反方向
int[] OPP = {1, 0, 3, 2};
moveBlank(OPP[dir]);
}
public void print() {
for (int i = 0; i < SIZE; i++) {
for (int j = 0; j < SIZE; j++) {
int val = board[i * SIZE + j];
System.out.printf(val == 0 ? " " : "%4d", val);
}
System.out.println();
}
System.out.println();
}
// 逆序数、曼哈顿距离、线性冲突等方法的完整实现见上文
// ...
public static void main(String[] args) {
// 一个简单的可解初始状态(需要6步)
int[] init = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 0, 15};
Puzzle15 puzzle = new Puzzle15(init);
System.out.println("初始状态:");
puzzle.print();
if (!puzzle.isSolvable()) {
System.out.println("该状态不可解!");
return;
}
IDAStarSolver solver = new IDAStarSolver();
List<String> solution = solver.solve(puzzle);
if (solution == null) {
System.out.println("无解!");
} else {
System.out.println("最优解步数:" + solution.size());
System.out.println("搜索扩展节点数:" + solver.getNodesExpanded());
System.out.println("操作序列:" + String.join(" -> ", solution));
}
}
}
复杂度分析
| 指标 | 值 | 说明 |
|---|---|---|
| 状态空间大小 | 16! / 2 ≈ 10¹³ | 16个排列中约一半可解 |
| 逆序数判定 | O(n²) | n=16 |
| 曼哈顿距离 | O(n) | 遍历16个格子 |
| IDA*时间 | 最坏O(b^d) | 实际中曼哈顿距离大幅剪枝 |
| IDA*空间 | O(d) | d为解深度,仅需递归栈 |
15数码的最优解深度平均约52步,最坏可达80步。IDA*配合线性冲突启发式可以在毫秒级求解大多数实例。
总结
本文实现了15数码的完整Java求解系统,核心亮点包括:
- 逆序数可解性判定:O(n²)时间快速判断任意状态是否可解,避免在不可解状态上浪费搜索资源
- 曼哈顿距离启发式:满足可采纳性的经典启发式,为A和IDA提供高效的估价函数
- 线性冲突增强:在曼哈顿距离基础上检测同行/同列的阻挡冲突,进一步提升启发式精度
- IDA*迭代加深:以O(d)空间复杂度实现最优解搜索,完美解决了15数码的状态空间爆炸问题
读者可以尝试实现更多高级技巧,如模式数据库(Pattern Database)、 disjoint pattern databases 等,进一步将求解速度提升数个数量级。