每日算法 — 使用java实现中国象棋:迭代深化搜索与置换表优化

中国象棋是中华民族传承千年的智力竞技项目,其复杂的局面变化和深远的策略博弈,为人工智能研究提供了绝佳的试验场。本文将用Java实现一个带AI的中国象棋程序,核心讲解迭代深化搜索(Iterative Deepening)如何在有限时间内动态扩展搜索深度,以及置换表(Transposition Table)如何通过缓存已搜索局面来避免重复计算,配合Alpha-Beta剪枝实现高效的博弈决策。

一、中国象棋AI的核心挑战

与井字棋、五子棋等游戏相比,中国象棋的搜索空间极其庞大:

  • 分支因子高:平均每回合有约40种合法走法
  • 局面复杂:10×9的棋盘、7种棋子类型、各不相同的走法规则
  • 深度需求大:要达到人类入门水平,通常需要搜索6-8层

直接枚举所有可能的走法序列显然不可行。因此,我们需要一套高效的搜索框架:迭代深化控制搜索节奏,置换表消除冗余计算,Alpha-Beta剪枝大幅削减搜索树。

二、局面表示与走法生成

2.1 棋盘编码

使用一维整数数组表示10行×9列的棋盘,每个位置存储棋子类型(正值为红方,负值为黑方):

public class ChineseChess {
    // 棋子类型常量
    public static final int EMPTY = 0;
    public static final int R_KING = 1, R_ADVISOR = 2, R_BISHOP = 3;
    public static final int R_KNIGHT = 4, R_ROOK = 5, R_CANNON = 6, R_PAWN = 7;
    public static final int B_KING = -1, B_ADVISOR = -2, B_BISHOP = -3;
    public static final int B_KNIGHT = -4, B_ROOK = -5, B_CANNON = -6, B_PAWN = -7;

    // 一维棋盘: index = row * 9 + col, 共90个位置
    private int[] board = new int[90];
    private boolean redTurn = true; // 当前轮到红方走棋

    public ChineseChess() {
        initBoard();
    }

    // 初始化标准开局
    private void initBoard() {
        int[] init = {
            B_ROOK, B_KNIGHT, B_BISHOP, B_ADVISOR, B_KING,
            B_ADVISOR, B_BISHOP, B_KNIGHT, B_ROOK,
            EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY,
            EMPTY, B_CANNON, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, B_CANNON, EMPTY,
            B_PAWN, EMPTY, B_PAWN, EMPTY, B_PAWN, EMPTY, B_PAWN, EMPTY, B_PAWN,
            EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY,
            EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY,
            R_PAWN, EMPTY, R_PAWN, EMPTY, R_PAWN, EMPTY, R_PAWN, EMPTY, R_PAWN,
            EMPTY, R_CANNON, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, R_CANNON, EMPTY,
            EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY,
            R_ROOK, R_KNIGHT, R_BISHOP, R_ADVISOR, R_KING,
            R_ADVISOR, R_BISHOP, R_KNIGHT, R_ROOK
        };
        System.arraycopy(init, 0, board, 0, 90);
    }
}

2.2 走法生成器

每种棋子的走法规则各不相同。以”马走日”为例,需要检查”蹩马腿”:

public class MoveGenerator {
    // 四个方向:上、下、左、右(用于马腿检查等)
    private static final int[] DIR4 = {-9, 9, -1, 1};
    // 马的可跳位置(相对当前位置的偏移)
    private static final int[][] KNIGHT_MOVES = {
        {-17, -15}, // 上方向两格后的左右
        {17, 15},   // 下方向两格后的左右
        {-11, -7},  // 左方向两格后的上下
        {11, 7}     // 右方向两格后的上下
    };
    private static final int[] KNIGHT_LEG = {-9, 9, -1, 1}; // 马腿位置偏移

    /**
     * 生成所有合法走法
     * @param board 当前棋盘
     * @param redTurn 是否红方走棋
     * @return 走法列表,每个走法用 from<<8 | to 编码
     */
    public List<Integer> generateAllMoves(int[] board, boolean redTurn) {
        List<Integer> moves = new ArrayList<>();
        for (int from = 0; from < 90; from++) {
            int piece = board[from];
            if (piece == EMPTY) continue;
            if (redTurn && piece < 0) continue; // 红方回合跳过黑棋
            if (!redTurn && piece > 0) continue; // 黑方回合跳过红棋

            switch (Math.abs(piece)) {
                case 1: generateKingMoves(board, from, moves); break;
                case 2: generateAdvisorMoves(board, from, moves); break;
                case 3: generateBishopMoves(board, from, moves); break;
                case 4: generateKnightMoves(board, from, moves); break;
                case 5: generateRookMoves(board, from, moves); break;
                case 6: generateCannonMoves(board, from, moves); break;
                case 7: generatePawnMoves(board, from, moves, redTurn); break;
            }
        }
        return moves;
    }

