每日算法 — 使用java实现黑白棋:PVS主变例搜索与主轴排序优化

黑白棋(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 主轴排序的三个启发式

  1. 角优先:四个角是永久格,绝不可能被翻回,价值极高。
  2. 避免星位:角旁边的 X 位(如 b2)一旦占据,等于把角送给对手。
  3. 行动力优先:优先选择让对手合法走法最少的落子。这是黑白棋最强的启发式——逼对手无子可走,被迫放弃回合。

排序时把这三个启发式组合成静态分: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)。

七、还能怎么提升

  1. 选择性搜索(Selective Search):只对排序分高于阈值的走法深搜,其余浅搜,牺牲极小精度换大幅提速。
  2. 残局精确求解:空格少于 12 个时改用完全搜索(无评估函数),直接算出胜负。这是黑白棋引擎棋力的最大跃升点。
  3. MTD(f):用一系列零窗口搜索逼近真实值,配合好的置换表比 PVS 再快 20% 左右。

搜索优化的本质是用领域知识换取剪枝效率:PVS 提供了框架,走法排序提供了信息,两者缺一不可。