每日算法 — 使用java实现黑白棋:MTD(f)零窗口搜索与置换表

黑白棋(Reversi / Othello)有一个迷人的特性:它的规则让「子多」几乎等于「劣势」。中局阶段子力越多,对手的行动力越强,你反而被逼着走烂棋。这让黑白棋成为搜索算法效率的最佳试验田——因为评估函数永远不完美,胜负只能靠更深、更快的搜索来保证。

本文用 Java 实现黑白棋引擎,重点不是「写个能下棋的程序」,而是讲清一件事:MTD(f) 为什么比常规 Alpha-Beta 更快,以及它的加速来自哪里、代价是什么。

一、黑白棋的三条规则决定了搜索结构

规则只有三条,但每一条都影响代码结构:

  1. 落子必须能至少翻转一枚对手棋子,翻转方向是「直线夹住」。
  2. 无子可落时必须让过(pass),而不是结束。
  3. 双方都无子可落才终局,按子数多寡判胜。

第 2 条是最容易被写错的地方。很多实现「无子可落就判负」,结果评估函数在终局附近彻底失真。正确的处理是递归地 pass:

if (moves == 0) {
    if (legalMoves(s.pass()) == 0) return evaluate(s, 0);   // 双方都无子可落 → 终局
    return -alphaBeta(s.pass(), depth, -beta, -alpha);      // 让过一手
}

注意 -alphaBeta(..., -beta, -alpha) 那对取反的窗口参数——这是 negamax 的标准写法,pass 相当于「零成本的着法」,所以深度不减。

二、位棋盘:把 64 格压进一个 long

8×8 棋盘正好 64 格,用两个 long 分别表示黑子和白子,一次位运算就能并行处理整块棋盘。翻子判断走八个方向逐个扫描:

static long flips(State s, int r, int c) {
    long opp = s.opp(), own = s.own(), total = 0;
    for (int d = 0; d < 8; d++) {
        int rr = r + DR[d], cc = c + DC[d];
        long run = 0;
        while (rr >= 0 && rr < 8 && cc >= 0 && cc < 8) {
            int bit = rr * 8 + cc;
            if (((opp >>> bit) & 1L) != 0)      run |= 1L << bit;   // 连续对手子,先记下
            else if (((own >>> bit) & 1L) != 0) { total |= run; break; }  // 被自己夹住 → 合法
            else break;                                              // 空格 → 该方向作废
        }
    }
    return total;
}

这段逻辑有个隐含陷阱:run 必须在确认被自己的子夹住之后才累加进 total。如果边扫边累加,遇到空格的终止分支就会把无效棋子算进去,导致「非法着法也被认为可落」。这是位棋盘实现里最常见的 bug。

三、MTD(f):把搜索当成二分判定器

这是本文的核心。先回忆 Alpha-Beta 的性质:它接受一个窗口 (alpha, beta),返回的不是精确值,而是一个界——

  • 如果返回值 ≤ alpha,说明真实值 ≤ alpha(fail-low);
  • 如果返回值 ≥ beta,说明真实值 ≥ beta(fail-high);
  • 只有落在窗口内才是精确值。

关键洞察:把窗口收窄到只有 1 的宽度 (beta-1, beta),那么搜索只需要回答一个是非题——「真实分值 ≥ beta 吗?」。既然只是个判定,就不需要算精确值,剪枝可以凶狠到极致。剩下的工作交给二分查找:

int mtdf(State s, int firstGuess, int depth) {
    int g = firstGuess, lower = -INF, upper = INF;
    while (lower < upper) {
        int beta = (g == lower) ? g + 1 : g;      // 关键:避免零宽窗口
        g = alphaBeta(s, depth, beta - 1, beta);  // 宽度为 1 的零窗口搜索
        if (g < beta) upper = g; else lower = g;  // 根据判定结果收缩区间
    }
    return g;
}

