每日算法 — 使用java实现十五拼图:A*搜索与逆序数可解性判定

引言

十五拼图(15-Puzzle)是Sam Loyd在1878年推广的经典滑块谜题,在4×4棋盘上通过滑动数字方块将乱序状态恢复为有序。本文用Java完整实现十五拼图求解器,核心讲解两个算法:一是利用逆序数(Inversion Count)快速判定拼图是否有解,二是使用A*搜索配合曼哈顿距离启发式寻找最优移动序列。

核心算法设计

1. 逆序数可解性判定

对于N×N拼图,将序列(去掉空白格)展平后,若逆序数与空白格所在行号之和的奇偶性满足特定条件,则拼图有解。对于4×4标准拼图:
– 从底部往上数空白格行号为 rowFromBottom
– 若棋盘宽度为奇数,逆序数必须为偶数
– 若棋盘宽度为偶数,逆序数与 rowFromBottom 的奇偶性必须相同

2. A*搜索与曼哈顿距离

将棋盘状态编码为字符串或一维数组,每个状态的启发式值h为所有方块到目标位置的曼哈顿距离之和。A*按 f = g + h 扩展状态,g为已走步数。

完整Java实现

import java.util.*;

/**
 * 十五拼图求解器:逆序数判定 + A*搜索
 */
public class FifteenPuzzle {
    static final int N = 4;           // 4x4 棋盘
    static final int[] GOAL = {1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,0};

    public static void main(String[] args) {
        // 测试用例:有解状态
        int[] start = {1,2,3,4,5,6,7,8,9,10,11,12,13,14,0,15};

        FifteenPuzzle solver = new FifteenPuzzle();
        if (!solver.isSolvable(start)) {
            System.out.println("该拼图状态无解!");
            return;
        }
        System.out.println("拼图有解,开始A*搜索...");
        List<String> path = solver.solve(start);
        System.out.println("最少步数: " + path.size());
        System.out.println("移动序列: " + path);
    }

    /**
     * 逆序数可解性判定
     * 规则:对于4x4拼图,逆序数 + 空白格从底部数行号 必须为偶数
     */
    boolean isSolvable(int[] board) {
        int inversions = 0;
        int blankRow = -1;

        for (int i = 0; i < board.length; i++) {
            if (board[i] == 0) {
                blankRow = i / N;  // 空白格所在行(0-based从上往下)
                continue;
            }
            for (int j = i + 1; j < board.length; j++) {
                if (board[j] != 0 && board[i] > board[j]) {
                    inversions++;
                }
            }
        }

        int rowFromBottom = N - blankRow; // 从底部往上数

        // 对于偶数宽度(如4),逆序数与rowFromBottom同奇偶则有解
        if (N % 2 == 0) {
            return (inversions + rowFromBottom) % 2 == 0;
        } else {
            return inversions % 2 == 0;
        }
    }

    /**
     * A*搜索求解
     */
    List<String> solve(int[] start) {
        PriorityQueue<State> open = new PriorityQueue<>(Comparator.comparingInt(s -> s.f));
        Map<String, Integer> dist = new HashMap<>(); // 记录已发现的最短距离
        Map<String, State> parent = new HashMap<>();

        State startState = new State(start, 0, heuristic(start), null, "");
        open.offer(startState);
        dist.put(startState.key, 0);

        while (!open.isEmpty()) {
            State cur = open.poll();

            if (Arrays.equals(cur.board, GOAL)) {
                return reconstructPath(cur);
            }

            if (cur.g > dist.getOrDefault(cur.key, Integer.MAX_VALUE)) continue;

            int blankIdx = findBlank(cur.board);
            int br = blankIdx / N, bc = blankIdx % N;

            // 四个方向移动
            int[][] dirs = {{-1,0,"下"},{1,0,"上"},{0,-1,"右"},{0,1,"左"}};
            for (int[] d : dirs) {
                int nr = br + d[0], nc = bc + d[1];
                if (nr < 0 || nr >= N || nc < 0 || nc >= N) continue;

                int nIdx = nr * N + nc;
                int[] nextBoard = cur.board.clone();
                // 交换空白格与相邻数字
                nextBoard[blankIdx] = nextBoard[nIdx];
                nextBoard[nIdx] = 0;

                State next = new State(nextBoard, cur.g + 1, heuristic(nextBoard), cur, d[2]);
                int bestG = dist.getOrDefault(next.key, Integer.MAX_VALUE);

                if (next.g < bestG) {
                    dist.put(next.key, next.g);
                    open.offer(next);
                }
            }
        }
        return Collections.emptyList(); // 理论上不会发生(已判定有解)
    }

    /**
     * 曼哈顿距离启发式:所有数字到目标位置的曼哈顿距离之和
     */
    int heuristic(int[] board) {
        int sum = 0;
        for (int i = 0; i < board.length; i++) {
            if (board[i] == 0) continue;
            int targetR = (board[i] - 1) / N;
            int targetC = (board[i] - 1) % N;
            int curR = i / N, curC = i % N;
            sum += Math.abs(curR - targetR) + Math.abs(curC - targetC);
        }
        return sum;
    }

    int findBlank(int[] board) {
        for (int i = 0; i < board.length; i++) if (board[i] == 0) return i;
        return -1;
    }

    List<String> reconstructPath(State goal) {
        LinkedList<String> path = new LinkedList<>();
        State cur = goal;
        while (cur.move != null && !cur.move.isEmpty()) {
            path.addFirst(cur.move);
            cur = cur.parent;
        }
        return path;
    }

    /** 搜索状态 */
    class State {
        int[] board;
        int g, h, f;
        String key;
        State parent;
        String move; // 到达此状态的移动方向

        State(int[] board, int g, int h, State parent, String move) {
            this.board = board.clone();
            this.g = g;
            this.h = h;
            this.f = g + h;
            this.parent = parent;
            this.move = move;
            this.key = boardToKey(board);
        }
    }

    String boardToKey(int[] board) {
        StringBuilder sb = new StringBuilder();
        for (int v : board) sb.append(v).append(',');
        return sb.toString();
    }

    /** 打印棋盘 */
    void printBoard(int[] board) {
        for (int i = 0; i < N; i++) {
            for (int j = 0; j < N; j++) {
                int v = board[i * N + j];
                System.out.printf(v == 0 ? "   " : "%2d ", v);
            }
            System.out.println();
        }
        System.out.println();
    }
}

复杂度分析

  • 逆序数判定:展平数组后双重循环统计,时间复杂度 O(N²),空间 O(1)。这是判定拼图可解性的最优方法,无需实际搜索即可拒绝无解输入。
  • A*搜索:状态空间为 (N²-1)!/2(考虑等价状态),对于4×4棋盘约为 10¹³。但曼哈顿距离作为强启发式函数,实际搜索节点数远小于此。在最优情况下时间复杂度接近 O(有效状态数),空间复杂度 O(队列最大长度)。

总结

本文实现了十五拼图的完整求解流程:先用逆序数 O(N²) 判定可解性,避免对无解状态做无效搜索;再用A搜索配合曼哈顿距离启发式寻找最优解。读者可以进一步尝试线性冲突(Linear Conflict)启发式以进一步减少搜索节点,或实现IDA算法以降低内存占用。