每日算法 — 使用java实现围棋:Zobrist哈希与棋盘连通块气计算

引言

围棋是规则最简单、复杂度最高的棋类游戏之一,其状态空间约为10^170。实现围棋引擎的首要挑战是状态表示与规则判定——尤其是围棋特有的”气”和”提子”规则。本文用Java实现围棋核心数据结构,重点讲解Zobrist哈希实现O(1)局面唯一编码,以及基于并查集(Union-Find)的连通块与气计算。

围棋规则算法核心

围棋棋盘为19×19(或9×9简化版),黑白双方轮流落子。核心规则算法包括:
1. 气(Liberty)计算:棋子或连通块相邻的空点数量,气为0时被提走
2. 打劫(Ko)规则:不能立即回提,需通过局面哈希检测
3. 连通块检测:同色相邻棋子属于同一连通块,共享气

Zobrist哈希为每个坐标、每种棋子状态预分配随机64位数,局面哈希为所有棋子对应随机数的异或和。落子/提子时只需异或操作即可O(1)更新哈希。

import java.util.*;

/**
 * 围棋引擎核心:Zobrist哈希 + 并查集连通块气计算
 * 支持9×9和19×19棋盘
 */
public class GoEngine {

    public static final int BOARD_SIZE = 9; // 可用9或19
    public static final int EMPTY = 0;
    public static final int BLACK = 1;
    public static final int WHITE = 2;

    // Zobrist哈希表:[size][size][3] 0=空,1=黑,2=白
    private final long[][][] zobristTable;
    private final Random random;

    // 棋盘状态
    private final int[][] board;
    // 当前哈希值(用于打劫检测和置换表)
    private long hash;

    // 并查集:每个格子的父节点,-1表示无父(自己是根)
    private final int[] parent;
    // 连通块的气数缓存(仅根节点有效)
    private final int[] liberties;
    // 连通块的大小(棋子数,仅根节点有效)
    private final int[] size;

    // 历史哈希(用于打劫检测)
    private final Set<Long> history;

    public GoEngine() {
        this.random = new Random(42); // 固定种子保证可复现
        this.zobristTable = new long[BOARD_SIZE][BOARD_SIZE][3];
        initZobrist();

        this.board = new int[BOARD_SIZE][BOARD_SIZE];
        int total = BOARD_SIZE * BOARD_SIZE;
        this.parent = new int[total];
        this.liberties = new int[total];
        this.size = new int[total];
        this.hash = 0L;
        this.history = new HashSet<>();

        // 初始所有格子为空,空状态的Zobrist值已包含在hash中
        for (int r = 0; r < BOARD_SIZE; r++) {
            for (int c = 0; c < BOARD_SIZE; c++) {
                hash ^= zobristTable[r][c][EMPTY];
                int idx = r * BOARD_SIZE + c;
                parent[idx] = idx;
                liberties[idx] = 0; // 空格子不缓存气
                size[idx] = 0;
            }
        }
        history.add(hash);
    }

    /**
     * 初始化Zobrist随机哈希表
     */
    private void initZobrist() {
        for (int r = 0; r < BOARD_SIZE; r++) {
            for (int c = 0; c < BOARD_SIZE; c++) {
                for (int s = 0; s < 3; s++) {
                    zobristTable[r][c][s] = random.nextLong();
                }
            }
        }
    }

    /**
     * 坐标转索引
     */
    private int idx(int r, int c) {
        return r * BOARD_SIZE + c;
    }

    /**
     * 并查集查找根节点(路径压缩)
     */
    private int find(int x) {
        if (parent[x] == x) return x;
        return parent[x] = find(parent[x]);
    }

    /**
     * 并查集合并两个连通块
     */
    private void union(int a, int b) {
        int ra = find(a), rb = find(b);
        if (ra == rb) return;
        // 按大小合并:小的接在大的下面
        if (size[ra] < size[rb]) {
            int tmp = ra; ra = rb; rb = tmp;
        }
        parent[rb] = ra;
        size[ra] += size[rb];
        liberties[ra] += liberties[rb];
    }

