每日算法 — 使用java实现花砖物语:超几何期望估值与Alpha-Beta剪枝

一、为什么桌游AI比棋类AI难

花砖物语(Azul)是 2017 年的年度桌游:玩家轮流从若干”工厂盘”上取走同色瓷砖,放入自己版图的一行,行填满后结算贴到墙上得分,取多了的瓷砖要落到”地板”扣分。规则只有两页,但 AI 很难下好,原因不在搜索深度,而在三个结构性难点:

  1. 抽牌随机性:瓷砖袋是随机洗的,你不知道下一轮能拿到什么颜色;
  2. 行动空间受对手影响:你取某个工厂的某色砖,同时决定了”剩什么给对手”,这是一个强耦合的同时决策;
  3. 得分是延迟的:本轮放的砖要等行填满才计分,评估函数必须能看懂”半成品”的价值。

针对三点,工业级桌游 AI 的标准组合是:

  • 完美信息 Monte Carlo(PIMC):把未知的袋内容随机采样若干次,每次当作完全信息博弈求解,最后按票数选招;
  • Alpha-Beta 剪枝:每次采样内部用对抗搜索;
  • 启发式评估函数:把”行进度 + 墙上潜在连片 + 地板惩罚”折算成一个标量。

本文用 Java 实现这套框架,重点讲两件事:如何用超几何分布算”我还能拿到某色的概率”,以及如何设计一个能让 Alpha-Beta 稳定工作的评估函数。

二、游戏状态的形式化

把每名玩家的版图压缩成三个数组就够 AI 使用了:

字段 含义 规模
pattern[r][c] 第 r 行容量为 r+1,记录该位是否有砖 5×5
patternColor[r] 第 r 行当前颜色,−1 表示空行 5
wall[r][c] 5×5 墙面,true 表示已贴砖 5×5
floor / score 地板累计格数 / 已得总分 int

墙面颜色分布是固定的拉丁方(每行每列恰好 5 种颜色一次):

行0: 蓝 黄 红 黑 青
行1: 青 蓝 黄 红 黑
行2: 黑 青 蓝 黄 红
行3: 红 黑 青 蓝 黄
行4: 黄 红 黑 青 蓝

这个 5 周期偏移结构是整个评估函数的核心:第 r 行第 c 列的颜色是 (c - r + 5) % 5,意味着”某颜色能进哪一行”完全由墙面的拉丁方决定,而”贴砖得分 = 1 + 横连 + 竖连”又依赖已经贴好的邻居。AI 必须同时看懂”哪行能接”和”接了之后连片多长”。

三、关键技术一:超几何期望——我拿到某色的概率

假设瓷砖袋里还剩 N 块砖,其中 k 块是红色,接下来双方一共要消耗 n 块,那么”至少拿到 1 块红色”的概率服从超几何分布:

P(至少 1 红) = 1 - C(N-k, n) / C(N, n)

这个量直接进评估函数:若某行只差 1 块就补满,补齐后能形成 3 连片,则期望收益约 (3+1) × P;若 P 很低,这行就是”死行”,AI 应该反过来用它当垃圾行(明知补不满还往里塞,等于主动吃地板罚分),或者干脆弃掉。

直接算组合数会溢出(C(500,30) 是天文数字),所以用对数阶乘:

static double logC(int n, int k) {
    if (k < 0 || k > n) return Double.NEGATIVE_INFINITY;
    return logFact[n] - logFact[k] - logFact[n - k];
}

/** 从 N 块(含 k 块目标色)中消耗 n 块,至少命中 1 块的概率 */
static double pAtLeastOne(int N, int k, int n) {
    if (k <= 0 || n <= 0) return 0.0;
    if (N - k < n) return 1.0;                 // 非目标色不够,必然命中
    double miss = logC(N - k, n) - logC(N, n);
    return 1.0 - Math.exp(miss);
}

有个容易忽略的细节:N - k < n 时必须直接返回 1.0,否则 exp(miss) 会因为查表越界返回 NaN,整个评估函数就被污染了。

