中国象棋是中华民族传承千年的智力竞技项目,其复杂的局面变化和深远的策略博弈,为人工智能研究提供了绝佳的试验场。本文将用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 为搜索深度。
九、进阶优化方向
- 历史启发(History Heuristic):为在浅层搜索中表现好的走法赋予更高优先级,深层搜索时优先尝试
- 空着裁剪(Null Move Pruning):在己方回合尝试”不走棋”,若仍能让对手无法逆转,则大幅裁剪当前分支
- 静态搜索(Quiescence Search):在叶子节点只搜索吃子走法,避免”地平线效应”
- 开局库(Opening Book):预存经典开局走法,前15回合直接查表
总结
本文从中国象棋的局面表示出发,依次实现了走法生成、评估函数、Alpha-Beta剪枝,并重点讲解了迭代深化搜索如何在有限时间内动态分配计算资源,以及置换表如何通过Zobrist哈希消除重复局面的冗余计算。这两个技术的结合,使得Java实现的中国象棋AI能够在普通PC上以每秒搜索数十万节点的能力,达到业余爱好者的对弈水平。理解这些机制后,读者可以进一步探索历史启发、空着裁剪等进阶技术,不断提升AI棋力。