beta = (g == lower) ? g + 1 : g 这一行是 MTD(f) 的签名式写法。它的作用是:当上一次搜索返回的下界正好等于猜测值时,窗口必须是 (g, g+1) 而不是 (g-1, g),否则会退化成一个空的「零宽度」窗口,判定永远失败。漏掉这个 +1,程序会陷入死循环——这是 MTD(f) 实现的第一号陷阱。

MTD(f) 有三个必要前提,缺一个就会得到错误结果:

前提 作用 违反的后果
fail-soft Alpha-Beta 返回值可超出窗口边界,为二分提供更紧的界 只能得到 alpha/beta 边界值,二分收敛极慢
置换表区分 EXACT/LOWER/UPPER 零窗口反复复用同一批节点,必须知道存的是精确值还是界 把界当成精确值 → 分值错误
着法排序 好着法先搜,零窗口才能快速判定 剪枝失效,节点数反而暴涨

四、置换表:三个标志位决定正确性

因为 MTD(f) 会对同一个局面反复调用几十次(每次窗口不同),置换表不是「优化」,而是「正确性的必要条件」。每条记录必须标注这个值的语义:

static final int EXACT = 0, LOWER = 1, UPPER = 2;

int flag = (best <= alphaOrig ? UPPER          // 全部着法都 ≤ alpha → 这是个上界
         : (best >= betaOrig  ? LOWER          // 有一步 ≥ beta    → 这是个下界
                              : EXACT));       // 落在窗口内       → 精确值
tt.put(key, new TTEntry(best, depth, flag));

查询时:

if (e.flag == EXACT) return e.value;
if (e.flag == LOWER && e.value >  alpha) alpha = e.value;   // 抬高下界
if (e.flag == UPPER && e.value <  beta)  beta  = e.value;   // 压低上界
if (alpha >= beta) return e.value;                           // 界已交叉 → 直接剪断

同时必须保存 alphaOrig / betaOrig 这两个「原始窗口」的副本——判断 UPPER 还是 LOWER,比较的基准是进入函数时的窗口,不是被置换表收紧之后的窗口。用错基准会让 EXACT 被误标成 LOWER,这是第二个高发 bug。

五、着法排序:Alpha-Beta 的真正命脉

有个残酷的事实:Alpha-Beta 的效率几乎 100% 取决于着法顺序。理想排序(最优着法永远第一个)下复杂度是 O(b^(d/2)),最差排序下退化成纯 minimax O(b^d)。黑白棋的启发式排序规则:

static int[] orderMoves(State s, long moves) {
    // 占角 +50,占 X 位(角旁边的斜对角)-20,翻子数 ×2,行动力增量
    if ((bit & CORNERS)   != 0) score += 50;
    if ((bit & X_SQUARES) != 0) score -= 20;
    score += 2 * Long.bitCount(flips(s, sq / 8, sq % 8));
    score += Long.bitCount(legalMoves(next.pass())) - Long.bitCount(legalMoves(next));
    // 着法数 ≤ 33,插入排序足够
}

「占角优先」不只是棋理,也是搜索效率的关键:角上的子永远无法被翻转,占角后的分支通常直接导向胜负分明的局面,剪枝一发生就是整棵子树被砍掉。

六、实测:节点数到底降了多少

在中局局面(42 个空格、黑方 7 个合法着法)上对比 MTD(f)(迭代加深给初值)与全窗口 Alpha-Beta:

深度 MTD(f) 节点 全窗口节点 节点缩减率 分值一致
4 668 644 -3.7% 是
6 5715 7882 27.5% 是
8 80472 168266 52.2% 是

这张表比任何理论都诚实:深度 4 时 MTD(f) 反而多走了 24 个节点。原因很直白——MTD(f) 需要多次零窗口调用(假设分值落在 [-100,100],二分开销约 8 次调用),浅层搜索时「多次调用的固定成本」压过了「单次调用的剪枝收益」。深度 6 打平,深度 8 才拉开 52% 的差距。

这就是 MTD(f) 的适用边界:深搜才划算。 实践中它总是和迭代加深配合,用浅层结果当下一层的起始猜测值,把二分次数压到 2–3 次:

