每日算法 — 使用java实现六角棋(Hex):六向邻居建模与并查集连通判定

一、游戏规则与算法问题的转化

六角棋(Hex)是 1952 年由丹麦数学家 Piet Hein 发明、后由 John Nash 独立提出的二人完全信息博弈。棋盘由边长 N 的菱形六角网格构成,共 N × N 个格子:

  • 玩家 A 需要把棋盘东侧与西侧连通;
  • 玩家 B 需要把棋盘北侧与南侧连通。

棋盘铺满时的第一步等价于一个漂亮的性质:没有平局,必有一方胜利。原因在于,如果把黑色棋子与东、西两块”虚拟边界”看作一个整体,白色棋子与北、南两块虚拟边界看作另一个整体,那么两种连通在拓扑上必然有一个成立(这是 Brouwer 不动点定理在离散网格上的化身)。

从算法角度看,这个游戏能讲清三件事:

  1. 连通性判定:用并查集或 BFS 在网格上判断胜负,这是最基础的图论能力;
  2. 状态编码:六角网格不是矩形网格,邻居关系必须特殊处理,可以用”位图 + 边界哑节点”的方式把判定做到 O(1) 次 BFS;
  3. 开局策略:第一手下天元(中心)的理论最优性,以及第二手的对称应对。

本文用 Java 从零实现一套可交互、可对战的 Hex,并实现一个基于单步胜率评估 + 威胁计数的 AI。

二、六角网格的数据结构与邻居关系

六角网格有两种常见坐标系:轴向坐标(axial) 与 偏移坐标(offset)。我们使用轴向坐标 (q, r),其中 q 为列,r 为行,菱形棋盘满足 0 ≤ q < N、0 ≤ r < N。

轴向坐标下,六个邻居的固定偏移为:

(+1, 0)  (-1, 0)  (0, +1)  (0, -1)  (+1, -1)  (-1, +1)

因此菱形棋盘在视觉上是一个”斜的”菱形。这在实现中非常方便,因为行列范围都是长方形,不需要做奇怪的裁剪。

public final class HexBoard {

    public static final int EMPTY = 0;
    public static final int RED   = 1;   // 东-西连通
    public static final int BLUE  = 2;   // 北-南连通

    // 六个邻居的轴向坐标偏移
    private static final int[] DQ = {+1, -1, 0, 0, +1, -1};
    private static final int[] DR = {0, 0, +1, -1, -1, +1};

    private final int n;          // 棋盘边长
    private final int[] cells;    // 长度 n * n
    private int moveCount;

    public HexBoard(int n) {
        this.n = n;
        this.cells = new int[n * n];
    }

    public int size() {
        return n;
    }

    public boolean inRange(int q, int r) {
        return q >= 0 && q < n && r >= 0 && r < n;
    }

    public int get(int q, int r) {
        return inRange(q, r) ? cells[r * n + q] : -1;
    }

    /** 落子,返回是否成功。 */
    public boolean place(int q, int r, int player) {
        if (!inRange(q, r) || cells[r * n + q] != EMPTY) {
            return false;
        }
        cells[r * n + q] = player;
        moveCount++;
        return true;
    }

    public void undo(int q, int r) {
        if (inRange(q, r) && cells[r * n + q] != EMPTY) {
            cells[r * n + q] = EMPTY;
            moveCount--;
        }
    }

    public boolean isFull() {
        return moveCount == n * n;
    }

    public int moveCount() {
        return moveCount;
    }
}

注意 get 对越界返回 -1,而不是抛异常。这样在扫描邻居时可以自然跳过边界,省掉大量 if。

三、胜负判定:并查集方案

最直观的判定方式是并查集。我们为两个玩家各建一个并查集,并引入两个”虚拟节点”作为目标端点:

  • 红方(东-西):左侧 q == 0 的所有格子都并到 WEST,右侧 q == n-1 的所有格子都并到 EAST;
  • 蓝方(北-南):r == 0 的格子并到 NORTH,r == n-1 的格子并到 SOUTH。

