每日算法 — 使用java实现四连环:位棋盘与迭代加深负极大值搜索

四连环(Connect Four)是「规则一分钟讲完、棋力一辈子练不完」的典型:7 列 6 行,双方轮流落子,先连成四子者胜。它的理论分支因子是 7,看似简单,但 1974 年就被证明先手必胜,而这个必胜策略需要 42 层深度的完全搜索才能得到——人类至今也没能靠直觉完全掌握。

它因此成为位运算与博弈搜索最好的练习场:一个 long 就能装下整张棋盘,四连判定只要 6 次位运算,而搜索效率的差距可以大到 1860 倍(本文实测:深度 10,纯 MiniMax 需 3.11 亿节点,加上列序启发只需 16.7 万节点)。

一、位棋盘编码:49 个 bit 装下整张棋盘

Connect Four 的经典编码来自 Pascal Pons 的论文《Solving Connect 4: A tutorial》,思路是给每一列分配 7 个 bit:

bit:    col 0        col 1        col 2      ...   col 6
        0 1 2 3 4 5 6 7 8 9 10 12 13 ...
行:     r0..r5 + 哨兵
  • 每列 7 位:低 6 位对应 6 个行,最高位是哨兵位(永远为 0,只用于防止列间进位串位)。
  • 全盘 7 × 7 = 49 位,只需一个 long(64 位)就能装下,还剩 15 位空闲。

为什么需要哨兵位?因为落子用「加法」实现:

    /** 该列落子后的棋子位图(不会修改棋盘) */
    long possibleMove(int col) {
        return (mask + bottomMaskCol(col)) & columnMask(col);
    }

mask + bottomMaskCol(col) 的含义是「在当前占位上加一个底部 bit」——如果该列已堆了 k 个棋子,加法会把 1 进位到第 k+1 位,正好是「下一个空位」。但如果没有哨兵位,当一列堆满 6 个时进位会溢出到下一列的最低位,产生幽灵棋子。哨兵位(每列第 7 位恒为 0)挡住了这个进位,& columnMask(col) 再把可能溢出到高位的脏 bit 清掉。

极简的状态表示:两个 long

整个棋盘只需要两个 long:

    private long position;   // 当前行动方的棋子
    private long mask;       // 全部棋子(双方并集)

对手的棋子 = mask ^ position。这个「异或表示」是位棋盘最优雅的地方:落子时只要交换 position 与 mask^position 的角色,就自动切换了行动方,完全不需要额外的「轮到谁」变量。

    /** 落子:position 与 mask 异或即切换行动方,再把新棋子加入 mask */
    void play(int col) {
        long move = possibleMove(col);
        position ^= mask;      // 原对手的棋子变成本方的视角
        mask |= move;          // 新棋子加入总占位
        moves++;
    }

这行 position ^= mask 值得单独看一眼:它把「我的棋子」变成「mask 减我的棋子」= 「对手的棋子」,于是 position 现在表示「新行动方的棋子」。一次异或就完成了行动方切换。

二、四连判定:6 次位运算解决四种方向

这是位棋盘最漂亮的技巧。四连有四个方向:横向、竖向、右上斜、右下斜。在「每列 7 位」的编码下,它们对应的位偏移恰好是:

方向 相邻步长 两连判定 四连判定
横向 +1 pos & (pos >> 1) 再 & (m >> 2)
竖向 +7 pos & (pos >> 7) 再 & (m >> 14)
右上斜 +6 pos & (pos >> 6) 再 & (m >> 12)
右下斜 +8 pos & (pos >> 8) 再 & (m >> 16)

原理:pos & (pos >> k) 找出「某位和它偏移 k 的邻居都为 1」的位置,即所有「二连」的起点。再把结果自身右移 2k 位取交,就得到「这组二连的后面还有一组二连」——也就是四连。

    /** 位运算四连判定:同时检查横、两条对角、竖四个方向 */
    static boolean isAlignment(long pos) {
        // 横向:相邻 1 位,四连需要偏移 1*3
        long m = pos & (pos >> 1);
        if ((m & (m >> 2)) != 0) return true;
        // 竖向:相邻 7 位(含哨兵,等价于同一列下一行)
        m = pos & (pos >> 7);
        if ((m & (m >> 14)) != 0) return true;
        // 对角(右上):6 位(7-1)
        m = pos & (pos >> 6);
        if ((m & (m >> 12)) != 0) return true;
        // 对角(右下):8 位(7+1)
        m = pos & (pos >> 8);
        if ((m & (m >> 16)) != 0) return true;
        return false;
    }

整段没有一次循环、没有一次数组访问、没有字符串操作——四条方向加总只有 8 次位运算 + 4 次比较。

