每日算法 — 使用java实现黑白棋:Bitboard位棋盘与popcount增量评估

黑白棋(Reversi/Othello)的分支因子并不大——开局时合法着法只有 4 个,中局大约 10 个——但一局要走满 60 手,搜索深度几乎顶到棋盘填满。这意味着胜负往往不取决于你搜得多深,而取决于每秒能评估多少个叶子节点。传统二维数组棋盘每做一次评估都要双层循环遍历 64 格,而 8×8 恰好等于 64,正好塞进一个 long。把棋盘压进 64 位整数之后,”合法着法生成”和”翻转判定”都能变成纯位运算,评估速度可以提升一个数量级。本文就用位棋盘(Bitboard)重构黑白棋,并用 Long.bitCount 做增量式局面评估,再配一套 Negamax + Alpha-Beta 的搜索引擎。

一、为什么 64 格刚好等于 64 位

位棋盘的核心洞察只有一句话:棋盘状态是 64 个二值量。每一格的取值只有三种——黑子、白子、空——用两张 64 位掩码即可完全表达:

  • black:第 i 位为 1 表示第 i 格是黑子;
  • white:第 i 位为 1 表示第 i 格是白子;
  • 空格的集合就是 ~(black | white)(忽略高位溢出)。

坐标映射约定为 index = row * 8 + col,且 row=0 在棋盘最上方、col=0 在最左侧,于是第 i 位对应第 i/8 行第 i%8 列。这个约定决定了后面所有移位方向,一旦选错,整盘棋会”斜着走”。

格数正好是 64 这件事并非巧合带来的便利,它还带来第二个红利:统计棋子数量等同于统计 1 的个数。Long.bitCount 在主流 JVM 上会被 JIT 编译成单条 POPCNT 指令,一次调用就是一个时钟周期级别的操作,这让”行动力(mobility)”这类需要反复计数的评估特征从 O(64) 降到 O(1)。

二、移位与边界掩码:位棋盘的第一个坑

位棋盘最容易出错的地方是跨行回绕。把 a8 向左移一位,它会跑到 h1(因为第 7 位左移变成第 8 位,而第 8 位是下一行第一列),棋盘上就凭空多出一颗斜着飞的棋子。解决办法是给每次移位配上”源端掩码”:

private static final long FILE_A = 0x0101010101010101L; // 所有 a 列
private static final long FILE_H = 0x8080808080808080L; // 所有 h 列

/** 沿方向 d 平移一格,自动屏蔽跨行回绕 */
static long shift(long x, int d) {
    return switch (d) {
        case 0 -> (x & ~FILE_H) << 1;   // 东: 源不能是 h 列
        case 1 -> (x & ~FILE_A) >> 1;   // 西: 源不能是 a 列
        case 2 -> x >> 8;               // 北: 高位溢出自然丢弃
        case 3 -> x << 8;               // 南
        case 4 -> (x & ~FILE_H) >> 7;   // 东北: 源不能是 h 列
        case 5 -> (x & ~FILE_A) >> 9;   // 西北: 源不能是 a 列
        case 6 -> (x & ~FILE_H) << 9;   // 东南
        default -> (x & ~FILE_A) << 7;  // 西南
    };
}

注意北/南两个方向的位移量是 8,恰好整行对齐,不会回绕,所以不需要掩码;而上/下溢出位会被 long 自然截断,也不需要额外处理。只有四个斜方向和东西方向需要掩码——这是位棋盘实现里最值得反复确认的细节。

一个常见错误是把掩码加在结果端而不是源端((x << 1) & ~FILE_A),看起来也能”防回绕”,但它会把真正合法的 h8 → 出界 之外的位移误删。记住原则:掩码约束的是”谁能移动”,不是”移到哪”。

三、合法着法生成:三次移位搜索一条夹线

黑白棋的落子规则:在某个空位落子后,必须至少夹住一条沿八个方向延伸、两端为己方棋子与落点的连续对方棋子。把它翻译成位运算,就是沿着每个方向”先找到对手,一路延伸,直到遇到空格或己方”:

