每日算法 — 使用java实现点格棋:博弈树搜索与Minimax对抗策略

引言

点格棋(Dots and Boxes)是一款经典的双人纸笔游戏,规则简单却策略深远。两名玩家轮流在网格点之间画线,完成一个方格四边闭合的玩家占领该格并额外获得一次行动机会。

本文将用Java实现点格棋的AI对手,核心算法采用Minimax博弈树搜索配合Alpha-Beta剪枝,让计算机能够进行高水平的对抗。

游戏规则与状态表示

游戏规则

  • 棋盘为 $(N+1) \times (M+1)$ 的点阵,形成 $N \times M$ 个方格
  • 两名玩家轮流连接相邻两点画线(水平或垂直)
  • 当一条线使某个方格四边闭合时,画该线的玩家占领此方格(+1分)
  • 占领方格的玩家获得额外一次行动机会(连击)
  • 游戏结束当所有线都被画完,占领方格多者获胜

状态表示

// 棋盘由两种边组成
- horizontalEdges[N+1][M]:   // 水平边,(i,j)连接点(i,j)和(i,j+1)
- verticalEdges[N][M+1]:     // 垂直边,(i,j)连接点(i,j)和(i+1,j)
- boxes[N][M]:               // 每个方格的归属:0=未占,1=玩家1,2=玩家2

核心算法:Minimax with Alpha-Beta Pruning

Minimax原理

Minimax是一种对抗搜索算法,假设对手也会采取最优策略:
– Max层(我方回合):选择使我方收益最大的行动
– Min层(对方回合):选择使我方收益最小的行动(即对方收益最大)

递归评估博弈树,叶节点的价值由终局评估函数给出。

Alpha-Beta剪枝

在Minimax基础上增加剪枝:
– Alpha:Max节点目前能保证的最小得分下界
– Beta:Min节点目前能保证的最大得分上界
– 当 $\alpha \geq \beta$ 时,该分支无需继续搜索

评估函数设计

点格棋的评估需要综合考虑:
1. 当前得分差:已占领方格数之差
2. 链与环的结构:控制长链/环的玩家通常有优势
3. 可移动性:剩余可选边的数量
4. 关键边:可能送给对方方格的”毒药”边

Java实现

项目结构

dots-and-boxes/
├── src/
│   └── com/
│       └── dotsboxes/
│           ├── GameState.java          // 游戏状态
│           ├── Move.java               // 移动操作
│           ├── Evaluator.java          // 评估函数
│           ├── MinimaxAI.java          // Minimax AI
│           ├── GameBoard.java          // 游戏界面
│           └── Main.java               // 入口

游戏状态与移动

package com.dotsboxes;

import java.util.*;

/**
 * 移动操作
 */
class Move {
    enum Direction { HORIZONTAL, VERTICAL }

    final Direction direction;
    final int row;
    final int col;

    Move(Direction direction, int row, int col) {
        this.direction = direction;
        this.row = row;
        this.col = col;
    }

    @Override
    public String toString() {
        return String.format("%s(%d,%d)", direction, row, col);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Move move = (Move) o;
        return row == move.row && col == move.col && direction == move.direction;
    }

    @Override
    public int hashCode() {
        return Objects.hash(direction, row, col);
    }
}

/**
 * 游戏状态
 */
class GameState {
    final int rows;      // 方格行数
    final int cols;      // 方格列数
    final boolean[][] hEdges;  // 水平边
    final boolean[][] vEdges;  // 垂直边
    final int[][] boxes;       // 方格归属:0=空,1=玩家1,2=玩家2

    int currentPlayer;   // 当前行动玩家:1或2
    int score1;          // 玩家1得分
    int score2;          // 玩家2得分
    int movesMade;       // 已进行的移动数

    GameState(int rows, int cols) {
        this.rows = rows;
        this.cols = cols;
        this.hEdges = new boolean[rows + 1][cols];
        this.vEdges = new boolean[rows][cols + 1];
        this.boxes = new int[rows][cols];
        this.currentPlayer = 1;
        this.score1 = 0;
        this.score2 = 0;
        this.movesMade = 0;
    }

    /**
     * 深拷贝
     */
    GameState(GameState other) {
        this.rows = other.rows;
        this.cols = other.cols;
        this.hEdges = new boolean[rows + 1][cols];
        this.vEdges = new boolean[rows][cols + 1];
        this.boxes = new int[rows][cols];

        for (int i = 0; i <= rows; i++) {
            System.arraycopy(other.hEdges[i], 0, this.hEdges[i], 0, cols);
        }
        for (int i = 0; i < rows; i++) {
            System.arraycopy(other.vEdges[i], 0, this.vEdges[i], 0, cols + 1);
            System.arraycopy(other.boxes[i], 0, this.boxes[i], 0, cols);
        }

        this.currentPlayer = other.currentPlayer;
        this.score1 = other.score1;
        this.score2 = other.score2;
        this.movesMade = other.movesMade;
    }