实测验证(用 bit(c, r) = 1L << (c*7 + r) 构造位图):

构造的棋型 判定结果 期望
横向 4 连 (0,0)(1,0)(2,0)(3,0) true true
横向 3 连 (0,0)(1,0)(2,0) false false
竖向 4 连 (0,0)(0,1)(0,2)(0,3) true true
斜 4 连 (0,0)(1,1)(2,2)(3,3) true true
斜 4 连 (0,3)(1,2)(2,1)(3,0) true true

注意哨兵位的陷阱:如果人为把哨兵位(每列第 6 位)点亮,横向判定会误报。真实对局中哨兵位恒为 0(play 里的 & columnMask(col) 保证了这点),所以不会发生。但如果你自己手写位图做单元测试,务必只用 r ∈ [0, 5]。

三、迭代加深 + 负极大值搜索

搜索用 negamax(负极大值)形式:分数永远从「当前行动方」视角计算,父子节点取负号。这样代码里只有一套比较逻辑,不用写「max 层」和「min 层」两段几乎相同的代码。

    int think(BitBoard board, int maxDepth) {
        nodes = 0;
        int bestCol = -1;
        for (int depth = 1; depth <= maxDepth; depth++) {
            int alpha = -INF, beta = INF;
            int localBest = -1, localValue = -INF;
            int[] rootCols = useOrdering ? COL_ORDER : NATURAL_ORDER;
            for (int col : rootCols) {
                if (!board.canPlay(col)) continue;
                BitBoard next = board.copy();
                if (board.isWinningMove(col)) {
                    if (WIN_SCORE - 1 > localValue) {
                        localValue = WIN_SCORE - 1;
                        localBest = col;
                    }
                    if (WIN_SCORE - 1 > alpha) alpha = WIN_SCORE - 1;   // 别忘了更新 alpha
                    continue;
                }
                next.play(col);
                int v = -negamax(next, depth - 1, -beta, -alpha);
                if (v > localValue) { localValue = v; localBest = col; }
                if (v > alpha) alpha = v;
            }
            if (localBest != -1) bestCol = localBest;
        }
        return bestCol;
    }

迭代加深的价值不只是「随时可中断」:它的每一层结果都成为了下一层的排序先验,而排序正是 Alpha-Beta 剪枝效率的全部来源(后面有数据)。

negamax 主体:

    private int negamax(BitBoard b, int depth, int alpha, int beta) {
        nodes++;
        if (depth == 0) return evaluate(b);

        int best = -INF;
        int[] cols = useOrdering ? COL_ORDER : NATURAL_ORDER;
        for (int col : cols) {
            if (!b.canPlay(col)) continue;
            if (b.isWinningMove(col)) return WIN_SCORE - 1;   // 立即获胜,无需再搜
            BitBoard next = b.copy();
            next.play(col);
            int v = -negamax(next, depth - 1, -beta, -alpha);
            if (v > best) best = v;
            if (v > alpha) alpha = v;
            if (usePrune && alpha >= beta) break;              // 剪枝
        }
        if (best == -INF) return 0;                            // 无子可落(满盘)
        return best;
    }

if (b.isWinningMove(col)) return WIN_SCORE - 1; 这一行是效率的关键:一旦发现能立即获胜的着法,不必再搜索这一层的其他分支,直接返回。在一个 Alpha-Beta 搜索里,这种「必胜短路」能省掉整棵子树。

四、列序启发:让剪枝真正生效

Alpha-Beta 的剪枝率完全取决于好着法被尝试的顺序。四连环有一个非常自然且有效的排序原则:先试中间列。

原因有二:

  1. 中心列参与的连线窗口最多。第 3 列(索引 3)参与的横向四格窗口有 4 个,第 0 列只有 1 个。占据中心意味着更多的攻防潜力。
  2. 中心列的局面变化更快。搜索树中,中心列的分支往往更快到达「胜负已定」,从而更早产生 alpha/beta 截断。
    /** 中心优先列序:4 最重要,其次 3/5,再次 2/6,最后 1/7 */
    static final int[] COL_ORDER = {3, 4, 2, 5, 1, 6, 0};

只靠「3 变 7」这个顺序,就能带来一个数量级的差距——下面有消融测试的数据。

五、评估函数:四格窗口打分

