冰壶是少见的”信息完全、执行不确定”的博弈:你知道所有壶的位置,但你的每一次投掷都可能因为冰面摩擦、旋转(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 的共通骨架。