    /**
     * 生成所有合法移动
     */
    List<Move> generateMoves() {
        List<Move> moves = new ArrayList<>();

        // 水平边
        for (int i = 0; i <= rows; i++) {
            for (int j = 0; j < cols; j++) {
                if (!hEdges[i][j]) {
                    moves.add(new Move(Move.Direction.HORIZONTAL, i, j));
                }
            }
        }

        // 垂直边
        for (int i = 0; i < rows; i++) {
            for (int j = 0; j <= cols; j++) {
                if (!vEdges[i][j]) {
                    moves.add(new Move(Move.Direction.VERTICAL, i, j));
                }
            }
        }

        return moves;
    }

    /**
     * 应用移动,返回占领的方格数
     */
    int applyMove(Move move) {
        int boxesCaptured = 0;

        if (move.direction == Move.Direction.HORIZONTAL) {
            hEdges[move.row][move.col] = true;
            // 检查下方的方格(如果存在)
            if (move.row < rows) {
                if (isBoxComplete(move.row, move.col)) {
                    boxes[move.row][move.col] = currentPlayer;
                    boxesCaptured++;
                }
            }
            // 检查上方的方格(如果存在)
            if (move.row > 0) {
                if (isBoxComplete(move.row - 1, move.col)) {
                    boxes[move.row - 1][move.col] = currentPlayer;
                    boxesCaptured++;
                }
            }
        } else {
            vEdges[move.row][move.col] = true;
            // 检查右方的方格(如果存在)
            if (move.col < cols) {
                if (isBoxComplete(move.row, move.col)) {
                    boxes[move.row][move.col] = currentPlayer;
                    boxesCaptured++;
                }
            }
            // 检查左方的方格(如果存在)
            if (move.col > 0) {
                if (isBoxComplete(move.row, move.col - 1)) {
                    boxes[move.row][move.col - 1] = currentPlayer;
                    boxesCaptured++;
                }
            }
        }

        // 更新得分
        if (currentPlayer == 1) score1 += boxesCaptured;
        else score2 += boxesCaptured;

        movesMade++;

        // 如果没有占领方格,切换玩家
        if (boxesCaptured == 0) {
            currentPlayer = (currentPlayer == 1) ? 2 : 1;
        }

        return boxesCaptured;
    }

    /**
     * 检查指定方格是否四边闭合
     */
    private boolean isBoxComplete(int r, int c) {
        return hEdges[r][c] && hEdges[r + 1][c] && vEdges[r][c] && vEdges[r][c + 1];
    }

    /**
     * 检查游戏是否结束
     */
    boolean isGameOver() {
        return movesMade == (rows + 1) * cols + rows * (cols + 1);
    }

    /**
     * 获取当前玩家视角的得分差
     */
    int getScoreDiff() {
        return (currentPlayer == 1) ? (score1 - score2) : (score2 - score1);
    }

    @Override
    public String toString() {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i <= rows; i++) {
            // 打印点行
            for (int j = 0; j <= cols; j++) {
                sb.append("+");
                if (j < cols) {
                    sb.append(hEdges[i][j] ? "---" : "   ");
                }
            }
            sb.append("\n");

            // 打印方格行
            if (i < rows) {
                for (int j = 0; j <= cols; j++) {
                    if (j < cols) {
                        sb.append(vEdges[i][j] ? "|" : " ");
                        if (boxes[i][j] == 0) sb.append("   ");
                        else if (boxes[i][j] == 1) sb.append(" 1 ");
                        else sb.append(" 2 ");
                    } else {
                        sb.append(vEdges[i][j] ? "|" : " ");
                    }
                }
                sb.append("\n");
            }
        }
        sb.append("Score: P1=").append(score1).append(" P2=").append(score2);
        sb.append(" Current=").append(currentPlayer);
        return sb.toString();
    }
}

评估函数

package com.dotsboxes;

/**
 * 评估函数
 * 从当前行动玩家的视角评估局面
 */
public class Evaluator {