    /**
     * 计算单个格子的气(相邻空点数量)
     */
    private int countLiberties(int r, int c) {
        int count = 0;
        int[][] dirs = {{-1,0},{1,0},{0,-1},{0,1}};
        for (int[] d : dirs) {
            int nr = r + d[0], nc = c + d[1];
            if (inBounds(nr, nc) && board[nr][nc] == EMPTY) {
                count++;
            }
        }
        return count;
    }

    private boolean inBounds(int r, int c) {
        return r >= 0 && r < BOARD_SIZE && c >= 0 && c < BOARD_SIZE;
    }

    /**
     * 尝试在(r,c)落子(color: BLACK/WHITE)
     * 返回是否成功,若打劫或自杀则返回false
     */
    public boolean play(int r, int c, int color) {
        if (!inBounds(r, c) || board[r][c] != EMPTY) {
            return false; // 非法坐标或非空
        }

        // 模拟落子:更新哈希
        long newHash = hash ^ zobristTable[r][c][EMPTY] ^ zobristTable[r][c][color];

        // 临时记录被提走的子,用于回滚或确认
        List<int[]> captured = new ArrayList<>();
        int opposite = (color == BLACK) ? WHITE : BLACK;

        // 先放置棋子到临时状态
        int myIdx = idx(r, c);
        board[r][c] = color;
        parent[myIdx] = myIdx;
        size[myIdx] = 1;
        liberties[myIdx] = countLiberties(r, c);

        // 检查相邻异色连通块:若落子导致其气为0,则提子
        int[][] dirs = {{-1,0},{1,0},{0,-1},{0,1}};
        for (int[] d : dirs) {
            int nr = r + d[0], nc = c + d[1];
            if (inBounds(nr, nc) && board[nr][nc] == opposite) {
                int nbIdx = idx(nr, nc);
                int root = find(nbIdx);
                liberties[root]--; // 该连通块失去一个气
                if (liberties[root] == 0) {
                    // 提走该连通块所有子
                    captureGroup(root, opposite, captured);
                }
            }
        }

        // 检查相邻同色连通块:合并
        for (int[] d : dirs) {
            int nr = r + d[0], nc = c + d[1];
            if (inBounds(nr, nc) && board[nr][nc] == color) {
                union(myIdx, idx(nr, nc));
            }
        }

        // 更新新连通块的气(合并后的根)
        int myRoot = find(myIdx);
        // 重新计算合并后的气数:所有相邻空点去重计数
        liberties[myRoot] = countGroupLiberties(myRoot, color);

        // 检查自杀:若落子后自己的气为0且未提子,则自杀非法
        if (liberties[myRoot] == 0 && captured.isEmpty()) {
            // 回滚
            rollbackMove(r, c, color, captured);
            return false;
        }

        // 处理被提子的Zobrist更新
        for (int[] stone : captured) {
            int cr = stone[0], cc = stone[1];
            newHash ^= zobristTable[cr][cc][opposite] ^ zobristTable[cr][cc][EMPTY];
        }

        // 打劫检测:新局面不能立即重复历史
        if (history.contains(newHash)) {
            rollbackMove(r, c, color, captured);
            return false; // 打劫
        }

        hash = newHash;
        history.add(hash);
        return true;
    }

    /**
     * 提走指定根节点的整个连通块
     */
    private void captureGroup(int root, int color, List<int[]> captured) {
        for (int r = 0; r < BOARD_SIZE; r++) {
            for (int c = 0; c < BOARD_SIZE; c++) {
                int i = idx(r, c);
                if (board[r][c] == color && find(i) == root) {
                    captured.add(new int[]{r, c});
                    board[r][c] = EMPTY;
                    parent[i] = i;
                    size[i] = 0;
                    liberties[i] = 0;
                }
            }
        }
    }

