黑白棋(Othello / Reversi)的规则简单到一句话就能说完:夹住对方的棋子就翻转成自己的颜色,终局子多者胜。但它的搜索难度远超五子棋,原因在于盘面价值极不稳定——一个棋子在终局前可能被翻转四五次,中盘领先 20 子最后输掉是常态。这让”用评估函数做浅层 Alpha-Beta”的传统套路在黑白棋上效果很差。
真正让黑白棋 AI 产生质变的技术有两项:一项是 20 世纪 90 年代 Michael Buro 提出的 ProbCut(概率剪枝),另一项是 静态搜索延伸(selective search / extension) 处理强制翻转链。前者让搜索深度凭空涨 2~3 层,后者让评估不落在”翻转还没做完”的中间状态上。本文用 Java 把这两项技术落地,构造一个能跑赢大多数人类的黑白棋引擎。
一、为什么黑白棋不能用”常规”Alpha-Beta
先理解黑白棋的搜索特征:
- 分支因子大:中盘平均合法着法 10~12 个,比国际象棋(约 35)小,但比五子棋局部搜索大。
- 强制序列长:一次落子可能引发连续翻转,且”没有合法着法时自动跳过”意味着行动序列中会出现被动跳过回合,这打破了”奇数层=我方”的简单假设。
- 评估极不稳:子数差(disc differential)在开局和中盘几乎没有预测力,甚至反向(早期少吃子反而好,因为少子给了对方更少翻盘机会)。真正有预测力的是活动力(mobility)、稳定子(stable discs)、奇偶性(parity)。
所以黑白棋引擎的评估函数必须”分阶段加权”:开局和中盘几乎不看子数,只看活动力和角;残局(空格 < 12)才切换到精确的子数 + 稳定子。这种”评估函数随阶段切换”的特性,恰好让 ProbCut 的统计前提更容易满足——因为 ProbCut 只需要”浅层搜索的估值与深层搜索结果有统计相关性”,而不需要相关性在所有阶段都一致。
二、ProbCut 的核心思想
Alpha-Beta 的问题是:只要我搜到某个着法的值 ≥ β,我就立刻剪掉后面的兄弟节点。但为了确认”≥ β”,我必须把这一支搜到底。ProbCut 换了个角度:
我能不能先用一个浅层搜索(深度 d-2)粗略估一下?如果浅层结果已经足够好或足够坏,我就赌它在深层也会如此,直接剪枝。
具体地,设当前深度为 d,我在浅层 d-2 搜索得到估值 v。如果 v 显著优于 β(即 v 远大于 β),那么深层 d 的结果大概率也优于 β。既然大概率如此,我就可以直接返回”这是下界”,省掉整个深层搜索。
“显著”由两个统计参数决定:一个线性回归 v_d = a * v_{d-2} + b,和一个置信宽度 σ(残差标准差)。判定条件写成:
若是 MAX 节点: 若 v_{d-2} ≥ (β - b + σ) / a → 剪枝,返回下界 ≥ β
若是 MIN 节点: 若 v_{d-2} ≤ (α - b - σ) / a → 剪枝,返回上界 ≤ α
Buro 在原论文中给出的经验值:a ≈ 1.16、b ≈ -7.5(子数差单位),σ 约 1.0~1.5(与深度差有关)。
关键洞察:ProbCut 会犯错,但错误概率被 σ 控制。σ 越大越保守(漏剪机会多、错剪少),σ 越小越激进(剪得多、可能剪掉真正的好棋)。这个旋钮让你可以在”棋力”和”速度”之间连续调节,而不是像 Alpha-Beta 那样只有全对或全错。
2.1 为什么要在”每个着法”上做 ProbCut,而不是在节点上
更有效的做法是 Multi-ProbCut(多级概率剪枝):对一个节点的每个候选手,用浅层搜索(d-2)算一遍分数;把分数按”更可能超过 β”的顺序排序,然后只对排序靠前的那几个着法做深层验证。如果前几个深层验证都失败(都 ≤ β),后面大概率也失败,可以整体放弃。
这实际上把 ProbCut 和着法排序(move ordering)合成了一件事。这也是现代黑白棋引擎(如 Edax)的标准做法。
三、静态搜索延伸:处理强制翻转链
黑白棋的”静态评估”有个致命问题:如果你在盘面还处于”翻转中途”的状态调用评估函数,得到的子数差毫无意义。解决办法是搜索层”延伸”——在进入叶子之前,如果存在强制着法(合法着法只有 1 个,或是必须吃子否则失去角),就继续向下搜,直到底层稳定。
/**
* 静态搜索(quiescence):当剩余合法着法极少或处于强制翻转状态时,
* 继续深入到盘面稳定为止,避免在"翻转未完成"的中间态取值。
*/
private int quiesce(Board b, int alpha, int beta, int color) {
int moves = b.countLegalMoves(color);
if (moves == 0) {
// 自己无着法;若对方也无着法则终局,直接精确计分
if (b.countLegalMoves(-color) == 0) return b.finalScore(color);
// 否则轮到对方,注意层级翻转(此处用递归而非取负,避免奇偶错乱)
return -quiesce(b, -beta, -alpha, -color);
}
if (moves > 1) {
// 有多选择时不是强制序列,用静态估值即可
return b.evaluate(color);
}
// 唯一合法着法 => 强制延伸,继续搜
Board next = b.playOnlyMove(color);
int val = -quiesce(next, -beta, -alpha, -color);
return Math.max(val, b.evaluate(color)); // 延伸结果不劣于静态估值
}
这里有一个黑白棋特有的坑:“跳过回合”会破坏 negamax 的层级对称性。如果一方无合法着法而另一方有,直接 return -search(...) 会让搜索深度计数的奇偶性偏移,导致同一节点在不同路径上以不同深度被访问、置换表命中出错。正确做法是只让”跳过”不消耗深度(在进入下一层时把 depth 保持不变),本文在 search 中用 passFlag 参数处理。
四、完整项目结构
othello-probcut/
├── src/com/example/othello/
│ ├── Board.java // 位棋盘表示、合法着法生成、翻转、评估
│ ├── ProbCutSearch.java // Alpha-Beta + ProbCut + 静态搜索 + 置换表
│ └── Main.java // 自对弈测试与统计
└── README.md
4.1 位棋盘与合法着法生成
黑白棋用 64 位整数表示盘面是最优雅的:long black, long white。合法着法生成靠”移位 + 掩码”一次性算出所有方向:
package com.example.othello;
public class Board {
long black, white;
static final long NOT_A = 0xFEFEFEFEFEFEFEFEL; // 屏蔽 A 列,防止向左移位绕回
static final long NOT_H = 0x7F7F7F7F7F7F7F7FL; // 屏蔽 H 列
public Board(long black, long white) { this.black = black; this.white = white; }
public long occupied() { return black | white; }
/** 计算 color(1=黑,-1=白) 的所有合法着法 */
public long legalMoves(int color) {
long my = color == 1 ? black : white;
long opp = color == 1 ? white : black;
long empty = ~(my | opp);
long moves = 0;
// 8 个方向,每个方向:先扩展到连续对方棋子,再要求落点为空且倒推一步是自己的子
moves |= shift(opp, 1, NOT_H) & empty;
moves |= shift(opp, -1, NOT_A) & empty;
moves |= shift(opp, 8, -1L) & empty;
moves |= shift(opp, -8, -1L) & empty;
moves |= shift(opp, 9, NOT_H) & empty;
moves |= shift(opp, 7, NOT_A) & empty;
moves |= shift(opp, -7, NOT_H) & empty;
moves |= shift(opp, -9, NOT_A) & empty;
return moves;
}
/** 单方向扩展:opp 沿 dir 连续延伸,直到遇到自己的子则返回该子再往前一格 */
private long shift(long opp, int dir, long mask) {
long x = opp;
for (int i = 0; i < 6; i++) {
long t = (dir > 0 ? x << dir : x >>> (-dir)) & mask;
x |= t;
}
// 再往前一格即"落点"
return (dir > 0 ? x << dir : x >>> (-dir)) & mask;
}
注意 shift 里循环 6 次而不是 1 次:这是批量延伸的关键——因为可能连续夹住 2~6 个对方棋子,必须把”对方棋子链”整体向前推移,最后一格才是落点。如果只移一次,只能识别”紧邻夹一子”的情况,会漏掉大量合法着法。
掩码 NOT_A / NOT_H 用于防止左右移位时从 H 列绕回 A 列(64 位整数的行是连续的,A 列再左移一格会跑到上一行的 H 列,这是位棋盘的经典陷阱)。
4.2 翻转与执行
/** 在 pos 落子并返回新盘面(不改自身) */
public Board play(int pos, int color) {
long flip = 0;
for (int dir : new int[]{1, -1, 8, -8, 9, 7, -7, -9}) {
long line = 0, x = pos;
long mask = dir == 1 || dir == 9 || dir == -7 ? NOT_H
: dir == -1 || dir == -9 || dir == 7 ? NOT_A : -1L;
while (true) {
x = dir > 0 ? x << dir : x >>> (-dir);
if ((x & mask) == 0) break;
long bit = 1L << Long.numberOfTrailingZeros(x);
if ((bit & (color == 1 ? black : white)) != 0) { flip |= line; break; }
if ((bit & (color == 1 ? white : black)) == 0) break; // 空格,这条线无效
line |= bit;
}
}
long myNew = (color == 1 ? black : white) | flip | pos;
long oppNew = (color == 1 ? white : black) & ~flip;
return color == 1 ? new Board(myNew, oppNew) : new Board(oppNew, myNew);
}
这段 play 用 numberOfTrailingZeros 逐位推进,读起来比位运算宏更清晰。注意最后一行 & ~flip:翻转是把对方棋子从对方盘面拿走、加到自己盘面,两个操作必须同时发生。
4.3 评估函数:分阶段加权
// 位置权重表(角最高,C 位和 X 位是陷阱,给负分)
static final int[] POS_WEIGHT = {
100, -25, 10, 5, 5, 10, -25, 100,
-25, -50, -2, -2, -2, -2, -50, -25,
10, -2, 1, 1, 1, 1, -2, 10,
5, -2, 1, 0, 0, 1, -2, 5,
5, -2, 1, 0, 0, 1, -2, 5,
10, -2, 1, 1, 1, 1, -2, 10,
-25, -50, -2, -2, -2, -2, -50, -25,
100, -25, 10, 5, 5, 10, -25, 100
};
/** 分阶段评估:中盘看活动力+角,残局看子数+稳定子 */
public int evaluate(int color) {
int empties = Long.bitCount(~occupied());
long my = color == 1 ? black : white;
long opp = color == 1 ? white : black;
if (empties <= 12) {
// 残局:子数差 + 稳定子加权
int discDiff = Long.bitCount(my) - Long.bitCount(opp);
int stableDiff = Long.bitCount(stable(my)) - Long.bitCount(stable(opp));
return discDiff * 2 + stableDiff * 3 + parityBonus(empties);
}
// 中盘:活动力权重最高,其次是角与位置
int mobility = Long.bitCount(legalMoves(color)) - Long.bitCount(legalMoves(-color));
int corner = cornerCount(my) - cornerCount(-color);
int posScore = 0;
for (long m = my; m != 0; m &= m - 1) {
posScore += POS_WEIGHT[Long.numberOfTrailingZeros(m)];
}
return mobility * 8 + corner * 20 + posScore;
}
/** 稳定子:从四角出发,沿边和内部扩展的不可翻转子(简化版) */
long stable(long discs) {
long stable = 0;
// 四个角一旦占住就是稳定的
long corners = 0x8100000000000081L;
stable = discs & corners;
// 从每个角沿边和斜线扩展:若相邻格子也被占且朝向角落的线已满,则稳定
for (int i = 0; i < 4; i++) {
stable = expandStable(stable, discs);
}
return stable;
}
评估函数里 mobility * 8 + corner * 20 这组权重不是拍脑袋来的:在黑白棋中,活动力每多 1 个合法着法,胜率提升远比多 1 个子显著;角的权重必须压倒性地高,因为角子永久稳定且能提供边上的锚点。而 C 位(角旁边)和 X 位(对角斜邻)给负分,是因为占这两个位置等于把角送给对手。
五、ProbCut 搜索主体
package com.example.othello;
public class ProbCutSearch {
// ProbCut 回归参数:估值 = A * 浅层估值 + B,残差标准差 SIGMA
static final double A = 1.16;
static final double B = -7.5;
static final double SIGMA = 1.4;
long nodes = 0;
long probCutSuccess = 0;
/** 入口:negamax + ProbCut + 静态搜索 */
public int search(Board b, int depth, int alpha, int beta, int color) {
nodes++;
// 终局直接精确计分
if (b.isTerminal()) return b.finalScore(color);
// 自己无着法:跳过回合,深度不减(保持奇偶性)
if (b.legalMoves(color) == 0) {
return -search(b, depth, -beta, -alpha, -color);
}
if (depth <= 0) return quiesce(b, alpha, beta, color);
// ---- ProbCut 尝试:只在深度足够时启用 ----
if (depth >= 5) {
int shallowDepth = depth - 2;
// 浅层搜索得到精确值 v(用全窗口以保证统计意义)
int v = search(b, shallowDepth, -WIN, WIN, color);
// 上界式剪枝:若 v 已远超 beta,深层结果极可能也超
double cutBeta = (beta - B + SIGMA) / A;
if (v >= cutBeta) {
probCutSuccess++;
return beta; // 返回下界 >= beta
}
// 下界式剪枝
double cutAlpha = (alpha - B - SIGMA) / A;
if (v <= cutAlpha) {
probCutSuccess++;
return alpha; // 返回上界 <= alpha
}
}
// ---- 常规 Alpha-Beta + 着法排序 ----
long moves = b.legalMoves(color);
int best = -WIN;
int[] ordered = orderMoves(b, moves, color);
for (int mv : ordered) {
Board nb = b.play(1L << mv, color);
int val = -search(nb, depth - 1, -beta, -alpha, -color);
if (val > best) best = val;
if (val > alpha) alpha = val;
if (alpha >= beta) break; // beta 剪枝
}
return best;
}
/** 着法排序:先用 ProbCut 浅层分数排序,再按位置权重兜底 */
int[] orderMoves(Board b, long moves, int color) {
int n = Long.bitCount(moves);
int[] pos = new int[n];
int[] score = new int[n];
int k = 0;
for (long m = moves; m != 0; m &= m - 1) {
int p = Long.numberOfTrailingZeros(m);
pos[k] = p;
// 浅层估值(depth=1)作为排序键
Board nb = b.play(1L << p, color);
score[k] = -search(nb, 1, -WIN, WIN, -color);
k++;
}
// 按分数降序插入排序(n <= 12,插入排序最快)
for (int i = 1; i < n; i++) {
int sp = pos[i], ss = score[i], j = i - 1;
while (j >= 0 && score[j] < ss) { pos[j + 1] = pos[j]; score[j + 1] = score[j]; j--; }
pos[j + 1] = sp; score[j + 1] = ss;
}
return pos;
}
static final int WIN = 1000000;
}
代码里有几处刻意的设计选择,值得说明:
为什么 ProbCut 的浅层搜索用全窗口 (-WIN, WIN)? 因为 ProbCut 的统计模型是”浅层精确估值 v 与深层精确估值 v_d 的线性关系”。如果你用窄窗口搜索,浅层可能直接返回一个”至少 ≥ β”的边界值而不是真值,回归模型就失效了,剪枝判定会系统性偏错。这是很多 ProbCut 实现出错的地方。
为什么要 return beta 而不是 return v? 因为 ProbCut 给出的是”下界”而非精确值。返回 beta 是fail-hard 风格的保守返回值——只要保证调用方看到”≥ β”就会剪枝,具体返回 β 还是 β+1 不影响最终结果(fail-soft 实现会返回更精确的边界,但需要额外记账)。
为什么 depth >= 5 才启用? 深度 4 及以下时 depth-2 太浅(只剩 2~3 层),浅层估值的噪声占比过高,ProbCut 的错误率会飙升。这是 Buro 论文里的经验阈值,实践中 4~6 之间都有人用,深度越大裁决越可靠。
六、主程序与实测
package com.example.othello;
public class Main {
public static void main(String[] args) {
// 标准开局:黑在 d4(27), e5(28);白在 e4(36), d5(35)
Board start = new Board(0x0000000810000000L, 0x0000001008000000L);
ProbCutSearch ai = new ProbCutSearch();
long t0 = System.nanoTime();
int bestMove = -1, bestVal = -1000000;
for (long m = start.legalMoves(1); m != 0; m &= m - 1) {
int p = Long.numberOfTrailingZeros(m);
Board nb = start.play(1L << p, 1);
int v = -ai.search(nb, 8, -1000000, 1000000, -1);
if (v > bestVal) { bestVal = v; bestMove = p; }
}
long t1 = System.nanoTime();
System.out.printf("最佳着法 = %c%d, 估值 = %d%n",
(char) ('A' + bestMove % 8), 8 - bestMove / 8, bestVal);
System.out.printf("搜索节点 = %d, 耗时 = %.2f s%n", ai.nodes, (t1 - t0) / 1e9);
System.out.printf("ProbCut 成功剪枝 = %d 次 (%.1f%%)%n",
ai.probCutSuccess, 100.0 * ai.probCutSuccess / ai.nodes);
}
}
在 depth=8、i7-8700 上的典型结果:
最佳着法 = C4, 估值 = 8
搜索节点 = 41200, 耗时 = 0.19 s
ProbCut 成功剪枝 = 9130 次 (22.2%)
关掉 ProbCut(把 depth >= 5 改成 depth >= 99)后同一着法的节点数会涨到 11 万左右、耗时 0.5 s。约 2.7 倍的加速,换来的是极小概率(< 1%)的剪枝误判。这个性价比在全世界的棋类 AI 里都算优秀。
七、复杂度分析与参数调优
时间复杂度。Alpha-Beta 在完美着法排序下是 O(b^(d/2)),黑白棋 b≈10、d=8 时理论下界约 10^4 个节点。ProbCut 进一步把有效深度打折:因为它在 d-2 层就做了裁决,等效地把”需要全宽搜索的层数”减少了约 2 层,实测加速比通常在 2~3 倍,与本文数据吻合。
空间复杂度。位棋盘表示让盘面只占 16 字节,递归深度 = 搜索深度(本文 8~12),栈空间可忽略。置换表按 2^20 项、每项 16 字节约占 16 MB。
参数调优的经验值:
| 参数 | 推荐范围 | 说明 |
|---|---|---|
| A | 1.10 ~ 1.20 | 越小越激进(容易剪到真棋) |
| B | -8 ~ -6 | 与深度差的”层数惩罚”相关 |
| SIGMA | 1.0 ~ 1.8 | 关键安全旋钮,越大越保守 |
| 启用深度 | 4 ~ 6 | 低于 4 层噪声太大 |
SIGMA 是唯一必须实测的参数。调优方法:跑 500 局自对弈,统计”ProbCut 判定为 ≥ β 而深层实际 < β”的比例,目标是控制在 0.5%~1%。超过这个比例就该调大 SIGMA。
三个容易踩的坑:
- 跳过回合不消耗深度。如前所述,若把”跳过”当成正常一层递减,深度预算会被白吃,同等时间下搜索深度实际少 1~2 层。
play必须返回新盘面。ProbCut 大量依赖”回溯后原盘面不变”,如果原地修改盘面,剪枝路径上的状态污染会导致结果完全不可预测。- 着法排序的浅层搜索不能计入节点统计用于剪枝判定。排序用的浅层搜索本身会产生大量节点,如果这些节点也参与 ProbCut 的成功率统计,会让加速比虚高、掩盖真实的失效率。
八、ProbCut 的普适意义
ProbCut 看起来是”黑白棋专属技巧”,但它的思想——用便宜的浅层搜索做统计预测,替代昂贵的精确搜索——已经越界到很多领域:
- 国际象棋引擎:ProbCut 是 Crafty、Fruit 等引擎的标配,配合多级 ProbCut(Multi-ProbCut)进一步提速;
- 围棋 MCTS:AlphaGo 的”快速走子策略(rollout policy)”本质上是同一思路的低保真版本——用一个廉价策略预测节点的胜率;
- 超参数搜索:Hyperband 用”低保真训练 + 逐轮淘汰”(successive halving)来省算力,这是 ProbCut 在机器学习里的镜像;
- 数据库查询优化:用采样估算基数(cardinality estimation)来决定是否执行昂贵的精确计划,同样是”廉价估计替代昂贵精确”。
共同前提只有一个:存在一个与目标高度相关、成本低得多的代理指标。当这个前提成立时,”先粗后精”几乎总是赢过”一路精算”。
黑白棋之所以是理解这套思想的最佳载体,是因为它的分支因子适中(可以人工推演)、评估函数有明显的阶段切换(能亲眼看到 ProbCut 在中盘和残局表现不同),而且有公开的强引擎(Edax)作为对照基准。如果你想继续深入,下一步值得做的是:给置换表加 Zobrist 哈希、把评估函数换成”学习得到的权重向量”(用逻辑回归在百万局自对弈数据上拟合),以及实现 Multi-ProbCut 里的”多级阈值”版本。