每落一子,把它与六个同色邻居合并。合并后检查 WEST 与 EAST 是否同根,若是则红方胜。

public final class UnionFind {
    private final int[] parent;
    private final int[] rank;

    public UnionFind(int size) {
        parent = new int[size];
        rank = new int[size];
        for (int i = 0; i < size; i++) {
            parent[i] = i;
        }
    }

    public int find(int x) {
        while (parent[x] != x) {
            parent[x] = parent[parent[x]];   // 路径减半
            x = parent[x];
        }
        return x;
    }

    public boolean union(int a, int b) {
        int ra = find(a), rb = find(b);
        if (ra == rb) {
            return false;
        }
        if (rank[ra] < rank[rb]) {
            int t = ra; ra = rb; rb = t;
        }
        parent[rb] = ra;
        if (rank[ra] == rank[rb]) {
            rank[ra]++;
        }
        return true;
    }

    public boolean connected(int a, int b) {
        return find(a) == find(b);
    }
}

并查集的增量判定非常快,但它有个致命问题:无法撤销。搜索树回溯时需要撤销落子,并查集做不到(除非用可持久化并查集,代价高)。因此下面的 AI 使用 BFS,因为 BFS 天然支持”落子 → 判定 → 撤销”的临时修改。

/** 并查集 + 边界哑节点的完整实现(仅用于对局判定,不做搜索)。 */
public final class UnionFindJudge {

    private static final int[] DQ = {+1, -1, 0, 0, +1, -1};
    private static final int[] DR = {0, 0, +1, -1, -1, +1};

    private final HexBoard board;
    private final int n;
    private final UnionFind redUf;
    private final UnionFind blueUf;
    private final int westDummy, eastDummy, northDummy, southDummy;

    public UnionFindJudge(HexBoard board) {
        this.board = board;
        this.n = board.size();
        int total = n * n + 4;
        this.redUf = new UnionFind(total);
        this.blueUf = new UnionFind(total);
        int base = n * n;
        this.westDummy = base;
        this.eastDummy = base + 1;
        this.northDummy = base + 2;
        this.southDummy = base + 3;
    }

    /** 落子后调用,返回获胜方(0 表示无人获胜)。 */
    public int placeAndJudge(int q, int r, int player) {
        if (!board.place(q, r, player)) {
            return 0;
        }
        int idx = r * n + q;

        if (player == HexBoard.RED) {
            if (q == 0) redUf.union(idx, westDummy);
            if (q == n - 1) redUf.union(idx, eastDummy);
            for (int d = 0; d < 6; d++) {
                int nq = q + DQ[d], nr = r + DR[d];
                if (board.get(nq, nr) == HexBoard.RED) {
                    redUf.union(idx, nr * n + nq);
                }
            }
            return redUf.connected(westDummy, eastDummy) ? HexBoard.RED : 0;
        } else {
            if (r == 0) blueUf.union(idx, northDummy);
            if (r == n - 1) blueUf.union(idx, southDummy);
            for (int d = 0; d < 6; d++) {
                int nq = q + DQ[d], nr = r + DR[d];
                if (board.get(nq, nr) == HexBoard.BLUE) {
                    blueUf.union(idx, nr * n + nq);
                }
            }
            return blueUf.connected(northDummy, southDummy) ? HexBoard.BLUE : 0;
        }
    }
}

复杂度:每次落子合并至多 8 组关系,O(α(n²)) 近似常数。整局 O(n²・α)。

四、BFS 判定:可撤销、可复用的方案

BFS 判定把两个边界当作源点集合,判断能否扩散到对侧边界。它需要 visited 数组,但我们可以每次使用一个版本号(stamp) 数组避免重复清零——这是竞赛和工程里都极常用的技巧。

public final class BfsJudge {

    private static final int[] DQ = {+1, -1, 0, 0, +1, -1};
    private static final int[] DR = {0, 0, +1, -1, -1, +1};

    private final int n;
    private final int[] stamp;
    private int currentStamp = 0;

    public BfsJudge(int n) {
        this.n = n;
        this.stamp = new int[n * n];
    }

