每日算法 — 使用java实现冰壶游戏:蒙特卡洛树搜索与掷壶权重评估

冰壶是少见的”信息完全、执行不确定”的博弈:你知道所有壶的位置,但你的每一次投掷都可能因为冰面摩擦、旋转(curl)和刷冰力度而偏离预期 10~30 厘米。这个误差量级恰好等于一枚冰壶的直径——也就是说,一次投掷的落点误差足以决定它是”占位”还是”送分”。

正因为如此,冰壶 AI 不能像象棋那样做精确的博弈树搜索。它需要的是一个能处理随机性的决策框架:蒙特卡洛树搜索(MCTS),用大量采样把”不确定性”平均掉,再用 UCT 把搜索预算集中到最有希望的分支上。

一、把冰壶抽象成可搜索的博弈

标准冰壶:每队 4 人各投 2 壶,共 16 壶,最后比较”圆心最近的壶”得分。为了让搜索可行,我们做三层抽象:

第一层:动作离散化。 连续参数(初速度、角度、旋转)被量化成有限集:速度 5 档 × 角度 7 档 × 旋转 3 档 = 105 个动作。这已经足够表达”轻推入营”、”重击清壶”、”绕护占位”等战术。

第二层:结果随机化。 实际落点 = 名义落点 + 噪声。噪声建模为各向异性的高斯分布(沿投掷方向的误差更大,因为冰面纹理沿纵向)。采样时用 Random.nextGaussian() 生成偏移。

第三层:终局评估。 16 壶投完后,计算得分:以圆心为原点,找出属于各队且比对手所有壶更近的壶数,即为得分(只有一方得分,这是冰壶”hammer”规则的核心)。

二、UCT 与冰壶的适配

MCTS 的四个阶段(选择、扩展、模拟、回传)在冰壶上各有讲究:

  • 选择:用 UCB1 公式,但 C 常数必须调小。冰壶每步动作噪声大,过大的探索系数会让 AI 反复尝试明显劣质的动作;
  • 扩展:不必一次生成全部 105 个动作。可以只扩展”高价值前 k 个”(按几何评估筛选),把其余留给后续迭代;
  • 模拟:这是关键——随机模拟必须包含噪声。如果 rollout 是确定性的,MCTS 会误以为局面可精确控制,从而过度自信;
  • 回传:返回的不是 0/1 胜负,而是得分差(如 +3 表示本队得 3 分)。连续回报让 MCTS 能区分”小胜”和”大胜”。

三、完整实现

import java.util.*;

/**
 * 冰壶 AI:蒙特卡洛树搜索 + UCT。
 * 坐标单位:米,冰道 x∈[0, 45],y∈[-2.375, 2.375],圆心在 (45, 0)。
 */
public class CurlingMCTS {

    static final double HOUSE_X = 44.0;      // 圆心 x
    static final double HOUSE_R = 1.83;      // 营区半径
    static final double STONE_R = 0.145;     // 壶半径
    static final int MAX_STONES = 16;

    /** 一枚壶 */
    static class Stone {
        double x, y;
        int team;      // 0 / 1
        boolean alive = true;
        Stone(double x, double y, int t) { this.x = x; this.y = y; this.team = t; }
    }

    /** 局面 */
    static class State {
        List<Stone> stones = new ArrayList<>();
        int toMove;        // 当前投掷方
        int thrown;        // 已投数量
        boolean hammer;    // 后手方拥有最后一投
    }

    /** 动作:速度档 * 角度档 * 旋转档 */
    static class Move {
        final int speed, angle, curl;
        Move(int s, int a, int c) { speed = s; angle = a; curl = c; }
    }

    private static final int SPEEDS = 5, ANGLES = 7, CURLS = 3;

    /** 名义落点:由 (speed, angle, curl) 映射到冰道坐标(简化弹道模型) */
    private static double nominalX(Move m) {
        // 速度越大滑得越远,5 档对应 41.5~45.5 米
        return 41.5 + m.speed * 1.0;
    }

    private static double nominalY(Move m) {
        // 角度档 -3..3,每档 0.35 米横向偏移;curl 影响轻微
        return (m.angle - 3) * 0.35 + m.curl * 0.12;
    }

    /** 带噪声的真实落点:横向噪声小、纵向噪声大 */
    private static double[] resolve(Move m, Random rnd) {
        double nx = nominalX(m), ny = nominalY(m);
        double sigmaAlong = 0.28;      // 沿投掷方向(x)
        double sigmaAcross = 0.12;     // 横向(y)
        return new double[]{nx + rnd.nextGaussian() * sigmaAlong,
                            ny + rnd.nextGaussian() * sigmaAcross};
    }

