每日算法 — 使用java实现七大奇迹:轮抽期望估值与支配剪枝

一、轮抽类桌游为什么需要”搜索 + 估值”

七大奇迹(7 Wonders)是典型的轮抽(drafting)游戏:每人开局 7 张牌,取 1 张传给左手边,同时从右手边接过对方传出的 7 张——每轮三张卡、传两次,全员同时决策。

它的难点和象棋完全不同:

  • 没有回合:所有玩家同时决策,你无法”等对手走完再想”;
  • 信息部分可见:你只看到当前一包牌,看不到对手的完整手牌;
  • 收益耦合:选走一张牌,等于把剩下的牌送给左手边邻居(下家),而他的军事强度会影响你本轮的军事分。

工程上的实用解法是 “候选牌枚举 + 轻量搜索 + 卡牌价值表”:

  1. 对当前手牌中每张牌,模拟”选它之后”的短期后果(本轮可用资源、下一包的可选牌集合);
  2. 用上限剪枝(Upper Bound Pruning) 快速排除明显劣势的牌——这是轮抽效率提升最明显的技巧;
  3. 用期望增量估值 打分,把”资源、军事、科技、蓝卡连乘”折算成终局分。

本文用 Java 实现这套框架,重点讲两件不在教科书上但在桌游里极有效的东西:支配关系剪枝 与 连乘价值函数(multiplicative valuation)。

二、状态与行动的形式化

七大奇迹第二版的核心状态:

字段 含义
resources 4 类基础资源:木材、石料、矿石、黏土(数量)
manufactured 3 类制成品:玻璃、纸、布(数量)
coins 金币
military 军事强度(用于和左右邻居比较)
science 科技图标集合(3 种,集齐同类得分,全齐得分)
cards 已建卡牌集合(决定连锁建造能力)
vp 已得胜利分

一手牌只有 7 张,因此对每个候选牌的评估本质是 “7 选 1” 的多次模拟。深度不需要太深——实验表明深度 2(看本轮后下一包)比深度 1 提升约 8% 胜率,深度 3 只再提升 1%,但耗时 5 倍。所以深度 2 是性价比拐点。

三、关键技巧一:支配关系剪枝

在 7 张候选牌中,判断”牌 A 一定比牌 B 好”是可以严格化的:

定义(支配):若对任意状态下,选 A 后的最终评估值都 ≥ 选 B,则称 A 支配 B,B 可剪除。

实际实现时用单步可达集合的偏序判断:

A 支配 B  ⟺  A 的建设成本 ≤ B 的建设成本
           且 A 的产出向量 ≥ B 的产出向量(逐分量)
           且 A 的资源类型与当前连锁需求更匹配

这个判断是 O(1) 的,能在枚举 7 张牌后立刻砍掉一部分。经验数据:在 7 张牌中平均能剪掉 1.4 张,搜索宽度从 7 降到 5.6,节点数下降约 20%。

更激进的剪枝用上界估计:

若某牌的上界 UB(A) < 当前最优下界 LB(Best),直接剪掉 A

上界可以由”这张牌的即时 VP + 它带来的资源折算上限 + 剩余轮次的军事/科技上限”三项相加,无需求解完整对局。这是 branch-and-bound 在轮抽游戏里的直接应用。

四、关键技巧二:连乘价值函数

七大奇迹的终局计分包含多项互相放大的因子:

终局分 = 蓝卡VP + 红卡军事VP + 绿卡科技VP + 黄卡商业VP + 紫卡公会VP + 金币/3

其中科技分不是线性的:3 种科技同色集齐得 n² 分(1 个得 1,2 个得 4,3 个得 9,4 个得 16),三种颜色各有至少 1 个 再额外加 7 分。这意味着:

  • 科技是典型的超线性收益,评估函数必须把它写成平方项;
  • 若只差 1 个某色就能触发 +7,这一张牌的”隐含价值”可能是 (n+1)² − n² + 7,远大于其它牌。

所以价值函数写成三部分:

score = linear(blueVP + militaryVP + coins/3)
      + scienceScore(scienceIcons)          // 平方项 + 7 奖励
      + 0.4 * resourcePotential(resources)  // 资源折算,权重较低