    /**
     * 判断指定玩家是否已经形成贯通路径。
     * 使用整型数组的队列而非 ArrayDeque,避免大量对象分配。
     */
    public boolean hasWon(int[] cells, int player) {
        int[] queue = new int[n * n];
        int head = 0, tail = 0;
        currentStamp++;

        // 把起始边界的本方棋子全部入队
        for (int i = 0; i < n; i++) {
            int q = (player == HexBoard.RED) ? 0 : i;
            int r = (player == HexBoard.RED) ? i : 0;
            if (cells[r * n + q] == player) {
                stamp[r * n + q] = currentStamp;
                queue[tail++] = r * n + q;
            }
        }

        while (head < tail) {
            int idx = queue[head++];
            int q = idx % n, r = idx / n;

            // 到达目标边界即获胜
            if (player == HexBoard.RED && q == n - 1) return true;
            if (player == HexBoard.BLUE && r == n - 1) return true;

            for (int d = 0; d < 6; d++) {
                int nq = q + DQ[d], nr = r + DR[d];
                if (nq < 0 || nq >= n || nr < 0 || nr >= n) continue;
                int nIdx = nr * n + nq;
                if (stamp[nIdx] != currentStamp && cells[nIdx] == player) {
                    stamp[nIdx] = currentStamp;
                    queue[tail++] = nIdx;
                }
            }
        }
        return false;
    }
}

复杂度:O(n²) 时间、O(n²) 空间,但因 stamp 技巧免去每次清零的开销。

五、AI 设计:威胁计数 + 单步胜率评估

Hex 的搜索空间随 n 指数增长(n=11 时有约 11! 量级的对局分支),纯 alpha-beta 在无好的启发函数时表现糟糕。我们采用两层策略:

5.1 单步致胜与单步致败检测

这是最廉价也最有效的剪枝:

  • 若我方任意空点落子即可连通 → 立刻落子取胜;
  • 若对方任意空点落子即可连通 → 必须堵住该点;
  • 若对方有多个致胜点(双重威胁)→ 局面已基本必败,选一个堵住以减少损失。
import java.util.ArrayList;
import java.util.List;

public final class HexAI {

    private static final int[] DQ = {+1, -1, 0, 0, +1, -1};
    private static final int[] DR = {0, 0, +1, -1, -1, +1};

    private final int n;
    private final int me;
    private final int opp;
    private final BfsJudge judge;

    public HexAI(int n, int me) {
        this.n = n;
        this.me = me;
        this.opp = (me == HexBoard.RED) ? HexBoard.BLUE : HexBoard.RED;
        this.judge = new BfsJudge(n);
    }

    public int[] chooseMove(HexBoard board) {
        List<int[]> empties = collectEmpties(board);
        if (empties.isEmpty()) {
            return null;
        }

        // 开局:天元(中心)
        if (board.moveCount() == 0) {
            int c = n / 2;
            return new int[]{c, c};
        }

        // 1. 自己能赢就赢
        for (int[] mv : empties) {
            board.place(mv[0], mv[1], me);
            boolean win = judge.hasWon(board.cellsSnapshot(), me);
            board.undo(mv[0], mv[1]);
            if (win) return mv;
        }

        // 2. 对手能赢就堵
        List<int[]> oppWins = new ArrayList<>();
        for (int[] mv : empties) {
            board.place(mv[0], mv[1], opp);
            boolean win = judge.hasWon(board.cellsSnapshot(), opp);
            board.undo(mv[0], mv[1]);
            if (win) oppWins.add(mv);
        }
        if (oppWins.size() == 1) {
            return oppWins.get(0);
        }

        // 3. 一般情况下用启发式评分
        return bestByHeuristic(board, empties);
    }

    private List<int[]> collectEmpties(HexBoard board) {
        List<int[]> list = new ArrayList<>();
        for (int r = 0; r < n; r++) {
            for (int q = 0; q < n; q++) {
                if (board.get(q, r) == HexBoard.EMPTY) {
                    list.add(new int[]{q, r});
                }
            }
        }
        return list;
    }