private long movesInDir(long own, long opp, long empty, int d) {
    long chain = shift(own, d) & opp;   // 紧邻己方的第一颗被夹棋子
    long res = 0;
    while (chain != 0) {
        long next = shift(chain, d);
        if ((next & opp) != 0) {        // 还是对方棋子: 继续延伸
            chain |= next;              // 链上所有位都要保留, 供后续判断
        } else if ((next & empty) != 0) { // 撞到空格: 这就是合法落点
            res |= next;
            break;
        } else {                        // 撞到己方棋子或出界: 本方向无着法
            break;
        }
    }
    return res;
}

这段循环的复杂度是关键:一条夹线的长度最多 6 格,所以每个方向最多迭代 6 次,8 个方向合计不超过 48 次移位——而朴素实现要对 64 个空位逐一做 8 方向扫描,代价接近 512 次比较。位运算把常数压掉了一个数量级。

把八个方向的结果取并,就得到当前行动方的全部合法着法集合。再配一个 Long.numberOfTrailingZeros 就能顺序取出每个落点,不需要维护任何数组:

while (moves != 0) {
    int sq = Long.numberOfTrailingZeros(moves);
    moves &= moves - 1;   // 清掉最低位的 1
    // sq 即落点
}

四、翻转:落子之后一次扫描求出整条夹线

落子后需要把被夹住的对方棋子全部翻成己方。做法与找落点对称——从落点出发向外扫,直到碰到己方棋子:

private long flipsOf(long move, long own, long opp) {
    long flips = 0;
    for (int d = 0; d < 8; d++) {
        long chain = shift(move, d) & opp;   // 第一颗对手
        long acc = 0;
        while (chain != 0) {
            long next = shift(chain, d);
            if ((next & own) != 0) {          // 撞到己方: 整条链都可以翻
                flips |= acc | chain;
                break;
            }
            if ((next & opp) == 0) {          // 空格或出界: 此方向不作数
                break;
            }
            acc |= chain;
            chain = next;
        }
    }
    return flips;
}

翻转完成后更新状态只需三行:black |= move | flips; white &= ~flips;(轮到黑方时)。这里没有任何循环写入数组的操作,缓存友好度极高。

五、增量式评估:用 popcount 把评估函数压到常数时间

黑白棋的经典评估特征是”行动力差”(我方合法着法数减去对方合法着法数)、”角点控制”和”前沿子”(与空格相邻的己方棋子,越少越好,因为它给对手提供的行动力越少)。有了位棋盘,这三项都能用 popcount 直接算出:

static int evaluate(long black, long white, boolean blackToMove) {
    long own = blackToMove ? black : white;
    long opp = blackToMove ? white : black;
    long empty = ~(own | opp);

    int mobility = Long.bitCount(Rules.legalMoves(own, opp))
                 - Long.bitCount(Rules.legalMoves(opp, own));

    long corners = 0x8100000000000081L;               // a1 h1 a8 h8
    int corner = Long.bitCount(own & corners) - Long.bitCount(opp & corners);

    // 前沿子: 与空格相邻的己方棋子
    long frontier = 0;
    for (int d = 0; d < 8; d++) frontier |= BitBoard.shift(own, d);
    frontier &= empty;
    int myFrontier = Long.bitCount(frontier);

    int disk = Long.bitCount(own) - Long.bitCount(opp);
    return 12 * corner + 8 * mobility - 6 * myFrontier + disk;
}

权重不是随便写的:角点是黑白棋最强的稳定子,几乎不可能被翻转,因此权重最高;行动力体现主动权,尤其在 60 手对局中后盘价值极高;前沿子权重为负,因为自己的每一颗前沿子都在”送”给对手一个落点。

完全增量维护(只更新变化的方向)会更快,但实现复杂度陡增且极易出 bug。实践中的折中方案是:只在评估函数内部缓存 popcount 结果,把 bitCount(own) 这类重复调用的值提前算好放进局部变量即可——一面棋盘上最多 4~5 次 popcount,已经足够快。

六、完整可运行实现

把位棋盘、规则、评估、搜索组织成一个可直接运行的工程骨架。为避免踩坑,先给出目录结构:

othello-bitboard/
└── src/main/java/othello/
    ├── BitBoard.java       # 位棋盘与移位原语
    ├── Rules.java          # 合法着法、翻转、落子
    ├── Evaluator.java      # 增量评估
    ├── Negamax.java        # 带 Alpha-Beta 的搜索引擎
    └── Demo.java           # main: 自我对弈演示

