每日算法 — 使用java实现华容道:模式数据库与A*搜索启发式

引言

华容道是中国传统益智游戏的经典代表,其目标是在有限的空间内移动各尺寸滑块,将最大的”曹操”块移至出口位置。本文将用Java实现基于模式数据库(Pattern Database, PDB)启发式的A搜索算法来解决华容道问题。模式数据库通过预计算子问题的最优解距离,为A搜索提供可采纳的启发式估计,大幅加速求解过程。

华容道问题建模

标准华容道在一个4×5的棋盘上进行,包含以下滑块:
– 1个2×2的”曹操”块(目标块)
– 5个1×2或2×1的”五虎将”块
– 4个1×1的”小兵”块

游戏状态可以用每个滑块的位置来表示。由于滑块类型和数量固定,状态空间相对有限(约2.9×10^4种可达状态),非常适合A*搜索。

模式数据库设计

模式数据库的核心思想是将完整问题分解为若干子问题。对于华容道,我们可以设计以下模式:

  1. 曹操位置模式:只考虑曹操块的位置,忽略其他滑块。预计算从每个位置到出口的最短步数(假设其他滑块不存在)。
  2. 小兵分布模式:只考虑4个小兵的分布,忽略大将块。预计算小兵在不同布局下对曹操路径的影响。

本文采用曹操位置+阻挡物模式的组合策略:预计算曹操在棋盘每个位置上,周围各方向被阻挡时需要的最少移动步数。

Java实现

棋盘状态表示

import java.util.*;

/**
 * 华容道棋盘状态
 * 棋盘大小: 4行 x 5列
 */
public class KlotskiState {
    public static final int ROWS = 5;
    public static final int COLS = 4;

    // 块类型
    public static final int EMPTY = 0;
    public static final int CAOCAO = 1;   // 2x2
    public static final int VERTICAL = 2; // 1x2 竖块
    public static final int HORIZONTAL = 3; // 2x1 横块
    public static final int SOLDIER = 4;  // 1x1 小兵

    // 曹操的目标出口位置(左下角2x2区域)
    public static final int TARGET_ROW = 3;
    public static final int TARGET_COL = 1;

    private int[][] board;
    private int caoRow, caoCol; // 曹操左上角位置

    public KlotskiState() {
        this.board = new int[ROWS][COLS];
        initializeStandardLayout();
    }

    public KlotskiState(int[][] board, int caoRow, int caoCol) {
        this.board = new int[ROWS][COLS];
        for (int i = 0; i < ROWS; i++) {
            System.arraycopy(board[i], 0, this.board[i], 0, COLS);
        }
        this.caoRow = caoRow;
        this.caoCol = caoCol;
    }

    /**
     * 初始化标准华容道布局
     * 横刀立马布局
     */
    private void initializeStandardLayout() {
        // 曹操 2x2 在顶部中央
        caoRow = 0; caoCol = 1;
        fillBlock(caoRow, caoCol, 2, 2, CAOCAO);

        // 关羽 2x1 横块 在曹操下方
        fillBlock(2, 1, 1, 2, HORIZONTAL);

        // 四个竖块(张飞、赵云、马超、黄忠)
        fillBlock(0, 0, 2, 1, VERTICAL); // 左上
        fillBlock(0, 3, 2, 1, VERTICAL); // 右上
        fillBlock(2, 0, 2, 1, VERTICAL); // 左下
        fillBlock(2, 3, 2, 1, VERTICAL); // 右下

        // 四个小兵
        fillBlock(3, 1, 1, 1, SOLDIER);
        fillBlock(3, 2, 1, 1, SOLDIER);
        fillBlock(4, 0, 1, 1, SOLDIER);
        fillBlock(4, 3, 1, 1, SOLDIER);
    }

    private void fillBlock(int row, int col, int h, int w, int type) {
        for (int r = row; r < row + h && r < ROWS; r++) {
            for (int c = col; c < col + w && c < COLS; c++) {
                board[r][c] = type;
            }
        }
    }

