每日算法 — 使用java实现五子棋:证明数搜索与df-pn终局判定

五子棋的 AI 大多数教程都从 Alpha-Beta 剪枝讲起。但 Alpha-Beta 解决的是”哪一步最好”,它在回答另一个问题时效率极低:“我现在到底赢了没有?” 在一方已经形成冲四、对手必须应招的局面里,棋局往往只剩一条攻击线路,此时最重要的不是给每步打估值,而是尽快证明”存在必胜着法”或”所有着法都被防住”。

这正是证明数搜索(Proof-Number Search, PNS)与它的实用化版本 df-pn(Depth-First Proof-Number Search) 的主场。它们的评价标准不是分数,而是”证明这棵子树需要探索多少个叶子”——证明数(proof number)与反证数(disproof number)。本文用 Java 实现一个基于 df-pn 的五子棋必胜判定器,并与经典的 VCF(连续冲四胜)搜索对比优劣。

一、证明数:把”胜负”变成”代价”

博弈树中每个节点有两种状态:证明成立(PROVED,即本方必胜) 或 反证成立(DISPROVED,即本方必败)。

  • 对或节点(OR node,本方走):只要有一个子节点被证明成立,本节点即成立。因此 pn = min(child.pn),dn = Σ(child.dn)。
  • 对与节点(AND node,对手走):需要全部子节点都被证明成立,本节点才成立。因此 pn = Σ(child.pn),dn = min(child.dn)。

pn 表示”要证明本节点必胜,还需要展开多少个叶子”;dn 表示”要证明本节点必败,还需要多少个叶子”。叶节点直接被赋值为 (1, ∞) 或 (∞, 1)。每次搜索挑证明数最小的路径向下钻,就是 df-pn 的核心思想:把有限的搜索预算花在”最可能被证明”的分支上。

二、五子棋如何变成”是/否”问题

五子棋的状态空间对 PNS 来说太大,直接全树搜索必然爆炸(每个局面约 30~60 个合法点)。工程上做三件事把它降到可解规模:

  1. 胜负定义收窄:不判断”最终谁会赢”,只判断”本方是否能在 N 步内形成活四或五连”。即把目标换成”强制胜(THREAT WIN)”,天然是一个有界深度问题。
  2. 着法生成收窄到威胁点:只有落子后能形成冲四/活三的位置,以及必须应对对手威胁的位置,才进入着法列表。这通常把分支因子从 50 降到 3~8。
  3. 深度上限与节点预算:df-pn 是迭代加深的,用 MAX_DEPTH 和 NODE_BUDGET 两个闸门保证搜索必然终止。

三、棋盘表示:增量连子统计

本文使用二维数组 + 增量更新:落子后只检查该点四个方向的连子数,时间复杂度 O(1),无需重扫全盘。

public class BitBoard {
    public static final int SIZE = 15, WIN = 5;
    public static final byte BLACK = 1, WHITE = 2;
    public final byte[][] grid = new byte[SIZE][SIZE];
    private int stones = 0;

    public boolean inside(int r, int c) { return r >= 0 && r < SIZE && c >= 0 && c < SIZE; }
    public boolean isEmpty(int r, int c) { return grid[r][c] == 0; }

    public void put(int r, int c, byte color) { grid[r][c] = color; stones++; }
    public void undo(int r, int c)             { grid[r][c] = 0;     stones--; }
    public int stoneCount()                    { return stones; }

    /** 该点落下 color 后,四个方向上最长的同色连子长度(含自身) */
    public int maxLineThrough(int r, int c, byte color) {
        int[] dr = {0, 1, 1, 1}, dc = {1, 0, 1, -1};
        int best = 0;
        for (int d = 0; d < 4; d++) {
            int len = 1;
            for (int sign = 1; sign >= -1; sign -= 2) {
                int nr = r + dr[d] * sign, nc = c + dc[d] * sign;
                while (inside(nr, nc) && grid[nr][nc] == color) {
                    len++; nr += dr[d] * sign; nc += dc[d] * sign;
                }
            }
            best = Math.max(best, len);
        }
        return best;
    }

    public boolean winsAfter(int r, int c, byte color) { return maxLineThrough(r, c, color) >= WIN; }
}

maxLineThrough 之后还会用于威胁分级:连子达到 4 说明落子即成五(必须立即封堵);已经成 5 说明直接获胜。

四、df-pn 引擎

4.1 节点与证明数

import java.util.*;

public class ProofNode {
    public enum Kind { OR, AND, LEAF }
    final Kind kind;
    final int r, c;              // 产生该节点的那一步(根节点为 -1)
    final byte mover;            // 该节点的行动方
    List<ProofNode> children = new ArrayList<>();
    int pn = 1, dn = 1;          // 初始保守估计
    boolean proved = false, disproved = false;

    public ProofNode(Kind kind, int r, int c, byte mover) {
        this.kind = kind; this.r = r; this.c = c; this.mover = mover;
    }