    /**
     * 综合评估函数
     * 正值表示对当前玩家有利
     */
    public static int evaluate(GameState state) {
        if (state.isGameOver()) {
            int diff = state.score1 - state.score2;
            return (state.currentPlayer == 1) ? diff : -diff;
        }

        int score = 0;

        // 1. 直接得分差(权重最高)
        score += state.getScoreDiff() * 100;

        // 2. 链与环分析(控制链的玩家有巨大优势)
        score += analyzeChains(state) * 10;

        // 3. 三方格威胁(即将完成3边的方格)
        score += analyzeThreats(state) * 5;

        // 4. 可完成方格数估计
        score += estimateCapturableBoxes(state) * 3;

        return score;
    }

    /**
     * 分析链结构
     * 长链和环的控制权是点格棋的高级策略核心
     */
    private static int analyzeChains(GameState state) {
        int chainValue = 0;
        boolean[][] visited = new boolean[state.rows][state.cols];

        for (int r = 0; r < state.rows; r++) {
            for (int c = 0; c < state.cols; c++) {
                if (state.boxes[r][c] != 0 || visited[r][c]) continue;

                int edges = countBoxEdges(state, r, c);
                if (edges == 0 || edges == 1) {
                    // 可能是链的起点,追踪链
                    int chainLen = traceChain(state, r, c, visited);
                    if (chainLen >= 3) {
                        // 长链的控制权分析:通常让对手先打开长链的玩家获利
                        chainValue += (chainLen % 2 == 0) ? chainLen : -chainLen;
                    }
                }
            }
        }

        return chainValue;
    }

    private static int countBoxEdges(GameState state, int r, int c) {
        int count = 0;
        if (state.hEdges[r][c]) count++;
        if (state.hEdges[r + 1][c]) count++;
        if (state.vEdges[r][c]) count++;
        if (state.vEdges[r][c + 1]) count++;
        return count;
    }

    private static int traceChain(GameState state, int startR, int startC, boolean[][] visited) {
        // BFS追踪开放的方格链
        int len = 0;
        java.util.Queue<int[]> queue = new java.util.LinkedList<>();
        queue.add(new int[]{startR, startC});

        while (!queue.isEmpty()) {
            int[] pos = queue.poll();
            int r = pos[0], c = pos[1];
            if (visited[r][c]) continue;
            visited[r][c] = true;
            len++;

            // 检查四个方向的相邻方格
            int[][] dirs = {{-1,0}, {1,0}, {0,-1}, {0,1}};
            for (int[] d : dirs) {
                int nr = r + d[0], nc = c + d[1];
                if (nr >= 0 && nr < state.rows && nc >= 0 && nc < state.cols
                    && state.boxes[nr][nc] == 0 && !visited[nr][nc]) {
                    // 检查两个方格之间是否已有边
                    if (hasSharedEdge(state, r, c, nr, nc)) {
                        // 如果共享边已经画好,它们是连通的
                        // 但如果共享边未画,需要检查是否只能通过这条边连接
                    }
                    queue.add(new int[]{nr, nc});
                }
            }
        }

        return len;
    }

    private static boolean hasSharedEdge(GameState state, int r1, int c1, int r2, int c2) {
        // 简化实现:假设相邻方格总是连通的
        return true;
    }

    /**
     * 分析威胁方格(已有3边的方格)
     */
    private static int analyzeThreats(GameState state) {
        int threats = 0;
        for (int r = 0; r < state.rows; r++) {
            for (int c = 0; c < state.cols; c++) {
                if (state.boxes[r][c] != 0) continue;
                if (countBoxEdges(state, r, c) == 3) {
                    // 这个方格即将被完成,下一位玩家会占领它
                    threats--;
                }
            }
        }
        return threats;
    }

    /**
     * 估计双方还能占领的方格数
     */
    private static int estimateCapturableBoxes(GameState state) {
        int capturable = 0;
        for (int r = 0; r < state.rows; r++) {
            for (int c = 0; c < state.cols; c++) {
                if (state.boxes[r][c] != 0) continue;
                int edges = countBoxEdges(state, r, c);
                if (edges == 2) capturable += 1;
            }
        }
        return capturable;
    }
}

Minimax AI with Alpha-Beta

package com.dotsboxes;

import java.util.*;

/**
 * Minimax AI with Alpha-Beta剪枝
 */
public class MinimaxAI {
    private final int maxDepth;
    private int nodesEvaluated = 0;

    public MinimaxAI(int maxDepth) {
        this.maxDepth = maxDepth;
    }

