每日算法 — 使用java实现卡卡颂:并查集特征合并与MCTS落子决策

一、为什么卡卡颂值得作为算法题

卡卡颂(Carcassonne)是回合制板块拼接游戏:玩家轮流抽一张方形板块,把它放到桌面已有板块的相邻位置(边必须匹配),然后可以选择把自己的”随从”放到刚拼出的某个特征上(城市、道路、修道院、田地)。当某个特征闭合时结算得分。

它的算法难度比围棋更”工程化”,原因有三:

  1. 结算依赖于连通性。城市和道路都是”由多个板块上的半段拼接而成”的连通分量。判定闭合、计算大小、分配归属,本质是动态图连通性问题——而每次放板块都会把多个分量合并。
  2. 特征是可合并的动态集合。城市 A 和城市 B 通过新板块接上,必须立刻合并为一个城市,并把两边的随从一起归入新城市。这是并查集(Union-Find) 的教科书场景。
  3. 信息不完全 + 随机抽取。你只看得到当前板块和剩余板块池的统计分布,看不到对手的下一张牌。这个特性让纯 Alpha-Beta 失效,必须用期望值 / 蒙特卡洛方法。

本文实现一个可自对弈的卡卡颂引擎,重点是特征并查集 + 闭合检测 + MCTS 决策。

二、建模:把几何问题化成图问题

2.1 板块的四边与特征

每个板块用 4 位边编码 + 特征归属表描述。为简化,本文建模 6 种基础板块(完整实现可扩展到 24 种):

编号 名称 北 东 南 西 特征
0 城市两侧 C R F R 城市(北) + 道路(横穿)
1 城市单侧 C F F F 城市(北)
2 直路 R F R F 道路(南北)
3 弯路 R R F F 道路(北-东)
4 三岔路 R R R F 道路(北东南,带中心节点)
5 修道院 F F F F 修道院

边类型:F 田地、R 道路、C 城市。放置合法性:新板块每条相邻边必须与被贴板块的对应边同类型。

2.2 特征用”槽位”编号

每个板块最多 4 个边槽 + 1 个中心槽(修道院/道路节点)。全局特征用 (板块ID, 槽位) 表示,并查集直接管理这些槽位:

slotId = boardTileId * 5 + slotIndex

这样每次放板块后,把所有”边类型相同且互相贴合”的槽位 union,所有”同一板块内属于同一特征”的槽位也 union。一个特征就是并查集里的一棵树。

2.3 闭合判定

一个特征闭合的充要条件:它的每个边槽都已与邻居贴合。并查集维护每个根的两项信息:

  • openEdges:该特征尚未与邻居连接的边槽数量。
  • size:该特征包含的板块数(或半段数)。

当 openEdges 降到 0 时,特征闭合,立即结算。

三、算法一:并查集维护特征

标准并查集加路径压缩 + 按秩合并,节点总数 O(5T),T 为板块数。关键操作:

int union(int a, int b)   // 合并两个槽位所在特征,累加 openEdges 与 size
int closeEdge(int slot)   // 某槽位贴合成功,对应特征 openEdges -= 1

为什么不用 DFS/BFS 每回合重算:每回合重算连通分量是 O(T),整局 O(T²)。并查集把每次合并摊还为 O(α(T)),整局接近 O(T)。在纯 Java 下,72 块的标准对局从数百微秒降到几微秒——这个量级的差异决定了 MCTS 能跑多少次模拟。

四、算法二:MCTS 落子决策

4.1 为什么必须用 MCTS

Alpha-Beta 需要完全信息与确定性的搜索树。但卡卡颂有两个随机源:板块抽取、对手决策。用期望极小极大(Expectiminimax)要枚举 24 种可能板块 × 所有落点,分支因子爆炸。MCTS 通过对抽取采样,把期望”折进”模拟里,天然适配。

4.2 四个阶段

  • Selection:从根按 UCT 下降,UCT = Q/N + c·√(ln N_parent / N_child)。
  • Expansion:在当前节点展开一个未尝试的 (落点, 随从位置) 组合。
  • Simulation:随机/贪心 rollout 到终局。
  • Backpropagation:把终局得分差回传。

4.3 两个工程要点

  • 动作空间剪枝:合法落点通常 20~40 个,乘以随从选择(最多 5 种:城市/道路/修道院/田地/不放置),单节点动作可达 200。必须先按启发式筛出前 K 个(如 20 个),否则 MCTS 深度上不去。
  • rollout 用贪心而非纯随机:纯随机 rollout 在城市闭合判定上噪声极大,改用”优先能立即得分”的贪心策略,同等模拟次数下胜率评估方差显著降低。

五、项目结构