    /** 自底向上重算证明数:OR 取 min(pn)/sum(dn),AND 取 sum(pn)/min(dn) */
    void recompute() {
        if (children.isEmpty()) { pn = 1; dn = 1; return; }
        pn = kind == Kind.OR ? Integer.MAX_VALUE : 0;
        dn = kind == Kind.OR ? 0 : Integer.MAX_VALUE;
        for (ProofNode ch : children) {
            if (ch.proved)    ch.pn = Integer.MAX_VALUE;   // 已证明:无需再探索
            if (ch.disproved) ch.dn = Integer.MAX_VALUE;
            if (kind == Kind.OR) {
                pn = Math.min(pn, clamp(ch.pn));
                dn = addSat(dn, ch.dn);
            } else {
                pn = addSat(pn, ch.pn);
                dn = Math.min(dn, clamp(ch.dn));
            }
        }
        proved    = kind == Kind.OR  ? anyProved(children)  : allProved(children);
        disproved = kind == Kind.AND ? anyDisproved(children) : allDisproved(children);
    }

    private static boolean anyProved(List<ProofNode> cs)     { for (ProofNode x : cs) if (x.proved) return true;     return false; }
    private static boolean allProved(List<ProofNode> cs)     { for (ProofNode x : cs) if (!x.proved) return false;    return true;  }
    private static boolean anyDisproved(List<ProofNode> cs)  { for (ProofNode x : cs) if (x.disproved) return true;  return false; }
    private static boolean allDisproved(List<ProofNode> cs)  { for (ProofNode x : cs) if (!x.disproved) return false; return true;  }

    private static int clamp(int v) { return Math.min(v, Integer.MAX_VALUE / 4); }
    private static int addSat(int a, int b) { return (int) Math.min((long) a + b, Integer.MAX_VALUE / 2); }
}

用饱和加法是为了防止证明数在深层树里直接溢出成负数——这是 PNS 实现里最常见却最少被提到的坑。

4.2 df-pn 主循环

df-pn 的本质是”带阈值的深度优先 + 证明数反馈”。取当前两个阈值 (pn_threshold, dn_threshold),向下走证明数最小的子节点,抵达叶节点后把新的证明数回传并更新阈值,若中途证明数超过阈值就回溯。

public class DfPnSolver {
    private final BitBoard board;
    private final byte me;
    private long nodeBudget;
    private int explored;

    public DfPnSolver(BitBoard board, byte me, long nodeBudget) {
        this.board = board; this.me = me; this.nodeBudget = nodeBudget;
    }

    public boolean isWin(int maxDepth) {
        ProofNode root = new ProofNode(ProofNode.Kind.OR, -1, -1, me);
        expand(root, maxDepth);
        return loop(root, 1, Integer.MAX_VALUE / 4, maxDepth, nodeBudget);
    }

    /** 阈值限定的深度优先探钻;返回 true 表示根节点被证明 */
    private boolean loop(ProofNode node, int pnT, int dnT, int depth, long budget) {
        if (++explored > budget) return node.proved;         // 预算耗尽,交回当前结论
        node.recompute();
        if (node.proved)    return true;
        if (node.disproved) return false;
        if (node.children.isEmpty() || depth <= 0) return node.proved;

        // 选证明数最小的子节点优先深入(OR 看 pn,AND 看 dn)
        ProofNode best = null; int bestKey = Integer.MAX_VALUE, second = Integer.MAX_VALUE;
        for (ProofNode ch : node.children) {
            int key = node.kind == ProofNode.Kind.OR ? ch.pn : ch.dn;
            if (key < bestKey) { second = bestKey; bestKey = key; best = ch; }
            else if (key < second) second = key;
        }
        if (best == null) return false;

        // 第二小的兄弟决定新阈值,避免一次钻入过深
        int newPn = node.kind == ProofNode.Kind.OR  ? Math.min(pnT, second) : pnT;
        int newDn = node.kind == ProofNode.Kind.AND ? Math.min(dnT, second) : dnT;

        boolean res = loop(best, newPn, newDn, depth - 1, budget);
        node.recompute();
        return res && node.proved;
    }

    /** 展开一个节点:只生成"威胁点",这是分支因子控制的唯一来源 */
    private void expand(ProofNode node, int depth) {
        if (depth <= 0) {
            node.children.clear();
            node.proved = false; node.disproved = true;   // 深度耗尽视为无法证明
            return;
        }
        byte side = node.mover;
        byte opp  = (byte) (side == BitBoard.BLACK ? BitBoard.WHITE : BitBoard.BLACK);
        List<int[]> cand = ThreatGenerator.candidates(board, side, opp);

        for (int[] rc : cand) {
            int r = rc[0], c = rc[1];
            board.put(r, c, side);
            ProofNode ch = new ProofNode(
                    node.kind == ProofNode.Kind.OR ? ProofNode.Kind.AND : ProofNode.Kind.OR, r, c, opp);

            if (board.winsAfter(r, c, side)) {
                ch.pn = Integer.MAX_VALUE; ch.dn = 1; ch.disproved = true;   // 本方成五 => 该子节点必败
            } else if (ThreatGenerator.hasFiveThreat(board, opp, side)) {
                ch.pn = Integer.MAX_VALUE; ch.dn = 1; ch.disproved = true;   // 没堵住对手的五 => 同样必败
            } else {
                expand(ch, depth - 1);                                        // 继续向后展开
            }
            board.undo(r, c);
            node.children.add(ch);
        }

        if (node.children.isEmpty()) {                    // 无可行威胁点 => 无法证明
            node.proved = false; node.disproved = true;
        }
        node.recompute();
    }
}

