每日算法 — 使用java实现数字华容道:双向BFS与模式数据库启发式

引言

数字华容道(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 为最优解长度。

优化方向

  1. 完整 PDB 预计算:使用 7-8-8 或 6-6-3 不相交模式,可将搜索节点减少数个数量级
  2. 哈希优化:用 Zobrist Hashing 或排列索引直接编码状态,降低哈希开销
  3. IDA 替代 A:迭代加深 A* 大幅降低内存占用,适合 PDB 启发值极强的场景
  4. GPU 并行搜索:批量扩展节点,利用 GPU 高并行性加速大规模状态搜索

总结

本文实现了数字华容道的双向 BFS 求解框架,并引入曼哈顿距离与线性冲突作为启发函数。双向搜索将搜索深度减半,是处理组合爆炸问题的重要技巧;模式数据库则将经验知识编码为可查询的启发值,是 AI 求解滑动拼图问题的核心技术之一。