深度到不了底层时,评估函数决定棋力。四连环的经典方案是枚举全部 69 个「四格窗口」(所有可能形成四连的四格线段),按窗口内双方子数打分:

    /** 全部 69 个「四格窗口」的位掩码,静态预计算 */
    private static final long[] WINDOWS = buildWindows();

    private static long[] buildWindows() {
        java.util.List<Long> list = new java.util.ArrayList<>();
        int[][] dirs = {{0, 1}, {1, 0}, {1, 1}, {1, -1}};   // 横、竖、右上斜、右下斜
        for (int c = 0; c < BitBoard.COLS; c++) {
            for (int r = 0; r < BitBoard.ROWS; r++) {
                for (int[] d : dirs) {
                    long m = 0;
                    boolean ok = true;
                    for (int k = 0; k < 4; k++) {
                        int cc = c + d[0] * k, rr = r + d[1] * k;
                        if (cc < 0 || cc >= BitBoard.COLS || rr < 0 || rr >= BitBoard.ROWS) { ok = false; break; }
                        m |= 1L << (cc * (BitBoard.ROWS + 1) + rr);
                    }
                    if (ok) list.add(m);
                }
            }
        }
        long[] out = new long[list.size()];
        for (int i = 0; i < out.length; i++) out[i] = list.get(i);
        return out;
    }

    /**
     * 静态评估:经典「四格窗口打分」。
     * 对 69 个可能四连的窗口,按「我方子数 / 对方子数」给出权重:
     *  3 子 + 1 空 → 强威胁;2 子 + 2 空 → 潜力;对方占位则反向扣分。
     */
    private int evaluate(BitBoard b) {
        long pos = b.getPosition();
        long opp = pos ^ b.getMask();
        int score = 0;

        for (long w : WINDOWS) {
            int mine = Long.bitCount(pos & w);
            int theirs = Long.bitCount(opp & w);
            if (mine > 0 && theirs > 0) continue;            // 双方混杂,窗口已死
            if (mine == 3) score += 50;                      // 我方 3 子 1 空:直接威胁
            else if (mine == 2) score += 10;
            else if (mine == 1) score += 1;
            if (theirs == 3) score -= 50;
            else if (theirs == 2) score -= 10;
            else if (theirs == 1) score -= 1;
        }

        for (int c = 0; c < BitBoard.COLS; c++) {            // 中心列控制
            int wgt = 3 - Math.abs(3 - c);
            score += Long.bitCount(pos & BitBoard.columnMask(c)) * wgt * 2;
            score -= Long.bitCount(opp & BitBoard.columnMask(c)) * wgt * 2;
        }
        return score;
    }

关键在于 if (mine > 0 && theirs > 0) continue;:一个窗口里如果双方都有棋子,它就永远不可能形成四连,直接跳过。这个判断把 69 个窗口里绝大多数无效的淘汰掉。

权重设计(50 / 10 / 1)的直觉:

  • 3 子 1 空 = 50 分:这是「下一步即可获胜」的直接威胁。若对手也有一个 3 子窗口,双方会陷入威胁交换,但单侧的 50 分差已经足以驱动 AI 优先堵截。
  • 2 子 2 空 = 10 分:潜力,需要两步才能成事,价值是直接威胁的 1/5。
  • 1 子 3 空 = 1 分:仅作为「中心控制之外的微弱倾向」,用于在等价局面下选择更靠中心的落点。

一个踩过的坑:不要用「胜负独断」

在调参过程中我试过更「激进」的评估:一旦检测到一方在某列可以立即获胜,就直接返回 WIN_SCORE。结果搜索变弱了——深度 12 的 AI 反而输给深度 8 的 AI。

原因就是视界效应(horizon effect):搜索到达深度 0 时直接返回「我下一步就能赢」,但对手下一步也会赢,实际是和棋或反输。评估必须克制,只评估当前真实局面,把「下一步能赢」的判断交给搜索去演示。这是所有博弈引擎的通病,不只是四连环。

六、实测:1860 倍的剪枝差距

消融测试(空棋盘,同一初始局面):

配置 深度 6 深度 8 深度 10
列序启发 + 剪枝 3,255 19,181 167,104
仅剪枝(自然列序 0..6) 23,551 400,284 5,665,214
全关(纯 MiniMax,无剪枝) 160,125 7,230,135 311,000,008

三个结论:

  • 深度 10 时,完整版 167,104 节点 vs 纯 MiniMax 311,000,008 节点,差 1860 倍。 这是 Alpha-Beta + 列序启发的合力:剪枝本身贡献约 55 倍(5,665,214 → 167,104),列序贡献约 34 倍(5,665,214 来自 311,000,008 ÷ 55)。
  • 列序启发比剪枝本身更被低估。同样开启剪枝,自然列序(0→6)要 566 万节点,中心优先只需 16.7 万——34 倍差距全部来自「先试哪一列」这一个决定。
  • 深度的代价是超线性的。从深度 8 到 10,完整版节点数增长 8.7 倍(19,181 → 167,104),接近 b^2 = 49 的一半——这正是 O(b^{d/2}) 在真实数据上的形状。

搜索规模与耗时(空棋盘,JDK 17 单核):

