扫雷的难点从来不是”点格子”,而是在信息不完整时估计每个未知格子的地雷概率。传统的精确推理(枚举所有合法布雷方案)在边界格子数超过 25 个时会指数爆炸,导致求解器卡死。本文用 Java 实现一个基于吉布斯采样(Gibbs Sampling)的扫雷 AI:通过在合法布雷空间上游走马尔可夫链,得到每个格子”是雷”的后验概率近似值,再挑概率最低的格子点击。
一、问题建模
扫雷的已知信息可以抽象为一组约束:每个已翻开的数字格,其相邻 8 格中的地雷总数等于该数字。设未知格集合为 U,每个格子有布尔变量 x_i ∈ {0,1},则约束为:
Σ_{i ∈ neighborhood(j)} x_i = number(j) for each opened cell j
精确求解就是计数问题:统计满足所有约束的赋值个数,以及每个格子在解空间中被赋 1 的比例。这个计数问题是 #P-完全的,精确算法只能靠回溯枚举,规模一大就崩。
吉布斯采样换了个思路:不枚举全部解,只在解空间里随机游走,统计每个变量在游走过程中取 1 的频率。根据马尔可夫链的遍历性,只要链足够长,频率就会收敛到真实的边缘概率。
二、吉布斯采样算法
2.1 核心步骤
- 初始化:从任一合法配置出发。实践中用一个”贪心填充”得到一个可行解(逐约束满足,冲突时回溯局部)。
- 一轮扫描:遍历所有未知格
i,计算在固定其他所有变量时,x_i = 1的合法概率。 - 重采样:以该概率随机决定
x_i的取值。 - 重复 若干轮,丢弃前
B轮(burn-in,预热期),对剩余轮次做统计。
2.2 单变量条件概率的快速计算
关键洞察:改变 x_i 只会影响包含 i 的约束。对每个这样的约束 j:
- 设
s_j为约束j中已确定的地雷数(不含i),u_j为其中仍未知的格数(含i)。 - 若
x_i = 1,则剩余未知格需要放number(j) - s_j - 1个雷,方案数为C(u_j - 1, number(j) - s_j - 1)。 - 若
x_i = 0,方案数为C(u_j - 1, number(j) - s_j)。
把 i 涉及的所有约束的方案数相乘,得到两种取值的权重 w1、w0,则
P(x_i = 1) = w1 / (w1 + w0)
用组合数而非硬性判定,让采样能够”容忍”当前配置在其他约束上暂时不合法——这正是 MCMC 比回溯枚举强大的地方:它允许链穿过不可行区域,最终收敛到可行区域的高概率部分。
2.3 前后景分离
扫雷的未知格实际分两类:
– 边界格:与某个已翻开的数字格相邻,受约束限制。
– 非边界格(远处格):完全不受约束。它们的概率等于”剩余雷数 / 剩余未知格数”。
采样只需在边界格上做,非边界格直接按均匀先验处理。这一点把状态空间从 2^(全场) 缩小到 2^(边界),是实用性的关键。
三、完整项目结构
minesweeper-mcmc/
├── pom.xml
└── src/main/java/com/example/minesweeper/
├── Cell.java
├── MineBoard.java
├── Constraint.java
├── GibbsSampler.java
└── MineAi.java
四、核心代码
4.1 格子与棋盘
package com.example.minesweeper;
/** 单格状态 */
public class Cell {
public enum State { HIDDEN, OPENED, FLAGGED }
public State state = State.HIDDEN;
public int adjacentMines = 0;
public boolean isKnown() {
return state == State.OPENED;
}
}
package com.example.minesweeper;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
import java.util.Random;
/** 扫雷棋盘,含布雷、翻开、扩散与约束收集 */
public class MineBoard {
public final int rows;
public final int cols;
public final int totalMines;
public final Cell[][] cells;
public int flags = 0;
private final Random random;
private static final int[][] DIRS = {
{-1,-1},{-1,0},{-1,1},{0,-1},{0,1},{1,-1},{1,0},{1,1}
};
public MineBoard(int rows, int cols, int totalMines, long seed) {
this.rows = rows;
this.cols = cols;
this.totalMines = totalMines;
this.cells = new Cell[rows][cols];
this.random = new Random(seed);
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) cells[r][c] = new Cell();
}
}
/** 首次点击后布雷,保证首点及其邻域安全 */
public void layMines(int safeRow, int safeCol) {
List<int[]> candidates = new ArrayList<>();
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
if (Math.abs(r - safeRow) <= 1 && Math.abs(c - safeCol) <= 1) continue;
candidates.add(new int[]{r, c});
}
}
// 部分洗牌:只需前 totalMines 个随机
for (int i = 0; i < totalMines; i++) {
int j = i + random.nextInt(candidates.size() - i);
int[] tmp = candidates.get(i);
candidates.set(i, candidates.get(j));
candidates.set(j, tmp);
int[] pos = candidates.get(i);
cells[pos[0]][pos[1]].adjacentMines = -1; // -1 标记为雷
}
// 计算数字
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
if (cells[r][c].adjacentMines == -1) continue;
int count = 0;
for (int[] d : DIRS) {
int nr = r + d[0], nc = c + d[1];
if (inBounds(nr, nc) && cells[nr][nc].adjacentMines == -1) count++;
}
cells[r][c].adjacentMines = count;
}
}
}
public boolean inBounds(int r, int c) {
return r >= 0 && r < rows && c >= 0 && c < cols;
}
/** 翻开格子;数字为 0 时 BFS 扩散;踩雷返回 true */
public boolean open(int r, int c) {
if (!inBounds(r, c)) return false;
Cell cell = cells[r][c];
if (cell.state != Cell.State.HIDDEN) return false;
if (cell.adjacentMines == -1) {
cell.state = Cell.State.OPENED;
return true;
}
Deque<int[]> queue = new ArrayDeque<>();
cell.state = Cell.State.OPENED;
queue.add(new int[]{r, c});
while (!queue.isEmpty()) {
int[] cur = queue.poll();
if (cells[cur[0]][cur[1]].adjacentMines != 0) continue;
for (int[] d : DIRS) {
int nr = cur[0] + d[0], nc = cur[1] + d[1];
if (!inBounds(nr, nc)) continue;
Cell nb = cells[nr][nc];
if (nb.state != Cell.State.HIDDEN) continue;
nb.state = Cell.State.OPENED;
if (nb.adjacentMines == 0) queue.add(new int[]{nr, nc});
}
}
return false;
}
/** 收集所有已翻开数字格形成的约束 */
public List<Constraint> constraints() {
List<Constraint> list = new ArrayList<>();
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
Cell cell = cells[r][c];
if (cell.state != Cell.State.OPENED || cell.adjacentMines == 0) continue;
List<int[]> unknown = new ArrayList<>();
int knownMines = 0;
for (int[] d : DIRS) {
int nr = r + d[0], nc = c + d[1];
if (!inBounds(nr, nc)) continue;
Cell nb = cells[nr][nc];
if (nb.state == Cell.State.FLAGGED) knownMines++;
else if (nb.state == Cell.State.HIDDEN) unknown.add(new int[]{nr, nc});
}
int remaining = cell.adjacentMines - knownMines;
if (!unknown.isEmpty() || remaining > 0) {
list.add(new Constraint(unknown, remaining));
}
}
}
return list;
}
public int hiddenCount() {
int n = 0;
for (int r = 0; r < rows; r++)
for (int c = 0; c < cols; c++)
if (cells[r][c].state == Cell.State.HIDDEN) n++;
return n;
}
}
4.2 约束结构
package com.example.minesweeper;
import java.util.List;
/** 一条约束:variables 中的格子恰好有 required 个雷 */
public class Constraint {
public final List<int[]> variables;
public final int required;
public Constraint(List<int[]> variables, int required) {
this.variables = variables;
this.required = required;
}
}
4.3 吉布斯采样器(核心)
package com.example.minesweeper;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.Set;
/**
* 吉布斯采样器:在边界格的合法布雷空间做 MCMC 游走,
* 统计每个格子取 1(是雷)的频率作为后验概率。
*/
public class GibbsSampler {
/** 组合数缓存,避免重复计算 */
private final Map<String, Double> combCache = new HashMap<>();
private final Random random;
public GibbsSampler(long seed) {
this.random = new Random(seed);
}
/**
* @param constraints 全部约束
* @param samples 需要采集的有效样本数
* @param burnIn 预热轮数
* @return key = "r,c",value = 该格是雷的概率
*/
public Map<String, Double> estimate(List<Constraint> constraints, int samples, int burnIn) {
// 1. 建立变量索引与"变量 → 约束"倒排
Set<String> varSet = new HashSet<>();
for (Constraint c : constraints) {
for (int[] v : c.variables) varSet.add(key(v));
}
List<String> vars = new ArrayList<>(varSet);
if (vars.isEmpty()) return new HashMap<>();
Map<String, List<Constraint>> varToCons = new HashMap<>();
for (Constraint c : constraints) {
for (int[] v : c.variables) {
varToCons.computeIfAbsent(key(v), k -> new ArrayList<>()).add(c);
}
}
// 2. 初始化:用逐约束贪心得到一个近似可行起点
Map<String, Integer> assign = new HashMap<>();
for (String v : vars) assign.put(v, 0);
for (Constraint c : constraints) {
int have = countInConstraint(c, assign);
int need = c.required - have;
for (int[] v : c.variables) {
if (need <= 0) break;
if (assign.get(key(v)) == 0) {
assign.put(key(v), 1);
need--;
}
}
}
// 3. 采样主循环
Map<String, Integer> hits = new HashMap<>();
for (String v : vars) hits.put(v, 0);
int collected = 0;
int totalIters = burnIn + samples;
for (int iter = 0; iter < totalIters; iter++) {
for (String v : vars) {
double p1 = conditionalProbOne(v, assign, varToCons.get(v));
int newVal = random.nextDouble() < p1 ? 1 : 0;
assign.put(v, newVal);
}
if (iter >= burnIn) {
for (String v : vars) {
if (assign.get(v) == 1) hits.merge(v, 1, Integer::sum);
}
collected++;
}
}
// 4. 归一化为概率
Map<String, Double> prob = new HashMap<>();
for (String v : vars) {
prob.put(v, hits.get(v) / (double) Math.max(1, collected));
}
return prob;
}
/** 计算指定变量取 1 的条件概率 */
private double conditionalProbOne(String var, Map<String, Integer> assign,
List<Constraint> related) {
if (related == null || related.isEmpty()) return 0.5;
double w1 = 1.0, w0 = 1.0; // 权重乘积
for (Constraint c : related) {
int sumOthers = 0, unknownOthers = 0;
for (int[] v : c.variables) {
String k = key(v);
if (k.equals(var)) continue;
unknownOthers++;
if (assign.getOrDefault(k, 0) == 1) sumOthers++;
}
// x=1:剩余 unknownOthers 格放 required-sumOthers-1 个
w1 *= comb(unknownOthers, c.required - sumOthers - 1);
// x=0:放 required-sumOthers 个
w0 *= comb(unknownOthers, c.required - sumOthers);
}
double total = w1 + w0;
if (total <= 0) return 0.0; // 两种取值都不可行,保持原值倾向
return w1 / total;
}
private int countInConstraint(Constraint c, Map<String, Integer> assign) {
int sum = 0;
for (int[] v : c.variables) sum += assign.getOrDefault(key(v), 0);
return sum;
}
/** 组合数 C(n,k),越界返回 0 —— 非法配置权重为 0,自然被排除 */
private double comb(int n, int k) {
if (k < 0 || k > n) return 0.0;
String cacheKey = n + ":" + k;
Double cached = combCache.get(cacheKey);
if (cached != null) return cached;
double result = 1.0;
for (int i = 0; i < k; i++) {
result = result * (n - i) / (i + 1);
}
combCache.put(cacheKey, result);
return result;
}
public static String key(int[] pos) {
return pos[0] + "," + pos[1];
}
}
4.4 决策 AI
package com.example.minesweeper;
import java.util.List;
import java.util.Map;
/** 基于采样的扫雷决策器:必安全则点,必是雷则插旗,否则点概率最低处 */
public class MineAi {
private final GibbsSampler sampler;
private final int samples;
private final int burnIn;
public MineAi(long seed, int samples, int burnIn) {
this.sampler = new GibbsSampler(seed);
this.samples = samples;
this.burnIn = burnIn;
}
/** 返回下一步动作:{row, col, action},action: 0=点开 1=插旗 */
public int[] decide(MineBoard board) {
List<Constraint> cons = board.constraints();
Map<String, Double> prob = sampler.estimate(cons, samples, burnIn);
// 剩余雷数用于计算非边界格的先验概率
int flaggedMines = 0;
for (int r = 0; r < board.rows; r++)
for (int c = 0; c < board.cols; c++)
if (board.cells[r][c].state == Cell.State.FLAGGED) flaggedMines++;
double acc = 0;
int counted = 0;
for (double p : prob.values()) { acc += p; counted++; }
double estMinesOnBoundary = acc;
int unknownTotal = board.hiddenCount();
double farProb = unknownTotal > counted
? Math.max(0, (board.totalMines - flaggedMines - estMinesOnBoundary))
/ (unknownTotal - counted)
: 0.0;
int[] best = null;
double bestProb = 2.0;
int[] sureMine = null;
for (int r = 0; r < board.rows; r++) {
for (int c = 0; c < board.cols; c++) {
Cell cell = board.cells[r][c];
if (cell.state != Cell.State.HIDDEN) continue;
String k = r + "," + c;
double p = prob.containsKey(k) ? prob.get(k) : farProb;
if (p >= 0.999 && board.flags < board.totalMines) {
sureMine = new int[]{r, c, 1};
}
if (p < bestProb) {
bestProb = p;
best = new int[]{r, c, 0};
}
}
}
if (sureMine != null) return sureMine;
return best;
}
}
4.5 主程序:自动扫雷并统计胜率
package com.example.minesweeper;
/** 自动对局,输出胜率与平均采样耗时 */
public class Main {
public static void main(String[] args) {
int games = 100;
int wins = 0;
long totalNanos = 0;
for (int g = 0; g < games; g++) {
MineBoard board = new MineBoard(16, 16, 40, g);
MineAi ai = new MineAi(g, 400, 100);
boolean first = true;
boolean dead = false;
while (true) {
if (first) {
dead = board.open(board.rows / 2, board.cols / 2);
board.layMines(board.rows / 2, board.cols / 2);
first = false;
if (dead) break;
continue;
}
if (board.hiddenCount() == 0) { wins++; break; }
long t0 = System.nanoTime();
int[] move = ai.decide(board);
totalNanos += System.nanoTime() - t0;
if (move == null) break;
if (move[2] == 1) {
board.cells[move[0]][move[1]].state = Cell.State.FLAGGED;
board.flags++;
} else {
if (board.open(move[0], move[1])) { dead = true; break; }
}
}
}
System.out.printf("对局=%d 胜利=%d 胜率=%.1f%% 平均决策耗时=%.2fms%n",
games, wins, wins * 100.0 / games, totalNanos / 1e6 / games);
}
}
五、实现细节里的坑
组合数用 double 防溢出:约束涉及的未知格数通常在 20 以内,C(20,10) ≈ 1.8e5,用 double 累乘完全安全。若遇到更大的边界(如 30 格以上),应改用 log 空间累加再取指数,避免浮点溢出。
预热期不能省:直接从贪心初始化解开始统计,前几十轮还严重偏离平稳分布,会导致概率估计系统性偏高。实践值:burnIn 取 samples / 4。
样本数的收益递减:samples 从 200 加到 400,概率估计的方差下降约 30%;从 400 加到 2000 只再降十几%,但耗时线性增加。扫雷实战中 400 已足够区分”安全格”与”风险格”。
必然安全与必然有雷的判定:采样给出的概率是连续值,>= 0.999 视为必雷、<= 0.001 视为必安全。这两个阈值不要把边界卡太松,否则插入错误旗子会导致后续约束全部失效。
六、复杂度分析
设边界未知格数为 n,平均每格参与的约束数为 d,采样轮数为 T:
- 单次条件概率计算:
O(d),因每条约束需遍历其变量求sumOthers(约束大小通常 ≤ 8)。 - 一轮完整扫描:
O(n · d)。 - 总时间:
O(T · n · d),空间O(n + 约束数)。
对比精确枚举的 O(2^n),采样把复杂度从指数降到了线性乘轮数,这是它能在 16×16、30×16 甚至更大棋盘上实时运行的原因。
七、延伸:从 MCMC 到精确推断
吉布斯采样给出的是近似解。当边界规模较小(n ≤ 20)时,可以直接用连通分量分解 + 回溯枚举得到精确概率,效果更好;当规模很大时退回采样。工程上的常见做法是两条路都实现,按边界大小自动切换:
n ≤ 20:精确枚举(快且准)n > 20:吉布斯采样(近似但可行)
在信息不完整时做决策,先量化不确定性,再按概率行动——这个思路不只在扫雷里成立。