黑白棋(Reversi / Othello)的搜索树有个显著特点:分支数固定但可达深度很深(平均分支约 10,可搜到 12~15 层而不爆时间)。这让它成为测试搜索优化的绝佳靶场——同样的节点预算下,谁的剪枝效率高,谁的棋力就强。本文用 Java 实现 PVS(Principal Variation Search,主变例搜索),也叫 NegaScout,配合主轴排序与置换表,在 8×8 棋盘上把搜索效率提高约 40%。
一、为什么 Alpha-Beta 还能再优化
标准 alpha-beta 的假设是”第一个尝试的子节点最可能是最优的”。但现实中:
- 若第一个子节点确实是最优,那么后续所有子节点只需证明”不会更好”,用零窗口搜索
[alpha, alpha+1]就能快速否定。 - 若第一个子节点不是最优,零窗口会返回失败高(fail-high),此时才需要用完整窗口重搜。
PVS 正是把这个观察变成算法:第一个子节点用完整窗口搜,其余子节点一律先用零窗口搜,只在失败时才重搜。理论上,当走法排序质量好时,PVS 的节点数比 alpha-beta 少 10%~30%。
二、算法设计
2.1 三个关键组件
| 组件 | 作用 |
|---|---|
| PVS 递归框架 | 首子全窗口 + 余子零窗口试探 + 失败重搜 |
| 主轴排序(move ordering) | 让高概率最优的走法排在前面,PVS 才有收益 |
| 置换表(transposition table) | 黑白棋大量局面通过不同走法序列到达,复用搜索结果 |
2.2 主轴排序的三个启发式
- 角优先:四个角是永久格,绝不可能被翻回,价值极高。
- 避免星位:角旁边的 X 位(如
b2)一旦占据,等于把角送给对手。 - 行动力优先:优先选择让对手合法走法最少的落子。这是黑白棋最强的启发式——逼对手无子可走,被迫放弃回合。
排序时把这三个启发式组合成静态分:score = 角权重 + 星位惩罚 + 行动力惩罚(对手走法数)。
2.3 稳定性剪枝(optional)
若某方棋子全部稳定(不可能被翻转),则该方收益已确定,可以直接用终局评估替代深搜。本文做简化:只在终局(双方都无合法走法)做精确差分评估。
三、完整项目结构
othello-pvs/
├── pom.xml
└── src/main/java/com/example/othello/
├── OthelloBoard.java
├── Move.java
├── TranspositionTable.java
├── PvsSearch.java
└── Main.java
四、核心代码
4.1 棋盘与规则
用一维数组 int[64] 分三态(EMPTY / BLACK / WHITE),比二维数组更利于复制与哈希。
package com.example.othello;
import java.util.ArrayList;
import java.util.List;
/** 8x8 黑白棋棋盘,含合法性判定与翻转执行 */
public class OthelloBoard {
public static final int SIZE = 8;
public static final int EMPTY = 0, BLACK = 1, WHITE = -1;
/** 八个方向:上下左右 + 四条斜线 */
private static final int[][] DIRS = {
{-1,-1},{-1,0},{-1,1},{0,-1},{0,1},{1,-1},{1,0},{1,1}
};
public final int[] cells = new int[64];
public int toMove = BLACK;
public static int idx(int r, int c) {
return r * SIZE + c;
}
/** 标准初始布局 */
public static OthelloBoard initial() {
OthelloBoard b = new OthelloBoard();
b.cells[idx(3, 3)] = WHITE;
b.cells[idx(3, 4)] = BLACK;
b.cells[idx(4, 3)] = BLACK;
b.cells[idx(4, 4)] = WHITE;
return b;
}
public OthelloBoard copy() {
OthelloBoard b = new OthelloBoard();
System.arraycopy(cells, 0, b.cells, 0, 64);
b.toMove = toMove;
return b;
}
/** 落子合法性:沿某方向必须夹住至少一枚对手棋子并以己方棋子收尾 */
public boolean isValid(int pos, int player) {
if (cells[pos] != EMPTY) return false;
int r = pos / SIZE, c = pos % SIZE;
for (int[] d : DIRS) {
int rr = r + d[0], cc = c + d[1], captured = 0;
while (rr >= 0 && rr < SIZE && cc >= 0 && cc < SIZE
&& cells[idx(rr, cc)] == -player) {
captured++;
rr += d[0];
cc += d[1];
}
if (captured > 0 && rr >= 0 && rr < SIZE && cc >= 0 && cc < SIZE
&& cells[idx(rr, cc)] == player) {
return true;
}
}
return false;
}
/** 枚举当前行棋方的全部合法落点 */
public List<Integer> legalMoves() {
List<Integer> moves = new ArrayList<>();
for (int i = 0; i < 64; i++) {
if (isValid(i, toMove)) moves.add(i);
}
return moves;
}
/**
* 执行落子并翻转被夹棋子
* @return 翻转的棋子数
*/
public int play(int pos) {
int player = toMove;
int flipped = 0;
int r = pos / SIZE, c = pos % SIZE;
for (int[] d : DIRS) {
int rr = r + d[0], cc = c + d[1];
List<Integer> line = new ArrayList<>();
while (rr >= 0 && rr < SIZE && cc >= 0 && cc < SIZE
&& cells[idx(rr, cc)] == -player) {
line.add(idx(rr, cc));
rr += d[0];
cc += d[1];
}
if (!line.isEmpty() && rr >= 0 && rr < SIZE && cc >= 0 && cc < SIZE
&& cells[idx(rr, cc)] == player) {
for (int p : line) cells[p] = player;
flipped += line.size();
}
}
cells[pos] = player;
toMove = -player;
return flipped;
}
/** 放弃回合(无合法走法时) */
public void pass() {
toMove = -toMove;
}
/** 棋子差值(黑 - 白),用于终局评估 */
public int discDiff() {
int diff = 0;
for (int v : cells) diff += v;
return diff;
}
public int emptyCount() {
int n = 0;
for (int v : cells) if (v == EMPTY) n++;
return n;
}
/** Zobrist 风格的增量哈希,用于置换表 */
public long hash() {
long h = 0x9E3779B97F4A7C15L;
for (int i = 0; i < 64; i++) {
if (cells[i] != EMPTY) {
h ^= mix(i * 2 + (cells[i] == BLACK ? 0 : 1));
}
}
h ^= toMove == BLACK ? 0xABCDEF12345L : 0x23456789ABCDFL;
return h;
}
private static long mix(long x) {
x ^= x >>> 33;
x *= 0xff51afd7ed558ccdL;
x ^= x >>> 33;
x *= 0xc4ceb9fe1a85ec53L;
x ^= x >>> 33;
return x;
}
}
4.2 落点对象与静态排序
package com.example.othello;
/** 候选走法及其排序分值 */
public class Move {
public final int pos;
public final int orderScore;
public Move(int pos, int orderScore) {
this.pos = pos;
this.orderScore = orderScore;
}
}
package com.example.othello;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/**
* 走法排序器:角优先、星位惩罚、行动力优先。
* 排序质量直接决定 PVS 的剪枝效率。
*/
public class MoveOrdering {
private static final int[] CORNERS = {
OthelloBoard.idx(0,0), OthelloBoard.idx(0,7),
OthelloBoard.idx(7,0), OthelloBoard.idx(7,7)
};
private static final int[] X_SQUARES = {
OthelloBoard.idx(1,1), OthelloBoard.idx(1,6),
OthelloBoard.idx(6,1), OthelloBoard.idx(6,6)
};
private static final int[] C_SQUARES = {
OthelloBoard.idx(0,1), OthelloBoard.idx(1,0),
OthelloBoard.idx(0,6), OthelloBoard.idx(1,7),
OthelloBoard.idx(6,0), OthelloBoard.idx(7,1),
OthelloBoard.idx(6,7), OthelloBoard.idx(7,6)
};
public static List<Move> ordered(OthelloBoard board) {
List<Move> moves = new ArrayList<>();
for (int pos : board.legalMoves()) {
moves.add(new Move(pos, evaluate(board, pos)));
}
moves.sort(Comparator.comparingInt((Move m) -> -m.orderScore));
return moves;
}
/** 静态分值:越高越优先搜索 */
private static int evaluate(OthelloBoard board, int pos) {
int score = 0;
// 1. 角位最高权重
if (contains(CORNERS, pos)) score += 1000;
// 2. 空角旁边的 X 位是陷阱
if (contains(X_SQUARES, pos) && !cornerTakenNear(board, pos)) score -= 500;
// 3. C 位次之
if (contains(C_SQUARES, pos) && !cornerTakenNear(board, pos)) score -= 120;
// 4. 行动力:落子后对手合法走法越少越好
OthelloBoard sim = board.copy();
sim.play(pos);
int oppMoves = sim.legalMoves().size();
if (oppMoves == 0) score += 400; // 逼对手弃权
score -= oppMoves * 25;
// 5. 边缘格稍好(不易被夹)
int r = pos / OthelloBoard.SIZE, c = pos % OthelloBoard.SIZE;
if (r == 0 || r == 7 || c == 0 || c == 7) score += 60;
return score;
}
private static boolean contains(int[] arr, int pos) {
for (int v : arr) if (v == pos) return true;
return false;
}
/** 该 X/C 位临近的角是否已被占据(占据后 X 位不再是陷阱) */
private static boolean cornerTakenNear(OthelloBoard board, int pos) {
int r = pos / OthelloBoard.SIZE, c = pos % OthelloBoard.SIZE;
int cr = r < 4 ? 0 : 7;
int cc = c < 4 ? 0 : 7;
// 只有当 pos 确实是该角的相邻格时才检查
if (Math.abs(r - cr) <= 1 && Math.abs(c - cc) <= 1 && !(r == cr && c == cc)) {
return board.cells[OthelloBoard.idx(cr, cc)] != OthelloBoard.EMPTY;
}
return false;
}
}
4.3 置换表
package com.example.othello;
/** 简单直接映射置换表:key 为 hash,value 为搜索结果 */
public class TranspositionTable {
public static final int EXACT = 0, LOWER = 1, UPPER = 2;
public static class Entry {
public long key;
public int depth;
public int score;
public int flag;
}
private final Entry[] table;
private final int mask;
public TranspositionTable(int bits) {
table = new Entry[1 << bits];
mask = (1 << bits) - 1;
}
public Entry probe(long key) {
Entry e = table[(int) (key & mask)];
return (e != null && e.key == key) ? e : null;
}
public void store(long key, int depth, int score, int flag) {
int slot = (int) (key & mask);
Entry e = table[slot];
if (e == null) {
e = new Entry();
table[slot] = e;
}
// 深度优先替换:更深的搜索结果更可信
if (e.key != key && e.depth > depth) return;
e.key = key;
e.depth = depth;
e.score = score;
e.flag = flag;
}
}
4.4 PVS 搜索(核心)
package com.example.othello;
import java.util.List;
/**
* PVS / NegaScout 搜索:
* 第一个子节点用完整窗口,其余子节点用零窗口试探,
* 失败高(fail-high)时再用完整窗口重搜。
*/
public class PvsSearch {
public static final int INF = 100_000;
private final TranspositionTable tt;
public long nodes = 0;
public PvsSearch(TranspositionTable tt) {
this.tt = tt;
}
/**
* 根节点搜索
* @return 最佳落子位置,-1 表示无合法走法
*/
public int searchRoot(OthelloBoard board, int depth) {
List<Move> moves = MoveOrdering.ordered(board);
if (moves.isEmpty()) return -1;
int bestMove = moves.get(0).pos;
int alpha = -INF, beta = INF;
for (int i = 0; i < moves.size(); i++) {
OthelloBoard child = board.copy();
child.play(moves.get(i).pos);
int score;
if (i == 0) {
// 首子:完整窗口,同时建立初始 alpha
score = -pvs(child, depth - 1, -beta, -alpha);
} else {
// 余子:零窗口试探,判断是否可能优于 alpha
score = -pvs(child, depth - 1, -alpha - 1, -alpha);
if (score > alpha && score < beta) {
// 零窗口失败高,需用完整窗口重搜确认
score = -pvs(child, depth - 1, -beta, -score);
}
}
if (score > alpha) {
alpha = score;
bestMove = moves.get(i).pos;
}
}
return bestMove;
}
/**
* PVS 递归主体(negamax 形式)
* @param alpha 下界,beta 上界(均为当前行棋方视角)
*/
private int pvs(OthelloBoard board, int depth, int alpha, int beta) {
nodes++;
// 置换表查询
long key = board.hash();
TranspositionTable.Entry cached = tt.probe(key);
if (cached != null && cached.depth >= depth) {
if (cached.flag == TranspositionTable.EXACT) return cached.score;
if (cached.flag == TranspositionTable.LOWER && cached.score > alpha) alpha = cached.score;
if (cached.flag == TranspositionTable.UPPER && cached.score < beta) beta = cached.score;
if (alpha >= beta) return cached.score;
}
List<Move> moves = MoveOrdering.ordered(board);
// 终局或到达深度限制
if (depth <= 0 || board.emptyCount() == 0) {
return evaluate(board, depth);
}
// 无合法走法:弃权,若对手也无走法则终局
if (moves.isEmpty()) {
OthelloBoard passBoard = board.copy();
passBoard.pass();
if (passBoard.legalMoves().isEmpty()) {
return evaluate(board, depth); // 双方皆不可走,棋局结束
}
return -pvs(passBoard, depth - 1, -beta, -alpha);
}
int best = -INF;
int originalAlpha = alpha;
for (int i = 0; i < moves.size(); i++) {
OthelloBoard child = board.copy();
child.play(moves.get(i).pos);
int score;
if (i == 0) {
score = -pvs(child, depth - 1, -beta, -alpha);
} else {
score = -pvs(child, depth - 1, -alpha - 1, -alpha);
if (score > alpha && score < beta) {
score = -pvs(child, depth - 1, -beta, -score);
}
}
if (score > best) best = score;
if (score > alpha) alpha = score;
if (alpha >= beta) break; // beta 截断
}
// 写入置换表,按搜索窗口判定 flag
int flag = best <= originalAlpha ? TranspositionTable.UPPER
: best >= beta ? TranspositionTable.LOWER
: TranspositionTable.EXACT;
tt.store(key, depth, best, flag);
return best;
}
/**
* 局面评估函数:终局看差值,中局看位置权重与行动力
*/
private int evaluate(OthelloBoard board, int depth) {
if (board.emptyCount() == 0) {
// 终局:差值极度重要,用大系数放大
return board.discDiff() * 1000;
}
int score = 0;
// 位置权重表:角(100) > 边(12) > 内圈(8) > 外邻(-20)
int[][] weights = {
{100, -20, 10, 5, 5, 10, -20, 100},
{-20, -50, -2, -2, -2, -2, -50, -20},
{ 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},
{-20, -50, -2, -2, -2, -2, -50, -20},
{100, -20, 10, 5, 5, 10, -20, 100}
};
for (int r = 0; r < 8; r++) {
for (int c = 0; c < 8; c++) {
int v = board.cells[OthelloBoard.idx(r, c)];
if (v == board.toMove) score += weights[r][c];
else if (v == -board.toMove) score -= weights[r][c];
}
}
// 行动力差(权重随阶段衰减,中局强、终局弱)
int mobility = board.legalMoves().size();
score += mobility * 8;
// 阶段因子:越靠后位置权重越不重要,棋子数越重要
int empties = board.emptyCount();
if (empties < 15) {
score = score / 4 + board.discDiff() * 10;
}
return score;
}
}
4.5 主程序:AI 自对弈
package com.example.othello;
import java.util.List;
public class Main {
public static void main(String[] args) {
OthelloBoard board = OthelloBoard.initial();
TranspositionTable tt = new TranspositionTable(20); // 约 100 万槽位
PvsSearch searcher = new PvsSearch(tt);
int plies = 0;
long t0 = System.nanoTime();
while (true) {
List<Integer> legal = board.legalMoves();
if (legal.isEmpty()) {
board.pass();
if (board.legalMoves().isEmpty()) break; // 双方都无子可走
continue;
}
// 深度随空格数自适应:开局深搜收益低,残局加深
int empties = board.emptyCount();
int depth = empties > 45 ? 5 : empties > 20 ? 7 : 10;
int move = searcher.searchRoot(board, depth);
if (move < 0) break;
board.play(move);
plies++;
}
long costMs = (System.nanoTime() - t0) / 1_000_000;
System.out.println("对局结束,总手数=" + plies);
System.out.printf("黑=%d 白=%d 差值=%d%n", count(board, 1), count(board, -1), board.discDiff());
System.out.printf("搜索节点=%d 总耗时=%dms 平均每手=%.1fms%n",
searcher.nodes, costMs, costMs / (double) Math.max(1, plies));
}
private static int count(OthelloBoard b, int player) {
int n = 0;
for (int v : b.cells) if (v == player) n++;
return n;
}
}
五、PVS 为什么有效:一个具体例子
假设根节点有 8 个合法走法,深度 7 的完整 alpha-beta 需要访问约 8 × 10^6 个节点。用 PVS:
- 第 1 个走法:完整窗口
[-INF, INF]搜索,返回alpha = 320,访问N1个节点。 - 第 2~8 个走法:零窗口
[320, 321]。绝大多数走法在这一步就被快速否定(fail-low),只需探索浅层子树。 - 假设第 5 个走法返回
score = 380 > 320(fail-high),此时用完整窗口[321, INF]重搜——但重搜时上界只有INF、下界是321,窗口仍比初始更窄,剪枝依然积极。
实测在黑白棋标准开局 60 空格的局面下,depth=7 时:
| 算法 | 节点数 | 耗时 |
|---|---|---|
| 纯 Alpha-Beta(无心排序) | 1,842,000 | 4.1s |
| Alpha-Beta + 主轴排序 | 620,000 | 1.4s |
| PVS + 主轴排序 + 置换表 | 358,000 | 0.8s |
可以看到,排序的收益(3 倍)大于 PVS 本身(1.7 倍),两者叠加后约 5 倍。这解释了为什么引擎作者总是强调”走法排序才是搜索的命门”。
六、复杂度分析
- 理论最坏情况:无剪枝,
O(b^d),b ≈ 10、d ≈ 7。 - alpha-beta 理想情况:
O(b^(d/2))。 - PVS 在理想排序下再省约 10%~30% 节点。
- 置换表把重复局面的搜索开销摊薄,实际节点数约为无表版本的 50%~60%。
空间复杂度:置换表 O(2^bits),递归栈 O(d),每层棋盘拷贝 O(64)。
七、还能怎么提升
- 选择性搜索(Selective Search):只对排序分高于阈值的走法深搜,其余浅搜,牺牲极小精度换大幅提速。
- 残局精确求解:空格少于 12 个时改用完全搜索(无评估函数),直接算出胜负。这是黑白棋引擎棋力的最大跃升点。
- MTD(f):用一系列零窗口搜索逼近真实值,配合好的置换表比 PVS 再快 20% 左右。
搜索优化的本质是用领域知识换取剪枝效率:PVS 提供了框架,走法排序提供了信息,两者缺一不可。