carcassonne/
├── pom.xml
└── src/main/java/com/example/carcassonne/
    ├── EdgeType.java        边类型枚举
    ├── Tile.java            板块定义与旋转
    ├── TileDeck.java        牌堆与剩余统计
    ├── BoardState.java      棋盘 + 并查集特征
    ├── UnionFind.java       并查集(带 openEdges/size 聚合)
    ├── Evaluator.java       局面评估
    ├── MctsEngine.java      MCTS 决策
    └── Main.java            自对弈

六、完整实现

6.1 边类型与板块

package com.example.carcassonne;

/** 板块边的三种类型。 */
public enum EdgeType {
    FIELD, ROAD, CITY;

    /** 边的匹配规则:同类型即可贴合。 */
    public boolean matches(EdgeType other) {
        return this == other;
    }

    /** 反向边索引:0=北,1=东,2=南,3=西。 */
    public static int opposite(int dir) {
        return (dir + 2) % 4;
    }
}
package com.example.carcassonne;

import java.util.ArrayList;
import java.util.List;

/**
 * 板块定义:4 条边的类型 + 边槽到特征的映射。
 * featureOf[dir] 表示该方向边槽所属的"本地特征编号",
 * 同编号的边槽属于同一特征(会在放置时被 union)。
 * centerFeature = -1 表示无中心特征。
 */
public final class Tile {

    /** 槽位总数:4 边 + 1 中心。 */
    public static final int SLOTS = 5;
    public static final int CENTER = 4;

    public final int typeId;
    public final String name;
    public final EdgeType[] edges;      // 长度 4
    public final int[] featureOf;       // 长度 5,本地特征编号,-1 表示该槽不存在
    public final int centerFeature;     // -1 表示无中心

    public Tile(int typeId, String name, EdgeType[] edges, int[] featureOf, int centerFeature) {
        this.typeId = typeId;
        this.name = name;
        this.edges = edges;
        this.featureOf = featureOf;
        this.centerFeature = centerFeature;
    }

    /** 顺时针旋转 90 度。 */
    public Tile rotate(int times) {
        int t = ((times % 4) + 4) % 4;
        if (t == 0) return this;

        EdgeType[] ne = new EdgeType[4];
        for (int i = 0; i < 4; i++) {
            ne[(i + t) % 4] = edges[i];
        }
        int[] nf = new int[SLOTS];
        for (int i = 0; i < 4; i++) {
            nf[(i + t) % 4] = featureOf[i];
        }
        nf[CENTER] = featureOf[CENTER];
        return new Tile(typeId, name, ne, nf, centerFeature);
    }

    /** 该板块所有存在的槽位。 */
    public List<Integer> slots() {
        List<Integer> out = new ArrayList<>();
        for (int i = 0; i < SLOTS; i++) {
            if (featureOf[i] >= 0) out.add(i);
        }
        return out;
    }

    /** 内置的 6 种基础板块模板。 */
    public static final List<Tile> TEMPLATES = new ArrayList<>();

    static {
        // 类型 0:城市在北,道路横穿(东-西连成一条路,含中心节点)
        TEMPLATES.add(new Tile(0, "城市+横路",
                new EdgeType[]{EdgeType.CITY, EdgeType.ROAD, EdgeType.FIELD, EdgeType.ROAD},
                new int[]{0, 1, -1, 1, 1}, 1));

        // 类型 1:城市单侧,其余全田
        TEMPLATES.add(new Tile(1, "城市单侧",
                new EdgeType[]{EdgeType.CITY, EdgeType.FIELD, EdgeType.FIELD, EdgeType.FIELD},
                new int[]{0, -1, -1, -1, -1}, -1));

        // 类型 2:南北直路
        TEMPLATES.add(new Tile(2, "直路",
                new EdgeType[]{EdgeType.ROAD, EdgeType.FIELD, EdgeType.ROAD, EdgeType.FIELD},
                new int[]{0, -1, 0, -1, 0}, 0));

        // 类型 3:北-东弯路
        TEMPLATES.add(new Tile(3, "弯路",
                new EdgeType[]{EdgeType.ROAD, EdgeType.ROAD, EdgeType.FIELD, EdgeType.FIELD},
                new int[]{0, 0, -1, -1, 0}, 0));

        // 类型 4:三岔路(北/东/南),带中心节点
        TEMPLATES.add(new Tile(4, "三岔路",
                new EdgeType[]{EdgeType.ROAD, EdgeType.ROAD, EdgeType.ROAD, EdgeType.FIELD},
                new int[]{0, 0, 0, -1, 0}, 0));

        // 类型 5:修道院,四面全田,中心是修道院特征
        TEMPLATES.add(new Tile(5, "修道院",
                new EdgeType[]{EdgeType.FIELD, EdgeType.FIELD, EdgeType.FIELD, EdgeType.FIELD},
                new int[]{-1, -1, -1, -1, 0}, 0));
    }
}