深度 最佳列 节点数 耗时
2 3 27 0.0 ms
4 3 342 0.2 ms
6 3 3,255 4.2 ms
8 3 19,181 12.3 ms
10 3 167,104 50.2 ms
12 4 2,285,893 323.0 ms

空棋盘下最优列一直是 3(中心列),只在深度 12 时转向 4——因为中心列本身是对称等价的(3 和 4 都在中间,但 3 是「4 个横向窗口」与「4 也是」的对称点),深度不够时评估函数无法区分,深度足够后才看出先手应占 3。

七、自对弈:一个必须注意的测试陷阱

跑强 AI vs 弱 AI 时,我犯过一个测试设计错误:先让随机开局走一步,再让「强 AI 当先手」。结果强 AI 实际上变成了后手,而四连环是先手必胜(1988 年 Allis 证明,需要 42 层完全搜索)——于是「深度 12 输给深度 10」这种诡异结果就出现了,看起来像是评估函数坏了。

修正为「随机开局两步(双方各一步)」后,强 AI 才真正是先手。这也解释了为什么四连环的 AI 对战中,「搜索深度」的收益总被「先后手优势」掩盖:

对局 胜 平 负 平均节点/步
深度 8 vs 4 4 4 2 17,681
深度 8 vs 6 3 2 5 20,046
深度 10 vs 6 3 4 3 169,478
深度 10 vs 8 3 2 5 135,422
深度 12 vs 10 4 3 3 1,552,245
深度 12 vs 8 5 1 4 1,556,497

结论很明确:在四连环里靠加深搜索赢不了多少,因为真正决定胜负的是「双威胁(double threat)」这种需要 7~9 层前瞻才能看穿的战术模式,而超过 10 层之后,评估函数的误差开始主导结果。这也是为什么成熟的四连环引擎(如 Pons 的开源解算器)用的是基于威胁空间的搜索(Threat Space Search)+ 精确终局解算,而不是单纯加深 Alpha-Beta。深度 12 的完全解算需要 40 秒以上,而「先手必胜」的完整证明需要 42 层——这就是为什么它至今仍是一个「已解但未实用化」的游戏。

八、复杂度与改进方向

复杂度

环节 复杂度 实测
落子 play O(1),6 次位运算 < 5 ns
四连判定 isAlignment O(1),8 次位运算 + 4 次比较 < 5 ns
静态评估 O(69) 窗口 + O(7) 中心 约 0.3 μs
搜索 最坏 O(b^d),b≤7 深度 10 = 167,104 节点
空间 O(d) 递归 + 69 个 long 窗口表 < 1 KB

改进方向

  1. Zobrist 置换表:四连环的置换率极高(不同落子顺序到达同一局面),置换表能再省 30~50% 节点。需要给每个 (列, 行) 位置分配随机数,并在 play 里增量异或。
  2. 威胁空间搜索(TSS):只在「能立即获胜 / 必须堵截」的着法上构建搜索树,跳过所有中性着法。这是四连环专用引擎的核心,能把深度 15+ 的必胜判定压缩到毫秒级。
  3. 位棋盘对称性约化:空盘与近空盘时棋盘左右镜像对称,只需搜索 4 列而非 7 列,直接省一半。
  4. 空盘开局库:先手必胜的第一步是中间列,第二步及之后的最优应手可以预计算成一个小型开局库(约 20 个局面),避免每次重复搜索。
  5. 其余空位计数:终局阶段用「剩余空格数」判断是否还能形成四连(若某方向剩余空位不足,直接剪掉),能显著效果于深度 20+ 的端局。
  6. 评估权重的自对弈调参:50/10/1 与中心权重 2 都是手调值,用 SPSA 或爬山法在几百局自对弈中优化,通常能再提升一档棋力。

九、小结

四连环的三层结构非常清晰:

  • 表示层:49 位 + 两个 long(position / mask),用「异或」完成行动方切换,用「哨兵位 + 加法进位」完成落子定位。整张棋盘的状态拷贝只是两次 long 赋值——比任何对象拷贝都快。
  • 判定层:四连判定用 4 个方向的 pos & (pos>>k) 二次交叠,8 次位运算解决问题,比遍历 4×4 窗口快两个数量级。
  • 搜索层:迭代加深 + Alpha-Beta + 中心优先列序。实测深度 10 时,从纯 MiniMax 的 3.11 亿节点降到 16.7 万节点,1860 倍。其中 34 倍完全来自「先试中间列」这一个顺序决定。

最后留一个值得记住的工程教训:自对弈测试一定要控制先后手。四连环先手必胜,如果测试把强 AI 放在后手,你会得到一个「加深搜索反而变弱」的假结论,然后花大量时间去修一个根本不存在的评估bug。