下面的合并版本把四个类写成嵌套结构,javac 一个文件即可运行:

package othello;

public final class BitBoard {
    public static final long FILE_A = 0x0101010101010101L;
    public static final long FILE_H = 0x8080808080808080L;
    public static final long CORNERS = 0x8100000000000081L;

    public long black, white;

    public BitBoard() {
        init();
    }

    /** 标准初始局面: 黑在 e4、d5, 白在 d4、e5 (index = row*8+col, row=0 为最上行) */
    public void init() {
        black = (1L << (4 * 8 + 4)) | (1L << (3 * 8 + 3)); // e4, d5
        white = (1L << (4 * 8 + 3)) | (1L << (3 * 8 + 4)); // d4, e5
    }

    public long occupied() { return black | white; }
    public long empty()    { return ~(black | white); }

    /** 沿方向 d 平移一格, 屏蔽跨行回绕 */
    public static long shift(long x, int d) {
        return switch (d) {
            case 0 -> (x & ~FILE_H) << 1;
            case 1 -> (x & ~FILE_A) >> 1;
            case 2 -> x >> 8;
            case 3 -> x << 8;
            case 4 -> (x & ~FILE_H) >> 7;
            case 5 -> (x & ~FILE_A) >> 9;
            case 6 -> (x & ~FILE_H) << 9;
            default -> (x & ~FILE_A) << 7;
        };
    }
}

final class Rules {
    /** 当前行动方的全部合法落点 */
    static long legalMoves(long own, long opp) {
        long empty = ~(own | opp);
        long moves = 0;
        for (int d = 0; d < 8; d++) moves |= movesInDir(own, opp, empty, d);
        return moves;
    }

    private static long movesInDir(long own, long opp, long empty, int d) {
        long chain = BitBoard.shift(own, d) & opp;
        long res = 0;
        while (chain != 0) {
            long next = BitBoard.shift(chain, d);
            if ((next & opp) != 0) {
                chain |= next;
            } else {
                if ((next & empty) != 0) res |= next;
                break;
            }
        }
        return res;
    }

    /** 落子 move 后, 行动方 own 可以翻转的对方棋子 */
    static long flipsOf(long move, long own, long opp) {
        long flips = 0;
        for (int d = 0; d < 8; d++) {
            long chain = BitBoard.shift(move, d) & opp;
            long acc = 0;
            while (chain != 0) {
                long next = BitBoard.shift(chain, d);
                if ((next & own) != 0) { flips |= acc | chain; break; }
                if ((next & opp) == 0) break;
                acc |= chain;
                chain = next;
            }
        }
        return flips;
    }

    /** 在副本上落子, 返回新局面 (黑方行动时 blackToMove=true) */
    static BitBoard apply(BitBoard b, int sq, boolean blackToMove) {
        BitBoard nb = new BitBoard();
        nb.black = b.black; nb.white = b.white;
        long move = 1L << sq;
        long own  = blackToMove ? nb.black : nb.white;
        long opp  = blackToMove ? nb.white : nb.black;
        long flips = flipsOf(move, own, opp);
        if (blackToMove) {
            nb.black |= move | flips;
            nb.white &= ~flips;
        } else {
            nb.white |= move | flips;
            nb.black &= ~flips;
        }
        return nb;
    }

    /** 是否存在任何合法着法 (用于停权判定) */
    static boolean hasMove(long own, long opp) { return legalMoves(own, opp) != 0; }
}

final class Evaluator {
    static int evaluate(BitBoard b, boolean blackToMove) {
        long own = blackToMove ? b.black : b.white;
        long opp = blackToMove ? b.white : b.black;
        long empty = ~(own | opp);

        int myMob  = Long.bitCount(Rules.legalMoves(own, opp));
        int opMob  = Long.bitCount(Rules.legalMoves(opp, own));
        int corner = Long.bitCount(own & BitBoard.CORNERS)
                   - Long.bitCount(opp & BitBoard.CORNERS);

        long frontier = 0;
        for (int d = 0; d < 8; d++) frontier |= BitBoard.shift(own, d);
        int myFrontier = Long.bitCount(frontier & empty);

        return 12 * corner + 8 * (myMob - opMob) - 6 * myFrontier
             + (Long.bitCount(own) - Long.bitCount(opp));
    }
}

