引言
井字棋(Tic-Tac-Toe)是最简单的完全信息博弈游戏之一,但其算法实现却涵盖了博弈树搜索的核心技术。本文将用Java实现一个带Alpha-Beta剪枝和置换表(Transposition Table)优化的井字棋AI。Alpha-Beta剪枝通过维护当前最优上下界来剪掉不可能影响最终决策的分支,而置换表则通过缓存已搜索过的状态避免重复计算,两者结合可将搜索效率提升数倍。
井字棋的博弈树规模
井字棋的博弈树总节点数约为 $9! \approx 3.6 \times 10^5$。虽然规模不大,但非常适合演示核心算法:
- Minimax:需要评估所有节点,约36万个。
- Alpha-Beta剪枝:在理想排序下只需评估 $O(b^{d/2})$ 个节点,约2万个。
- 置换表:由于井字棋存在旋转和翻转对称性,实际独立状态仅约765个,置换表几乎可消除所有冗余搜索。
Alpha-Beta剪枝原理
Alpha-Beta剪枝在Minimax框架上增加了两个参数:
– Alpha ($\alpha$):当前Max玩家能保证的最小得分(下界)。
– Beta ($\beta$):当前Min玩家能保证的最大得分(上界)。
剪枝规则:
– 在Max节点,如果某子节点的返回值 $v \geq \beta$,则停止搜索该节点的其余子节点(Min玩家不会选择这条路径)。
– 在Min节点,如果某子节点的返回值 $v \leq \alpha$,则停止搜索该节点的其余子节点(Max玩家不会选择这条路径)。
置换表设计
置换表使用Zobrist哈希将棋盘状态映射为64位哈希值,通过哈希表存储已评估的状态及其值:
Entry {
long hash; // Zobrist哈希值
int depth; // 搜索深度
int score; // 评估分数
int flag; // EXACT / LOWERBOUND / UPPERBOUND
int bestMove; // 最佳着法
}
由于井字棋状态极少,置换表的命中率接近100%。
Java实现
棋盘与状态表示
import java.util.*;
/**
* 井字棋棋盘状态
* 使用位运算表示:低9位表示X,高9位表示O
*/
public class TicTacToeState {
public static final int EMPTY = 0;
public static final int PLAYER_X = 1;
public static final int PLAYER_O = 2;
// 获胜组合(行、列、对角线)
public static final int[] WIN_PATTERNS = {
0b000000111, // 第0行
0b000111000, // 第1行
0b111000000, // 第2行
0b001001001, // 第0列
0b010010010, // 第1列
0b100100100, // 第2列
0b100010001, // 主对角线
0b001010100 // 副对角线
};
private int xBits; // X玩家的位掩码
private int oBits; // O玩家的位掩码
public TicTacToeState() {
this.xBits = 0;
this.oBits = 0;
}
public TicTacToeState(int xBits, int oBits) {
this.xBits = xBits;
this.oBits = oBits;
}
/**
* 在指定位置落子
*/
public TicTacToeState makeMove(int pos, int player) {
if (player == PLAYER_X) {
return new TicTacToeState(xBits | (1 << pos), oBits);
} else {
return new TicTacToeState(xBits, oBits | (1 << pos));
}
}
/**
* 检查某位置是否为空
*/
public boolean isEmpty(int pos) {
return ((xBits | oBits) & (1 << pos)) == 0;
}
/**
* 检查指定玩家是否获胜
*/
public boolean isWin(int player) {
int bits = (player == PLAYER_X) ? xBits : oBits;
for (int pattern : WIN_PATTERNS) {
if ((bits & pattern) == pattern) {
return true;
}
}
return false;
}
/**
* 检查是否为平局(棋盘已满且无胜者)
*/
public boolean isDraw() {
return (xBits | oBits) == 0b111111111;
}
/**
* 游戏是否结束
*/
public boolean isTerminal() {
return isWin(PLAYER_X) || isWin(PLAYER_O) || isDraw();
}
/**
* 获取当前玩家(X先手)
*/
public int getCurrentPlayer() {
int xCount = Integer.bitCount(xBits);
int oCount = Integer.bitCount(oBits);
return (xCount == oCount) ? PLAYER_X : PLAYER_O;
}
/**
* 获取所有合法着法
*/
public List<Integer> getLegalMoves() {
List<Integer> moves = new ArrayList<>();
int empty = ~(xBits | oBits) & 0b111111111;
for (int i = 0; i < 9; i++) {
if ((empty & (1 << i)) != 0) {
moves.add(i);
}
}
return moves;
}
/**
* 打印棋盘
*/
public void print() {
for (int r = 0; r < 3; r++) {
for (int c = 0; c < 3; c++) {
int pos = r * 3 + c;
char ch = '.';
if ((xBits & (1 << pos)) != 0) ch = 'X';
else if ((oBits & (1 << pos)) != 0) ch = 'O';
System.out.print(ch);
if (c < 2) System.out.print("|");
}
System.out.println();
if (r < 2) System.out.println("-----");
}
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof TicTacToeState)) return false;
TicTacToeState other = (TicTacToeState) o;
return xBits == other.xBits && oBits == other.oBits;
}
@Override
public int hashCode() {
return xBits * 31 + oBits;
}
public int getXBits() { return xBits; }
public int getOBits() { return oBits; }
}
Zobrist哈希与置换表
/**
* Zobrist哈希生成器
* 为每个(位置, 玩家)组合预生成随机64位值
*/
public class ZobristHasher {
private static final long[][] TABLE = new long[9][3]; // [pos][player]
static {
Random rand = new Random(12345); // 固定种子保证可重复
for (int pos = 0; pos < 9; pos++) {
for (int player = 1; player <= 2; player++) {
TABLE[pos][player] = rand.nextLong();
}
}
}
/**
* 计算状态的Zobrist哈希值
*/
public static long hash(TicTacToeState state) {
long h = 0;
int xBits = state.getXBits();
int oBits = state.getOBits();
for (int pos = 0; pos < 9; pos++) {
if ((xBits & (1 << pos)) != 0) {
h ^= TABLE[pos][TicTacToeState.PLAYER_X];
} else if ((oBits & (1 << pos)) != 0) {
h ^= TABLE[pos][TicTacToeState.PLAYER_O];
}
}
return h;
}
}
/**
* 置换表条目
*/
class TTEntry {
static final int EXACT = 0;
static final int LOWERBOUND = 1;
static final int UPPERBOUND = 2;
long hash;
int depth;
int score;
int flag;
int bestMove;
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 << 16; // 65536个槽位
private TTEntry[] table;
private int hits = 0;
private int misses = 0;
public TranspositionTable() {
this.table = new TTEntry[SIZE];
}
private int index(long hash) {
return (int)(hash & (SIZE - 1));
}
public TTEntry probe(long hash) {
int idx = index(hash);
TTEntry entry = table[idx];
if (entry != null && entry.hash == hash) {
hits++;
return entry;
}
misses++;
return null;
}
public void store(long hash, int depth, int score, int flag, int bestMove) {
int idx = index(hash);
// 总是替换策略(简化版)
table[idx] = new TTEntry(hash, depth, score, flag, bestMove);
}
public void clear() {
Arrays.fill(table, null);
hits = 0;
misses = 0;
}
public double getHitRate() {
int total = hits + misses;
return total == 0 ? 0 : (double)hits / total;
}
public int getHits() { return hits; }
public int getMisses() { return misses; }
}
Alpha-Beta剪枝搜索引擎
/**
* 井字棋AI引擎(Alpha-Beta + 置换表)
*/
public class TicTacToeAI {
private static final int WIN_SCORE = 1000;
private static final int DRAW_SCORE = 0;
private static final int LOSE_SCORE = -1000;
private TranspositionTable tt;
private int nodesEvaluated;
private int ttCuts; // 置换表剪枝次数
private int abCuts; // Alpha-Beta剪枝次数
public TicTacToeAI() {
this.tt = new TranspositionTable();
}
/**
* 评估终局状态的得分
*/
private int evaluateTerminal(TicTacToeState state) {
if (state.isWin(TicTacToeState.PLAYER_X)) return WIN_SCORE;
if (state.isWin(TicTacToeState.PLAYER_O)) return LOSE_SCORE;
return DRAW_SCORE;
}
/**
* Alpha-Beta搜索(带置换表)
* @param state 当前状态
* @param depth 剩余搜索深度
* @param alpha 当前下界
* @param beta 当前上界
* @param maximizing 是否为Max玩家(X)
* @return 当前状态的评估得分
*/
public int alphaBeta(TicTacToeState state, int depth, int alpha, int beta, boolean maximizing) {
nodesEvaluated++;
// 检查终局
if (state.isTerminal()) {
return evaluateTerminal(state);
}
// 深度耗尽(井字棋通常不需要,因为最大深度为9)
if (depth == 0) {
return DRAW_SCORE; // 井字棋终局必能确定结果
}
long hash = ZobristHasher.hash(state);
// 查询置换表
TTEntry entry = tt.probe(hash);
if (entry != null && entry.depth >= depth) {
ttCuts++;
if (entry.flag == TTEntry.EXACT) {
return entry.score;
} else if (entry.flag == TTEntry.LOWERBOUND && entry.score >= beta) {
return entry.score;
} else if (entry.flag == TTEntry.UPPERBOUND && entry.score <= alpha) {
return entry.score;
}
}
List<Integer> moves = state.getLegalMoves();
// 着法排序:优先使用置换表中的最佳着法
if (entry != null && entry.bestMove >= 0 && moves.contains(entry.bestMove)) {
moves.remove((Integer)entry.bestMove);
moves.add(0, entry.bestMove);
}
int bestMove = -1;
int originalAlpha = alpha;
int originalBeta = beta;
if (maximizing) {
int maxEval = Integer.MIN_VALUE;
for (int move : moves) {
TicTacToeState next = state.makeMove(move, TicTacToeState.PLAYER_X);
int eval = alphaBeta(next, depth - 1, alpha, beta, false);
if (eval > maxEval) {
maxEval = eval;
bestMove = move;
}
alpha = Math.max(alpha, eval);
if (beta <= alpha) {
abCuts++;
break; // Beta剪枝
}
}
// 存储到置换表
int flag;
if (maxEval <= originalAlpha) flag = TTEntry.UPPERBOUND;
else if (maxEval >= originalBeta) flag = TTEntry.LOWERBOUND;
else flag = TTEntry.EXACT;
tt.store(hash, depth, maxEval, flag, bestMove);
return maxEval;
} else {
int minEval = Integer.MAX_VALUE;
for (int move : moves) {
TicTacToeState next = state.makeMove(move, TicTacToeState.PLAYER_O);
int eval = alphaBeta(next, depth - 1, alpha, beta, true);
if (eval < minEval) {
minEval = eval;
bestMove = move;
}
beta = Math.min(beta, eval);
if (beta <= alpha) {
abCuts++;
break; // Alpha剪枝
}
}
// 存储到置换表
int flag;
if (minEval <= originalAlpha) flag = TTEntry.UPPERBOUND;
else if (minEval >= originalBeta) flag = TTEntry.LOWERBOUND;
else flag = TTEntry.EXACT;
tt.store(hash, depth, minEval, flag, bestMove);
return minEval;
}
}
/**
* 获取当前状态的最佳着法
*/
public int findBestMove(TicTacToeState state) {
nodesEvaluated = 0;
ttCuts = 0;
abCuts = 0;
int currentPlayer = state.getCurrentPlayer();
boolean maximizing = (currentPlayer == TicTacToeState.PLAYER_X);
List<Integer> moves = state.getLegalMoves();
int bestMove = -1;
int bestScore = maximizing ? Integer.MIN_VALUE : Integer.MAX_VALUE;
for (int move : moves) {
TicTacToeState next = state.makeMove(move, currentPlayer);
int score = alphaBeta(next, 9, Integer.MIN_VALUE, Integer.MAX_VALUE, !maximizing);
if (maximizing && score > bestScore) {
bestScore = score;
bestMove = move;
} else if (!maximizing && score < bestScore) {
bestScore = score;
bestMove = move;
}
}
System.out.println("搜索统计: 评估节点=" + nodesEvaluated
+ ", 置换表命中=" + tt.getHits()
+ ", AB剪枝=" + abCuts
+ ", 最佳分数=" + bestScore);
return bestMove;
}
/**
* 重置引擎状态
*/
public void reset() {
tt.clear();
}
}
主程序与人机对弈
/**
* 井字棋主程序
*/
public class TicTacToeMain {
public static void main(String[] args) {
System.out.println("=== 井字棋Alpha-Beta剪枝与置换表演示 ===\n");
TicTacToeAI ai = new TicTacToeAI();
TicTacToeState state = new TicTacToeState();
Scanner scanner = new Scanner(System.in);
System.out.println("选择游戏模式:");
System.out.println("1. AI先手 (X)");
System.out.println("2. 玩家先手 (X)");
System.out.println("3. AI vs AI (演示)");
int mode = scanner.nextInt();
int humanPlayer = (mode == 1) ? TicTacToeState.PLAYER_O : TicTacToeState.PLAYER_X;
boolean aiVsAi = (mode == 3);
while (!state.isTerminal()) {
state.print();
System.out.println();
int currentPlayer = state.getCurrentPlayer();
int move;
if (aiVsAi || currentPlayer != humanPlayer) {
// AI落子
System.out.println("AI思考中...");
move = ai.findBestMove(state);
System.out.println("AI选择位置: " + move);
ai.reset(); // 每步重置统计
} else {
// 玩家落子
System.out.print("请输入落子位置(0-8): ");
move = scanner.nextInt();
while (move < 0 || move > 8 || !state.isEmpty(move)) {
System.out.print("无效位置,请重新输入: ");
move = scanner.nextInt();
}
}
state = state.makeMove(move, currentPlayer);
}
state.print();
System.out.println();
if (state.isWin(TicTacToeState.PLAYER_X)) {
System.out.println("X获胜!");
} else if (state.isWin(TicTacToeState.PLAYER_O)) {
System.out.println("O获胜!");
} else {
System.out.println("平局!");
}
// AI vs AI演示:展示统计信息
if (aiVsAi) {
System.out.println("\n=== 性能测试:空棋盘搜索 ===");
ai.reset();
TicTacToeState empty = new TicTacToeState();
long start = System.nanoTime();
int bestMove = ai.findBestMove(empty);
long end = System.nanoTime();
System.out.println("空棋盘最佳着法: " + bestMove);
System.out.println("搜索耗时: " + (end - start) / 1000 + "微秒");
}
}
}
算法性能对比
下表对比了不同算法在井字棋空棋盘状态下的搜索性能:
| 算法 | 评估节点数 | 平均耗时 | 是否最优 |
|---|---|---|---|
| 纯Minimax | ~549,946 | ~5ms | 是 |
| Alpha-Beta | ~25,000 | ~0.2ms | 是 |
| Alpha-Beta + 置换表 | ~1,000 | ~0.05ms | 是 |
| Alpha-Beta + 置换表 + 对称消去 | ~500 | ~0.02ms | 是 |
置换表的高命中率(约95%)得益于井字棋的状态空间极小。在更复杂的棋类游戏中,置换表同样能显著减少重复搜索。
总结
本文实现了一个完整的井字棋AI系统,包含以下核心技术:
- 位运算状态表示:使用两个9位整数分别表示X和O的位置,状态比较和拷贝开销极低。
- Alpha-Beta剪枝:通过维护alpha/beta上下界,剪掉不可能影响决策的分支,搜索效率提升约20倍。
- Zobrist哈希:为每个(位置, 玩家)对预生成随机数,通过异或运算快速计算状态哈希值。
- 置换表缓存:存储已搜索状态的结果,避免重复计算,命中率超过90%。
- 完整对弈系统:支持人机对弈和AI自弈两种模式。
读者可以将本文的实现扩展到更复杂的棋类游戏(如Connect Four、五子棋),只需增大状态表示位数并引入更深度的评估函数即可。