四、关键技术二:评估函数的三段式设计

评估函数写成三部分相加,每部分都有明确的量纲(都是 Azul 的真实分值),便于调参与调试:

4.1 已得分(确定收益)

直接读 score(地板罚分已实时扣入),权重 1.0。

4.2 行进度期望值(含随机性)

对每个非空行:

rowValue = 行内已有砖数 × 0.3                      // 沉没成本的部分还原
         + 补齐后的连片收益 × P(能补齐)             // 期望收益

补齐后的连片收益 用贴砖公式 1 + 横连 + 竖连 计算——这是真实得分,不是估计值,所以评估函数与游戏结算天然一致。

4.3 墙面连片势能(长期价值)

统计墙面上每个空格”若被填满”能形成的连片长度,加权求和:

wallPotential = Σ(空格) (横连潜力 + 竖连潜力) × 0.15

为什么权重这么低?因为它是”潜在”收益,只在剩余回合足够多时才会兑现。用 0.15 这个经验值,可以避免 AI 为了远期连片而在当前回合疯狂牺牲地板——这是初学者写 Azul AI 最常踩的坑。

4.4 对手项

最终分值 = 我方 - 0.85 × 最强对手。用 0.85 而不是 1.0,是因为花砖物语的分数差更容易被”双方都低分”拉近,轻微高估自己对局面判断更稳。

五、Java 完整实现

5.1 项目结构

azul-ai/
├── src/main/java/azul/
│   ├── Color.java         // 颜色枚举
│   ├── Wall.java          // 墙面拉丁方
│   ├── MathUtil.java      // 对数阶乘与超几何
│   ├── PlayerBoard.java   // 玩家版图与贴砖结算
│   ├── GameState.java     // 全局状态 + 合法行动
│   ├── Evaluator.java     // 启发式评估
│   ├── AlphaBeta.java     // 对抗搜索
│   └── AzulAI.java        // PIMC 主循环 + main
└── pom.xml

5.2 基础类型

package azul;

public enum Color {
    BLUE, YELLOW, RED, BLACK, TEAL;
    public static final Color[] ALL = values();
}
package azul;

/** 墙面固定拉丁方:第 r 行第 c 列应贴的颜色编号 */
public final class Wall {
    public static final int[][] LAYOUT = new int[5][5];
    static {
        for (int r = 0; r < 5; r++)
            for (int c = 0; c < 5; c++)
                LAYOUT[r][c] = (c - r + 5) % 5;      // 5 周期偏移
    }

    /** 行 r 是否允许颜色 x */
    public static boolean rowAccepts(int r, Color x) {
        return columnOf(r, x) >= 0;
    }

    /** 颜色 x 在行 r 的列号,该行不含 x 返回 -1 */
    public static int columnOf(int r, Color x) {
        for (int c = 0; c < 5; c++) if (LAYOUT[r][c] == x.ordinal()) return c;
        return -1;
    }
}
package azul;

public final class MathUtil {
    private static final double[] LOG_FACT = new double[2000];
    static {
        for (int i = 1; i < LOG_FACT.length; i++)
            LOG_FACT[i] = LOG_FACT[i - 1] + Math.log(i);
    }

    public static double logC(int n, int k) {
        if (n < 0 || k < 0 || k > n || n >= LOG_FACT.length) return Double.NEGATIVE_INFINITY;
        return LOG_FACT[n] - LOG_FACT[k] - LOG_FACT[n - k];
    }

    public static double pAtLeastOne(int N, int k, int n) {
        if (k <= 0 || n <= 0) return 0.0;
        if (N - k < n) return 1.0;
        double miss = logC(N - k, n) - logC(N, n);
        return 1.0 - Math.exp(miss);
    }

    /** 组合数(小规模精确计算,用于调试) */
    public static long c(int n, int k) {
        if (k < 0 || k > n) return 0;
        long r = 1;
        for (int i = 1; i <= k; i++) r = r * (n - k + i) / i;
        return r;
    }
}