    public int[][] getBoard() { return board; }
    public int getCaoRow() { return caoRow; }
    public int getCaoCol() { return caoCol; }

    /**
     * 检查是否为目标状态(曹操到达出口)
     */
    public boolean isGoal() {
        return caoRow == TARGET_ROW && caoCol == TARGET_COL;
    }

    /**
     * 获取某位置的块类型
     */
    public int get(int row, int col) {
        return board[row][col];
    }

    /**
     * 生成所有可能的下一步状态
     */
    public List<KlotskiState> getNeighbors() {
        List<KlotskiState> neighbors = new ArrayList<>();
        boolean[][] moved = new boolean[ROWS][COLS]; // 避免重复移动同一块

        for (int r = 0; r < ROWS; r++) {
            for (int c = 0; c < COLS; c++) {
                if (board[r][c] == EMPTY || moved[r][c]) continue;

                int type = board[r][c];
                int[] blockPos = findBlockOrigin(r, c, type);
                int br = blockPos[0], bc = blockPos[1];

                if (moved[br][bc]) continue;
                moved[br][bc] = true;

                // 尝试四个方向移动
                int[][] dirs = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
                for (int[] dir : dirs) {
                    KlotskiState next = tryMoveBlock(br, bc, type, dir[0], dir[1]);
                    if (next != null) {
                        neighbors.add(next);
                    }
                }
            }
        }
        return neighbors;
    }

    /**
     * 找到某块的原点位置(左上角)
     */
    private int[] findBlockOrigin(int row, int col, int type) {
        int br = row, bc = col;
        if (type == CAOCAO) {
            while (br > 0 && board[br - 1][col] == CAOCAO) br--;
            while (bc > 0 && board[row][bc - 1] == CAOCAO) bc--;
        } else if (type == VERTICAL) {
            while (br > 0 && board[br - 1][col] == VERTICAL) br--;
        } else if (type == HORIZONTAL) {
            while (bc > 0 && board[row][bc - 1] == HORIZONTAL) bc--;
        }
        return new int[]{br, bc};
    }

    /**
     * 尝试移动一个块
     */
    private KlotskiState tryMoveBlock(int row, int col, int type, int dr, int dc) {
        int h = 1, w = 1;
        if (type == CAOCAO) { h = 2; w = 2; }
        else if (type == VERTICAL) { h = 2; w = 1; }
        else if (type == HORIZONTAL) { h = 1; w = 2; }

        // 检查目标位置是否为空
        for (int r = row; r < row + h; r++) {
            for (int c = col; c < col + w; c++) {
                int nr = r + dr, nc = c + dc;
                if (nr < 0 || nr >= ROWS || nc < 0 || nc >= COLS) return null;
                // 目标位置必须是空的,或者是该块自身的一部分
                if (board[nr][nc] != EMPTY && !(nr >= row && nr < row + h && nc >= col && nc < col + w)) {
                    return null;
                }
            }
        }

        // 创建新状态
        int[][] newBoard = new int[ROWS][COLS];
        for (int i = 0; i < ROWS; i++) {
            System.arraycopy(board[i], 0, newBoard[i], 0, COLS);
        }

        // 清除原位置
        for (int r = row; r < row + h; r++) {
            for (int c = col; c < col + w; c++) {
                newBoard[r][c] = EMPTY;
            }
        }

        // 放置到新位置
        int newRow = row + dr, newCol = col + dc;
        for (int r = newRow; r < newRow + h; r++) {
            for (int c = newCol; c < newCol + w; c++) {
                newBoard[r][c] = type;
            }
        }

        int newCaoRow = caoRow, newCaoCol = caoCol;
        if (type == CAOCAO) {
            newCaoRow = newRow;
            newCaoCol = newCol;
        }

        return new KlotskiState(newBoard, newCaoRow, newCaoCol);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof KlotskiState)) return false;
        KlotskiState other = (KlotskiState) o;
        return Arrays.deepEquals(board, other.board);
    }

    @Override
    public int hashCode() {
        return Arrays.deepHashCode(board);
    }

    @Override
    public String toString() {
        StringBuilder sb = new StringBuilder();
        for (int r = 0; r < ROWS; r++) {
            for (int c = 0; c < COLS; c++) {
                char ch = '.';
                switch (board[r][c]) {
                    case CAOCAO: ch = 'C'; break;
                    case VERTICAL: ch = 'V'; break;
                    case HORIZONTAL: ch = 'H'; break;
                    case SOLDIER: ch = 'S'; break;
                    case EMPTY: ch = '.'; break;
                }
                sb.append(ch).append(' ');
            }
            sb.append('\n');
        }
        return sb.toString();
    }
}