    /**
     * 回滚非法落子
     */
    private void rollbackMove(int r, int c, int color, List<int[]> captured) {
        board[r][c] = EMPTY;
        int i = idx(r, c);
        parent[i] = i;
        size[i] = 0;
        liberties[i] = 0;
        // 恢复被提子(简化实现:遍历重设)
        int opposite = (color == BLACK) ? WHITE : BLACK;
        for (int[] stone : captured) {
            board[stone[0]][stone[1]] = opposite;
        }
        // 重新构建并查集(简化:完整重建)
        rebuildUnionFind();
    }

    /**
     * 完全重建并查集(用于回滚后恢复一致性)
     */
    private void rebuildUnionFind() {
        for (int r = 0; r < BOARD_SIZE; r++) {
            for (int c = 0; c < BOARD_SIZE; c++) {
                int i = idx(r, c);
                parent[i] = i;
                if (board[r][c] != EMPTY) {
                    size[i] = 1;
                    liberties[i] = countLiberties(r, c);
                } else {
                    size[i] = 0;
                    liberties[i] = 0;
                }
            }
        }
        int[][] dirs = {{-1,0},{1,0},{0,-1},{0,1}};
        for (int r = 0; r < BOARD_SIZE; r++) {
            for (int c = 0; c < BOARD_SIZE; c++) {
                if (board[r][c] == EMPTY) continue;
                for (int[] d : dirs) {
                    int nr = r + d[0], nc = c + d[1];
                    if (inBounds(nr, nc) && board[nr][nc] == board[r][c]) {
                        union(idx(r, c), idx(nr, nc));
                    }
                }
            }
        }
        // 重新计算所有连通块的气
        boolean[] visited = new boolean[BOARD_SIZE * BOARD_SIZE];
        for (int r = 0; r < BOARD_SIZE; r++) {
            for (int c = 0; c < BOARD_SIZE; c++) {
                if (board[r][c] == EMPTY) continue;
                int root = find(idx(r, c));
                if (!visited[root]) {
                    liberties[root] = countGroupLiberties(root, board[r][c]);
                    visited[root] = true;
                }
            }
        }
    }

    /**
     * 计算某个连通块(按根)的去重气数
     */
    private int countGroupLiberties(int root, int color) {
        Set<Integer> libSet = new HashSet<>();
        int[][] dirs = {{-1,0},{1,0},{0,-1},{0,1}};
        for (int r = 0; r < BOARD_SIZE; r++) {
            for (int c = 0; c < BOARD_SIZE; c++) {
                if (board[r][c] != color) continue;
                if (find(idx(r, c)) != root) continue;
                for (int[] d : dirs) {
                    int nr = r + d[0], nc = c + d[1];
                    if (inBounds(nr, nc) && board[nr][nc] == EMPTY) {
                        libSet.add(idx(nr, nc));
                    }
                }
            }
        }
        return libSet.size();
    }

    /**
     * 获取当前局面哈希(用于置换表和打劫检测)
     */
    public long getHash() {
        return hash;
    }

    /**
     * 打印棋盘
     */
    public void printBoard() {
        System.out.print("  ");
        for (char c = 'A'; c < 'A' + BOARD_SIZE; c++) System.out.print(c + " ");
        System.out.println();
        for (int r = BOARD_SIZE - 1; r >= 0; r--) {
            System.out.print((r + 1) + " ");
            for (int c = 0; c < BOARD_SIZE; c++) {
                char ch = switch (board[r][c]) {
                    case EMPTY -> '.';
                    case BLACK -> 'X';
                    case WHITE -> 'O';
                    default -> '?';
                };
                System.out.print(ch + " ");
            }
            System.out.println(r + 1);
        }
        System.out.print("  ");
        for (char c = 'A'; c < 'A' + BOARD_SIZE; c++) System.out.print(c + " ");
        System.out.println("\nHash: " + hash);
    }

    /**
     * 获取某连通块的气数(调试用)
     */
    public int getGroupLibertyAt(int r, int c) {
        if (board[r][c] == EMPTY) return -1;
        return liberties[find(idx(r, c))];
    }