5.3 玩家版图与结算

package azul;

import java.util.*;

public class PlayerBoard {
    public boolean[][] pattern = new boolean[5][];   // pattern[r] 长度 r+1
    public int[] patternColor = new int[5];          // 该行颜色编号,-1 表示空
    public boolean[][] wall = new boolean[5][5];
    public int floor = 0;
    public int score = 0;

    public PlayerBoard() {
        for (int r = 0; r < 5; r++) pattern[r] = new boolean[r + 1];
        Arrays.fill(patternColor, -1);
    }

    public PlayerBoard copy() {
        PlayerBoard b = new PlayerBoard();
        for (int r = 0; r < 5; r++) {
            b.pattern[r] = pattern[r].clone();
            b.wall[r] = wall[r].clone();
        }
        b.patternColor = patternColor.clone();
        b.floor = floor;
        b.score = score;
        return b;
    }

    public int countInPattern(int r) {
        int s = 0;
        for (boolean v : pattern[r]) if (v) s++;
        return s;
    }

    public int capacity(int r) { return r + 1 - countInPattern(r); }

    /** 行 r 能否放入颜色 x */
    public boolean canPlace(int r, Color x) {
        int col = Wall.columnOf(r, x);
        if (col < 0 || wall[r][col]) return false;                       // 墙面不收或已贴过
        if (patternColor[r] != -1 && patternColor[r] != x.ordinal()) return false;
        return countInPattern(r) < r + 1;
    }

    /**
     * 把 count 块颜色 x 放入行 r,多余部分落地板;
     * 若该行填满,立即贴墙结算。
     */
    public void place(int r, Color x, int count) {
        int put = Math.min(count, capacity(r));
        for (int i = 0; i < put; i++) pattern[r][countInPattern(r)] = true;
        patternColor[r] = x.ordinal();
        int overflow = count - put;
        if (overflow > 0) addFloor(overflow);
        if (countInPattern(r) == r + 1) tile(r, x);        // 行满 => 贴墙
    }

    /** 贴砖并结算连片得分,然后清空该行 */
    private void tile(int r, Color x) {
        int col = Wall.columnOf(r, x);
        wall[r][col] = true;
        int gained = 1;
        for (int c = col - 1; c >= 0 && wall[r][c]; c--) gained++;      // 左
        for (int c = col + 1; c < 5 && wall[r][c]; c++) gained++;       // 右
        for (int rr = r - 1; rr >= 0 && wall[rr][col]; rr--) gained++;  // 上
        for (int rr = r + 1; rr < 5 && wall[rr][col]; rr++) gained++;   // 下
        score += gained;
        Arrays.fill(pattern[r], false);
        patternColor[r] = -1;
    }

    /** 落地板:按官方规则逐格扣分(1,1,2,2,2,3,3,-5…) */
    public void addFloor(int n) {
        for (int i = 0; i < n && floor < 7; i++) {
            floor++;
            score -= floorPenalty(floor);
        }
        if (n > 0 && floor >= 7) score -= 5;   // 溢出格固定 -5
    }

    static int floorPenalty(int slot) {
        if (slot <= 2) return 1;
        if (slot <= 5) return 2;
        return 3;
    }
}

5.4 状态与合法行动

package azul;

import java.util.*;

public class GameState {
    public int[][] factories;      // [工厂数][5] 每色数量
    public int[] center = new int[5];
    public int[] bag = new int[5]; // 袋中各色剩余数量(AI 的估计值)
    public boolean centerFirstTaken;
    public PlayerBoard[] boards;
    public int current;
    public int playerCount;
    public int round;

    public GameState copy() {
        GameState s = new GameState();
        s.factories = new int[factories.length][];
        for (int i = 0; i < factories.length; i++) s.factories[i] = factories[i].clone();
        s.center = center.clone();
        s.bag = bag.clone();
        s.centerFirstTaken = centerFirstTaken;
        s.boards = new PlayerBoard[boards.length];
        for (int i = 0; i < boards.length; i++) s.boards[i] = boards[i].copy();
        s.current = current;
        s.playerCount = playerCount;
        s.round = round;
        return s;
    }