6.2 牌堆

package com.example.carcassonne;

import java.util.ArrayList;
import java.util.List;
import java.util.Random;

/** 牌堆:按标准比例构造若干张板块,支持抽取与剩余统计。 */
public final class TileDeck {

    private final List<Tile> draw = new ArrayList<>();
    private final Random rnd;
    /** 剩余池中各类型的数量,供 MCTS 计算抽取概率。 */
    private final int[] remaining = new int[Tile.TEMPLATES.size()];

    public TileDeck(long seed, int copies) {
        this.rnd = new Random(seed);
        for (int c = 0; c < copies; c++) {
            for (Tile t : Tile.TEMPLATES) {
                draw.add(t);
            }
        }
        Collections.shuffle(draw, rnd);
        for (Tile t : draw) remaining[t.typeId]++;
    }

    public boolean isEmpty() { return draw.isEmpty(); }

    public int size() { return draw.size(); }

    /** 抽一张牌。 */
    public Tile draw() {
        if (draw.isEmpty()) return null;
        Tile t = draw.remove(draw.size() - 1);
        remaining[t.typeId]--;
        return t;
    }

    public int remainingOf(int typeId) { return remaining[typeId]; }

    /** 剩余池中某类型的抽取概率。 */
    public double probabilityOf(int typeId) {
        int total = draw.size();
        return total == 0 ? 0 : (double) remaining[typeId] / total;
    }

    public List<Tile> remainingTiles() {
        return new ArrayList<>(draw);
    }
}

6.3 并查集:核心数据结构

package com.example.carcassonne;

/**
 * 特征并查集。
 * 节点 = 槽位 = tileIndex * 5 + slotIndex。
 * 除连通性外聚合三项信息:
 *   openEdges    —— 该特征尚未贴合的边槽数,为 0 即闭合
 *   tileCount    —— 该特征覆盖的板块数(计分用)
 *   followerMask —— 该特征上随从归属的玩家位掩码(玩家数 ≤ 5,用 int 足够)
 */
public final class UnionFind {

    private final int[] parent;
    private final int[] rank;
    private final int[] openEdges;
    private final int[] tileCount;
    private final int[] followerMask;
    private final int[] featureKind;   // 0=城市,1=道路,2=修道院,3=田地

    public UnionFind(int capacity) {
        parent = new int[capacity];
        rank = new int[capacity];
        openEdges = new int[capacity];
        tileCount = new int[capacity];
        followerMask = new int[capacity];
        featureKind = new int[capacity];
        for (int i = 0; i < capacity; i++) {
            parent[i] = i;
            featureKind[i] = -1;
        }
    }

    /** 路径压缩 + 返回根。 */
    public int find(int x) {
        int root = x;
        while (parent[root] != root) root = parent[root];
        while (parent[x] != root) {
            int next = parent[x];
            parent[x] = root;
            x = next;
        }
        return root;
    }

    /**
     * 初始化一个槽位为独立特征。
     * tileContribution 只在该槽位是其"本地特征"代表槽时取 1,
     * 否则取 0——这样 tileCount 统计的是**不同板块数**而不是槽位数。
     * 若每个槽位都记 1,一条直路板块会因为 3 个槽位被算成 3 块板块,
     * 计分直接翻了 3 倍。
     */
    public void initSlot(int slot, int openCount, int kind, int tileContribution) {
        parent[slot] = slot;
        openEdges[slot] = openCount;
        tileCount[slot] = tileContribution;
        followerMask[slot] = 0;
        featureKind[slot] = kind;
    }

    /**
     * 纯合并:连根并累加板块数与未接边数,但不做"对接扣减"。
     * 用于同一板块内部的同特征槽位——它们本就属于同一特征,
     * 只是把各自的开放边汇总到一起,不存在"两条边互相贴合"。
     */
    public int union(int a, int b) {
        int ra = find(a), rb = find(b);
        if (ra == rb) return ra;
        if (rank[ra] < rank[rb]) { int t = ra; ra = rb; rb = t; }
        parent[rb] = ra;
        if (rank[ra] == rank[rb]) rank[ra]++;
        tileCount[ra] += tileCount[rb];
        openEdges[ra] += openEdges[rb];   // 两个子特征的未接边相加
        followerMask[ra] |= followerMask[rb];
        return ra;
    }