int iterativeMTD(State s, int maxDepth) {
    int v = 0;
    for (int d = 2; d <= maxDepth; d += 2) v = mtdf(s, v, d);   // 上次分值作初值
    return v;
}

初值越准,二分越快。这也是为什么「上一次搜索结果」在博弈引擎里是一等公民。

七、完整可运行代码

项目结构(单文件,无第三方依赖):

othello-mtdf/
├── src/
│   └── OthelloMTDf.java
└── out/
javac -d out src/OthelloMTDf.java
java  -cp out OthelloMTDf
import java.util.*;

/**
 * 黑白棋(Reversi)MTD(f) 零窗口搜索演示。
 *
 * MTD(f) 把「带窗口的 Alpha-Beta」当作二分判定器,反复调用窄窗口搜索来逼近真实分值:
 *      g = firstGuess; lower = -INF; upper = +INF;
 *      while (lower < upper) {
 *          beta = (g == lower) ? g + 1 : g;
 *          g = alphabeta(beta - 1, beta);
 *          if (g < beta) upper = g; else lower = g;
 *      }
 * 关键前提:内置的 Alpha-Beta 必须是「fail-soft」的,并且置换表要把 EXACT/LOWER/UPPER
 * 三种语义分开记录,否则零窗口反复复用会产生错误分值。
 */
public final class OthelloMTDf {

    static final int SIZE = 64;
    static final int INF = 1_000_000;
    static final int[] DR = {-1, -1, -1, 0, 0, 1, 1, 1};
    static final int[] DC = {-1, 0, 1, -1, 1, -1, 0, 1};

    /** 棋盘状态:位棋盘表示,bit=1 表示该格有对应颜色的棋子 */
    static final class State {
        long black, white;
        boolean blackToMove;
        State(long b, long w, boolean t) { black = b; white = w; blackToMove = t; }
        long own() { return blackToMove ? black : white; }
        long opp() { return blackToMove ? white : black; }
        State pass() { return new State(black, white, !blackToMove); }
    }

    /** 在 (r,c) 落子可翻转的对手棋子掩码,0 表示不合法 */
    static long flips(State s, int r, int c) {
        if ((((s.black | s.white) >>> (r * 8 + c)) & 1L) != 0) return 0;
        long opp = s.opp(), own = s.own(), total = 0;
        for (int d = 0; d < 8; d++) {
            int rr = r + DR[d], cc = c + DC[d];
            long run = 0;
            while (rr >= 0 && rr < 8 && cc >= 0 && cc < 8) {
                int bit = rr * 8 + cc;
                if (((opp >>> bit) & 1L) != 0) {
                    run |= 1L << bit;                 // 连续对手子,先记下
                } else if (((own >>> bit) & 1L) != 0) {
                    total |= run;                     // 被自己的子夹住 → 合法
                    break;
                } else {
                    break;                            // 空格 → 该方向作废
                }
                rr += DR[d]; cc += DC[d];
            }
        }
        return total;
    }

    static long legalMoves(State s) {
        long moves = 0, empty = ~(s.black | s.white);
        while (empty != 0) {
            int sq = Long.numberOfTrailingZeros(empty);
            if (flips(s, sq / 8, sq % 8) != 0) moves |= 1L << sq;
            empty &= empty - 1;
        }
        return moves;
    }

    static State apply(State s, int sq) {
        long f = flips(s, sq / 8, sq % 8), own = s.own(), opp = s.opp();
        long newOwn = own | f | (1L << sq);
        long newOpp = opp & ~f;
        return s.blackToMove ? new State(newOwn, newOpp, false) : new State(newOpp, newOwn, true);
    }

    // ---------------------- 置换表 ----------------------
    static final int EXACT = 0, LOWER = 1, UPPER = 2;

    static final class TTEntry {
        int value, depth;
        byte flag;
        TTEntry(int v, int d, int f) { value = v; depth = d; flag = (byte) f; }
    }

    final HashMap<Long, TTEntry> tt = new HashMap<>();
    long nodes;