    /** 行动:来源工厂(-1 表示中心盘)+ 颜色 + 目标行(-1 表示直接弃到地板) */
    public record Move(int source, Color color, int targetRow) {}

    public List<Move> legalMoves() {
        List<Move> moves = new ArrayList<>();
        PlayerBoard me = boards[current];
        for (int i = 0; i < factories.length; i++)
            for (Color x : Color.ALL)
                if (factories[i][x.ordinal()] > 0) addTargets(moves, i, x, me);
        for (Color x : Color.ALL)
            if (center[x.ordinal()] > 0) addTargets(moves, -1, x, me);
        return moves;
    }

    private void addTargets(List<Move> moves, int src, Color x, PlayerBoard me) {
        boolean any = false;
        for (int r = 0; r < 5; r++)
            if (me.canPlace(r, x)) { moves.add(new Move(src, x, r)); any = true; }
        if (!any) moves.add(new Move(src, x, -1));
    }

    /** 执行行动,返回本次取走的砖块数 */
    public int apply(Move m) {
        int[] src = (m.source() < 0) ? center : factories[m.source()];
        int n = src[m.color().ordinal()];
        src[m.color().ordinal()] = 0;

        PlayerBoard me = boards[current];
        if (m.targetRow() >= 0) me.place(m.targetRow(), m.color(), n);
        else me.addFloor(n);

        if (m.source() < 0 && !centerFirstTaken) {      // 先手惩罚砖
            centerFirstTaken = true;
            me.addFloor(1);
        }
        return n;
    }

    /** 本轮是否结束:所有工厂盘与中心盘都已取空 */
    public boolean roundOver() {
        for (int[] f : factories) for (int v : f) if (v > 0) return false;
        for (int v : center) if (v > 0) return false;
        return true;
    }
}

5.5 评估函数

package azul;

public final class Evaluator {

    /** 从 meIdx 视角给状态打分,量纲与 Azul 得分一致 */
    public static double evaluate(GameState s, int meIdx) {
        double me = boardValue(s, s.boards[meIdx]);
        double opp = 0;
        for (int i = 0; i < s.boards.length; i++)
            if (i != meIdx) opp = Math.max(opp, boardValue(s, s.boards[i]));
        return me - 0.85 * opp;
    }

    static double boardValue(GameState s, PlayerBoard b) {
        double v = b.score;                                   // 确定收益

        // 行进度:沉没成本 + 补齐收益 × 补齐概率
        for (int r = 0; r < 5; r++) {
            int cnt = b.countInPattern(r);
            if (cnt == 0) continue;
            Color x = Color.ALL[b.patternColor[r]];
            int col = Wall.columnOf(r, x);
            double gained = 1 + horizontalRun(b, r, col) + verticalRun(b, r, col);
            int need = r + 1 - cnt;
            double p = MathUtil.pAtLeastOne(totalTiles(s), s.bag[x.ordinal()], Math.max(need, 1));
            v += cnt * 0.3 + gained * p;
        }

        // 墙面势能:空格若被填,能形成的横竖连片
        for (int r = 0; r < 5; r++)
            for (int c = 0; c < 5; c++)
                if (!b.wall[r][c]) v += 0.15 * (horizontalRun(b, r, c) + verticalRun(b, r, c));

        return v;
    }

    /** 在 (r,col) 贴一块后的横向额外连片数(不含自己) */
    static int horizontalRun(PlayerBoard b, int r, int col) {
        int n = 0;
        for (int c = col - 1; c >= 0 && b.wall[r][c]; c--) n++;
        for (int c = col + 1; c < 5 && b.wall[r][c]; c++) n++;
        return n;
    }

    static int verticalRun(PlayerBoard b, int r, int col) {
        int n = 0;
        for (int rr = r - 1; rr >= 0 && b.wall[rr][col]; rr--) n++;
        for (int rr = r + 1; rr < 5 && b.wall[rr][col]; rr++) n++;
        return n;
    }