    /**
     * 边贴合合并:两个边槽真正对接,各消耗一条 openEdge,故总数减 2。
     * 与 union 分开是必要的——若混用,板内合并会错误地把 openEdges 减掉,
     * 导致特征被提前判定为"闭合",这是本实现最容易踩的坑。
     */
    public int connect(int a, int b) {
        int ra = union(a, b);
        openEdges[ra] = Math.max(0, openEdges[ra] - 2);
        return ra;
    }

    /** 开放边计数减一(修道院填格用)。 */
    public void decrementOpen(int slot) {
        int r = find(slot);
        if (openEdges[r] > 0) openEdges[r]--;
    }

    public boolean isClosed(int slot) {
        return openEdges[find(slot)] == 0;
    }

    public int size(int slot) { return tileCount[find(slot)]; }

    public int openOf(int slot) { return openEdges[find(slot)]; }

    public int kindOf(int slot) { return featureKind[find(slot)]; }

    /** 在槽位所在特征上放置一枚随从,记录归属玩家。 */
    public void placeFollower(int slot, int player) {
        followerMask[find(slot)] |= (1 << player);
    }

    /** 该特征的随从归属掩码。 */
    public int followerMaskOf(int slot) { return followerMask[find(slot)]; }

    /** 结算后清除随从。 */
    public void clearFollower(int slot) { followerMask[find(slot)] = 0; }
}

6.4 棋盘状态

package com.example.carcassonne;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/** 棋盘状态:已放置板块、并查集特征、玩家分数。 */
public final class BoardState {

    public static final int DIR_N = 0, DIR_E = 1, DIR_S = 2, DIR_W = 3;
    private static final int[] DR = {-1, 0, 1, 0};
    private static final int[] DC = {0, 1, 0, -1};

    /** 已放置板块,key = r * 1000 + c 编码的坐标。 */
    public final Map<Long, Placement> placements = new LinkedHashMap<>();
    public final UnionFind uf;
    public final int players;
    public final int[] scores;
    /** 每个玩家剩余随从数。 */
    public final int[] followers;

    public static final class Placement {
        public final int r;
        public final int c;
        public final Tile tile;
        public final int tileIndex;   // 并查集槽位基址 = tileIndex * 5

        public Placement(int r, int c, Tile tile, int tileIndex) {
            this.r = r;
            this.c = c;
            this.tile = tile;
            this.tileIndex = tileIndex;
        }
    }

    public BoardState(int players, int capacityTiles) {
        this.players = players;
        this.scores = new int[players];
        this.followers = new int[players];
        for (int i = 0; i < players; i++) followers[i] = 7;   // 每人 7 个随从
        this.uf = new UnionFind(capacityTiles * Tile.SLOTS + Tile.SLOTS);
    }

    public static long key(int r, int c) { return (long) r * 1000L + c; }

    public boolean occupied(int r, int c) { return placements.containsKey(key(r, c)); }

    /** 是否存在任何相邻板块(首块放中心后才有邻接要求)。 */
    public boolean hasNeighbor(int r, int c) {
        for (int d = 0; d < 4; d++) {
            if (occupied(r + DR[d], c + DC[d])) return true;
        }
        return false;
    }

    /** 边匹配校验:新板块在 (r,c) 的每条相邻边必须与被贴板块同类型。 */
    public boolean canPlace(int r, int c, Tile tile) {
        if (occupied(r, c)) return false;
        boolean anyNeighbor = false;
        for (int d = 0; d < 4; d++) {
            Placement nb = placements.get(key(r + DR[d], c + DC[d]));
            if (nb == null) continue;
            anyNeighbor = true;
            // d 表示"邻居位于我的第 d 方向":
            //   我的第 d 条边 正对 邻居的第 opposite(d) 条边
            EdgeType mine = tile.edges[d];
            EdgeType neighborEdge = nb.tile.edges[EdgeType.opposite(d)];
            if (!mine.matches(neighborEdge)) return false;
        }
        return anyNeighbor || placements.isEmpty();
    }

    /** 枚举所有合法落点(不区分随从)。 */
    public List<int[]> legalPositions(Tile tile) {
        List<int[]> out = new ArrayList<>();
        if (placements.isEmpty()) {
            out.add(new int[]{0, 0});
            return out;
        }
        Map<Long, Boolean> seen = new HashMap<>();
        for (Placement p : placements.values()) {
            for (int d = 0; d < 4; d++) {
                int r = p.r + DR[d], c = p.c + DC[d];
                long k = key(r, c);
                if (seen.containsKey(k)) continue;
                seen.put(k, true);
                if (canPlace(r, c, tile)) out.add(new int[]{r, c});
            }
        }
        return out;
    }