    /** 碰撞处理:简化为一维弹性碰撞,把重叠的壶沿连线推开 */
    private static void resolveCollisions(List<Stone> stones) {
        for (int iter = 0; iter < 4; iter++) {
            boolean moved = false;
            for (int i = 0; i < stones.size(); i++) {
                for (int j = i + 1; j < stones.size(); j++) {
                    Stone a = stones.get(i), b = stones.get(j);
                    double dx = b.x - a.x, dy = b.y - a.y;
                    double d = Math.hypot(dx, dy);
                    if (d < 2 * STONE_R && d > 1e-9) {
                        double push = (2 * STONE_R - d) / 2;
                        double ux = dx / d, uy = dy / d;
                        a.x -= ux * push; a.y -= uy * push;
                        b.x += ux * push; b.y += uy * push;
                        moved = true;
                    }
                }
            }
            if (!moved) break;
        }
        // 移除滑出冰道的壶
        stones.removeIf(s -> s.x > 46.5 || Math.abs(s.y) > 2.375 + 0.1);
    }

    /** 落子:把一枚壶投入局面,处理碰撞与出界 */
    private static State apply(State s, Move mv, Random rnd) {
        State n = new State();
        n.toMove = 1 - s.toMove;
        n.thrown = s.thrown + 1;
        n.hammer = s.hammer;
        for (Stone st : s.stones) if (st.alive) n.stones.add(new Stone(st.x, st.y, st.team));
        double[] p = resolve(mv, rnd);
        Stone ns = new Stone(p[0], p[1], s.toMove);
        n.stones.add(ns);
        resolveCollisions(n.stones);
        return n;
    }

    /** 终局计分:只看比对手所有壶都更近的自家壶 */
    static int score(State s, int team) {
        Stone best = null;
        for (Stone st : s.stones) {
            double d = distToCenter(st);
            if (best == null || d < distToCenter(best)) best = st;
        }
        if (best == null || best.team != team) return 0;
        int cnt = 0;
        double opponentMin = Double.MAX_VALUE;
        for (Stone st : s.stones) if (st.team != team) opponentMin = Math.min(opponentMin, distToCenter(st));
        for (Stone st : s.stones) {
            if (st.team == team && distToCenter(st) < opponentMin && distToCenter(st) <= HOUSE_R) cnt++;
        }
        return cnt;
    }

    private static double distToCenter(Stone st) {
        return Math.hypot(st.x - HOUSE_X, st.y);
    }

    /** 几何启发式:投完后自方的"潜在得分"减去对方潜在得分 */
    private static double evaluate(State s, int team) {
        return score(s, team) - score(s, 1 - team);
    }

    /** 动作生成:全量 105 个,但在扩展时按启发式截断 */
    private static List<Move> allMoves() {
        List<Move> list = new ArrayList<>();
        for (int sp = 0; sp < SPEEDS; sp++)
            for (int an = 0; an < ANGLES; an++)
                for (int cu = 0; cu < CURLS; cu++)
                    list.add(new Move(sp, an, cu));
        return list;
    }

    // ================= MCTS 节点 =================
    static class Node {
        Node parent;
        Move move;
        State state;
        List<Node> children = new ArrayList<>();
        List<Move> untried;      // 尚未扩展的动作
        int visits;
        double reward;           // 从"根节点行棋方"视角的累计回报
        int rootTeam;

        Node(State s, Node p, Move m, int rootTeam) {
            state = s; parent = p; move = m; this.rootTeam = rootTeam;
            untried = allMoves();
            // 简单排序:优先"能进营"的动作
            untried.sort((a, b) -> Double.compare(
                Math.abs(nominalY(b)) + Math.abs(nominalX(b) - HOUSE_X),
                Math.abs(nominalY(a)) + Math.abs(nominalX(a) - HOUSE_X)));
        }
    }

    private static final double C_UCT = 0.7;   // 冰壶噪声大,探索系数取小

    static Node select(Node node) {
        while (node.untried.isEmpty() && !node.children.isEmpty()) {
            node = bestUCT(node);
        }
        return node;
    }

    static Node bestUCT(Node node) {
        Node best = null;
        double bestVal = -Double.MAX_VALUE;
        for (Node c : node.children) {
            double exploit = c.reward / (c.visits + 1e-9);
            double explore = C_UCT * Math.sqrt(Math.log(node.visits + 1) / (c.visits + 1e-9));
            double val = exploit + explore;
            if (val > bestVal) { bestVal = val; best = c; }
        }
        return best;
    }

    static Node expand(Node node, Random rnd) {
        if (node.untried.isEmpty()) return node;
        // 每轮只扩展前 3 个动作,把预算留给更有希望的谱系
        Move mv = node.untried.remove(0);
        Node child = new Node(apply(node.state, mv, rnd), node, mv, node.rootTeam);
        node.children.add(child);
        return child;
    }