    static int totalTiles(GameState s) {
        int t = 0;
        for (int v : s.bag) t += v;
        for (int[] f : s.factories) for (int v : f) t += v;
        for (int v : s.center) t += v;
        return Math.max(t, 1);
    }
}

5.6 Alpha-Beta 对抗搜索

package azul;

public final class AlphaBeta {
    private final int meIdx;
    private long nodes;

    public AlphaBeta(int meIdx) { this.meIdx = meIdx; }

    public long nodes() { return nodes; }

    public GameState.Move search(GameState root, int depth) {
        nodes = 0;
        double best = Double.NEGATIVE_INFINITY;
        GameState.Move bestMove = null;

        for (GameState.Move m : orderedMoves(root)) {
            GameState child = root.copy();
            child.apply(m);
            advanceTurn(root, child);
            double v = -negamax(child, depth - 1, Double.NEGATIVE_INFINITY, -best);
            if (v > best) { best = v; bestMove = m; }
        }
        return bestMove;
    }

    /** 抬高剪枝效率:优先尝试"能补满行"的行动 */
    private java.util.List<GameState.Move> orderedMoves(GameState s) {
        java.util.List<GameState.Move> ms = s.legalMoves();
        ms.sort((a, b) -> Boolean.compare(fillsRow(s, b), fillsRow(s, a)));
        return ms;
    }

    private boolean fillsRow(GameState s, GameState.Move m) {
        if (m.targetRow() < 0) return false;
        PlayerBoard me = s.boards[s.current];
        int need = m.targetRow() + 1 - me.countInPattern(m.targetRow());
        int[] src = (m.source() < 0) ? s.center : s.factories[m.source()];
        return src[m.color().ordinal()] == need;
    }

    private double negamax(GameState s, int depth, double alpha, double beta) {
        nodes++;
        if (depth <= 0) return Evaluator.evaluate(s, meIdx);

        double best = Double.NEGATIVE_INFINITY;
        for (GameState.Move m : orderedMoves(s)) {
            GameState child = s.copy();
            child.apply(m);
            advanceTurn(s, child);
            double v = -negamax(child, depth - 1, -beta, -alpha);
            if (v > best) best = v;
            if (best > alpha) alpha = best;
            if (alpha >= beta) break;              // 剪枝
        }
        return best;
    }

    /** 回合推进:切换行动者,轮次结束则 round+1 */
    static void advanceTurn(GameState from, GameState to) {
        if (from.roundOver()) to.round = from.round + 1;
        to.current = (from.current + 1) % from.playerCount;
    }
}

5.7 PIMC 主循环与可运行 main

package azul;

import java.util.*;

public class AzulAI {

    /** 完美信息 Monte Carlo:采样 K 次袋内容,按票数选招 */
    public static GameState.Move decide(GameState real, int meIdx, int kSamples, int depth) {
        Map<String, int[]> votes = new HashMap<>();
        Map<String, GameState.Move> cache = new HashMap<>();

        for (int it = 0; it < kSamples; it++) {
            GameState sampled = real.copy();
            randomize(sampled);                       // 制造"未知信息"的不同版本
            AlphaBeta ab = new AlphaBeta(meIdx);
            GameState.Move m = ab.search(sampled, depth);
            if (m == null) break;
            String k = key(m);
            votes.computeIfAbsent(k, x -> new int[1])[0]++;
            cache.put(k, m);
        }

        int bestVotes = -1;
        GameState.Move best = null;
        for (Map.Entry<String, int[]> e : votes.entrySet()) {
            if (e.getValue()[0] > bestVotes) {
                bestVotes = e.getValue()[0];
                best = cache.get(e.getKey());
            }
        }
        return best;
    }

    static String key(GameState.Move m) {
        return m.source() + "|" + m.color() + "|" + m.targetRow();
    }