    /**
     * 选择最佳移动
     */
    public Move findBestMove(GameState state) {
        List<Move> moves = state.generateMoves();
        if (moves.isEmpty()) return null;

        // 优先尝试可能得分的移动(移动排序优化)
        moves.sort((m1, m2) -> {
            GameState s1 = new GameState(state);
            int c1 = s1.applyMove(m1);
            GameState s2 = new GameState(state);
            int c2 = s2.applyMove(m2);
            return Integer.compare(c2, c1);  // 得分高的优先
        });

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

        for (Move move : moves) {
            GameState next = new GameState(state);
            next.applyMove(move);

            int value = minimax(next, maxDepth - 1, Integer.MIN_VALUE, Integer.MAX_VALUE, false);

            if (value > bestValue) {
                bestValue = value;
                bestMove = move;
            }
        }

        return bestMove;
    }

    /**
     * Minimax递归搜索
     * @param isMaximizing true表示当前是Max层(我方回合)
     */
    private int minimax(GameState state, int depth, int alpha, int beta, boolean isMaximizing) {
        nodesEvaluated++;

        // 终止条件
        if (depth == 0 || state.isGameOver()) {
            return Evaluator.evaluate(state);
        }

        List<Move> moves = state.generateMoves();
        if (moves.isEmpty()) {
            return Evaluator.evaluate(state);
        }

        if (isMaximizing) {
            int maxEval = Integer.MIN_VALUE;
            for (Move move : moves) {
                GameState next = new GameState(state);
                next.applyMove(move);

                // 如果占领了方格,仍然是当前玩家的回合(连击)
                boolean stillMyTurn = (next.currentPlayer == state.currentPlayer);

                int eval = minimax(next, depth - 1, alpha, beta, stillMyTurn);
                maxEval = Math.max(maxEval, eval);
                alpha = Math.max(alpha, eval);
                if (beta <= alpha) break;  // Beta剪枝
            }
            return maxEval;
        } else {
            int minEval = Integer.MAX_VALUE;
            for (Move move : moves) {
                GameState next = new GameState(state);
                next.applyMove(move);

                boolean stillOpponentTurn = (next.currentPlayer != state.currentPlayer);

                int eval = minimax(next, depth - 1, alpha, beta, stillOpponentTurn);
                minEval = Math.min(minEval, eval);
                beta = Math.min(beta, eval);
                if (beta <= alpha) break;  // Alpha剪枝
            }
            return minEval;
        }
    }

    public int getNodesEvaluated() { return nodesEvaluated; }
}

演示入口

package com.dotsboxes;

/**
 * 点格棋AI演示
 */
public class Main {
    public static void main(String[] args) {
        // 3x3 点格棋
        GameState state = new GameState(3, 3);
        MinimaxAI ai = new MinimaxAI(6);  // 搜索深度6

        System.out.println("=== 点格棋 Minimax AI ===\n");
        System.out.println("初始棋盘:");
        System.out.println(state);

        // AI vs 随机玩家 演示前10步
        java.util.Random random = new java.util.Random(123);

        for (int turn = 0; turn < 10 && !state.isGameOver(); turn++) {
            Move move;
            String playerName;

            if (state.currentPlayer == 1) {
                // AI移动
                playerName = "AI(玩家1)";
                move = ai.findBestMove(state);
            } else {
                // 随机移动
                playerName = "随机(玩家2)";
                var moves = state.generateMoves();
                move = moves.get(random.nextInt(moves.size()));
            }

            if (move == null) break;

            int captured = state.applyMove(move);
            System.out.println("\n" + playerName + " 移动: " + move +
                              (captured > 0 ? " [占领" + captured + "格!]" : ""));
            System.out.println(state);
        }

        System.out.println("\n评估节点数:" + ai.getNodesEvaluated());
    }
}

算法优化策略

1. 移动排序(Move Ordering)

优先尝试可能占领方格的移动,可显著提高Alpha-Beta剪枝效率。

2. 置换表(Transposition Table)

使用Zobrist哈希缓存已评估状态,避免重复计算。

3. 迭代加深(Iterative Deepening)

从浅层到深层逐步搜索,配合时间控制。

4. 终局数据库

对剩余边数很少的局面预计算最优解。

复杂度分析

状态空间

总边数为 $(N+1) \times M + N \times (M+1)$。对于 $3 \times 3$ 棋盘,共24条边,状态空间上界 $2^{24} \approx 1600$ 万。

搜索复杂度

  • Minimax:$O(b^d)$,$b$ 为分支因子,$d$ 为深度
  • Alpha-Beta剪枝:理想情况下 $O(b^{d/2})$,移动排序良好时接近此界限

总结

点格棋AI展示了博弈论中的核心概念:Minimax搜索、Alpha-Beta剪枝、评估函数设计。与简单的贪”吃”策略不同,高级点格棋玩法需要牺牲短期利益以控制长链——这是典型的组合博弈论(Combinatorial Game Theory)现象。这些技术同样适用于 chess、围棋、五子棋等更复杂的对抗性游戏AI。