    /** Zobrist 式混合:黑/白位棋盘 + 轮走方 */
    static long hash(State s) {
        long h = s.black * 0x9E3779B97F4A7C15L;
        h ^= (s.white + 0x165667B19E3779F9L) * 0xC2B2AE3D27D4EB4FL;
        h ^= (s.blackToMove ? 0x27D4EB2F165667C5L : 0x9E3779B97F4A7C15L);
        return h ^ (h >>> 29);
    }

    static final long CORNERS = 0x8100000000000081L;
    static final long X_SQUARES = 0x0042000000004200L;     // b2 g2 b7 g7

    /** 局面评估(轮走方视角):角、X 位、行动力、子差 */
    static int evaluate(State s, int empties) {
        int myDisks = Long.bitCount(s.own()), oppDisks = Long.bitCount(s.opp());
        if (empties == 0) return myDisks - oppDisks;          // 终局:纯子差,权重最大
        int corner = 30 * (Long.bitCount(s.own() & CORNERS) - Long.bitCount(s.opp() & CORNERS));
        int xSq = -12 * (Long.bitCount(s.own() & X_SQUARES) - Long.bitCount(s.opp() & X_SQUARES));
        int mob = 10 * (Long.bitCount(legalMoves(s)) - Long.bitCount(legalMoves(s.pass())));
        int disk = 2 * (myDisks - oppDisks);
        return corner + xSq + mob + disk;
    }

    /** fail-soft negamax + 置换表;返回从轮走方视角的分值 */
    int alphaBeta(State s, int depth, int alpha, int beta) {
        nodes++;
        long key = hash(s);
        int alphaOrig = alpha, betaOrig = beta;
        TTEntry e = tt.get(key);
        if (e != null) {
            if (e.depth >= depth) {
                if (e.flag == EXACT) return e.value;
                if (e.flag == LOWER && e.value > alpha) alpha = e.value;
                if (e.flag == UPPER && e.value < beta) beta = e.value;
                if (alpha >= beta) return e.value;
            }
        }
        long moves = legalMoves(s);
        if (moves == 0) {
            if (legalMoves(s.pass()) == 0) {                   // 双方都无子可落 → 终局
                return evaluate(s, 0);
            }
            return -alphaBeta(s.pass(), depth, -beta, -alpha);  // 让过一手
        }
        int empties = 64 - Long.bitCount(s.black | s.white);
        if (depth <= 0) return evaluate(s, empties);

        int[] order = orderMoves(s, moves);                   // 关键:着法排序决定剪枝效率
        int best = -INF;
        for (int sq : order) {
            int v = -alphaBeta(apply(s, sq), depth - 1, -beta, -alpha);
            if (v > best) {
                best = v;
                if (v > alpha) alpha = v;
            }
            if (alpha >= beta) break;                          // β 剪枝
        }
        int flag = (byte) (best <= alphaOrig ? UPPER : (best >= betaOrig ? LOWER : EXACT));
        tt.put(key, new TTEntry(best, depth, flag));
        return best;
    }

    /**
     * 着法排序(Move Ordering):Alpha-Beta 的剪枝效率几乎完全取决于「好着法是否先搜」。
     * 打分规则:占角 +50,占 X 位 -20,翻子数 ×2,另加行动力增量。
     * 稳定性好的排序能让剪枝率从 60% 提升到 90% 以上。
     */
    static int[] orderMoves(State s, long moves) {
        int n = Long.bitCount(moves);
        int[] squares = new int[n];
        int[] scores = new int[n];
        int i = 0;
        for (long m = moves; m != 0; m &= m - 1) {
            int sq = Long.numberOfTrailingZeros(m);
            long bit = 1L << sq;
            int score = 0;
            if ((bit & CORNERS) != 0) score += 50;
            if ((bit & X_SQUARES) != 0) score -= 20;
            score += 2 * Long.bitCount(flips(s, sq / 8, sq % 8));
            State next = apply(s, sq);
            score += Long.bitCount(legalMoves(next.pass())) - Long.bitCount(legalMoves(next));
            squares[i] = sq; scores[i] = score; i++;
        }
        // 按分数降序插入排序(着法数 ≤ 33,插入排序足够快且无额外分配开销)
        for (int a = 1; a < n; a++) {
            int ks = scores[a], kq = squares[a], b = a - 1;
            while (b >= 0 && scores[b] < ks) { scores[b + 1] = scores[b]; squares[b + 1] = squares[b]; b--; }
            scores[b + 1] = ks; squares[b + 1] = kq;
        }
        return squares;
    }

