四连环(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 的剪枝率完全取决于好着法被尝试的顺序。四连环有一个非常自然且有效的排序原则:先试中间列。
原因有二:
- 中心列参与的连线窗口最多。第 3 列(索引 3)参与的横向四格窗口有 4 个,第 0 列只有 1 个。占据中心意味着更多的攻防潜力。
- 中心列的局面变化更快。搜索树中,中心列的分支往往更快到达「胜负已定」,从而更早产生 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 |
改进方向
- Zobrist 置换表:四连环的置换率极高(不同落子顺序到达同一局面),置换表能再省 30~50% 节点。需要给每个
(列, 行)位置分配随机数,并在play里增量异或。 - 威胁空间搜索(TSS):只在「能立即获胜 / 必须堵截」的着法上构建搜索树,跳过所有中性着法。这是四连环专用引擎的核心,能把深度 15+ 的必胜判定压缩到毫秒级。
- 位棋盘对称性约化:空盘与近空盘时棋盘左右镜像对称,只需搜索 4 列而非 7 列,直接省一半。
- 空盘开局库:先手必胜的第一步是中间列,第二步及之后的最优应手可以预计算成一个小型开局库(约 20 个局面),避免每次重复搜索。
- 其余空位计数:终局阶段用「剩余空格数」判断是否还能形成四连(若某方向剩余空位不足,直接剪掉),能显著效果于深度 20+ 的端局。
- 评估权重的自对弈调参: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。