每日算法 — 使用java实现Dots and Boxes:博弈树与链式分析策略

Dots and Boxes(点格棋/围地盘)是一款经典的二人策略纸笔游戏。玩家在 n×m 的点阵网格上轮流连接相邻两点画线,当某位玩家画出的线闭合了一个 1×1 的方格时,该玩家获得这个方格并继续行动。游戏结束时拥有最多方格的玩家获胜。本文用 Java 实现 Dots and Boxes 的 AI 对手,核心算法基于博弈树搜索(Minimax)与链式结构分析(Chain Analysis)。

核心算法思路

Dots and Boxes 是一个有限信息的完全信息博弈,理论上可用 Minimax 求解。但由于棋盘上的可选边数通常为 20-40 条,完全搜索的博弈树极为庞大。因此 AI 需要结合以下策略:

1. 局面评估函数:
– 直接统计双方已占方格数。
– 分析”危险边”:某些边如果被对手画上会导致送出一个或多个方格。
– 识别”链式结构”:一系列首尾相连的未闭合方格形成的链。掌握链的控制权往往决定胜负。

2. 链式分析(Chain Analysis):
– 短链(长度为 1-2 的链):通常应尽快取走,避免给对手送分。
– 长链(长度 ≥ 3 的链):掌握”长链法则”——在标准规则下,先手玩家通常希望棋盘上的长链总数为奇数,后手希望为偶数。
– 双交叉策略(Double-Cross):当被迫进入长链时,通过只取走链末端两个方格(而非整条链)来切换控制权,这是 Dots and Boxes 最核心的战术。

3. 受限深度 Minimax:
– 由于完整搜索不可行,AI 在关键决策点(如选择进入哪条链、是否执行双交叉)使用受限深度的 Minimax 搜索。
– 对于”被迫行动”(如只有一条安全边可画)则直接执行,不消耗搜索深度。

Java 实现

import java.util.*;

public class DotsAndBoxesAI {
    private int rows, cols; // 点阵的行列数
    private boolean[][] hEdges; // 水平边 hEdges[r][c] 连接 (r,c) 和 (r,c+1)
    private boolean[][] vEdges; // 垂直边 vEdges[r][c] 连接 (r,c) 和 (r+1,c)
    private int[][] owner;      // 每个方格的拥有者:0=未占, 1=玩家1, 2=玩家2
    private int[] scores = new int[3];

    public DotsAndBoxesAI(int rows, int cols) {
        this.rows = rows;
        this.cols = cols;
        this.hEdges = new boolean[rows][cols - 1];
        this.vEdges = new boolean[rows - 1][cols];
        this.owner = new int[rows - 1][cols - 1];
    }

    /**
     * 判断某条边是否安全(不会立刻送给对方一个方格)
     */
    public boolean isSafeMove(int r, int c, boolean isHorizontal) {
        int boxesFilled = 0;
        if (isHorizontal) {
            // 上方方格
            if (r > 0) {
                int filled = countFilledEdges(r - 1, c, true);
                if (filled == 3) return false;
            }
            // 下方方格
            if (r < rows - 1) {
                int filled = countFilledEdges(r, c, true);
                if (filled == 3) return false;
            }
        } else {
            // 左方方格
            if (c > 0) {
                int filled = countFilledEdges(r, c - 1, false);
                if (filled == 3) return false;
            }
            // 右方方格
            if (c < cols - 1) {
                int filled = countFilledEdges(r, c, false);
                if (filled == 3) return false;
            }
        }
        return true;
    }

    /**
     * 统计某个方格已填充的边数
     */
    private int countFilledEdges(int boxR, int boxC, boolean ignoreHorizontal) {
        int count = 0;
        // 上边
        if (hEdges[boxR][boxC]) count++;
        // 下边
        if (hEdges[boxR + 1][boxC]) count++;
        // 左边
        if (vEdges[boxR][boxC]) count++;
        // 右边
        if (vEdges[boxR][boxC + 1]) count++;
        return count;
    }

    /**
     * 获取某条边画完后能得到的方格数
     */
    public int boxesGained(int r, int c, boolean isHorizontal) {
        int gained = 0;
        if (isHorizontal) {
            if (r > 0 && countFilledEdges(r - 1, c, true) == 3 && !hEdges[r][c]) gained++;
            if (r < rows - 1 && countFilledEdges(r, c, true) == 3 && !hEdges[r][c]) gained++;
        } else {
            if (c > 0 && countFilledEdges(r, c - 1, false) == 3 && !vEdges[r][c]) gained++;
            if (c < cols - 1 && countFilledEdges(r, c, false) == 3 && !vEdges[r][c]) gained++;
        }
        return gained;
    }