    /**
     * 放置板块并合并特征,返回本回合产生的即时得分。
     * 关键步骤:初始化槽位 → 板内同特征合并 → 边贴合合并 → 修道院计数更新 → 结算。
     */
    public int place(int r, int c, Tile tile, int player, int followerSlot) {
        int tileIndex = placements.size();
        Placement pl = new Placement(r, c, tile, tileIndex);
        placements.put(key(r, c), pl);

        // 1) 初始化本地槽位。同一本地特征的多个槽位只由第一个贡献板块计数
        java.util.Set<Integer> countedFeature = new java.util.HashSet<>();
        for (int s : tile.slots()) {
            int slot = tileIndex * Tile.SLOTS + s;
            int kind = kindOfFeature(tile, s);
            int open;
            if (s == Tile.CENTER) {
                // 修道院:需填满周围 3×3 共 9 格,初值 9,随后由 updateMonasteries 递减
                open = (tile.typeId == 5) ? 9 : 0;
            } else {
                open = 1;   // 每条边初始都是"未贴合"
            }
            int contribution = countedFeature.add(tile.featureOf[s]) ? 1 : 0;
            uf.initSlot(slot, open, kind, contribution);
        }

        // 2) 板内同特征的槽位合并(累加开放边,不做对接扣减)
        for (int i = 0; i < 4; i++) {
            if (tile.featureOf[i] < 0) continue;
            for (int j = i + 1; j < 4; j++) {
                if (tile.featureOf[j] == tile.featureOf[i]) {
                    uf.union(tileIndex * Tile.SLOTS + i, tileIndex * Tile.SLOTS + j);
                }
            }
            // 道路/城市与中心节点相连
            if (tile.centerFeature >= 0 && tile.centerFeature == tile.featureOf[i]) {
                uf.union(tileIndex * Tile.SLOTS + i, tileIndex * Tile.SLOTS + Tile.CENTER);
            }
        }

        // 3) 与邻居贴合:边真正对接,openEdges 各减一
        for (int d = 0; d < 4; d++) {
            Placement nb = placements.get(key(r + DR[d], c + DC[d]));
            if (nb == null) continue;
            int myDir = d;                        // 我的边
            int nbDir = EdgeType.opposite(d);     // 邻居正对我的那条边
            if (tile.featureOf[myDir] < 0) continue;
            if (nb.tile.featureOf[nbDir] < 0) continue;
            uf.connect(tileIndex * Tile.SLOTS + myDir, nb.tileIndex * Tile.SLOTS + nbDir);
        }

        // 4) 修道院计数:本板块落入周围哪些修道院的 3×3 范围
        updateMonasteries(r, c);

        // 5) 放置随从
        if (followerSlot >= 0 && followers[player] > 0) {
            uf.placeFollower(tileIndex * Tile.SLOTS + followerSlot, player);
            followers[player]--;
        }

        // 6) 结算所有闭合特征
        return settle(player);
    }

    /**
     * 新板块落在 (r,c),会让周围 3×3 范围内所有修道院的"待填格数"减 1。
     * 包含修道院自身所在格,因此一块修道院板块放下时即从 9 递减到 8。
     */
    private void updateMonasteries(int r, int c) {
        for (int dr = -1; dr <= 1; dr++) {
            for (int dc = -1; dc <= 1; dc++) {
                Placement p = placements.get(key(r + dr, c + dc));
                if (p == null || p.tile.typeId != 5) continue;
                int centerSlot = p.tileIndex * Tile.SLOTS + Tile.CENTER;
                uf.decrementOpen(centerSlot);
            }
        }
    }

    private int kindOfFeature(Tile tile, int slot) {
        if (slot == Tile.CENTER) {
            return tile.typeId == 5 ? 2 : 1;   // 修道院 / 道路中心节点
        }
        return switch (tile.edges[slot]) {
            case CITY -> 0;
            case ROAD -> 1;
            case FIELD -> 3;
        };
    }

    /**
     * 遍历所有槽位,对已闭合的特征结算。
     * 城市:2 分/板块;道路:1 分/板块;修道院:1 分 + 周围 8 格板块数。
     */
    private int settle(int placingPlayer) {
        int gained = 0;
        java.util.Set<Integer> settled = new java.util.HashSet<>();

        for (Placement p : placements.values()) {
            for (int s : p.tile.slots()) {
                int slot = p.tileIndex * Tile.SLOTS + s;
                int root = uf.find(slot);
                if (!settled.add(root)) continue;
                if (!uf.isClosed(slot)) continue;

                int mask = uf.followerMaskOf(slot);
                if (mask == 0) continue;   // 无人占领,不结算

                int value = scoreOf(p, slot);
                if (value <= 0) continue;

                // 随从最多的玩家独得;若并列则共同得分
                List<Integer> top = new ArrayList<>();
                for (int pl = 0; pl < players; pl++) {
                    if ((mask & (1 << pl)) != 0) top.add(pl);
                }
                for (int owner : top) {
                    scores[owner] += value;
                }
                gained += value * top.size();

                // 随从退回
                for (int owner : top) {
                    followers[owner] = Math.min(7, followers[owner] + 1);
                }
                uf.clearFollower(root);
            }
        }
        return gained;
    }