    private void generateKnightMoves(int[] board, int from, List<Integer> moves) {
        int row = from / 9, col = from % 9;
        for (int i = 0; i < 4; i++) {
            int legPos = from + KNIGHT_LEG[i];
            // 检查马腿是否在棋盘内且为空
            if (legPos < 0 || legPos >= 90) continue;
            int legRow = legPos / 9, legCol = legPos % 9;
            // 马腿必须与马横向或纵向相邻(防跨行)
            if (Math.abs(legRow - row) + Math.abs(legCol - col) != 1) continue;
            if (board[legPos] != EMPTY) continue; // 被蹩马腿

            for (int offset : KNIGHT_MOVES[i]) {
                int to = from + offset;
                if (to < 0 || to >= 90) continue;
                int toRow = to / 9, toCol = to % 9;
                // 确保走的是"日"字(横向2纵向1或横向1纵向2)
                if (Math.abs(toRow - row) == 2 && Math.abs(toCol - col) == 1) {
                    if (isValidTarget(board, from, to)) {
                        moves.add((from << 8) | to);
                    }
                } else if (Math.abs(toRow - row) == 1 && Math.abs(toCol - col) == 2) {
                    if (isValidTarget(board, from, to)) {
                        moves.add((from << 8) | to);
                    }
                }
            }
        }
    }

    private boolean isValidTarget(int[] board, int from, int to) {
        int fromPiece = board[from];
        int toPiece = board[to];
        if (toPiece == EMPTY) return true;
        // 不能吃己方棋子
        return (fromPiece > 0 && toPiece < 0) || (fromPiece < 0 && toPiece > 0);
    }
}

注:受篇幅限制,此处仅展示马的走法生成。完整的实现还需包含将、士、象、车、炮、兵的规则校验,核心逻辑均为遍历合法位置并检查边界与障碍。

三、局面评估函数

评估函数是AI的”棋力”核心,它将棋盘状态量化为一个数值(红方优势为正,黑方优势为负)。

public class Evaluator {
    // 基础棋子价值表
    private static final int[] PIECE_VALUE = {0, 10000, 250, 250, 400, 900, 450, 100};

    // 红方兵的位置加分表(10行×9列,越靠近对方九宫、过河后越有价值)
    private static final int[] PAWN_POS_BONUS = {
         0,  0,  0,  0,  0,  0,  0,  0,  0,
         0,  0,  0,  0,  0,  0,  0,  0,  0,
         0,  0,  0,  0,  0,  0,  0,  0,  0,
         0,  0,  0,  0,  0,  0,  0,  0,  0,
         0,  0,  0,  0,  0,  0,  0,  0,  0,
        10,  0, 10,  0, 20,  0, 10,  0, 10,
        20,  0, 20,  0, 30,  0, 20,  0, 20,
        30,  0, 30,  0, 40,  0, 30,  0, 30,
        40,  0, 40,  0, 50,  0, 40,  0, 40,
        50,  0, 50,  0, 60,  0, 50,  0, 50
    };

    /**
     * 评估当前局面
     * @return 正值表示红方优势,负值表示黑方优势
     */
    public int evaluate(int[] board) {
        int score = 0;
        for (int pos = 0; pos < 90; pos++) {
            int piece = board[pos];
            if (piece == 0) continue;
            int absType = Math.abs(piece);
            int value = PIECE_VALUE[absType];

            // 添加位置加分
            if (absType == 7) { // 兵/卒
                value += (piece > 0) ? PAWN_POS_BONUS[pos] 
                                     : PAWN_POS_BONUS[89 - pos];
            }

            // 机动性加分:能走的位置越多,棋子越灵活
            value += mobilityBonus(board, pos, piece);

            score += (piece > 0) ? value : -value;
        }
        return score;
    }

    private int mobilityBonus(int[] board, int pos, int piece) {
        // 简化版:仅统计该棋子的合法走法数量作为机动性指标
        // 实际工程中可用更精细的启发式
        return 0;
    }
}

四、Alpha-Beta剪枝搜索

Alpha-Beta剪枝是博弈树搜索的核心优化。通过维护两个阈值 alpha(当前能确保的最大收益)和 beta(对手能容忍的最小损失),剪掉不可能影响最终决策的分支。

public class SearchEngine {
    private Evaluator evaluator = new Evaluator();
    private MoveGenerator moveGen = new MoveGenerator();
    private TranspositionTable tt = new TranspositionTable();
    private int nodesSearched = 0;