    /**
     * 执行一步并返回是否获得了方格(可继续行动)
     */
    public boolean makeMove(int r, int c, boolean isHorizontal, int player) {
        if (isHorizontal) {
            if (hEdges[r][c]) throw new IllegalStateException("Edge already drawn");
            hEdges[r][c] = true;
        } else {
            if (vEdges[r][c]) throw new IllegalStateException("Edge already drawn");
            vEdges[r][c] = true;
        }

        int gained = 0;
        if (isHorizontal) {
            if (r > 0 && countFilledEdges(r - 1, c, true) == 4) {
                owner[r - 1][c] = player; gained++;
            }
            if (r < rows - 1 && countFilledEdges(r, c, true) == 4) {
                owner[r][c] = player; gained++;
            }
        } else {
            if (c > 0 && countFilledEdges(r, c - 1, false) == 4) {
                owner[r][c - 1] = player; gained++;
            }
            if (c < cols - 1 && countFilledEdges(r, c, false) == 4) {
                owner[r][c] = player; gained++;
            }
        }

        scores[player] += gained;
        return gained > 0;
    }

    /**
     * 撤销一步(用于回溯搜索)
     */
    public void undoMove(int r, int c, boolean isHorizontal, int player, int gained) {
        if (isHorizontal) {
            hEdges[r][c] = false;
            if (r > 0 && owner[r - 1][c] == player) { owner[r - 1][c] = 0; }
            if (r < rows - 1 && owner[r][c] == player) { owner[r][c] = 0; }
        } else {
            vEdges[r][c] = false;
            if (c > 0 && owner[r][c - 1] == player) { owner[r][c - 1] = 0; }
            if (c < cols - 1 && owner[r][c] == player) { owner[r][c] = 0; }
        }
        scores[player] -= gained;
    }

    /**
     * 获取所有合法移动
     */
    public List<Move> getAllMoves() {
        List<Move> moves = new ArrayList<>();
        for (int r = 0; r < rows; r++) {
            for (int c = 0; c < cols - 1; c++) {
                if (!hEdges[r][c]) moves.add(new Move(r, c, true));
            }
        }
        for (int r = 0; r < rows - 1; r++) {
            for (int c = 0; c < cols; c++) {
                if (!vEdges[r][c]) moves.add(new Move(r, c, false));
            }
        }
        return moves;
    }

    /**
     * 获取所有安全移动
     */
    public List<Move> getSafeMoves() {
        List<Move> safe = new ArrayList<>();
        for (Move m : getAllMoves()) {
            if (isSafeMove(m.r, m.c, m.horizontal)) safe.add(m);
        }
        return safe;
    }

    /**
     * 局面评估函数:从当前玩家视角评分
     */
    public int evaluate(int player) {
        int opponent = player == 1 ? 2 : 1;
        int score = (scores[player] - scores[opponent]) * 100;

        // 分析链式结构的数量(简化版:统计含恰好两条边的方格数作为链的代理指标)
        int chainIndicator = 0;
        for (int r = 0; r < rows - 1; r++) {
            for (int c = 0; c < cols - 1; c++) {
                if (owner[r][c] == 0) {
                    int filled = countFilledEdges(r, c, false);
                    if (filled == 2) chainIndicator++;
                }
            }
        }

        // 长链奇偶性影响(简化策略)
        score += chainIndicator * (player == 1 ? 1 : -1);
        return score;
    }

    /**
     * 受限深度 Minimax 搜索
     */
    public Move findBestMove(int player, int depth) {
        List<Move> moves = getAllMoves();
        if (moves.isEmpty()) return null;

        Move bestMove = null;
        int bestScore = Integer.MIN_VALUE;

        // 优先尝试能直接得分的移动
        moves.sort((a, b) -> {
            int ga = boxesGained(a.r, a.c, a.horizontal);
            int gb = boxesGained(b.r, b.c, b.horizontal);
            return Integer.compare(gb, ga);
        });

        for (Move m : moves) {
            int gained = boxesGained(m.r, m.c, m.horizontal);
            boolean extraTurn = makeMove(m.r, m.c, m.horizontal, player);

            int score;
            if (extraTurn) {
                // 获得方格可继续行动
                score = minimax(player, depth - 1, player, Integer.MIN_VALUE, Integer.MAX_VALUE);
            } else {
                int opponent = player == 1 ? 2 : 1;
                score = minimax(opponent, depth - 1, player, Integer.MIN_VALUE, Integer.MAX_VALUE);
            }

            undoMove(m.r, m.c, m.horizontal, player, gained);

            if (score > bestScore) {
                bestScore = score;
                bestMove = m;
            }
        }
        return bestMove;
    }

