引言
在博弈类游戏中,AI需要频繁评估和记忆大量的棋盘状态。例如在国际象棋、围棋或五子棋的搜索过程中,同一个局面可能通过不同的行棋顺序多次出现。如果不做优化,程序会重复计算相同的局面,造成巨大的性能浪费。Zobrist哈希正是为解决这一问题而生的高效哈希技术,它能够在O(1)时间内完成局面的哈希计算,并支持O(1)的增量更新,是 modern game AI 的核心组件之一。
本文将用 Java 完整实现 Zobrist 哈希算法,并结合置换表(Transposition Table)演示如何在博弈搜索中检测重复局面、避免循环以及加速评估。
算法原理
核心思想
Zobrist 哈希由 Albert Zobrist 于 1970 年提出,其核心思想非常简洁:
- 为棋盘上每一个位置的每一种棋子类型预生成一个随机 64 位整数
- 一个局面的哈希值等于所有存在棋子的位置所对应随机数的异或(XOR)之和
- 移动一个棋子时,只需 XOR 掉旧位置的随机数,再 XOR 上新位置的随机数,即可在 O(1) 时间内更新哈希值
为什么用异或?
异或运算具有完美的可逆性和交换律:
– a ^ a = 0(同一位置同一棋子异或两次等于没异或)
– a ^ b = b ^ a(交换律,棋子顺序不影响结果)
这意味着:
– 添加一个棋子:hash ^= randomTable[piece][position]
– 移除一个棋子:hash ^= randomTable[piece][position](同一操作)
– 移动一个棋子:hash ^= randomTable[piece][oldPos] ^ randomTable[piece][newPos]
碰撞概率
使用 64 位随机数时,两个不同局面产生相同哈希值的概率约为 1 / 2^64,在实际应用中几乎可以忽略不计。
Java 实现
1. Zobrist 哈希表生成器
import java.util.Random;
/**
* Zobrist哈希表生成器
* 为每个棋子类型的每个棋盘位置预生成随机64位哈希值
*/
public class ZobristHasher {
// 棋盘尺寸(以8x8国际象棋为例,可调整)
public static final int BOARD_SIZE = 8;
// 棋子类型数:空、白兵、白马、白象、白车、白后、白王、黑兵、黑马、黑象、黑车、黑后、黑王
public static final int PIECE_TYPES = 13;
// 三维数组:[pieceType][row][col]
private final long[][][] zobristTable;
// 当前局面的哈希值
private long currentHash;
public ZobristHasher() {
this.zobristTable = new long[PIECE_TYPES][BOARD_SIZE][BOARD_SIZE];
initializeRandomTable();
this.currentHash = 0L;
}
/**
* 使用固定种子初始化随机表,确保可复现性
* 实际应用中可使用更复杂的随机数生成策略
*/
private void initializeRandomTable() {
Random random = new Random(42); // 固定种子便于调试和测试
for (int piece = 0; piece < PIECE_TYPES; piece++) {
for (int row = 0; row < BOARD_SIZE; row++) {
for (int col = 0; col < BOARD_SIZE; col++) {
zobristTable[piece][row][col] = random.nextLong();
}
}
}
}
/**
* 从完整棋盘状态计算初始哈希值
* @param board 二维数组表示棋盘,每个元素为棋子类型索引
*/
public void computeHashFromBoard(int[][] board) {
currentHash = 0L;
for (int row = 0; row < BOARD_SIZE; row++) {
for (int col = 0; col < BOARD_SIZE; col++) {
int piece = board[row][col];
if (piece != 0) { // 0表示空位
currentHash ^= zobristTable[piece][row][col];
}
}
}
}
/**
* 在指定位置放置棋子,增量更新哈希值
* @param piece 棋子类型
* @param row 行坐标
* @param col 列坐标
*/
public void placePiece(int piece, int row, int col) {
if (piece != 0) {
currentHash ^= zobristTable[piece][row][col];
}
}
/**
* 移除指定位置的棋子,增量更新哈希值
* @param piece 棋子类型
* @param row 行坐标
* @param col 列坐标
*/
public void removePiece(int piece, int row, int col) {
if (piece != 0) {
currentHash ^= zobristTable[piece][row][col];
}
}
/**
* 移动棋子:从旧位置移除,在新位置放置
* @param piece 棋子类型
* @param oldRow 原行坐标
* @param oldCol 原列坐标
* @param newRow 新行坐标
* @param newCol 新列坐标
*/
public void movePiece(int piece, int oldRow, int oldCol, int newRow, int newCol) {
removePiece(piece, oldRow, oldCol);
placePiece(piece, newRow, newCol);
}
/**
* 获取当前局面的哈希值
*/
public long getHash() {
return currentHash;
}
/**
* 获取指定位置的预生成随机值(用于特殊处理,如置换表索引)
*/
public long getTableValue(int piece, int row, int col) {
return zobristTable[piece][row][col];
}
}
2. 置换表(Transposition Table)
import java.util.concurrent.atomic.AtomicInteger;
/**
* 置换表条目:存储已搜索局面的评估结果
* 用于避免重复计算和检测循环局面
*/
public class TTEntry {
// 存储的哈希值(用于验证碰撞)
public long hash;
// 搜索深度
public int depth;
// 评估分数
public int score;
// 节点类型:EXACT(精确值)、LOWERBOUND(下界)、UPPERBOUND(上界)
public int flag;
// 最佳走法(用于走法排序优化)
public int bestMove;
// 年龄标记(用于替换策略)
public int age;
public static final int EXACT = 0;
public static final int LOWERBOUND = 1;
public static final int UPPERBOUND = 2;
public TTEntry() {
this.hash = 0L;
this.depth = 0;
this.score = 0;
this.flag = EXACT;
this.bestMove = -1;
this.age = 0;
}
}
/**
* 置换表实现
* 使用固定大小的数组 + 线性探测处理冲突
*/
public class TranspositionTable {
// 表大小:2^20 约 100 万条目,每个条目约 40 字节,总内存约 40MB
private static final int TABLE_SIZE = 1 << 20;
private static final int MASK = TABLE_SIZE - 1;
private final TTEntry[] table;
private final AtomicInteger hits;
private final AtomicInteger collisions;
private int currentAge;
public TranspositionTable() {
this.table = new TTEntry[TABLE_SIZE];
for (int i = 0; i < TABLE_SIZE; i++) {
table[i] = new TTEntry();
}
this.hits = new AtomicInteger(0);
this.collisions = new AtomicInteger(0);
this.currentAge = 0;
}
/**
* 计算索引:取哈希值的低20位
*/
private int getIndex(long hash) {
return (int) (hash & MASK);
}
/**
* 查询置换表
* @param hash 当前局面的Zobrist哈希值
* @param depth 当前搜索深度
* @return 若找到有效条目则返回,否则返回null
*/
public TTEntry probe(long hash, int depth) {
int index = getIndex(hash);
TTEntry entry = table[index];
// 验证哈希值是否匹配(防止哈希碰撞)
if (entry.hash == hash && entry.depth >= depth) {
hits.incrementAndGet();
return entry;
}
return null;
}
/**
* 存储条目到置换表
* 使用替换策略:更深的搜索结果优先保留
*/
public void store(long hash, int depth, int score, int flag, int bestMove) {
int index = getIndex(hash);
TTEntry entry = table[index];
// 替换策略:如果新结果更深,或条目是旧轮次的,则替换
if (entry.hash == 0 || entry.depth <= depth || entry.age != currentAge) {
if (entry.hash != 0 && entry.hash != hash) {
collisions.incrementAndGet();
}
entry.hash = hash;
entry.depth = depth;
entry.score = score;
entry.flag = flag;
entry.bestMove = bestMove;
entry.age = currentAge;
}
}
/**
* 开始新一轮搜索,更新年龄标记
*/
public void newSearch() {
currentAge++;
}
/**
* 清空置换表
*/
public void clear() {
for (TTEntry entry : table) {
entry.hash = 0L;
}
hits.set(0);
collisions.set(0);
}
public int getHits() { return hits.get(); }
public int getCollisions() { return collisions.get(); }
}
3. 完整示例:五子棋中的 Zobrist 哈希应用
/**
* 简化版五子棋AI,演示Zobrist哈希与置换表的实战应用
*/
public class GomokuAI {
private static final int BOARD_SIZE = 15;
private static final int EMPTY = 0;
private static final int BLACK = 1;
private static final int WHITE = 2;
// Zobrist哈希器:只需3种棋子类型(空、黑、白)
private final ZobristHasher hasher;
private final TranspositionTable tt;
private final int[][] board;
public GomokuAI() {
this.hasher = new ZobristHasher();
this.tt = new TranspositionTable();
this.board = new int[BOARD_SIZE][BOARD_SIZE];
// 初始化哈希值(空棋盘)
hasher.computeHashFromBoard(board);
}
/**
* 在指定位置落子,同时更新Zobrist哈希
*/
public void makeMove(int row, int col, int player) {
board[row][col] = player;
hasher.placePiece(player, row, col);
}
/**
* 撤销落子,同时更新Zobrist哈希
*/
public void undoMove(int row, int col, int player) {
board[row][col] = EMPTY;
hasher.removePiece(player, row, col);
}
/**
* 带置换表优化的Minimax搜索
*/
public int search(int depth, int alpha, int beta, boolean isMaximizing) {
long hash = hasher.getHash();
// 查询置换表
TTEntry entry = tt.probe(hash, depth);
if (entry != null) {
switch (entry.flag) {
case TTEntry.EXACT: return entry.score;
case TTEntry.LOWERBOUND:
alpha = Math.max(alpha, entry.score);
break;
case TTEntry.UPPERBOUND:
beta = Math.min(beta, entry.score);
break;
}
if (alpha >= beta) {
return entry.score;
}
}
// 到达叶子节点或游戏结束
if (depth == 0 || isGameOver()) {
int score = evaluateBoard();
tt.store(hash, depth, score, TTEntry.EXACT, -1);
return score;
}
int bestMove = -1;
int originalAlpha = alpha;
if (isMaximizing) {
int maxScore = Integer.MIN_VALUE;
for (int[] move : generateMoves()) {
makeMove(move[0], move[1], BLACK);
int score = search(depth - 1, alpha, beta, false);
undoMove(move[0], move[1], BLACK);
if (score > maxScore) {
maxScore = score;
bestMove = move[0] * BOARD_SIZE + move[1];
}
alpha = Math.max(alpha, score);
if (beta <= alpha) break; // Alpha-Beta剪枝
}
// 存储到置换表
int flag = (maxScore <= originalAlpha) ? TTEntry.UPPERBOUND :
(maxScore >= beta) ? TTEntry.LOWERBOUND : TTEntry.EXACT;
tt.store(hash, depth, maxScore, flag, bestMove);
return maxScore;
} else {
int minScore = Integer.MAX_VALUE;
for (int[] move : generateMoves()) {
makeMove(move[0], move[1], WHITE);
int score = search(depth - 1, alpha, beta, true);
undoMove(move[0], move[1], WHITE);
if (score < minScore) {
minScore = score;
bestMove = move[0] * BOARD_SIZE + move[1];
}
beta = Math.min(beta, score);
if (beta <= alpha) break;
}
int flag = (minScore <= originalAlpha) ? TTEntry.UPPERBOUND :
(minScore >= beta) ? TTEntry.LOWERBOUND : TTEntry.EXACT;
tt.store(hash, depth, minScore, flag, bestMove);
return minScore;
}
}
// 以下为简化版辅助方法,实际应用需完善
private boolean isGameOver() { return false; }
private int evaluateBoard() { return 0; }
private int[][] generateMoves() { return new int[0][0]; }
public static void main(String[] args) {
GomokuAI ai = new GomokuAI();
// 模拟几个落子操作
ai.makeMove(7, 7, BLACK);
ai.makeMove(7, 8, WHITE);
ai.makeMove(8, 7, BLACK);
System.out.println("Current hash: " + ai.hasher.getHash());
// 验证异或的可逆性:撤销后哈希应恢复
long hashBefore = ai.hasher.getHash();
ai.undoMove(8, 7, BLACK);
ai.makeMove(8, 7, BLACK);
System.out.println("Hash reversible: " + (hashBefore == ai.hasher.getHash()));
// 运行搜索
ai.tt.newSearch();
int bestScore = ai.search(4, Integer.MIN_VALUE, Integer.MAX_VALUE, true);
System.out.println("Best score: " + bestScore);
System.out.println("TT hits: " + ai.tt.getHits());
}
}
复杂度分析
| 操作 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| 初始化随机表 | O(P × R × C) | O(P × R × C) | 预计算,只执行一次 |
| 计算完整哈希 | O(R × C) | O(1) | 遍历整个棋盘 |
| 增量更新哈希 | O(1) | O(1) | 单次异或操作 |
| 置换表查询 | O(1) | O(T) | T为表大小 |
| 置换表存储 | O(1) | O(T) | 固定数组索引 |
其中 P 为棋子类型数,R、C 为棋盘行列数,T 为置换表条目数。
进阶扩展
1. 处理特殊规则
对于需要记录额外状态的游戏(如国际象棋的吃过路兵权利、王车易位权利、逼和规则),可以为每个状态位额外生成随机数:
// 额外状态哈希值
private long castlingRightsHash; // 四项易位权利
private long enPassantHash; // 吃过路兵目标格
private long sideToMoveHash; // 轮到哪方行棋
// 切换行棋方时
public void switchSide() {
currentHash ^= sideToMoveHash;
}
2. 多线程安全
置换表在多线程环境下可能出现竞争条件。解决方案:
– 每个线程维护独立的置换表(内存换效率)
– 使用原子操作或无锁数据结构(复杂度换内存)
– 采用懒删除策略,允许多个条目共存
3. 哈希碰撞的进一步处理
虽然 64 位哈希的碰撞概率极低,但在关键应用中可采取:
– 双哈希验证:同时存储 64 位和 32 位哈希值
– 完整局面验证:置换表命中后,对比完整棋盘状态确认无误
总结
Zobrist 哈希以其极简的实现和卓越的性能,成为博弈 AI 领域不可替代的基础工具。通过本文的 Java 实现,我们展示了:
- 预生成随机表:一次计算,永久复用
- 异或增量更新:O(1) 时间维护局面哈希
- 置换表集成:避免重复搜索,实现指数级加速
- 碰撞安全:64 位空间 + 哈希验证双重保障
在实际的 Stockfish、Leela Chess Zero 等顶级引擎中,Zobrist 哈希都是核心基础设施。掌握这一技术,是构建高性能博弈 AI 的必经之路。