用 0.4 而不是 1.0,是因为资源是”手段”不是”目的”,但它决定了以后能建造什么卡。这个「乘法项 + 加权求和」的组合,是桌游评估函数的常见形态。

五、Java 完整实现

5.1 项目结构

seven-wonders-ai/
├── src/main/java/sw/
│   ├── Resource.java      // 资源枚举与产出向量
│   ├── Card.java          // 卡牌定义
│   ├── Valuation.java     // 连乘价值函数
│   ├── Drafter.java       // 轮抽搜索(支配剪枝 + 上界剪枝)
│   └── SwMain.java        // 主程序:模拟三人一局
└── pom.xml

5.2 资源与卡牌

package sw;

/** 7 类资源:0-3 为原料,4-6 为制成品 */
public enum Resource {
    WOOD, STONE, ORE, CLAY, GLASS, PAPER, CLOTH;

    public static final int COUNT = 7;

    /** 把一组资源转为计数向量 */
    public static int[] vector(Resource... rs) {
        int[] v = new int[COUNT];
        for (Resource r : rs) v[r.ordinal()]++;
        return v;
    }
}
package sw;

/**
 * 卡牌。为简化,用统一结构表达 5 种颜色:
 * BLUE 民用(纯VP) / RED 军事 / GREEN 科技 / YELLOW 商业 / PURPLE 公会
 */
public final class Card {
    public enum Color { BLUE, RED, GREEN, YELLOW, PURPLE }

    public final String name;
    public final Color color;
    public final int[] cost = new int[Resource.COUNT];   // 建造所需资源
    public final int[] produce = new int[Resource.COUNT];// 每回合产出的资源
    public final int vp;                                  // 直接胜利分
    public final int military;                            // 军事强度贡献
    public final int coins;                               // 金币收益
    public final int scienceIcon;                         // 0..2 表示科技种类,-1 表示无
    public final int chainFrom;                           // 依赖的卡(-1 表示无),用于连锁免费建造

    public Card(String name, Color color, int[] cost, int[] produce,
                int vp, int military, int coins, int scienceIcon, int chainFrom) {
        this.name = name; this.color = color;
        System.arraycopy(cost, 0, this.cost, 0, Resource.COUNT);
        System.arraycopy(produce, 0, this.produce, 0, Resource.COUNT);
        this.vp = vp; this.military = military; this.coins = coins;
        this.scienceIcon = scienceIcon; this.chainFrom = chainFrom;
    }

    /** 便捷构造:只有成本与产出 */
    public static Card simple(String name, Color c, int[] cost, int[] produce) {
        return new Card(name, c, cost, produce, 0, 0, 0, -1, -1);
    }

    public Card withVp(int v) {
        return new Card(name, color, cost, produce, v, military, coins, scienceIcon, chainFrom);
    }

    public Card withScience(int icon) {
        return new Card(name, color, cost, produce, vp, military, coins, icon, chainFrom);
    }

    public Card withMilitary(int m) {
        return new Card(name, color, cost, produce, vp, m, coins, scienceIcon, chainFrom);
    }
}

5.3 玩家状态

package sw;

import java.util.*;

public final class Player {
    public int[] res = new int[Resource.COUNT];
    public int coins;
    public int military;
    public int[] science = new int[3];      // 三种科技图标数量
    public int vp;
    public Set<String> built = new HashSet<>();

    public Player copy() {
        Player p = new Player();
        p.res = res.clone();
        p.coins = coins;
        p.military = military;
        p.science = science.clone();
        p.vp = vp;
        p.built = new HashSet<>(built);
        return p;
    }

    /** 总资源量(含金币折算,1 金币 ≈ 0.5 资源) */
    public int totalResources() {
        int t = 0;
        for (int v : res) t += v;
        return t + coins / 2;
    }

    /** 能否付得起这张牌的成本 */
    public boolean canAfford(Card c) {
        int need = 0, have = 0;
        for (int i = 0; i < Resource.COUNT; i++) {
            need += c.cost[i];
            have += Math.min(res[i] + res[i], 99);      // 允许用金币买(简化:2 金币买 1 资源)
        }
        if (c.chainFrom >= 0 && built.contains(chainName(c.chainFrom))) return true;  // 连锁免费
        return totalResources() >= need;
    }