    /**
     * Alpha-Beta搜索(负极大值形式)
     * @param board 棋盘
     * @param depth 剩余搜索深度
     * @param alpha 当前最大保障值
     * @param beta  当前最小上限值
     * @param redTurn 当前行动方
     * @return 该局面的评估值
     */
    public int alphaBeta(int[] board, int depth, int alpha, int beta, boolean redTurn) {
        nodesSearched++;

        // 终止条件:到达叶子节点
        if (depth == 0) {
            return evaluator.evaluate(board);
        }

        // 生成所有合法走法
        List<Integer> moves = moveGen.generateAllMoves(board, redTurn);
        if (moves.isEmpty()) {
            // 无子可动:被将死或逼和
            return redTurn ? -30000 : 30000;
        }

        // 尝试置换表命中(见第五节)
        long hash = ZobristHash.compute(board, redTurn);
        TTEntry entry = tt.probe(hash);
        if (entry != null && entry.depth >= depth) {
            if (entry.flag == TTEntry.EXACT) return entry.score;
            if (entry.flag == TTEntry.LOWER && entry.score >= beta) return entry.score;
            if (entry.flag == TTEntry.UPPER && entry.score <= alpha) return entry.score;
        }

        // 走法排序:优先尝试历史得分高、吃子走法(提升剪枝效率)
        sortMoves(moves, board, redTurn);

        int bestScore = redTurn ? Integer.MIN_VALUE : Integer.MAX_VALUE;
        int bestMove = -1;
        int origAlpha = alpha, origBeta = beta;

        for (int move : moves) {
            int from = move >> 8, to = move & 0xFF;
            int captured = board[to];

            // 执行走法
            board[to] = board[from];
            board[from] = EMPTY;

            // 递归搜索
            int score = alphaBeta(board, depth - 1, alpha, beta, !redTurn);

            // 撤销走法
            board[from] = board[to];
            board[to] = captured;

            if (redTurn) {
                // 红方选最大值
                if (score > bestScore) {
                    bestScore = score;
                    bestMove = move;
                }
                alpha = Math.max(alpha, score);
                if (alpha >= beta) break; // Beta剪枝
            } else {
                // 黑方选最小值
                if (score < bestScore) {
                    bestScore = score;
                    bestMove = move;
                }
                beta = Math.min(beta, score);
                if (alpha >= beta) break; // Alpha剪枝
            }
        }

        // 保存到置换表
        int flag;
        if (bestScore <= origAlpha) flag = TTEntry.UPPER;
        else if (bestScore >= origBeta) flag = TTEntry.LOWER;
        else flag = TTEntry.EXACT;
        tt.store(hash, depth, bestScore, flag, bestMove);

        return bestScore;
    }

    private void sortMoves(List<Integer> moves, int[] board, boolean redTurn) {
        // 简化排序:吃子走法优先(MVV-LVA启发式)
        moves.sort((a, b) -> {
            int va = Math.abs(board[a & 0xFF]);
            int vb = Math.abs(board[b & 0xFF]);
            return vb - va; // 吃价值高的棋子优先
        });
    }
}

五、迭代深化搜索

核心思想:从深度1开始逐层加深搜索,如果在时限内完成当前深度,则继续加深;若超时,则回退到上一层已完成的深度结果。

这样做的好处是:
1. 时间可控:随时可以被中断,保证返回可用结果
2. 走法排序优化:浅层搜索结果可用于深层搜索的走法排序(内部迭代深化)
3. 置换表预热:浅层搜索填充置换表,深层搜索命中率更高

public class IterativeDeepening {
    private SearchEngine engine = new SearchEngine();
    private long timeLimitMs = 5000; // 每步思考5秒

    /**
     * 迭代深化搜索主入口
     * @param board 当前棋盘
     * @param redTurn 是否红方
     * @return 最佳走法 (from<<8 | to)
     */
    public int searchBestMove(int[] board, boolean redTurn) {
        long startTime = System.currentTimeMillis();
        int bestMove = -1;

        // 从深度1开始逐层加深
        for (int depth = 1; ; depth++) {
            long elapsed = System.currentTimeMillis() - startTime;
            long remaining = timeLimitMs - elapsed;

            // 若剩余时间不足,停止深化
            if (remaining < 200) break;

            // 设置该深度的思考时限(简单的比例分配)
            engine.setTimeLimit(remaining / 3);

            int move = engine.searchRoot(board, depth, redTurn);
            if (move != -1) {
                bestMove = move;
                System.out.println("深度 " + depth + " 完成,最佳走法: " + 
                    formatMove(move));
            }

            // 检查总时间
            if (System.currentTimeMillis() - startTime >= timeLimitMs) break;
        }

        return bestMove;
    }

    private String formatMove(int move) {
        int from = move >> 8, to = move & 0xFF;
        return String.format("%c%d->%c%d", 
            (char)('A' + from % 9), from / 9 + 1,
            (char)('A' + to % 9), to / 9 + 1);
    }
}