    /** 迭代加深 + MTD(f):用浅一层的结果作为下一层的起始猜测值 */
    int iterativeMTD(State s, int maxDepth) {
        int v = 0;
        for (int d = 2; d <= maxDepth; d += 2) {
            v = mtdf(s, v, d);      // 上一次的分值通常离真值很近 → 零窗口调用次数锐减
        }
        return v;
    }

    /** MTD(f):以 firstGuess 为起点做零窗口二分 */
    int mtdf(State s, int firstGuess, int depth) {
        int g = firstGuess, lower = -INF, upper = INF;
        while (lower < upper) {
            int beta = (g == lower) ? g + 1 : g;
            g = alphaBeta(s, depth, beta - 1, beta);
            if (g < beta) upper = g; else lower = g;
        }
        return g;
    }

    /** 按 MTD(f) 的最终分值挑选最佳着法(再跑一次根节点枚举) */
    int bestMove(State s, int depth) {
        int best = mtdf(s, 0, depth);
        long moves = legalMoves(s);
        for (long m = moves; m != 0; m &= m - 1) {
            int sq = Long.numberOfTrailingZeros(m);
            if (-alphaBeta(apply(s, sq), depth - 1, -INF, INF) == best) return sq;
        }
        return Long.numberOfTrailingZeros(moves);
    }

    static String render(State s, int highlight) {
        StringBuilder sb = new StringBuilder("    a b c d e f g h\n");
        for (int r = 0; r < 8; r++) {
            sb.append((char) ('1' + r)).append("   ");
            for (int c = 0; c < 8; c++) {
                int bit = r * 8 + c;
                char ch;
                if (((s.black >>> bit) & 1L) != 0) ch = 'X';
                else if (((s.white >>> bit) & 1L) != 0) ch = 'O';
                else ch = '.';
                if (bit == highlight) ch = '*';        // 标注最佳着法
                sb.append(ch).append(' ');
            }
            sb.append('\n');
        }
        return sb.toString();
    }

    /** 用固定种子随机自对弈若干步,构造一个稠密中局用于测速 */
    static State randomMidgame(long seed, int plies) {
        State s = new State((1L << (3 * 8 + 3)) | (1L << (4 * 8 + 4)),
                            (1L << (3 * 8 + 4)) | (1L << (4 * 8 + 3)), true);
        Random rnd = new Random(seed);
        for (int i = 0; i < plies; i++) {
            long mv = legalMoves(s);
            if (mv == 0) { s = s.pass(); mv = legalMoves(s); if (mv == 0) break; }
            int pick = rnd.nextInt(Long.bitCount(mv));
            for (int k = 0; k < pick; k++) mv &= mv - 1;
            s = apply(s, Long.numberOfTrailingZeros(mv));
        }
        return s;
    }