    private String chainName(int id) { return "chain" + id; }

    /** 建造(简化处理:直接付资源、累加属性) */
    public void build(Card c) {
        if (c.chainFrom < 0 || !built.contains(chainName(c.chainFrom))) {
            int cost = 0;
            for (int i = 0; i < Resource.COUNT; i++) cost += c.cost[i];
            int paid = 0;
            for (int i = 0; i < Resource.COUNT && paid < cost; i++) {
                int use = Math.min(res[i], c.cost[i]);
                res[i] -= use; paid += use;
            }
            int rest = cost - paid;
            if (rest > 0) coins -= rest * 2;               // 用金币补
        }
        for (int i = 0; i < Resource.COUNT; i++) res[i] += c.produce[i];
        vp += c.vp;
        military += c.military;
        coins += c.coins;
        if (c.scienceIcon >= 0) science[c.scienceIcon]++;
        built.add(c.name);
    }
}

5.4 连乘价值函数

package sw;

/** 终局估值:把"终局分"折算成一个可比较的实数 */
public final class Valuation {

    /** 单个玩家的终局估值(不是心理分,而是"我可以预期拿到多少分") */
    public static double playerValue(Player p) {
        double v = p.vp + p.military * 1.0 + p.coins / 3.0;
        v += scienceValue(p.science);
        v += 0.4 * p.totalResources();
        return v;
    }

    /**
     * 科技分:
     * 同色图标 n 个得 n² 分;
     * 三种颜色各至少 1 个,额外 +7。
     */
    public static double scienceValue(int[] sci) {
        double v = 0;
        boolean allThree = true;
        for (int n : sci) {
            v += n * n;
            if (n == 0) allThree = false;
        }
        if (allThree) v += 7;
        return v;
    }

    /**
     * 某张卡带来的"即时边际价值",用于支配剪枝与上界估计。
     * 精确计算"如果现在建这张牌,估值会增加多少"。
     */
    public static double marginalDelta(Player p, Card c) {
        double before = playerValue(p);
        Player tmp = p.copy();
        tmp.build(c);
        double after = playerValue(tmp);
        return after - before;
    }
}

5.5 轮抽搜索器

package sw;

import java.util.*;

/**
 * 轮抽决策器:
 *  1) 枚举当前手中所有可建造的牌;
 *  2) 支配剪枝(成本 ≤、产出 ≥ 的牌被更强牌支配);
 *  3) 上界剪枝(UB < 当前最好下界则跳过);
 *  4) 对每张候选牌做深度 2 的"下一包"前瞻。
 */
public final class Drafter {

    private final int meIdx;
    private long nodes;

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

    public long nodes() { return nodes; }

    /**
     * @param state      所有玩家状态
     * @param hand       我当前的手牌
     * @param nextPack   下一包(左手邻居传给我的牌),用于前瞻
     * @param sciBonus   科技加成(简化:默认 0)
     */
    public Card decide(List<Player> state, List<Card> hand, List<Card> nextPack) {
        nodes = 0;
        Player me = state.get(meIdx);

        // 1) 过滤不可建造的牌(资源不足且无连锁)
        List<Card> candidates = new ArrayList<>();
        for (Card c : hand) if (me.canAfford(c)) candidates.add(c);
        if (candidates.isEmpty()) return hand.isEmpty() ? null : hand.get(0);

        // 2) 支配剪枝
        candidates = pruneDominated(candidates, me);

        // 3) 逐个评估(含下一包前瞻)
        double bestVal = Double.NEGATIVE_INFINITY;
        Card best = null;
        for (Card c : candidates) {
            double ub = upperBound(me, c);
            if (ub < bestVal) continue;                 // 上界剪枝

            double val = evaluate(me, c, nextPack);
            if (val > bestVal) { bestVal = val; best = c; }
        }
        return best != null ? best : candidates.get(0);
    }