    private int[] bestByHeuristic(HexBoard board, List<int[]> empties) {
        int[] best = empties.get(0);
        double bestScore = Double.NEGATIVE_INFINITY;
        for (int[] mv : empties) {
            board.place(mv[0], mv[1], me);
            double score = evaluate(board);
            board.undo(mv[0], mv[1]);
            if (score > bestScore) {
                bestScore = score;
                best = mv;
            }
        }
        return best;
    }

    /**
     * 我方视角的启发式评估:
     *   连接度:我方最长连通链的"有效长度"
     *   威胁度:对方最有希望的一条链上的棋子数
     */
    private double evaluate(HexBoard board) {
        return chainScore(board, me) * 1.0 - chainScore(board, opp) * 0.9;
    }

    /** 用双端 BFS 粗估某方从起始边到目标边的"最短板"。 */
    private int chainScore(HexBoard board, int player) {
        int[] cells = board.cellsSnapshot();
        int reach = 0;
        for (int i = 0; i < n; i++) {
            int q = (player == HexBoard.RED) ? 0 : i;
            int r = (player == HexBoard.RED) ? i : 0;
            if (cells[r * n + q] == player) {
                reach += 1 + bfsDepth(cells, q, r, player);
            }
        }
        return reach;
    }

    /** 从 (sq, sr) 出发 BFS,返回能到达的最远"目标方向"距离。 */
    private int bfsDepth(int[] cells, int sq, int sr, int player) {
        boolean[] visited = new boolean[n * n];
        int[] queue = new int[n * n];
        int head = 0, tail = 0;
        queue[tail++] = sr * n + sq;
        visited[sr * n + sq] = true;
        int best = 0;

        while (head < tail) {
            int idx = queue[head++];
            int q = idx % n, r = idx / n;
            int progress = (player == HexBoard.RED) ? q : r;
            best = Math.max(best, progress);

            for (int d = 0; d < 6; d++) {
                int nq = q + DQ[d], nr = r + DR[d];
                if (nq < 0 || nq >= n || nr < 0 || nr >= n) continue;
                int nIdx = nr * n + nq;
                if (!visited[nIdx] && cells[nIdx] == player) {
                    visited[nIdx] = true;
                    queue[tail++] = nIdx;
                }
            }
        }
        return best;
    }
}

这里需要一个 cellsSnapshot() 方法,把内部数组以只读方式暴露出来。为了不破坏封装,我们提供一个拷贝方法:

// 在 HexBoard 中追加
public int[] cellsSnapshot() {
    return cells.clone();
}

注意:每步 clone 一次是 O(n²) 的拷贝开销。若追求极致性能,可改为提供一个直接访问的包内可见引用,或让 BfsJudge 直接持有 HexBoard 引用。本文优先保证代码清晰与可读性。

5.2 启发式函数的改进方向

上面的 chainScore 只是”最远的推进距离”之和,过于朴素。实际工程中常用桥(bridge)检测:在六角网格上,相邻两个同色棋子若同时有两块空格作为”公共邻居”,则形成一座桥,对方无法单步切断。桥越多,连通势能越大。

/**
 * 桥检测:若 a 与 b 同色,且存在两个共同空邻居,
 * 则 a-b 之间形成桥(对方需要两步才能切断)。
 */
private int countBridges(int[] cells, int player) {
    int bridges = 0;
    for (int r = 0; r < n; r++) {
        for (int q = 0; q < n; q++) {
            if (cells[r * n + q] != player) continue;
            for (int d = 0; d < 6; d++) {
                int nq = q + DQ[d], nr = r + DR[d];
                if (nq < 0 || nq >= n || nr < 0 || nr >= n) continue;
                if (cells[nr * n + nq] != player) continue;
                // 统计两个格子的共同空邻居数量
                int common = 0;
                for (int e = 0; e < 6; e++) {
                    int aq = q + DQ[e], ar = r + DR[e];
                    if (aq < 0 || aq >= n || ar < 0 || ar >= n) continue;
                    if (cells[ar * n + aq] != HexBoard.EMPTY) continue;
                    // 该空点是否同时与 (nq, nr) 相邻
                    for (int f = 0; f < 6; f++) {
                        if (nq + DQ[f] == aq && nr + DR[f] == ar) {
                            common++;
                            break;
                        }
                    }
                }
                if (common >= 2) bridges++;
            }
        }
    }
    return bridges / 2;   // 每条桥被数了两次
}

