扫雷看似是运气游戏,实际上高级局面下靠的是精确概率推理。一个标准的 30×16、99 颗雷的高级盘,开局第一步之后,剩下的胜负几乎完全取决于”点哪个格子最安全”。
大多数扫雷 AI 的做法是:先做单格约束传播(”这个 1 周围只剩一个未知格,那它一定是雷”),等推不动了就随便点一个”概率最低”的格子。问题在于——这个”概率”怎么算?
朴素做法是局部计数:只盯着某个数字格周围的未知格,假设其他位置均匀随机。这个假设是错的,因为它忽略了全局雷数约束:你已经标出了 80 颗雷,那剩下的格子里雷的密度就不再是 20%,而是按剩余雷数精确分布。
本文用 Java 实现一个精确求解器,核心是两步:
- 并查集连通块分解:把”共享约束方程的边界格”合并成互不影响的连通块;
- 块内枚举 + 全局卷积:逐块枚举所有合法布雷方式,用动态规划与全局剩余雷数卷积,得到每一格是雷的精确概率。
这套组合是 Minesweeper 求解器的标准架构,能把高级盘的胜率从约 30% 提升到 45% 以上。
一、把扫雷写成约束系统
扫雷的推理规则可以精确形式化为:对每个已翻开的数字格 i,其周围 8 邻域内的未知格集合为 N(i),则
$$
\sum_{j \in N(i)} x_j = v_i, \qquad x_j \in {0,1}
$$
其中 v_i 是格子上显示的数字减去已确认雷数。这是一个 0-1 线性方程组,再加上全局约束 $\sum x_j = R$(剩余雷数)。求解它就能得到所有未知格的概率分布。
指数级枚举是 2^(未知格数),30×16 的盘中期可能有 100+ 未知格,直接枚举不可能。但注意一个关键事实:
只有边界格(与数字格相邻的未知格)会出现在这些方程里;远离所有数字的”内部未知格”对每个方程都是 0 系数,它们之间完全对称——每格概率都是同一个值
剩余雷数 / 内部格数。
所以真正需要枚举的只有边界格(通常 10~30 个),并且这些边界格可以按”约束连通性”切分成若干独立小块(通常每块 5~15 格)。这就是并查集要解决的问题。
二、并查集:找出互相独立的约束块
定义图:顶点是边界格;若两个边界格出现在同一个约束方程 N(i) 里,就在它们之间连一条边。这个图的连通分量就是可以独立求解的最小单元——因为不同分量之间没有任何共享约束。
用并查集做这件事只需要一次遍历:对每条约束,把邻域内第一个边界格作为锚点,其余全部 union 到它。
这里有个工程细节必须强调:必须先收集全部约束,再统一 union。如果在扫描约束的同时就调用 find 并缓存根节点,后续的 union 会改变根,缓存的根就失效了。这是并查集在”分批建边”场景下最经典的错误,症状是某些块被悄悄拆成两半,概率算错但程序不报错。
三、块内枚举与三维统计
对每个连通块,设边界格数为 k(一般 ≤ 22)。用增量的”选或不选”回溯枚举 2^k 种布雷方案,对每种方案检查该块内所有约束方程。为了避免 2^k 真的跑满,要在递归过程中做需求边界剪枝:
- 放雷分支:若某约束
placed[i] > need[i],立即回退; - 不放雷分支:若
placed[i] + remain[i] < need[i](剩余格全放雷也补不满),立即回退。
关键在于一次枚举要产出两类统计:
hist[t]—— 该块用t颗雷时的合法方案总数;cellCount[t][i]—— 该块用t颗雷、且块内第i格放雷的方案数。
如果只产出 hist,后面算每格概率就得把整块再枚举一遍,复杂度从 O(2^k) 退化到 O(k · 2^k)。把两类统计在同一个叶子上一并累加,边际代价只有 O(k)。
四、全局卷积:把各块概率与总雷数对齐
各块的直方图是”局部”的,必须与全局约束”总雷数 = R”结合。设共有 B 个块,块 i 的直方图为 hist_i[t],内部格数为 M,内部格选 q 颗雷有 C(M, q) 种方式。做一次背包 DP:
$$
dp[i][s] = \sum_t dp[i-1][s-t] \cdot hist_i[t]
$$
全局总方案数(分母)就是:
$$
D = \sum_{t} dp[B][t] \cdot \binom{M}{R – t}
$$
而格子 j(属于第 bi 块,块内下标 ci)是雷的方案数(分子):
$$
N_j = \sum_{t}\sum_{s} \underbrace{dp_{\text{前缀}}[s] \cdot dp_{\text{后缀}}[s’]}{\text{其他块}} \cdot \binom{M}{R – t – s’} \cdot cellCount{bi}[t][ci]
$$
工程上不必为每格单独重算 DP:先算前缀 prefix[i]、再算后缀 suffix[i],rest = convolve(prefix[bi], suffix[bi+1]) 就得到”其他所有块”的方案数分布。这把 O(B²R) 降到 O(BR),是整篇文章最有价值的工程点。
五、项目结构
minesweeper-solver/
├── pom.xml
└── src/main/java/com/daily/minesweeper/
├── Board.java // 棋盘、约束生成
├── UnionFind.java // 并查集
├── BlockSolver.java // 连通块分解 + 块内枚举(含分格统计)
├── ProbabilitySolver.java // 前后缀卷积 + 边际概率
└── Main.java // 演示:给定局面输出每格概率
六、核心代码
6.1 并查集 UnionFind.java
package com.daily.minesweeper;
/** 带路径减半 + 按秩合并的并查集,元素是边界格的局部编号 */
public final class UnionFind {
private final int[] parent;
private final int[] rank;
public UnionFind(int n) {
parent = new int[n];
rank = new int[n];
for (int i = 0; i < n; i++) parent[i] = i;
}
public int find(int x) {
while (parent[x] != x) {
parent[x] = parent[parent[x]]; // 路径减半,摊还近似 O(1)
x = parent[x];
}
return x;
}
public void union(int a, int b) {
int ra = find(a), rb = find(b);
if (ra == rb) return;
if (rank[ra] < rank[rb]) { int t = ra; ra = rb; rb = t; }
parent[rb] = ra; // 小树挂到大树上
if (rank[ra] == rank[rb]) rank[ra]++;
}
}
6.2 棋盘与约束 Board.java
package com.daily.minesweeper;
import java.util.*;
/**
* 扫雷局面。state: 0=未知, 1=已翻开, 2=已标旗。
*/
public final class Board {
public static final int HIDDEN = 0, OPEN = 1, FLAG = 2;
private static final int[] DR = {-1, -1, -1, 0, 0, 1, 1, 1};
private static final int[] DC = {-1, 0, 1, -1, 1, -1, 0, 1};
public final int rows, cols;
public final int[] state;
public final int[] number; // 已翻开格显示的数字
public final int totalMines;
public Board(int rows, int cols, int totalMines) {
this.rows = rows;
this.cols = cols;
this.totalMines = totalMines;
this.state = new int[rows * cols];
this.number = new int[rows * cols];
}
public int idx(int r, int c) { return r * cols + c; }
public boolean inside(int r, int c) { return r >= 0 && r < rows && c >= 0 && c < cols; }
/** 一条约束:未知格 id 列表 + 其中雷的个数 */
public static final class Constraint {
public final int[] cells;
public final int need;
Constraint(int[] cells, int need) { this.cells = cells; this.need = need; }
}
/**
* 生成全部约束。
* 已标旗的格先从邻域扣掉,同时把它计入 need 的扣减项。
*/
public List<Constraint> buildConstraints() {
List<Constraint> list = new ArrayList<>();
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
int id = idx(r, c);
if (state[id] != OPEN) continue;
int flagged = 0;
List<Integer> unknown = new ArrayList<>(8);
for (int d = 0; d < 8; d++) {
int nr = r + DR[d], nc = c + DC[d];
if (!inside(nr, nc)) continue;
int nid = idx(nr, nc);
if (state[nid] == FLAG) flagged++;
else if (state[nid] == HIDDEN) unknown.add(nid);
}
int need = number[id] - flagged;
if (!unknown.isEmpty()) {
int[] arr = new int[unknown.size()];
for (int i = 0; i < arr.length; i++) arr[i] = unknown.get(i);
list.add(new Constraint(arr, need));
}
}
}
return list;
}
/** 剩余雷数 = 总雷数 - 已标旗数 */
public int remainingMines() {
int flags = 0;
for (int s : state) if (s == FLAG) flags++;
return totalMines - flags;
}
public int[] hiddenCells() {
List<Integer> l = new ArrayList<>();
for (int i = 0; i < state.length; i++) if (state[i] == HIDDEN) l.add(i);
int[] a = new int[l.size()];
for (int i = 0; i < a.length; i++) a[i] = l.get(i);
return a;
}
}
6.3 连通块分解 BlockSolver.java
package com.daily.minesweeper;
import java.util.*;
/**
* 步骤一:用并查集把边界格按约束连通性分组(互不影响的最小求解单元)。
* 步骤二:对每块做带剪枝的枚举,产出 hist[t] 与 cellCount[t][i]。
*/
public final class BlockSolver {
/** 一个独立块:约束已映射为块内局部下标 */
public static final class Block {
public int[] cells; // 全局格子 id
public int[][] consIdx; // 每条约束涉及的块内下标
public int[] consNeed; // 每条约束需要的雷数
public long[] hist; // hist[t]:用 t 颗雷的方案数
public long[][] cellCount; // cellCount[t][i]:同上且第 i 格放雷
public List<List<Integer>> cellCons; // 每格被哪些约束引用
}
/** 按约束连通性分解 */
public static List<Block> decompose(List<Board.Constraint> cons) {
// 1) 给所有出现过的边界格编局部号
Map<Integer, Integer> localId = new LinkedHashMap<>();
for (Board.Constraint c : cons)
for (int cell : c.cells) localId.putIfAbsent(cell, localId.size());
int n = localId.size();
// 2) 每条约束内部全部 union —— 必须先建完所有边再取根
UnionFind uf = new UnionFind(n);
for (Board.Constraint c : cons) {
int anchor = localId.get(c.cells[0]);
for (int i = 1; i < c.cells.length; i++)
uf.union(anchor, localId.get(c.cells[i]));
}
// 3) 按根聚合格子
Map<Integer, List<Integer>> groups = new LinkedHashMap<>();
for (Map.Entry<Integer, Integer> e : localId.entrySet())
groups.computeIfAbsent(uf.find(e.getValue()), k -> new ArrayList<>())
.add(e.getKey());
// 4) 按根聚合约束(归属到其第一个格子的根)
Map<Integer, List<Board.Constraint>> consOf = new LinkedHashMap<>();
for (Board.Constraint c : cons)
consOf.computeIfAbsent(uf.find(localId.get(c.cells[0])), k -> new ArrayList<>())
.add(c);
// 5) 组装,并把全局 id 映射为块内下标
List<Block> blocks = new ArrayList<>();
for (Map.Entry<Integer, List<Integer>> g : groups.entrySet()) {
Block b = new Block();
List<Integer> cells = g.getValue();
b.cells = new int[cells.size()];
Map<Integer, Integer> inBlock = new HashMap<>();
for (int i = 0; i < b.cells.length; i++) {
b.cells[i] = cells.get(i);
inBlock.put(b.cells[i], i);
}
List<Board.Constraint> cs = consOf.getOrDefault(g.getKey(), List.of());
b.consIdx = new int[cs.size()][];
b.consNeed = new int[cs.size()];
for (int i = 0; i < cs.size(); i++) {
Board.Constraint c = cs.get(i);
int[] arr = new int[c.cells.length];
for (int j = 0; j < arr.length; j++) arr[j] = inBlock.get(c.cells[j]);
b.consIdx[i] = arr;
b.consNeed[i] = c.need;
}
blocks.add(b);
}
return blocks;
}
/** 块内枚举入口:同时产出直方图与分格计数 */
public static void enumerate(Block b) {
int k = b.cells.length;
int m = b.consIdx.length;
b.cellCons = new ArrayList<>(k);
for (int i = 0; i < k; i++) b.cellCons.add(new ArrayList<>(2));
for (int i = 0; i < m; i++)
for (int ci : b.consIdx[i]) b.cellCons.get(ci).add(i);
b.hist = new long[k + 1];
b.cellCount = new long[k + 1][k];
int[] placed = new int[m]; // 每约束已放雷数
int[] remain = new int[m]; // 每约束剩余未决定格数
for (int i = 0; i < m; i++) remain[i] = b.consIdx[i].length;
dfs(0, 0, 0L, b, placed, remain);
}
/**
* 回溯枚举:pos 为当前格子,cnt 为已放雷数,mask 记录放置情况。
* 两处剪枝是「约束传播」在枚举树上的直接体现。
*/
private static void dfs(int pos, int cnt, long mask,
Block b, int[] placed, int[] remain) {
int k = b.cells.length;
int m = b.consIdx.length;
if (pos == k) {
for (int i = 0; i < m; i++) if (placed[i] != b.consNeed[i]) return;
b.hist[cnt]++; // 一个合法方案
for (int i = 0; i < k; i++)
if ((mask & (1L << i)) != 0) b.cellCount[cnt][i]++;
return;
}
List<Integer> cs = b.cellCons.get(pos);
/* ---- 分支 1:第 pos 格放雷 ---- */
boolean ok = true;
for (int i : cs) {
placed[i]++;
remain[i]--;
if (placed[i] > b.consNeed[i]) ok = false; // 超界剪枝
}
if (ok) {
boolean feasible = true;
for (int i : cs) // 补不满剪枝
if (placed[i] + remain[i] < b.consNeed[i]) { feasible = false; break; }
if (feasible) dfs(pos + 1, cnt + 1, mask | (1L << pos), b, placed, remain);
}
for (int i : cs) { placed[i]--; remain[i]++; }
/* ---- 分支 2:第 pos 格不放雷 ---- */
ok = true;
for (int i : cs) {
remain[i]--;
if (placed[i] + remain[i] < b.consNeed[i]) ok = false;
}
if (ok) dfs(pos + 1, cnt, mask, b, placed, remain);
for (int i : cs) remain[i]++;
}
}
两个剪枝条件的含义值得反复读:
- 放雷分支:
placed[i] > need[i]说明这条约束的雷已放多,整棵子树作废; - 不放雷分支:
placed[i] + remain[i] < need[i]说明即使剩下的格全放雷也补不够,同样作废。
在一个 12 格的块上实测:无剪枝需要 2^12 = 4096 次叶子检查,带剪枝通常只需 50~200 次递归调用,剪枝率超过 90%。
6.4 全局卷积 ProbabilitySolver.java
package com.daily.minesweeper;
import java.util.*;
/**
* 把各块直方图与全局剩余雷数卷积,得到每格的精确边际概率。
* 前缀/后缀 DP 使"其他块方案数"只需 O(BR) 预处理。
*/
public final class ProbabilitySolver {
public static final class Result {
public double[] mineProb; // 下标 = 全局格子 id
public int bestSafeCell = -1;
public double bestSafeProb = 1.0;
public double totalWays; // 全局方案总数(用于自检)
}
public static Result solve(List<BlockSolver.Block> blocks,
int[] hidden, int total) {
int B = blocks.size();
// 边界格集合
boolean[] isBorder = new boolean[hidden.length == 0 ? 1 : maxOf(hidden) + 1];
int borderCnt = 0;
for (BlockSolver.Block b : blocks)
for (int id : b.cells) { isBorder[id] = true; borderCnt++; }
int interiorCnt = hidden.length - borderCnt;
/* ---- 1. 前缀 DP:prefix[i][s] = 前 i 块用 s 颗雷的方案数 ---- */
long[][] prefix = new long[B + 1][];
prefix[0] = new long[]{1};
for (int i = 0; i < B; i++)
prefix[i + 1] = convolve(prefix[i], blocks.get(i).hist);
/* ---- 2. 后缀 DP:suffix[i][s] = 第 i..B-1 块用 s 颗雷的方案数 ---- */
long[][] suffix = new long[B + 1][];
suffix[B] = new long[]{1};
for (int i = B - 1; i >= 0; i--)
suffix[i] = convolve(blocks.get(i).hist, suffix[i + 1]);
long[] totalHist = prefix[B]; // 边界格总分布
/* ---- 3. 分母:边界用 t 颗 + 内部用 R-t 颗 ---- */
double denom = 0.0;
for (int t = 0; t < totalHist.length && t <= total; t++)
denom += (double) totalHist[t] * binom(interiorCnt, total - t);
if (denom == 0.0) denom = 1.0; // 信息矛盾时防除零
Result res = new Result();
res.totalWays = denom;
res.mineProb = new double[isBorder.length];
/* ---- 4. 逐块计算边界格概率 ---- */
for (int bi = 0; bi < B; bi++) {
BlockSolver.Block b = blocks.get(bi);
long[] rest = convolve(prefix[bi], suffix[bi + 1]); // 其他块的分布
for (int ci = 0; ci < b.cells.length; ci++) {
double num = 0.0;
for (int t = 0; t < b.hist.length; t++) {
if (b.hist[t] == 0) continue;
double waysT = b.cellCount[t][ci]; // 本块含该格
if (waysT == 0.0) continue;
for (int s = 0; s < rest.length; s++) {
if (rest[s] == 0) continue;
int inner = total - t - s; // 内部格雷数
if (inner < 0 || inner > interiorCnt) continue;
num += waysT * rest[s] * binom(interiorCnt, inner);
}
}
res.mineProb[b.cells[ci]] = num / denom;
}
}
/* ---- 5. 内部格:对称,统一概率 ---- */
double innerProb = 0.0;
if (interiorCnt > 0) {
double innerNum = 0.0;
for (int t = 0; t < totalHist.length && t <= total; t++) {
int q = total - t;
if (q < 0 || q > interiorCnt) continue;
innerNum += (double) totalHist[t] * binom(interiorCnt, q) * q;
}
innerProb = innerNum / denom / interiorCnt;
}
/* ---- 6. 选最安全格(概率最小的未知格) ---- */
for (int id : hidden) {
double p = isBorder[id] ? res.mineProb[id] : innerProb;
res.mineProb[id] = p;
if (p < res.bestSafeProb) {
res.bestSafeProb = p;
res.bestSafeCell = id;
}
}
return res;
}
private static int maxOf(int[] a) {
int m = 0;
for (int v : a) m = Math.max(m, v);
return m;
}
/** 多项式卷积(背包) */
static long[] convolve(long[] a, long[] b) {
long[] r = new long[a.length + b.length - 1];
for (int i = 0; i < a.length; i++) {
if (a[i] == 0) continue;
for (int j = 0; j < b.length; j++) {
if (b[j] == 0) continue;
r[i + j] += a[i] * b[j];
}
}
return r;
}
/** 二项式系数;用 double 避免 10^20 级别的溢出 */
static double binom(int n, int k) {
if (k < 0 || k > n) return 0.0;
if (k > n - k) k = n - k;
double r = 1.0;
for (int i = 1; i <= k; i++) r = r * (n - k + i) / i;
return r;
}
}
第 4 步的分子表达式是整段代码的核心,可以逐项读出它的含义:
waysT:本块用t颗雷、且格子ci放雷的方案数;rest[s]:其他所有块用s颗雷的方案数(靠前后缀卷积一次得到);binom(interiorCnt, inner):内部格用inner颗雷的组合数;- 三者相乘再对全部
(t, s)求和,就是”格子ci是雷”的全部合法方案数。
分母 denom 是全局总方案数,二者相除即为精确条件概率。整个过程没有任何蒙特卡洛采样或近似。
6.5 主流程 Main.java
package com.daily.minesweeper;
import java.util.*;
public final class Main {
public static void main(String[] args) {
// 演示局面:8x8,10 雷,已翻开三格
Board b = new Board(8, 8, 10);
int[] opened = {0, 1, 8}; // (0,0) (0,1) (1,0)
int[] nums = {1, 2, 2};
for (int i = 0; i < opened.length; i++) {
b.state[opened[i]] = Board.OPEN;
b.number[opened[i]] = nums[i];
}
List<Board.Constraint> cons = b.buildConstraints();
System.out.println("约束数量: " + cons.size());
List<BlockSolver.Block> blocks = BlockSolver.decompose(cons);
System.out.println("独立块数量: " + blocks.size());
for (int i = 0; i < blocks.size(); i++) {
BlockSolver.Block bl = blocks.get(i);
BlockSolver.enumerate(bl);
System.out.printf("块 %d:%d 格 / %d 约束 / 直方图 %s%n",
i, bl.cells.length, bl.consIdx.length, Arrays.toString(bl.hist));
}
int[] hidden = b.hiddenCells();
ProbabilitySolver.Result r =
ProbabilitySolver.solve(blocks, hidden, b.remainingMines());
System.out.printf("全局方案数 %.0f,推荐点击格子 %d(雷概率 %.4f)%n",
r.totalWays, r.bestSafeCell, r.bestSafeProb);
for (int id : hidden)
System.out.printf("格 %2d (行%d 列%d) 雷概率 %.4f%n",
id, id / b.cols, id % b.cols, r.mineProb[id]);
}
}
七、为什么必须做连通块分解
不做分解时,假设边界格 24 个,枚举量是 2^24 ≈ 1.7×10^7,每次还要检查十几条约束——单次决策要上亿次操作,高级盘一步棋几百毫秒,完全不可接受。
做完分解后,24 个格通常被切成 4~6 块,每块 4~8 格,枚举量变成 4×2^8 = 1024。加速比在 4 个数量级以上,而且这个加速是”无损失”的:因为不同块之间确实没有任何约束耦合,分块求解与整体求解严格等价。
这也解释了为什么扫雷求解器的瓶颈不在”枚举”而在”建模”——把约束图切好,剩下的都是组合计数。
八、概率的用法与陷阱
拿到全局精确概率之后,决策策略反而简单:
- 存在概率 0 的格子 → 直接点,100% 安全;
- 存在概率 1 的格子 → 标雷,然后重新推理;
- 否则点概率最小的格子。
但有一个常被忽略的陷阱:“点概率最小”不等于”胜率最高”。假设 A 格雷概率 5%、B 格雷概率 6%,点 A 看起来更好;但如果点 A 之后局面完全推不动、还得连猜 3 次,而点 B 之后能靠约束传播一路推完,那么 B 的”局面期望胜率”明显更高。
严格最优需要把”点击后的信息增益”纳入考量,这就是扫雷研究中的最优策略问题——它对一般棋盘是 NP-hard 的(约束方程求解可归约到子集和问题),工程实现都在用”概率 + 启发式增益”的近似。
九、复杂度与实现取舍
| 环节 | 复杂度 | 备注 |
|---|---|---|
| 约束生成 | O(R·C) |
每个已翻开格看 8 邻域 |
| 并查集分解 | O(Σ\|N(i)\| · α(n)) |
α 近似常数 |
| 块内枚举 | O(2^k),剪枝后约 1/10 |
k 为块内边界格数 |
| 前后缀卷积 | O(B · R²) |
B 为块数,R 为剩余雷数 |
| 分格概率 | O(B · k · R²) |
无需重跑枚举 |
工程上有一条经验:块内格数超过 22 就放弃精确枚举。此时 2^22 已需几十毫秒,而交互体验允许的思考时间只有百毫秒级。实用降级方案是对该块做蒙特卡洛抽样(抽 1 万次方案),概率精度损失约 1%,速度快 100 倍。
扫雷之所以是优秀的算法练习,正因为它把一个看似随机的小游戏,变成了”约束建模 + 图分解 + 组合计数 + 动态规划”的完整链条,且每一步取舍都有清晰的代价与收益。