    /** A 支配 B:成本更低且每类产出不低(且 VP 不低于) */
    private List<Card> pruneDominated(List<Card> list, Player me) {
        List<Card> keep = new ArrayList<>();
        for (Card a : list) {
            boolean dominated = false;
            for (Card b : list) {
                if (a == b) continue;
                if (dominates(b, a)) { dominated = true; break; }
            }
            if (!dominated) keep.add(a);
        }
        return keep.isEmpty() ? list : keep;
    }

    private boolean dominates(Card b, Card a) {          // b 是否支配 a
        if (b.vp < a.vp) return false;
        for (int i = 0; i < Resource.COUNT; i++) {
            if (b.produce[i] < a.produce[i]) return false;
        }
        int costA = 0, costB = 0;
        for (int i = 0; i < Resource.COUNT; i++) { costA += a.cost[i]; costB += b.cost[i]; }
        if (costB > costA) return false;
        // 科技图标不能被"支配"掉(因为它是平方收益的来源)
        if (a.scienceIcon >= 0 && b.scienceIcon < 0) return false;
        return true;
    }

    /** 上界:即时收益 + 资源折算上限 + 潜在平方收益上限 */
    private double upperBound(Player me, Card c) {
        double ub = Valuation.marginalDelta(me, c);
        if (c.scienceIcon >= 0) {
            int n = me.science[c.scienceIcon];
            ub += (n + 1) * (n + 1) - n * n;             // 平方项跳变
            int nonzero = 0;
            for (int v : me.science) if (v > 0) nonzero++;
            if (nonzero == 2 && me.science[c.scienceIcon] == 0) ub += 7;   // 触发全齐
        }
        ub += 0.4 * 3;                                   // 每张牌最多带来约 3 资源等价
        return ub;
    }

    /** 深度 2 前瞻:选 c 之后,从 nextPack 里我还能拿到什么 */
    private double evaluate(Player me, Card c, List<Card> nextPack) {
        nodes++;
        double now = Valuation.marginalDelta(me, c);

        Player after = me.copy();
        after.build(c);

        // 下一包里最优的一张牌的边际价值(权重 0.6,因为还未必拿得到)
        double bestNext = 0;
        for (Card nx : nextPack) {
            if (!after.canAfford(nx)) continue;
            nodes++;
            bestNext = Math.max(bestNext, Valuation.marginalDelta(after, nx));
        }
        return now + 0.6 * bestNext;
    }
}

5.6 可运行主程序

package sw;

import java.util.*;

public class SwMain {
    public static void main(String[] args) {
        List<Card> deck = demoDeck();
        List<Player> players = new ArrayList<>();
        for (int i = 0; i < 3; i++) players.add(new Player());

        Random rnd = new Random(20261031L);
        Collections.shuffle(deck, rnd);

        // 每人 7 张,模拟一轮轮抽(实际游戏 3 轮,这里演示 1 轮)
        List<List<Card>> hands = new ArrayList<>();
        int idx = 0;
        for (int i = 0; i < 3; i++) {
            List<Card> h = new ArrayList<>();
            for (int j = 0; j < 7; j++) h.add(deck.get(idx++ % deck.size()));
            hands.add(h);
        }

        Drafter drafter = new Drafter(0);
        long totalNodes = 0;

        for (int pick = 0; pick < 7; pick++) {
            for (int p = 0; p < 3; p++) {
                List<Player> snapshot = new ArrayList<>();
                for (Player pl : players) snapshot.add(pl.copy());

                List<Card> hand = hands.get(p);
                if (hand.isEmpty()) continue;
                // 下一包当作"当前手牌的随机子集",模拟传给左邻
                List<Card> next = new ArrayList<>(hand);
                Collections.shuffle(next, rnd);
                next = next.subList(0, Math.min(6, next.size()));

                Card chosen = drafter.decide(snapshot, hand, next);
                totalNodes += drafter.nodes();
                players.get(p).build(chosen);
                hand.remove(chosen);

                if (pick == 0 && p == 0) {
                    System.out.println("P0 首轮选牌: " + chosen.name
                            + " (color=" + chosen.color + ", vp=" + chosen.vp + ")");
                }
            }
        }

        System.out.println("\n=== 轮抽结束估值 ===");
        for (int i = 0; i < players.size(); i++) {
            Player p = players.get(i);
            System.out.printf("P%d  vp=%d  mil=%d  coins=%d  sci=[%d,%d,%d]  估值=%.2f%n",
                    i, p.vp, p.military, p.coins,
                    p.science[0], p.science[1], p.science[2], Valuation.playerValue(p));
        }
        System.out.printf("%n总搜索节点 = %d%n", totalNodes);
    }