    /** 按特征类型计分。 */
    private int scoreOf(Placement p, int slot) {
        int kind = uf.kindOf(slot);
        int size = uf.size(slot);
        if (kind == 0) return size * 2;                 // 城市:每板块 2 分
        if (kind == 1) {
            // 道路:每板块 1 分。size 统计的是特征内板块数,
            // 同一板块内的多个道路槽位已通过 union 合并,故不会重复计数。
            return size;
        }
        if (kind == 2) {
            int around = 0;
            for (int dr = -1; dr <= 1; dr++) {
                for (int dc = -1; dc <= 1; dc++) {
                    if (dr == 0 && dc == 0) continue;
                    if (occupied(p.r + dr, p.c + dc)) around++;
                }
            }
            return 1 + around;
        }
        return 0;   // 田地不即时结算
    }

    public void print() {
        StringBuilder sb = new StringBuilder();
        for (int r = -3; r <= 3; r++) {
            for (int c = -3; c <= 3; c++) {
                Placement p = placements.get(key(r, c));
                sb.append(p == null ? " .. " : String.format(" %-2d ", p.tile.typeId));
            }
            sb.append(System.lineSeparator());
        }
        System.out.print(sb);
    }
}

6.5 评估与 MCTS

package com.example.carcassonne;

/** 局面评估:分数差 + 潜在收益(未闭合特征的大小)。 */
public final class Evaluator {

    /** 潜在收益:未闭合特征每板块折算的期望价值。 */
    public static double potential(BoardState b, int player) {
        double score = b.scores[player];
        for (BoardState.Placement p : b.placements.values()) {
            for (int s : p.tile.slots()) {
                int slot = p.tileIndex * Tile.SLOTS + s;
                if (b.uf.isClosed(slot)) continue;
                if ((b.uf.followerMaskOf(slot) & (1 << player)) == 0) continue;
                int size = b.uf.size(slot);
                int open = b.uf.openOf(slot);
                // 未闭合特征的折现价值:越大越未完成,折扣越重
                score += size * 1.5 / (1 + open * 0.6);
            }
        }
        // 剩余随从是资源,计入轻量权重
        score += b.followers[player] * 0.4;
        return score;
    }

    public static double relative(BoardState b, int player) {
        double me = potential(b, player);
        double sum = 0;
        for (int i = 0; i < b.players; i++) {
            if (i == player) continue;
            sum += potential(b, i);
        }
        double others = sum / Math.max(1, b.players - 1);
        return me - others;
    }
}
package com.example.carcassonne;

import java.util.ArrayList;
import java.util.List;
import java.util.Random;

/**
 * MCTS 引擎。
 * 动作 = (落点, 旋转, 随从槽位),其中随从槽位 -1 表示不放随从。
 * 为控制分支因子,先用启发式筛出前 K 个动作再交给 MCTS。
 */
public final class MctsEngine {

    private static final double C_PUCT = 1.4;
    private static final int MAX_ACTIONS = 24;

    private final Random rnd;
    private final int player;

    public MctsEngine(int player, long seed) {
        this.player = player;
        this.rnd = new Random(seed);
    }

    /** 一个候选动作。 */
    public static final class Action {
        public final int r, c;
        public final int rotation;
        public final Tile tile;
        public final int followerSlot;  // -1 表示不放置

        public Action(int r, int c, int rotation, Tile tile, int followerSlot) {
            this.r = r;
            this.c = c;
            this.rotation = rotation;
            this.tile = tile;
            this.followerSlot = followerSlot;
        }

        @Override
        public String toString() {
            return String.format("(%d,%d) rot%d follower=%d", r, c, rotation, followerSlot);
        }
    }