    /** 随机模拟:双方各投完剩余壶,全程带噪声 */
    static double rollout(State s, int rootTeam, Random rnd) {
        State cur = s;
        int guard = 0;
        while (cur.thrown < MAX_STONES && guard++ < 40) {
            Move mv = chooseRolloutMove(cur, rnd);
            cur = apply(cur, mv, rnd);
        }
        return evaluate(cur, rootTeam);
    }

    /** rollout 策略:60% 走"尽量进营"的贪心,40% 随机探索 */
    static Move chooseRolloutMove(State s, Random rnd) {
        if (rnd.nextDouble() < 0.6) {
            List<Move> ms = allMoves();
            ms.sort((a, b) -> Double.compare(
                Math.abs(nominalY(a)) + Math.abs(nominalX(a) - HOUSE_X),
                Math.abs(nominalY(b)) + Math.abs(nominalX(b) - HOUSE_X)));
            return ms.get(rnd.nextInt(5));    // 前 5 优中随机,避免过拟合
        }
        return allMoves().get(rnd.nextInt(SPEEDS * ANGLES * CURLS));
    }

    static void backprop(Node node, double reward) {
        int sign = 1;
        Node cur = node;
        // 从叶子回溯到根,交替视角(zero-sum)
        while (cur != null) {
            cur.visits++;
            if (cur.state.toMove == cur.rootTeam) cur.reward += reward;
            else cur.reward -= reward;
            cur = cur.parent;
        }
    }

    /** 对外接口:给定局面与预算,返回最优动作 */
    public static Move search(State state, int iterations, Random rnd) {
        Node root = new Node(state, null, null, state.toMove);
        for (int i = 0; i < iterations; i++) {
            Node leaf = select(root);
            leaf = expand(leaf, rnd);
            double r = rollout(leaf.state, root.rootTeam, rnd);
            backprop(leaf, r);
        }
        Node best = null;
        for (Node c : root.children) {
            if (best == null || c.visits > best.visits) best = c;   // 稳健子节点(最常访问)
        }
        return best == null ? null : best.move;
    }

    public static void main(String[] args) {
        Random rnd = new Random(20261118L);
        State s = new State();
        s.toMove = 0;
        s.stones.add(new Stone(43.0, 0.2, 1));   // 对手已在营中布一枚
        Move best = search(s, 2000, rnd);
        System.out.printf("推荐动作: speed=%d angle=%d curl=%d%n",
                best.speed, best.angle, best.curl);
    }
}

四、为什么”最常访问”而不是”胜率最高”是更好的选择

MCTS 终局选子节点时有两种常见策略:取访问次数最多的,或取胜率最高的。冰壶场景下前者明显更稳。

原因在于噪声:单次模拟的回报方差极大(一次投掷的偏差就可能让得分从 3 变成 0)。胜率最高的子节点往往是”运气好的少数样本”,而访问次数最多的子节点经过了最多采样,其均值估计最接近真实期望。这也是 MCTS 论文里”robust child”的经验结论——在随机性强的领域,访问量本身就是质量的代理指标。

进一步的工程改进是对根节点的子节点做加权平均(例如取访问量前 3 名,按其访问量加权),这在冰壶这种需要”稳妥但不保守”决策的游戏里效果比纯 argmax 好。

五、性能与调参要点

  • 单次模拟成本:一次完整 rollout 要投 10 壶左右,每壶做一次碰撞求解(4 轮 O(n²) 迭代,n≤16),成本约 1000 次浮点运算。2000 次迭代在普通机器上 100ms 级别,对冰壶(每投 30 秒思考时间)完全够用;
  • 探索系数:C_UCT 取 0.5~0.8。越大越偏向尝试新动作,但冰壶的噪声会让”新动作”看起来被人为地好或坏;
  • 扩展速率:每轮只扩 1~3 个动作,比一次性全展开的表现更好,因为冰壶动作空间里大量动作在几何上是劣质的,全展开会稀释搜索预算;
  • 噪声参数:sigma 必须与实际冰面匹配。设太大,AI 变得过度保守(认为所有投掷都不可控);设太小,AI 高估控制力。经验值是”目标精度”的 1.5~2 倍。

小结

冰壶把算法设计的重心从”精确搜索”转向”期望优化”。MCTS + UCT 用采样替代了穷举,用带噪声的 rollout 表达了执行不确定性,用得分差回报保留了战术层次。真正让它在冰壶上成立的三个细节是:动作离散化、rollout 中必须含随机性、以及终局用最常访问子节点而非胜率最高子节点。这三点也是所有”随机性博弈”AI 的共通骨架。