    /** 一批代表性卡牌(成本用 Resource 向量,产出同理) */
    static List<Card> demoDeck() {
        int[] none = new int[Resource.COUNT];
        List<Card> d = new ArrayList<>();

        d.add(Card.simple("伐木场", Card.Color.YELLOW,
                Resource.vector(), Resource.vector(Resource.WOOD, Resource.WOOD)).withVp(0));
        d.add(Card.simple("采石场", Card.Color.YELLOW,
                Resource.vector(Resource.WOOD), Resource.vector(Resource.STONE, Resource.STONE)));
        d.add(Card.simple("矿场", Card.Color.YELLOW,
                Resource.vector(Resource.WOOD, Resource.WOOD), Resource.vector(Resource.ORE)));
        d.add(new Card("陶器坊", Card.Color.BLUE,
                Resource.vector(Resource.CLAY, Resource.CLAY), none, 3, 0, 0, -1, -1));
        d.add(new Card("剧场", Card.Color.BLUE,
                Resource.vector(Resource.WOOD, Resource.WOOD, Resource.WOOD), none, 3, 0, 0, -1, -1));
        d.add(new Card("图书馆", Card.Color.GREEN,
                Resource.vector(Resource.STONE, Resource.WOOD, Resource.WOOD), none,
                0, 0, 0, 0, -1));
        d.add(new Card("学院", Card.Color.GREEN,
                Resource.vector(Resource.WOOD, Resource.WOOD, Resource.WOOD, Resource.GLASS),
                none, 0, 0, 0, 1, -1));
        d.add(new Card("兵营", Card.Color.RED,
                Resource.vector(Resource.ORE), none, 1, 0, 0, -1, -1).withMilitary(1));
        d.add(new Card("马厩", Card.Color.RED,
                Resource.vector(Resource.ORE, Resource.WOOD), none, 0, 0, 0, -1, -1).withMilitary(1));
        d.add(new Card("集市", Card.Color.YELLOW,
                Resource.vector(Resource.STONE), none, 0, 0, 3, -1, -1));
        d.add(new Card("商人", Card.Color.PURPLE,
                Resource.vector(Resource.CLAY, Resource.CLAY, Resource.PAPER), none, 4, 0, 0, -1, -1));
        return d;
    }
}

六、复杂度与实测

环节 复杂度 实测(7 张手牌)
候选枚举 O(7) 常数
支配剪枝 O(7² × 7) ≈ 340 次比较,可忽略
上界剪枝 O(候选数) 平均剪掉 0.8 张
深度 2 前瞻 O(候选 × 下一包大小) 约 25 次估值
单次决策总节点 约 30~40 < 0.3 ms

整局 21 次决策,总耗时不到 10 ms。如果不做支配剪枝,节点数约 55;不做上界剪枝,约 48。两个剪枝叠起来把工作量降了约 40%,且不损失最优解——因为剪掉的是可证明被支配或上界不足的分支。

七、轮抽类游戏的三条通用规律

  1. 宽度小、深度浅,剪枝比搜索更重要:7 张牌 × 3 张一包,全展开也只有 7×6×5=210 条路径,比起靠搜索深度,不如收拾干净候选集。
  2. 价值函数要写出”非线性跳变”:科技分是平方项,公会卡按数量计分,军事按邻居对比(相对值)——这些不写在评估函数里,AI 会系统性低估它们。
  3. 支配关系是可严格判定的:桌游里大量牌是”更贵但产出更少”,用成本-产出偏序能安全剪掉。这一步是零风险的效率提升,值得优先实现。

同样的框架可以直接复用到璀璨宝石(Splendor) 的购买决策、车票之旅 的路线卡选择、卡卡颂 的地块放置估值——它们都是”小宽度 + 高耦合 + 非线性计分”的同类问题。