模式数据库构建

/**
 * 华容道模式数据库
 * 预计算各子问题的最优解距离
 */
public class KlotskiPatternDB {

    // 曹操位置到目标的曼哈顿距离(最基础的下界)
    private static int[][] caocaoDist;

    // 小兵位置模式:记录小兵在不同分布下到目标的最短步数
    private static Map<String, Integer> soldierPattern;

    static {
        buildCaocaoDistance();
        soldierPattern = new HashMap<>();
        buildSoldierPattern();
    }

    /**
     * 构建曹操位置的曼哈顿距离表
     */
    private static void buildCaocaoDistance() {
        caocaoDist = new int[KlotskiState.ROWS][KlotskiState.COLS];
        for (int r = 0; r < KlotskiState.ROWS; r++) {
            for (int c = 0; c < KlotskiState.COLS; c++) {
                caocaoDist[r][c] = Math.abs(r - KlotskiState.TARGET_ROW) 
                                 + Math.abs(c - KlotskiState.TARGET_COL);
            }
        }
    }

    /**
     * 构建小兵分布的模式数据库
     * 通过BFS预计算小兵在不同位置时对曹操路径的影响
     */
    private static void buildSoldierPattern() {
        // 简化:只记录小兵是否阻挡曹操的四个关键位置
        // 在完整实现中,这里应该使用BFS遍历所有小兵分布
        // 本文提供简化版本,实际可扩展为完整PDB

        // 曹操出口周围的四个关键位置
        // (3,0), (3,1), (4,0), (4,1) 是出口区域
        // 小兵如果在这些位置会直接影响曹操的移动

        // 预计算几种典型情况
        soldierPattern.put("0000", 0); // 小兵不在关键位置
        soldierPattern.put("1000", 1); // 一个阻挡
        soldierPattern.put("1100", 2); // 两个阻挡
        soldierPattern.put("1110", 3); // 三个阻挡
        soldierPattern.put("1111", 4); // 四个阻挡
    }

    /**
     * 获取启发式估计值(可采纳的:不超过真实代价)
     */
    public static int heuristic(KlotskiState state) {
        // 基础:曹操到目标的曼哈顿距离
        int h = caocaoDist[state.getCaoRow()][state.getCaoCol()];

        // 增强:检查曹操路径上的阻挡物
        h += countPathObstacles(state);

        // 小兵分布模式
        h += soldierHeuristic(state);

        return h;
    }

    /**
     * 计算曹操路径上的直接阻挡物数量
     */
    private static int countPathObstacles(KlotskiState state) {
        int obstacles = 0;
        int[][] board = state.getBoard();
        int cr = state.getCaoRow(), cc = state.getCaoCol();

        // 检查曹操下方是否通畅(目标在下方)
        if (cr < KlotskiState.TARGET_ROW) {
            // 检查曹操下方两行两列区域
            for (int r = cr + 2; r <= Math.min(cr + 3, KlotskiState.ROWS - 1); r++) {
                for (int c = cc; c <= cc + 1 && c < KlotskiState.COLS; c++) {
                    if (r < KlotskiState.ROWS && board[r][c] != KlotskiState.EMPTY) {
                        obstacles++;
                    }
                }
            }
        }

        return obstacles / 2; // 归一化
    }