六、置换表与Zobrist哈希

置换表(Transposition Table)是象棋AI最重要的优化手段之一。相同的局面可能通过不同的走法序列到达(”置换”),第二次遇到时直接复用之前的搜索结果。

Zobrist哈希为每个局面生成一个几乎唯一的64位哈希值,作为置换表的键:

public class ZobristHash {
    private static final long[][][] zobristTable = new long[90][14][1];
    private static final long sideToMoveHash;
    private static final Random rand = new Random(0x192837465L);

    static {
        // 为每个位置、每种棋子预生成随机数
        for (int pos = 0; pos < 90; pos++) {
            for (int piece = 0; piece < 14; piece++) {
                zobristTable[pos][piece][0] = rand.nextLong();
            }
        }
        sideToMoveHash = rand.nextLong();
    }

    /** 计算当前局面的哈希值 */
    public static long compute(int[] board, boolean redTurn) {
        long hash = redTurn ? sideToMoveHash : 0;
        for (int pos = 0; pos < 90; pos++) {
            int piece = board[pos];
            if (piece != 0) {
                // 将棋子类型映射到 0-13 的索引
                int idx = piece > 0 ? piece : 7 - piece;
                hash ^= zobristTable[pos][idx][0];
            }
        }
        return hash;
    }
}

/** 置换表条目 */
public class TTEntry {
    public static final int EXACT = 0, LOWER = 1, UPPER = 2;

    long hash;      // 完整哈希值(用于校验碰撞)
    int depth;      // 搜索深度
    int score;      // 评估分数
    int flag;       // 精确值/下界/上界
    int bestMove;   // 最佳走法

    public TTEntry(long hash, int depth, int score, int flag, int bestMove) {
        this.hash = hash; this.depth = depth; 
        this.score = score; this.flag = flag; this.bestMove = bestMove;
    }
}

/** 固定大小的置换表(采用始终替换策略) */
public class TranspositionTable {
    private static final int SIZE = 1 << 20; // 约100万条目
    private TTEntry[] table = new TTEntry[SIZE];

    public TTEntry probe(long hash) {
        int idx = (int)(hash & (SIZE - 1));
        TTEntry e = table[idx];
        return (e != null && e.hash == hash) ? e : null;
    }

    public void store(long hash, int depth, int score, int flag, int bestMove) {
        int idx = (int)(hash & (SIZE - 1));
        // 始终替换:新结果直接覆盖(简单但有效)
        table[idx] = new TTEntry(hash, depth, score, flag, bestMove);
    }
}

七、完整调用示例

public class ChessGame {
    public static void main(String[] args) {
        ChineseChess chess = new ChineseChess();
        IterativeDeepening id = new IterativeDeepening();

        // 模拟一个对局循环
        for (int turn = 0; turn < 10; turn++) {
            System.out.println("\n=== 第 " + (turn + 1) + " 回合 ===");
            int bestMove = id.searchBestMove(chess.getBoard(), chess.isRedTurn());
            if (bestMove == -1) {
                System.out.println("无合法走法,对局结束");
                break;
            }
            chess.makeMove(bestMove);
            chess.printBoard();
        }
    }
}

八、复杂度分析

组件 时间复杂度 空间复杂度 优化效果
走法生成 O(b) 每节点 O(b) 基础操作
Alpha-Beta剪枝 最优O(b^(d/2)) O(d) 递归栈 减少约一半搜索层数
迭代深化 O(b^d) 最坏情况 O(b^d) 置换表 时间可控,走法排序更优
置换表 O(1) 查询/存储 O(TT_SIZE) 减少30%-50%重复搜索

其中 b 为平均分支因子(约40),d 为搜索深度。

九、进阶优化方向

  1. 历史启发(History Heuristic):为在浅层搜索中表现好的走法赋予更高优先级,深层搜索时优先尝试
  2. 空着裁剪(Null Move Pruning):在己方回合尝试”不走棋”,若仍能让对手无法逆转,则大幅裁剪当前分支
  3. 静态搜索(Quiescence Search):在叶子节点只搜索吃子走法,避免”地平线效应”
  4. 开局库(Opening Book):预存经典开局走法,前15回合直接查表

总结

本文从中国象棋的局面表示出发,依次实现了走法生成评估函数Alpha-Beta剪枝,并重点讲解了迭代深化搜索如何在有限时间内动态分配计算资源,以及置换表如何通过Zobrist哈希消除重复局面的冗余计算。这两个技术的结合,使得Java实现的中国象棋AI能够在普通PC上以每秒搜索数十万节点的能力,达到业余爱好者的对弈水平。理解这些机制后,读者可以进一步探索历史启发、空着裁剪等进阶技术,不断提升AI棋力。