把 countBridges 加入评估函数,AI 的棋力会有明显提升——因为它开始理解”哪些连接是不可切断的”,而不是纯粹贪心推进。

六、完整可运行的主程序

import java.util.Scanner;

public final class HexGame {

    public static void main(String[] args) {
        Scanner in = new Scanner(System.in);
        int n = 7;                     // 默认 7×7
        HexBoard board = new HexBoard(n);
        HexAI redAI = new HexAI(n, HexBoard.RED);
        HexAI blueAI = new HexAI(n, HexBoard.BLUE);
        UnionFindJudge judge = new UnionFindJudge(board);

        int turn = HexBoard.RED;
        int winner = 0;

        while (winner == 0 && !board.isFull()) {
            int[] mv;
            if (turn == HexBoard.RED) {
                System.out.print("红方落子 (q r): ");
                int q = in.nextInt(), r = in.nextInt();
                mv = new int[]{q, r};
            } else {
                mv = blueAI.chooseMove(board);
                System.out.printf("蓝方(AI)落子: (%d, %d)%n", mv[0], mv[1]);
            }
            winner = judge.placeAndJudge(mv[0], mv[1], turn);
            printBoard(board);
            turn = (turn == HexBoard.RED) ? HexBoard.BLUE : HexBoard.RED;
        }

        if (winner == HexBoard.RED) {
            System.out.println("红方(东-西)胜利!");
        } else if (winner == HexBoard.BLUE) {
            System.out.println("蓝方(北-南)胜利!");
        } else {
            System.out.println("棋盘已满。");
        }
    }

    /** 用字符画打印菱形棋盘。 */
    private static void printBoard(HexBoard b) {
        int n = b.size();
        for (int r = 0; r < n; r++) {
            StringBuilder sb = new StringBuilder();
            for (int i = 0; i < r; i++) sb.append(' ');
            for (int q = 0; q < n; q++) {
                int v = b.get(q, r);
                sb.append(v == HexBoard.EMPTY ? '.' : v == HexBoard.RED ? 'R' : 'B');
                sb.append(' ');
            }
            System.out.println(sb);
        }
        System.out.println();
    }
}

七、胜负判定的正确性论证

为什么”棋盘铺满必有一方获胜”?形式化地说明:

设红方检查东西连通、蓝方检查南北连通。把棋盘外的东西两侧边界染红、南北两侧边界染蓝。铺满后,每个格子都属于红或蓝。由平面图的对偶性质,红路径(东西贯通)与蓝路径(南北贯通)在拓扑上不能同时不存在:若不存在红路径,则红区域被蓝区域分隔,蓝区域必然从北到南贯通。这正是 Hex 无平局的根本原因。

在代码层面,我们依赖 BfsJudge 的正确性:BFS 从起始边界出发,只沿同色格子扩散,若能触达对侧边界,说明存在一条同色路径。由于 BFS 遍历的是连通分量,触达即等价于存在路径,判定是充分必要的。

八、复杂度与工程要点

环节 时间复杂度 空间复杂度 说明
并查集落子判定 O(α(n²)) O(n²) 近似常数,但不支持撤销
BFS 胜负判定 O(n²) O(n²) 支持临时落子/撤销
AI 单步致胜扫描 O(n²) × O(n²) O(n²) 每个空点跑一次 BFS
AI 启发式评估 O(n⁴) 量级 O(n²) 每步 clone + 双 BFS + 桥检测