    /**
     * 小兵分布启发式
     */
    private static int soldierHeuristic(KlotskiState state) {
        int[][] board = state.getBoard();
        StringBuilder key = new StringBuilder();

        // 检查出口区域的关键位置
        int[][] keyPositions = {{3, 0}, {3, 1}, {4, 0}, {4, 1}};
        for (int[] pos : keyPositions) {
            key.append(board[pos[0]][pos[1]] == KlotskiState.SOLDIER ? '1' : '0');
        }

        return soldierPattern.getOrDefault(key.toString(), 0);
    }

    /**
     * 更精细的启发式:考虑各滑块到目标区域的阻碍程度
     */
    public static int advancedHeuristic(KlotskiState state) {
        int h = 0;
        int[][] board = state.getBoard();

        // 曹操距离
        h += Math.abs(state.getCaoRow() - KlotskiState.TARGET_ROW) * 2;
        h += Math.abs(state.getCaoCol() - KlotskiState.TARGET_COL) * 2;

        // 检查每个非空块是否在曹操路径上
        for (int r = 0; r < KlotskiState.ROWS; r++) {
            for (int c = 0; c < KlotskiState.COLS; c++) {
                if (board[r][c] != KlotskiState.EMPTY && board[r][c] != KlotskiState.CAOCAO) {
                    // 如果在曹操下方区域,增加代价
                    if (r >= state.getCaoRow() + 1 && r <= KlotskiState.TARGET_ROW + 1
                        && c >= Math.max(0, state.getCaoCol() - 1) 
                        && c <= Math.min(KlotskiState.COLS - 1, state.getCaoCol() + 2)) {
                        h += 2;
                    }
                }
            }
        }

        return h;
    }
}

A*搜索求解器

/**
 * A*搜索求解华容道
 */
public class KlotskiSolver {

    /**
     * A*搜索节点
     */
    static class Node implements Comparable<Node> {
        KlotskiState state;
        Node parent;
        int g; // 从起点到当前节点的实际代价
        int h; // 启发式估计代价
        int f; // g + h

        Node(KlotskiState state, Node parent, int g, int h) {
            this.state = state;
            this.parent = parent;
            this.g = g;
            this.h = h;
            this.f = g + h;
        }

        @Override
        public int compareTo(Node other) {
            return Integer.compare(this.f, other.f);
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof Node)) return false;
            Node other = (Node) o;
            return state.equals(other.state);
        }

        @Override
        public int hashCode() {
            return state.hashCode();
        }
    }

    /**
     * 使用A*搜索求解华容道
     * @return 从初始状态到目标状态的状态序列
     */
    public static List<KlotskiState> solve(KlotskiState initial) {
        PriorityQueue<Node> openSet = new PriorityQueue<>();
        Set<KlotskiState> closedSet = new HashSet<>();
        Map<KlotskiState, Integer> gScore = new HashMap<>();

        int h0 = KlotskiPatternDB.heuristic(initial);
        Node start = new Node(initial, null, 0, h0);
        openSet.add(start);
        gScore.put(initial, 0);

        int nodesExpanded = 0;

        while (!openSet.isEmpty()) {
            Node current = openSet.poll();

            if (current.state.isGoal()) {
                System.out.println("搜索完成!扩展节点数: " + nodesExpanded);
                return reconstructPath(current);
            }

            if (closedSet.contains(current.state)) {
                continue;
            }

            closedSet.add(current.state);
            nodesExpanded++;

            // 生成邻居状态
            List<KlotskiState> neighbors = current.state.getNeighbors();
            for (KlotskiState neighbor : neighbors) {
                if (closedSet.contains(neighbor)) continue;

                int tentativeG = current.g + 1;

                if (!gScore.containsKey(neighbor) || tentativeG < gScore.get(neighbor)) {
                    gScore.put(neighbor, tentativeG);
                    int h = KlotskiPatternDB.heuristic(neighbor);
                    Node neighborNode = new Node(neighbor, current, tentativeG, h);
                    openSet.add(neighborNode);
                }
            }
        }

        System.out.println("未找到解。扩展节点数: " + nodesExpanded);
        return null;
    }

    /**
     * 重建路径
     */
    private static List<KlotskiState> reconstructPath(Node goal) {
        List<KlotskiState> path = new ArrayList<>();
        Node current = goal;
        while (current != null) {
            path.add(0, current.state);
            current = current.parent;
        }
        return path;
    }

    /**
     * 使用IDA*(迭代加深A*)求解,适合内存受限场景
     */
    public static List<KlotskiState> solveIDAStar(KlotskiState initial) {
        int threshold = KlotskiPatternDB.heuristic(initial);

        while (true) {
            List<KlotskiState> path = new ArrayList<>();
            path.add(initial);
            int result = idaSearch(initial, 0, threshold, path, new HashSet<>());
            if (result == -1) {
                System.out.println("IDA*找到解!步数: " + (path.size() - 1));
                return path;
            }
            if (result == Integer.MAX_VALUE) {
                System.out.println("IDA*未找到解");
                return null;
            }
            threshold = result;
            System.out.println("IDA*阈值提升到: " + threshold);
        }
    }

    private static int idaSearch(KlotskiState state, int g, int threshold, 
                                  List<KlotskiState> path, Set<KlotskiState> visited) {
        int f = g + KlotskiPatternDB.heuristic(state);
        if (f > threshold) return f;
        if (state.isGoal()) return -1;

        int min = Integer.MAX_VALUE;
        for (KlotskiState neighbor : state.getNeighbors()) {
            if (visited.contains(neighbor)) continue;

            visited.add(neighbor);
            path.add(neighbor);

            int result = idaSearch(neighbor, g + 1, threshold, path, visited);
            if (result == -1) return -1;
            if (result < min) min = result;

            path.remove(path.size() - 1);
            visited.remove(neighbor);
        }
        return min;
    }
}