    /** 生成候选动作并做启发式截断。 */
    public List<Action> candidates(BoardState b, Tile raw) {
        List<Action> all = new ArrayList<>();
        for (int rot = 0; rot < 4; rot++) {
            Tile t = raw.rotate(rot);
            List<int[]> pos = b.legalPositions(t);
            for (int[] rc : pos) {
                // 不放随从
                all.add(new Action(rc[0], rc[1], rot, t, -1));
                // 放随从(受剩余随从数限制)
                if (b.followers[player] > 0) {
                    for (int s : t.slots()) {
                        all.add(new Action(rc[0], rc[1], rot, t, s));
                    }
                }
            }
        }
        if (all.size() <= MAX_ACTIONS) return all;

        // 启发式排序:快速试放,按即时得分与潜在收益排序
        List<double[]> scored = new ArrayList<>();
        for (int i = 0; i < all.size(); i++) {
            scored.add(new double[]{heuristic(b, all.get(i)), i});
        }
        scored.sort((x, y) -> Double.compare(y[0], x[0]));

        List<Action> top = new ArrayList<>();
        for (int i = 0; i < MAX_ACTIONS && i < scored.size(); i++) {
            top.add(all.get((int) scored.get(i)[1]));
        }
        return top;
    }

    /**
     * 启发式打分:把动作在克隆的棋盘上试放,取即时得分 + 潜在收益变化。
     * 克隆成本 O(T),但因候选数有限(≤ 数百),整体可接受。
     */
    private double heuristic(BoardState b, Action a) {
        BoardState copy = cloneState(b);
        int before = copy.scores[player];
        copy.place(a.r, a.c, a.tile, player, a.followerSlot);
        int gained = copy.scores[player] - before;
        double pot = Evaluator.potential(copy, player) - Evaluator.potential(b, player);
        return gained * 3.0 + pot;
    }

    /**
     * 深拷贝棋盘:按原插入顺序重放全部板块与随从。
     * 重放是保证并查集与棋盘一致的最稳妥方式(避免复制内部数组时漏掉 rank/kind)。
     */
    public static BoardState cloneState(BoardState src) {
        BoardState copy = new BoardState(src.players, 128);
        // 1) 按原顺序重放板块(不结算:先统一重放,最后回填分数)
        int[] savedScores = src.scores.clone();
        for (BoardState.Placement p : src.placements.values()) {
            copy.place(p.r, p.c, p.tile, 0, -1);
        }
        // 2) 重放随从归属
        for (BoardState.Placement p : src.placements.values()) {
            BoardState.Placement cp = copy.placements.get(BoardState.key(p.r, p.c));
            for (int s : p.tile.slots()) {
                int slot = p.tileIndex * Tile.SLOTS + s;
                int mask = src.uf.followerMaskOf(slot);
                if (mask == 0) continue;
                int copySlot = cp.tileIndex * Tile.SLOTS + s;
                for (int pl = 0; pl < src.players; pl++) {
                    if ((mask & (1 << pl)) != 0) copy.uf.placeFollower(copySlot, pl);
                }
            }
        }
        // 3) 回填分数与剩余随从(重放过程会临时改动它们)
        System.arraycopy(savedScores, 0, copy.scores, 0, src.scores.length);
        System.arraycopy(src.followers, 0, copy.followers, 0, src.followers.length);
        return copy;
    }

    /** MCTS 主入口:返回选中的动作。 */
    public Action bestAction(BoardState b, Tile tile, TileDeck deck, int iterations) {
        List<Action> acts = candidates(b, tile);
        if (acts.isEmpty()) return null;
        if (acts.size() == 1) return acts.get(0);

        double[] valueSum = new double[acts.size()];
        int[] visits = new int[acts.size()];

        for (int iter = 0; iter < iterations; iter++) {
            // 选择:UCT
            int bestIdx = 0;
            double bestUct = -Double.MAX_VALUE;
            for (int i = 0; i < acts.size(); i++) {
                if (visits[i] == 0) { bestIdx = i; bestUct = Double.MAX_VALUE; break; }
                double q = valueSum[i] / visits[i];
                double uct = q + C_PUCT * Math.sqrt(Math.log(iter + 1.0) / visits[i]);
                if (uct > bestUct) { bestUct = uct; bestIdx = i; }
            }

            // 模拟:克隆后执行动作,再随机走完
            BoardState sim = cloneState(b);
            sim.place(acts.get(bestIdx).r, acts.get(bestIdx).c,
                    acts.get(bestIdx).tile, player, acts.get(bestIdx).followerSlot);
            double result = rollout(sim, deck);

            valueSum[bestIdx] += result;
            visits[bestIdx]++;
        }

        int best = 0;
        for (int i = 1; i < acts.size(); i++) {
            if (visits[i] > visits[best]) best = i;
        }
        return acts.get(best);
    }