final class Negamax {
    private static final int WIN = 100000;

    static int search(BitBoard b, boolean blackToMove, int depth, int alpha, int beta) {
        long own = blackToMove ? b.black : b.white;
        long opp = blackToMove ? b.white : b.black;
        long moves = Rules.legalMoves(own, opp);

        if (moves == 0) {
            if (!Rules.hasMove(opp, own)) {   // 双方均无着法: 终局结算
                int diff = Long.bitCount(b.black) - Long.bitCount(b.white);
                int sign = blackToMove ? 1 : -1;
                return sign * diff * 100;
            }
            return -search(b, !blackToMove, depth, -beta, -alpha); // 停权
        }
        if (depth == 0) return Evaluator.evaluate(b, blackToMove);

        int best = -WIN;
        // 两轮扫描着法: 第一轮只走角点, 第二轮走其余落点 —— 最廉价的着法排序
        best = tryMoves(moves & BitBoard.CORNERS,  b, blackToMove, depth, alpha, beta, best);
        if (alpha < beta) {
            best = tryMoves(moves & ~BitBoard.CORNERS, b, blackToMove, depth, alpha, beta, best);
        }
        return best;
    }

    /** 在 mask 内枚举着法并取最大值; alpha/beta/best 按值传递, 保持无副作用 */
    private static int tryMoves(long mask, BitBoard b, boolean blackToMove,
                                int depth, int alpha, int beta, int best) {
        while (mask != 0) {
            int sq = Long.numberOfTrailingZeros(mask);
            mask &= mask - 1;
            BitBoard nb = Rules.apply(b, sq, blackToMove);
            int v = -search(nb, !blackToMove, depth - 1, -beta, -alpha);
            if (v > best) best = v;
            if (best > alpha) alpha = best;
            if (alpha >= beta) break;
        }
        return best;
    }
}

final class Demo {
    public static void main(String[] args) {
        BitBoard b = new BitBoard();
        boolean black = true;
        System.out.printf("初始: 黑=%d 白=%d%n",
                Long.bitCount(b.black), Long.bitCount(b.white));

        long moves = Rules.legalMoves(b.black, b.white);
        System.out.println("黑方合法着法数 = " + Long.bitCount(moves));

        while (moves != 0) {
            int sq = Long.numberOfTrailingZeros(moves);
            moves &= moves - 1;
            int v = Negamax.search(Rules.apply(b, sq, black), !black, 5,
                                   -1000000, 1000000);
            System.out.printf("落子 (%d,%d) 评估=%d%n", sq / 8, sq % 8, v);
        }
    }
}

七、复杂度与实战调优

位棋盘本身是 O(1) 空间:整局棋的状态只有两个 long。时间上,合法着法生成每方向最多 6 次移位,总计约 48 次位操作 + 8 次掩码,实测在 10 万节点/秒以上的量级可以轻松跑到千万节点/秒——这使 6~8 层搜索在毫秒级完成。

搜索引擎的剪枝率决定实践复杂度:理想 move ordering 下 Alpha-Beta 有效分支因子约为 √b,从 10 降到 3.2 左右,等价于把 6 层搜索的 10^6 节点压到 10^3 量级。上面代码里的”角点优先”排序是成本最低的启发式;进一步可以加迭代加深(先搜 3 层找最佳着法,再用它做 PV 排序)来把剪枝率再提一档。

评估函数的调参方向值得记一条经验:残局必须切换到”子数差”主导。60 手对局临近结束时行动力特征没有意义,而子数差直接决定胜负,因此常见做法是第 45 手之后线性地把 disk 项的权重从 1 拉到 100 以上——本例把它写死为常数,是把结构性代码跑通为先。

八、小结

位棋盘的价值不在于”炫技”,而在于它把三个高频操作——找落点、求夹线、数棋子——全部降到了位运算级别。本文实现的三条主线是:源端掩码防回绕的 shift、沿链式延伸并回退的 legalMoves/flipsOf、以及基于 bitCount 的常数时间评估。把这套骨架搭好之后,无论是换更强的评估特征(稳定子、模式学习),还是换更深的搜索(PVS、置换表),都只是在这三块地基上叠砖,而不必再碰棋盘的表示方式。