    public static void main(String[] args) {
        // ---------- 1. 开局展示 ----------
        State open = new State((1L << (3 * 8 + 3)) | (1L << (4 * 8 + 4)),
                               (1L << (3 * 8 + 4)) | (1L << (4 * 8 + 3)), true);
        System.out.println("开局局面(黑先):\n" + render(open, -1));
        System.out.println("黑方合法着法数 = " + Long.bitCount(legalMoves(open)));

        // ---------- 2. 中局测速对比 ----------
        State mid = randomMidgame(7, 18);
        System.out.println("中局局面:\n" + render(mid, -1));
        System.out.println("空格数 = " + (64 - Long.bitCount(mid.black | mid.white))
                + ",合法着法数 = " + Long.bitCount(legalMoves(mid)));

        System.out.println("\n深度 | MTD(f) 分值/节点/耗时 | 全窗口 AB 分值/节点/耗时 | 节点缩减率");
        for (int d : new int[]{4, 6, 8}) {
            OthelloMTDf m = new OthelloMTDf();
            long t0 = System.nanoTime();
            int vm = m.iterativeMTD(mid, d);
            long t1 = System.nanoTime();

            OthelloMTDf p = new OthelloMTDf();
            long t2 = System.nanoTime();
            int vp = p.alphaBeta(mid, d, -INF, INF);
            long t3 = System.nanoTime();

            System.out.printf("%d | %d / %d / %.0fms | %d / %d / %.0fms | %.1f%%%n",
                    d, vm, m.nodes, (t1 - t0) / 1e6, vp, p.nodes, (t3 - t2) / 1e6,
                    100.0 * (p.nodes - m.nodes) / p.nodes);
            if (vm != vp) System.out.println("  [警告] 两者分值不一致,检查置换表语义!");
        }

        // ---------- 3. 最佳着法 ----------
        OthelloMTDf engine = new OthelloMTDf();
        int sq = engine.bestMove(mid, 6);
        System.out.println("\n深度 6 的最佳着法 = " + (char) ('a' + sq % 8) + (1 + sq / 8));
        System.out.println(render(mid, sq));
    }
}

运行输出(节选):

开局局面(黑先):
    a b c d e f g h
4   . . . X O . . .
5   . . . O X . . .

黑方合法着法数 = 4
中局局面:空格数 = 42,合法着法数 = 7

深度 | MTD(f) 分值/节点/耗时 | 全窗口 AB 分值/节点/耗时 | 节点缩减率
4 | -74 / 668 / 36ms | -74 / 644 / 10ms | -3.7%
6 | -70 / 5715 / 95ms | -70 / 7882 / 108ms | 27.5%
8 | -74 / 80472 / 735ms | -74 / 168266 / 1361ms | 52.2%

深度 6 的最佳着法 = g4

代码里两处输出分值完全一致,这不是巧合而是必须——如果 MTD(f) 和全窗口搜索给出不同分值,说明置换表的标志位语义写错了。用全窗口搜索当基准去校验 MTD(f),是最省事的正确性自测手段。

八、复杂度与实战调优

项目 数值 备注
单层分支因子 中局 7–12,开局 4 比国际象棋(~35)小得多
有效分支(剪枝后) ≈ √b 理想排序下
深度 8 节点数 8 万级,735 ms 单线程 Java,中局
置换表命中率 深搜时可 > 60% 这是 MTD(f) 的收益来源

三条实战调优建议:

  1. 置换表用数组而不是 HashMap。本文为了可读性用了 HashMap,生产引擎会用 2^n 大小的 Zobrist 索引数组,避免装箱和哈希冲突。深度 9 以上 HashMap 的开销会占掉一半时间。
  2. 置换表必须带深度校验(e.depth >= depth)。浅层搜索的结果不能直接用于深层,否则会低估局面。本文代码已包含这个判断。
  3. 空窗搜索(Null Window Search)可以替代部分 MTD(f) 调用。PVS/NegaScout 内部也在做零窗口试探,MTD(f) 与它们的收益部分重叠,不要盲目叠加。

九、小结

黑白棋的搜索优化史几乎就是「窗口宽度」的优化史:全窗口 → aspiration window → PVS → MTD(f)。每一步都在做同一件事——把「求精确值」降级成「回答一个是非题」,然后靠二分拼出精确值。

MTD(f) 是这个思路的终点形态:窗口宽度压到 1,代价是同一局面要访问多次,收益是剪枝率极高。实测数据显示这个交易要到深度 8 才划算,所以我把它标注为「深搜专用算法」而非「通用更快的算法」——任何声称 MTD(f) 无条件更快的说法,都缺了那张深度-节点数对照表。