    public static void main(String[] args) {
        GoEngine engine = new GoEngine();
        System.out.println("=== 初始空棋盘 ===");
        engine.printBoard();

        // 黑棋落子D3(3,3在0索引中为2,3)
        System.out.println("\n=== 黑棋落子 D3 ===");
        boolean ok = engine.play(2, 3, BLACK);
        System.out.println("落子成功: " + ok);
        engine.printBoard();
        System.out.println("D3连通块气数: " + engine.getGroupLibertyAt(2, 3));

        // 白棋落子D4
        System.out.println("\n=== 白棋落子 D4 ===");
        ok = engine.play(3, 3, WHITE);
        System.out.println("落子成功: " + ok);
        engine.printBoard();

        // 黑棋落子E4
        System.out.println("\n=== 黑棋落子 E4 ===");
        ok = engine.play(3, 4, BLACK);
        System.out.println("落子成功: " + ok);
        engine.printBoard();

        // 黑棋落子C4
        System.out.println("\n=== 黑棋落子 C4 ===");
        ok = engine.play(3, 2, BLACK);
        System.out.println("落子成功: " + ok);
        engine.printBoard();

        // 黑棋落子D5 —— 提走D4的白子
        System.out.println("\n=== 黑棋落子 D5(提子) ===");
        ok = engine.play(4, 3, BLACK);
        System.out.println("落子成功: " + ok);
        engine.printBoard();

        // 测试打劫:白棋立即回提D5是非法的
        System.out.println("\n=== 白棋尝试立即回提 D5(打劫) ===");
        ok = engine.play(4, 3, WHITE);
        System.out.println("落子成功: " + ok + " (应为false,打劫)");
        engine.printBoard();

        // 性能测试:大量随机落子
        System.out.println("\n=== 性能测试 ===");
        GoEngine perf = new GoEngine();
        Random rand = new Random(123);
        int moves = 0;
        long start = System.nanoTime();
        for (int i = 0; i < 10000; i++) {
            int r = rand.nextInt(BOARD_SIZE);
            int c = rand.nextInt(BOARD_SIZE);
            int color = (i % 2 == 0) ? BLACK : WHITE;
            if (perf.play(r, c, color)) moves++;
        }
        long ns = System.nanoTime() - start;
        System.out.println("10000次尝试, 成功落子 " + moves + " 次");
        System.out.println("平均每次尝试: " + (ns / 10000.0 / 1000) + " μs");
    }
}

Zobrist哈希的优势

在围棋中,Zobrist哈希解决了三个关键问题:

  1. 打劫检测:通过比对历史哈希集合,O(1)判断局面是否重复
  2. 置换表:搜索树中相同局面(不同路径到达)可复用评估结果
  3. 增量更新:落子或提子只需两次异或,远快于重新哈希整个棋盘

注意Zobrist哈希可能冲突(不同局面相同哈希),但64位哈希的冲突概率约为1/2^64,实战中可忽略。

并查集连通块管理

并查集将同色相邻棋子动态聚合为连通块,每个块维护统一的气数。路径压缩保证find均摊接近O(1)。落子时:
– 先检查四周异色块,递减其气数,气归零则提子
– 再与四周同色块合并,更新气数

这种设计避免了每次全棋盘BFS,使走法生成从O(n²)降到近乎O(1)。

复杂度分析

操作 朴素实现 并查集+Zobrist
走法合法性检验 O(n²) BFS O(α(n)) 并查集
气计算 O(n²) 全图扫描 O(气数) 局部更新
局面哈希 O(n²) 重新计算 O(1) 异或更新
打劫检测 O(k·n²) 历史比对 O(1) HashSet查询

其中α为阿克曼函数反函数,实际小于5。

总结

本文实现了围棋引擎的两个基石:Zobrist哈希提供O(1)局面编码与重复检测,并查集提供O(α(n))连通块管理。基于这两者的走法生成器足以支撑蒙特卡洛树搜索(MCTS)或神经网络策略评估,是构建现代围棋AI的起点。