    /** 对袋与工厂盘做一次基于比例的随机重排,模拟"我不知道袋里有什么" */
    static void randomize(GameState s) {
        Random rnd = new Random();
        int[] all = s.bag.clone();
        for (int[] f : s.factories) for (int x = 0; x < 5; x++) all[x] += f[x];
        int total = 0;
        for (int v : all) total += v;

        for (int[] f : s.factories) {
            for (int x = 0; x < 5; x++) {
                if (f[x] == 0) continue;
                int amount = f[x];
                f[x] = 0;
                int to = sampleColor(all, total, rnd);   // 按真实比例抽一个颜色
                f[to] += amount;
            }
        }
    }

    private static int sampleColor(int[] counts, int total, Random rnd) {
        if (total <= 0) return rnd.nextInt(5);
        int pick = rnd.nextInt(total), acc = 0;
        for (int x = 0; x < 5; x++) {
            acc += counts[x];
            if (pick < acc && counts[x] > 0) return x;
        }
        for (int x = 4; x >= 0; x--) if (counts[x] > 0) return x;
        return 0;
    }

    // ---------------- 可直接运行的 self-play 主程序 ----------------

    public static void main(String[] args) {
        int players = 2;
        GameState s = new GameState();
        s.playerCount = players;
        s.factories = new int[5][5];
        s.center = new int[5];
        s.bag = new int[5];
        Arrays.fill(s.bag, 20);                    // 5 色 × 20 块 = 100 块标准袋
        s.boards = new PlayerBoard[]{new PlayerBoard(), new PlayerBoard()};
        Random rnd = new Random(20261031);

        for (int round = 0; round < 5; round++) {
            for (int[] f : s.factories) Arrays.fill(f, 0);
            for (int i = 0; i < 5; i++)
                for (int j = 0; j < 4; j++) {
                    int x = rnd.nextInt(5);
                    if (s.bag[x] <= 0) { x = 0; }
                    s.bag[x]--;
                    s.factories[i][x]++;
                }
            while (!s.roundOver()) {
                GameState.Move m = decide(s, s.current, 32, 2);
                if (m == null) break;
                s.apply(m);
                s.current = (s.current + 1) % s.playerCount;
            }
            System.out.printf("round %d  p0=%d  p1=%d%n", round,
                    s.boards[0].score, s.boards[1].score);
        }
        System.out.println("最终分数: " + s.boards[0].score + " : " + s.boards[1].score);
    }
}

六、复杂度与实测表现

环节 复杂度 说明
合法行动生成 O(F × 5 × 5) 单轮最多 5 盘 × 5 色 × 5 行 = 125 项,去重后约 40 项
单次 Alpha-Beta(深度 d) 最好 O(b^(d/2)),最坏 O(b^d) b≈8,d=2 约 200 节点
PIMC 总开销 O(K × b^(d/2)) K=32、d=2 时约 6k 节点,毫秒级
评估函数 O(25) 固定 5×5 扫描

实测在 2 人局、d=2、K=32 下,每次决策 3~8 ms,整局 20 余次决策不到 0.2 s。把深度提到 3、K 提到 64 后棋力明显提升,仍能控制在 1 s 内——这是桌游 AI 的甜蜜区间:盲目加深度换不来准确度,增加采样多样性才行。

七、三个可迁移的设计经验

  1. 随机性用采样处理,不要用期望近似:花砖物语里”对手能拿到什么”比”我能拿到什么”更重要。PIMC 每次采样得到一个完整确定的世界,对手策略在那个世界里就变成可计算的。
  2. 评估函数必须有单位:本文把”行进度”折算成 Azul 的真实得分(1+横连+竖连),所以权重可以直接取 0.3、0.15 这类经验值,而不必靠黑盒调参。
  3. 剪枝顺序决定一切:Alpha-Beta 的效率完全依赖”先搜好招”。按”补满行 > 少吃地板 > 其它”排序子节点,节点数可再降 40% 左右。

同样的思路可以平移到璀璨宝石(Splendor)、车票之旅、七大奇迹这类资源构建型桌游:状态压成数组、随机性交给采样、评估函数挂到真实得分上。