主程序与演示

/**
 * 华容道求解器主程序
 */
public class KlotskiMain {

    public static void main(String[] args) {
        System.out.println("=== 华容道模式数据库+A*搜索演示 ===\n");

        // 创建初始状态
        KlotskiState initial = new KlotskiState();
        System.out.println("初始状态:");
        System.out.println(initial);
        System.out.println("启发式估计值: " + KlotskiPatternDB.heuristic(initial));
        System.out.println();

        // A*搜索
        long startTime = System.currentTimeMillis();
        List<KlotskiState> solution = KlotskiSolver.solve(initial);
        long endTime = System.currentTimeMillis();

        if (solution != null) {
            System.out.println("找到解!共" + (solution.size() - 1) + "步");
            System.out.println("耗时: " + (endTime - startTime) + "ms");

            // 打印关键步骤
            System.out.println("\n关键步骤演示(每10步显示一次):");
            for (int i = 0; i < solution.size(); i += Math.max(1, solution.size() / 5)) {
                System.out.println("第" + i + "步:");
                System.out.println(solution.get(i));
            }

            System.out.println("最终状态(曹操到达出口):");
            System.out.println(solution.get(solution.size() - 1));
        }
    }
}

算法复杂度分析

华容道状态空间约为2.9×10^4个可达状态:

  • BFS:时间复杂度 $O(b^d)$,空间复杂度 $O(b^d)$,其中 $b$ 为分支因子(约5-8),$d$ 为解深度(标准布局约81步)。
  • A*搜索:在可采纳启发式下,时间复杂度为 $O(b^{\epsilon d})$,其中 $\epsilon$ 取决于启发式的精确度。模式数据库可将搜索节点数减少90%以上。
  • IDA*:空间复杂度为 $O(d)$,适合内存受限场景,但需要重复扩展节点。

总结

本文实现了基于模式数据库启发式的华容道求解系统:

  1. 状态表示系统:完整的4×5棋盘建模,支持7种滑块类型的移动。
  2. 模式数据库:预计算曹操位置距离和小兵分布模式,提供可采纳的启发式估计。
  3. A*搜索引擎:带优先级队列的最优路径搜索,保证找到最少步数解。
  4. IDA*备选方案:迭代加深版本,适合内存受限环境。

读者可以通过扩展模式数据库(例如加入大将块的位置模式)进一步提升启发式的精确度,体验启发式搜索在状态空间问题中的强大能力。