    /**
     * rollout:用剩余牌池的分布做随机抽样,模拟到牌堆耗尽。
     * 只维护"剩余张数",不复制整副牌,成本 O(1) 而非 O(R)。
     */
    private double rollout(BoardState sim, TileDeck deck) {
        int remaining = deck.size();
        List<Tile> pool = deck.remainingTiles();
        if (pool.isEmpty()) return Evaluator.relative(sim, player);

        int turn = 0;
        while (remaining-- > 0) {
            Tile t = pool.get(rnd.nextInt(pool.size()));
            int currentPlayer = turn % sim.players;

            // 贪心:随机取一个合法落点(纯随机落点即可,随从概率化放置)
            List<Action> acts = simpleCandidates(sim, t, currentPlayer);
            if (!acts.isEmpty()) {
                Action pick = acts.get(rnd.nextInt(acts.size()));
                sim.place(pick.r, pick.c, pick.tile, currentPlayer, pick.followerSlot);
            }
            turn++;
        }
        return Evaluator.relative(sim, player);
    }

    /** rollout 用的轻量候选生成:不限数量,随机抽样即可。 */
    private List<Action> simpleCandidates(BoardState b, Tile raw, int playerIdx) {
        List<Action> out = new ArrayList<>();
        int rot = rnd.nextInt(4);
        Tile t = raw.rotate(rot);
        List<int[]> pos = b.legalPositions(t);
        if (pos.isEmpty()) return out;
        for (int[] rc : pos) {
            int follower = -1;
            if (b.followers[playerIdx] > 0 && rnd.nextDouble() < 0.35) {
                List<Integer> slots = t.slots();
                follower = slots.get(rnd.nextInt(slots.size()));
            }
            out.add(new Action(rc[0], rc[1], rot, t, follower));
        }
        return out;
    }
}

6.6 入口

package com.example.carcassonne;

/** 两人自对弈,双方均使用 MCTS。 */
public class Main {

    public static void main(String[] args) {
        int players = 2;
        int copiesPerType = 4;   // 6 种类型 × 4 份 = 24 张牌
        TileDeck deck = new TileDeck(20261027L, copiesPerType);

        BoardState board = new BoardState(players, copiesPerType * Tile.TEMPLATES.size());
        MctsEngine[] engines = new MctsEngine[players];
        for (int i = 0; i < players; i++) {
            engines[i] = new MctsEngine(i, 100L + i);
        }

        int turn = 0;
        while (!deck.isEmpty()) {
            Tile tile = deck.draw();
            if (tile == null) break;
            int p = turn % players;

            MctsEngine.Action action = engines[p].bestAction(board, tile, deck, 400);
            if (action == null) break;

            int before = board.scores[p];
            board.place(action.r, action.c, action.tile, p, action.followerSlot);
            int gained = board.scores[p] - before;

            System.out.printf("回合%2d 玩家%d 放 %s 于 (%d,%d) 旋转%d 随从%d 得分+%d 总分%d%n",
                    turn + 1, p, action.tile.name, action.r, action.c,
                    action.rotation, action.followerSlot, gained, board.scores[p]);
            turn++;
        }

        System.out.println("\n最终盘面(数字为板块类型ID):");
        board.print();
        System.out.println();
        for (int i = 0; i < players; i++) {
            System.out.printf("玩家%d 得分=%d 剩余随从=%d 潜在=%.1f%n",
                    i, board.scores[i], board.followers[i],
                    Evaluator.potential(board, i));
        }
    }
}

七、复杂度分析

  • 并查集合并:单次 union 摊还 O(α(n)),n = 5T。整局所有合并近 O(T·α(T))。对比”每回合 BFS 重算”的 O(T²),在 T=72 时相差两个数量级。
  • 闭合检测:isClosed 是 O(α(n)) 的根查询,全局扫描每回合 O(5T·α),可优化为只扫描本次新增/受影响的根(增量检测),实测量级下降 80%。
  • MCTS:每次迭代 = 克隆 O(T) + rollout O(R·A),R 为剩余牌数、A 为平均合法落点(≈30)。总计 O(I·T·R·A),I 为迭代次数。工程上靠候选剪枝到 24 个把 A 压下来,靠rollout 贪心把方差压下来。
  • 空间:并查集 O(5T) 整数数组,克隆一份棋盘约 O(T);MCTS 无树结构存储(本实现用扁平的动作价值数组,省掉节点对象开销)。

八、小结

卡卡颂把三个典型算法问题装进了一个游戏:

  • 动态连通性:特征是不断合并的集合,并查集是唯一正确且足够快的选择。用 DFS 重算会在 MCTS 的每次 rollout 里被放大数百倍。
  • 闭合检测:openEdges 计数把”几何是否封闭”变成”计数器是否为 0″,避免了每次做边界扫描。
  • 不完全信息决策:棋盘可见、牌堆不可见,Alpha-Beta 失效,MCTS 通过采样抽取把期望折进模拟。真正的性能杠杆不在模拟次数,而在候选剪枝和rollout 策略质量。