几个实测经验:

  1. n = 7 时 AI 每步耗时约 20–50 ms,完全可以接受;n = 11 时会升到数百毫秒,因为空点数从 49 涨到 121,且每次 BFS 规模变大。
  2. stamp 技巧是性能关键:如果每次 BFS 都 new boolean[n*n] 并清零,GC 压力会让 AI 明显变慢。
  3. 桥检测可以只在候选点附近计算(局部桥数),而不是全盘扫描,能把评估从 O(n⁴) 降到 O(n²) 量级。
  4. cellsSnapshot() 的 clone 是隐性开销。若把 BfsJudge 改为接收 int[] cells 参数并直接从 HexBoard 取内部数组引用(构造函数传入),可以省掉全部拷贝。

九、延伸:从 Hex 到一般连通性博弈

Hex 的判定框架可以推广到很多同类游戏:

  • Y 形棋:在三角棋盘上连接三个顶点,判定用三源 BFS;
  • 桥牌类围堵游戏:判断”是否被对手完全切断”,等价于求最小割;
  • 电子电路布线:判断两点是否连通,本质相同。

进一步地,若把”最短连通路径”作为目标,Hex 就变成了一个最短路问题:给每个空格赋代价 1、己方棋子赋代价 0、对方棋子赋不可通行,然后用 0-1 BFS 求己方从起始边到目标边的最小代价。这个代价其实是极好的评估函数——它直接告诉 AI”我还差几步能连通”。实现只需把队列换成双端队列:

import java.util.ArrayDeque;
import java.util.Deque;

/** 用 0-1 BFS 计算某方连通所需的"追加落子数"下界。 */
public int minAdditionalStones(int[] cells, int player) {
    final int INF = Integer.MAX_VALUE;
    int[] dist = new int[n * n];
    java.util.Arrays.fill(dist, INF);
    Deque<Integer> dq = new ArrayDeque<>();

    for (int i = 0; i < n; i++) {
        int q = (player == HexBoard.RED) ? 0 : i;
        int r = (player == HexBoard.RED) ? i : 0;
        int idx = r * n + q;
        if (cells[idx] == player) {
            dist[idx] = 0;
            dq.addFirst(idx);
        }
    }

    while (!dq.isEmpty()) {
        int idx = dq.pollFirst();
        int q = idx % n, r = idx / n;
        if ((player == HexBoard.RED && q == n - 1) ||
            (player == HexBoard.BLUE && r == n - 1)) {
            return dist[idx];
        }
        for (int d = 0; d < 6; d++) {
            int nq = q + DQ[d], nr = r + DR[d];
            if (nq < 0 || nq >= n || nr < 0 || nr >= n) continue;
            int nIdx = nr * n + nq;
            if (cells[nIdx] == oppOf(player)) continue;   // 对方棋子不可通行
            int w = (cells[nIdx] == player) ? 0 : 1;
            if (dist[idx] + w < dist[nIdx]) {
                dist[nIdx] = dist[idx] + w;
                if (w == 0) dq.addFirst(nIdx);
                else dq.addLast(nIdx);
            }
        }
    }
    return INF;
}

private int oppOf(int player) {
    return (player == HexBoard.RED) ? HexBoard.BLUE : HexBoard.RED;
}

这是 Hex AI 中最有价值的评估函数之一:双方 minAdditionalStones 的差值,就是”谁离胜利更近”的量化表达。它比”连通链长度”更准确,因为它考虑了绕行成本。

十、小结

本文从六角棋的规则出发,完整实现了:

  1. 轴向坐标的六边形邻居模型,用统一的 6 偏移数组处理所有格子;
  2. 并查集判定,O(α) 增量判断,适合正式对局;
  3. stamp-BFS 判定,支持落子/撤销,适合搜索;
  4. 三层 AI 决策:单步取胜 → 单步封堵 → 启发式评估;
  5. 桥检测与 0-1 BFS 最小追加落子两个进阶评估手段。

算法要点浓缩成一句话:把”连通性”抽象成图论可达性问题,胜负判定就退化为一次 BFS;把”离赢还差多少”抽象成带权最短路,AI 就有了方向感。