    private int minimax(int currentPlayer, int depth, int evalPlayer, int alpha, int beta) {
        if (depth == 0 || getAllMoves().isEmpty()) {
            return evaluate(evalPlayer);
        }

        List<Move> moves = getAllMoves();
        boolean isMaximizing = (currentPlayer == evalPlayer);

        if (isMaximizing) {
            int maxEval = Integer.MIN_VALUE;
            for (Move m : moves) {
                int gained = boxesGained(m.r, m.c, m.horizontal);
                boolean extra = makeMove(m.r, m.c, m.horizontal, currentPlayer);
                int nextPlayer = extra ? currentPlayer : (currentPlayer == 1 ? 2 : 1);
                int eval = minimax(nextPlayer, depth - 1, evalPlayer, alpha, beta);
                undoMove(m.r, m.c, m.horizontal, currentPlayer, gained);
                maxEval = Math.max(maxEval, eval);
                alpha = Math.max(alpha, eval);
                if (beta <= alpha) break;
            }
            return maxEval;
        } else {
            int minEval = Integer.MAX_VALUE;
            for (Move m : moves) {
                int gained = boxesGained(m.r, m.c, m.horizontal);
                boolean extra = makeMove(m.r, m.c, m.horizontal, currentPlayer);
                int nextPlayer = extra ? currentPlayer : (currentPlayer == 1 ? 2 : 1);
                int eval = minimax(nextPlayer, depth - 1, evalPlayer, alpha, beta);
                undoMove(m.r, m.c, m.horizontal, currentPlayer, gained);
                minEval = Math.min(minEval, eval);
                beta = Math.min(beta, eval);
                if (beta <= alpha) break;
            }
            return minEval;
        }
    }

    public void printBoard() {
        for (int r = 0; r < rows; r++) {
            // 打印点和水平边
            for (int c = 0; c < cols; c++) {
                System.out.print("•");
                if (c < cols - 1) System.out.print(hEdges[r][c] ? "───" : "   ");
            }
            System.out.println();

            // 打印垂直边和方格拥有者
            if (r < rows - 1) {
                for (int c = 0; c < cols; c++) {
                    System.out.print(vEdges[r][c] ? "│" : " ");
                    if (c < cols - 1) {
                        System.out.print(owner[r][c] == 1 ? " 1 " : owner[r][c] == 2 ? " 2 " : "   ");
                    } else {
                        System.out.print(" ");
                    }
                }
                System.out.println();
            }
        }
        System.out.println("Score - P1: " + scores[1] + " P2: " + scores[2]);
    }

    static class Move {
        int r, c;
        boolean horizontal;
        Move(int r, int c, boolean h) { this.r = r; this.c = c; this.horizontal = h; }
        public String toString() { return (horizontal ? "H" : "V") + "(" + r + "," + c + ")"; }
    }

    public static void main(String[] args) {
        DotsAndBoxesAI game = new DotsAndBoxesAI(3, 3); // 2x2 方格的小棋盘
        Random rand = new Random();
        int currentPlayer = 1;

        while (!game.getAllMoves().isEmpty()) {
            Move m;
            if (currentPlayer == 1) {
                m = game.findBestMove(1, 4);
                System.out.println("AI Player 1 chooses: " + m);
            } else {
                List<Move> moves = game.getAllMoves();
                m = moves.get(rand.nextInt(moves.size()));
                System.out.println("Random Player 2 chooses: " + m);
            }

            boolean extra = game.makeMove(m.r, m.c, m.horizontal, currentPlayer);
            game.printBoard();

            if (!extra) {
                currentPlayer = currentPlayer == 1 ? 2 : 1;
            }
        }

        System.out.println("Game Over! Winner: " + 
            (game.scores[1] > game.scores[2] ? "Player 1" : game.scores[2] > game.scores[1] ? "Player 2" : "Draw"));
    }
}

复杂度分析

时间复杂度:Minimax 搜索深度为 d 时,分支因子为剩余边数 E’。每次评估需要 O(R×C)。因此复杂度为 O(E’^d × R × C)。对于 3×3 点阵(4 条边),深度 4 是可接受的;对于更大的棋盘需要更深的策略分析而非纯搜索。

空间复杂度:棋盘状态和递归栈,O(R×C + d)。

策略要点

  1. 安全优先:在开放阶段,AI 应只画安全边,避免给对手送方格。
  2. 链控制:当被迫进入链时,通过”双交叉”只取 2 个方格并将控制权交给对手,迫使对手开启下一条链。
  3. 终局处理:在最后阶段,有时主动送一个方格给对手( sacrifice)反而能控制长链的分配。

总结

Dots and Boxes 的 AI 实现展示了博弈论在实际游戏中的复杂性和趣味性。Minimax 提供了基础的搜索框架,而链式分析则是该游戏特有的高级策略。通过将两者结合,AI 能够在小棋盘上展现出专家级别的水平,同时也为理解组合博弈论中的”控制理论”提供了绝佳的入门案例。