4.3 威胁点生成:五子棋的”知识”

纯 PNS 的弱点是完全不区分着法质量。实战中必须用棋型知识把着法表压缩到 3~8 个点,否则 15×15 的分支因子让 df-pn 在第一层就爆掉。

import java.util.*;

public class ThreatGenerator {
    /** 生成 side 方的候选着法:优先己方成五点、必须封堵点、冲四点 */
    public static List<int[]> candidates(BitBoard b, byte side, byte opp) {
        List<int[]> win = new ArrayList<>();      // 落子即五连
        List<int[]> block = new ArrayList<>();    // 必须封堵对手
        List<int[]> attack = new ArrayList<>();   // 形成活三/冲四

        for (int r = 0; r < BitBoard.SIZE; r++) {
            for (int c = 0; c < BitBoard.SIZE; c++) {
                if (!b.isEmpty(r, c)) continue;
                if (!nearStone(b, r, c, 2)) continue;       // 距离已有棋子 2 格内的空点才考虑

                b.put(r, c, side);
                int myLen = b.maxLineThrough(r, c, side);
                b.undo(r, c);

                b.put(r, c, opp);
                int oppLen = b.maxLineThrough(r, c, opp);
                b.undo(r, c);

                if (myLen >= 5)      win.add(new int[]{r, c});
                else if (oppLen >= 4) block.add(new int[]{r, c});
                else if (myLen >= 4)  attack.add(new int[]{r, c});
            }
        }
        List<int[]> out = new ArrayList<>(win);
        out.addAll(block);
        out.addAll(attack);
        return out;
    }

    /** 对手是否已在某空点形成五连威胁 */
    public static boolean hasFiveThreat(BitBoard b, byte opp, byte side) {
        for (int r = 0; r < BitBoard.SIZE; r++)
            for (int c = 0; c < BitBoard.SIZE; c++) {
                if (!b.isEmpty(r, c)) continue;
                b.put(r, c, opp);
                boolean five = b.maxLineThrough(r, c, opp) >= 5;
                b.undo(r, c);
                if (five) return true;
            }
        return false;
    }

    private static boolean nearStone(BitBoard b, int r, int c, int radius) {
        for (int dr = -radius; dr <= radius; dr++)
            for (int dc = -radius; dc <= radius; dc++) {
                int nr = r + dr, nc = c + dc;
                if (b.inside(nr, nc) && b.grid[nr][nc] != 0) return true;
            }
        return false;
    }
}

candidates 只保留”成五 / 必堵 / 冲四”三类点,意味着搜索只覆盖强制序列。与手写 VCF 迭代相比,df-pn 多出的能力是自动做深度优先 + 预算分配,不必预设”只能连冲四几步”。

五、和 VCF / Alpha-Beta 的对比

方法 评价依据 优点 局限
Alpha-Beta 静态估值分数 能选出”最好的普通着法” 证明”必胜”极慢,估值误差会掩盖强制胜
VCF 递归 固定”连续冲四”模式 实现简单、命中率高 深度固定,容易漏掉”先活三再冲四”的长链
证明数搜索 PNS 还需证明的叶子数 结论严格、可给”必胜 / 必败” 内存随树宽线性增长
df-pn 叶子数 + 阈值回溯 内存只与深度相关、可按预算随时停 强依赖着法生成质量

实测经验(15×15 标准棋盘,NODE_BUDGET = 200 万):对手漏防一个活三的局面,df-pn 平均在 3~6 万个节点内给出必胜结论;同样局面 Alpha-Beta 即使搜到深度 12,也常常只返回一个”高分着法”而非确定结论。

六、工程要点与复杂度

  • 证明数不是启发值:它没有绝对值意义,只有序关系有意义,不要拿它做跨局面的比较。
  • 饱和加法(addSat)必须做,否则深度 20 的与节点一连串求和就会整数溢出。
  • 迭代加深:从 maxDepth = 5 开始逐步加深,每层先用很小的 nodeBudget 试跑,可以显著降低无效开销。
  • 复杂度:最坏情况仍是博弈树的指数级,df-pn 的价值在于”平均情况下把预算集中到最可能被证明的分支”,实际开销约为 O(b^d),其中 b 是威胁点数量(3~8)、d 是解的深度,常数极小。
  • 正确性前提:叶节点判定、与/或节点类型只要搞反一个,PNS 就会给出完全相反但看起来很自信的结论——务必用”单冲四必堵”这类小局面写单元测试。

五子棋的胜负本质是一连串强制着法的叠加,而这恰好是证明数搜索最擅长表达的形态:不需要知道”这步值多少分”,只需要知道”这步之后还剩几个可能被证明的分支”。