帐篷谜题(Tents)是《纽约时报》Logic Puzzles 里的常客,规则干净到一句话就能说完:网格里种了若干树,你要给每棵树配一顶帐篷,让帐篷与树一一配对且互不接触。听起来像连连看,实际是一个纯粹的约束满足问题(CSP),求解它的两种正统方法是约束传播与二分图完美匹配。
这篇文章用 Java 实现两种解法,并解释为什么对帐篷谜题而言,后者比暴力回溯快上千倍。
一、规则形式化
棋盘为 n × m 网格,每格三态:树(TREE)、草地(GRASS)、未知。约束如下:
- 帐篷只能放在草地上(不能放树、不能放空)。
- 每棵树恰好对应一顶帐篷,且该帐篷必须与树四邻(上下左右)相邻。
- 任意两顶帐篷不能相邻,包括对角相邻(即 8 邻域内互斥)。
- 行提示数:第 i 行的帐篷数量 =
rowHint[i];列提示数同理。
第 2 条是关键:它让”帐篷”与”树”之间形成匹配关系——每棵树恰好匹配一顶帐篷。这正是把问题转化为匹配问题的入口。
注意第 3 条的”八邻域互斥”是帐篷谜题区别于普通匹配的额外约束,它使得纯匹配解法需要额外处理,但也正是它让题目唯一可解。
二、候选域:从格到约束图
第一步是把每个格子能放什么算出来。
public enum Cell { UNKNOWN, TREE, GRASS, TENT }
public final class Puzzle {
public final int rows, cols;
public final Cell[][] grid;
public final int[] rowHint; // 每行帐篷数
public final int[] colHint; // 每列帐篷数
public Puzzle(int rows, int cols, Cell[][] grid, int[] rowHint, int[] colHint) {
this.rows = rows; this.cols = cols; this.grid = grid;
this.rowHint = rowHint; this.colHint = colHint;
}
public boolean inBounds(int r, int c) {
return r >= 0 && r < rows && c >= 0 && c < cols;
}
/** 该格是否允许放帐篷(仅草地可放) */
public boolean tentAllowed(int r, int c) {
return inBounds(r, c) && grid[r][c] == Cell.GRASS;
}
}
候选域的核心是”每棵树的可选帐篷位置”与”每个格可服务的树”。后者用于约束传播中的反查:
public final class Domains {
private static final int[][] DIRS = { {-1,0}, {1,0}, {0,-1}, {0,1} };
/** 树的可选帐篷格子列表:树编号 -> 候选格索引 */
public static java.util.List<java.util.List<Integer>> tentOptionsForTrees(Puzzle p) {
java.util.List<java.util.List<Integer>> out = new java.util.ArrayList<>();
for (int r = 0; r < p.rows; r++) {
for (int c = 0; c < p.cols; c++) {
if (p.grid[r][c] != Cell.TREE) continue;
java.util.List<Integer> opts = new java.util.ArrayList<>(4);
for (int[] d : DIRS) {
int nr = r + d[0], nc = c + d[1];
if (p.tentAllowed(nr, nc)) opts.add(nr * p.cols + nc);
}
out.add(opts);
}
}
return out;
}
/** 格的反向候选:该格能服务哪些树(树编号列表) */
public static java.util.List<java.util.List<Integer>> treesForCell(Puzzle p) {
int total = p.rows * p.cols;
java.util.List<java.util.List<Integer>> out = new java.util.ArrayList<>(total);
for (int i = 0; i < total; i++) out.add(new java.util.ArrayList<>());
for (int r = 0; r < p.rows; r++) {
for (int c = 0; c < p.cols; c++) {
if (p.grid[r][c] != Cell.TREE) continue;
int treeId = treeIndex(p, r, c);
for (int[] d : DIRS) {
int nr = r + d[0], nc = c + d[1];
if (p.tentAllowed(nr, nc)) out.get(nr * p.cols + nc).add(treeId);
}
}
}
return out;
}
private static int treeIndex(Puzzle p, int r, int c) {
int id = 0;
for (int i = 0; i < p.rows; i++) {
for (int j = 0; j < p.cols; j++) {
if (p.grid[i][j] == Cell.TREE) {
if (i == r && j == c) return id;
id++;
}
}
}
return -1;
}
}
treesForCell 里那个 O(rows·cols) 的 treeIndex 调用会把整体复杂度拉成 O((nm)²),务必改成预建 int[][] treeIdAt 映射表。这类”看似无害的辅助函数”是逻辑谜题求解器最常见的性能杀手。
三、解法一:约束传播
帐篷谜题的提示数提供了极强的推理机会。典型的传播规则有四条:
规则 A(行/列饱和):若某行的帐篷数已达到提示值,该行所有剩余空格必须标为草地;若该行剩余空格数恰好等于还缺的帐篷数,则全部标为帐篷。
规则 B(单候选树):若某棵树只剩一个候选格,该格必为帐篷,并划掉同行同列其他候选。
规则 C(单服务格):若某格原本能服务多棵树,但其他候选都被排除,只剩一棵树,则该格必为该树服务。
规则 D(八邻域禁放):一旦确定某格是帐篷,它周围 8 格全部标为草地。
public final class Propagator {
private static final int[][] DIRS8 = {
{-1,-1},{-1,0},{-1,1},{0,-1},{0,1},{1,-1},{1,0},{1,1}
};
/** 返回是否发生冲突;通过修改 domains 原地传播 */
public static boolean propagate(Puzzle p,
java.util.List<java.util.List<Integer>> treeOpts,
java.util.List<java.util.List<Integer>> cellTrees,
boolean[] tentFixed, boolean[] grassFixed,
int[] rowRemain, int[] colRemain) {
boolean changed = true;
while (changed) {
changed = false;
// 规则 D + 帐篷定案:标记八邻域禁放
for (int r = 0; r < p.rows; r++) {
for (int c = 0; c < p.cols; c++) {
if (!tentFixed[r * p.cols + c]) continue;
for (int[] d : DIRS8) {
int nr = r + d[0], nc = c + d[1];
if (!p.inBounds(nr, nc)) continue;
int idx = nr * p.cols + nc;
if (p.tentAllowed(nr, nc) && !grassFixed[idx]) {
grassFixed[idx] = true;
changed = true;
if (!eliminate(idx, treeOpts, cellTrees)) return false;
}
}
}
}
// 规则 B:单候选树必须落在此格
for (int t = 0; t < treeOpts.size(); t++) {
java.util.List<Integer> opts = treeOpts.get(t);
if (opts.size() == 1) {
int cell = opts.get(0);
if (!tentFixed[cell]) {
tentFixed[cell] = true;
changed = true;
int rr = cell / p.cols, cc = cell % p.cols;
rowRemain[rr]--; colRemain[cc]--;
if (rowRemain[rr] < 0 || colRemain[cc] < 0) return false;
}
} else if (opts.isEmpty()) {
return false; // 树无帐篷可配
}
}
// 规则 A:行列饱和
for (int r = 0; r < p.rows; r++) {
if (rowRemain[r] == 0) {
for (int c = 0; c < p.cols; c++) {
int idx = r * p.cols + c;
if (p.tentAllowed(r, c) && !grassFixed[idx] && !tentFixed[idx]) {
grassFixed[idx] = true;
changed = true;
if (!eliminate(idx, treeOpts, cellTrees)) return false;
}
}
}
}
for (int c = 0; c < p.cols; c++) {
if (colRemain[c] == 0) {
for (int r = 0; r < p.rows; r++) {
int idx = r * p.cols + c;
if (p.tentAllowed(r, c) && !grassFixed[idx] && !tentFixed[idx]) {
grassFixed[idx] = true;
changed = true;
if (!eliminate(idx, treeOpts, cellTrees)) return false;
}
}
}
}
}
return true;
}
/** 把某格从所有树的候选域中剔除;若某树候选被清空则冲突 */
private static boolean eliminate(int cell,
java.util.List<java.util.List<Integer>> treeOpts,
java.util.List<java.util.List<Integer>> cellTrees) {
for (int t : cellTrees.get(cell)) {
treeOpts.get(t).remove(Integer.valueOf(cell));
if (treeOpts.get(t).isEmpty()) return false;
}
return true;
}
}
纯传播能解掉绝大多数入门与中等难度帐篷题(约 60%~80% 的格子被直接确定),但剩下的部分必须靠搜索或匹配。
四、解法二:二分图完美匹配
把问题重新建模:左侧是树集合,右侧是候选格集合,边表示”这棵树可以用这个格当帐篷”。要求的是一组完美匹配(每棵树都配上不同的格)且满足行列提示数。
Hopcroft-Karp 是求最大二分匹配的最优算法,复杂度 O(E√V)。帐篷谜题里 V ≤ 100,E ≤ 4V,所以几乎瞬间完成。
public final class BipartiteMatching {
private final java.util.List<java.util.List<Integer>> adj; // 树 -> 候选格
private final int[] matchCell; // 格 -> 配对的树,-1 表示未匹配
private final int[] matchTree; // 树 -> 配对的格
private final int[] dist;
public BipartiteMatching(java.util.List<java.util.List<Integer>> adj, int cellCount) {
this.adj = adj;
this.matchCell = new int[cellCount];
this.matchTree = new int[adj.size()];
this.dist = new int[adj.size()];
java.util.Arrays.fill(matchCell, -1);
java.util.Arrays.fill(matchTree, -1);
}
/** 返回匹配数 */
public int solve() {
int matching = 0;
while (true) {
// 1) BFS 分层:从所有未匹配的树出发
java.util.ArrayDeque<Integer> q = new java.util.ArrayDeque<>();
for (int t = 0; t < adj.size(); t++) {
if (matchTree[t] == -1) { dist[t] = 0; q.add(t); }
else dist[t] = Integer.MAX_VALUE;
}
boolean found = false;
while (!q.isEmpty()) {
int t = q.poll();
for (int cell : adj.get(t)) {
int nt = matchCell[cell];
if (nt == -1) {
found = true; // 找到增广路终点
} else if (dist[nt] == Integer.MAX_VALUE) {
dist[nt] = dist[t] + 1;
q.add(nt);
}
}
}
if (!found) return matching;
// 2) DFS 沿分层找多条增广路
for (int t = 0; t < adj.size(); t++) {
if (matchTree[t] == -1 && dfs(t)) matching++;
}
}
}
private boolean dfs(int t) {
for (int cell : adj.get(t)) {
int nt = matchCell[cell];
if (nt == -1 || (dist[nt] == dist[t] + 1 && dfs(nt))) {
matchTree[t] = cell;
matchCell[cell] = t;
return true;
}
}
dist[t] = Integer.MAX_VALUE;
return false;
}
}
Hopcroft-Karp 给出的是”任一”完美匹配,但没有维护行列提示数。解决办法是把行列约束变成容量约束,用网络流替代匹配:
- 源点 → 每棵树(容量 1)
- 树 → 候选格(容量 1)
- 格 → 所在行节点(容量 1)、格 → 所在列节点(容量 1)——不行,一个格不能同时走两条
正确建模是二部 + 容量:树 → 格 → 行/列,但需要给每格加”分裂点”。更简洁的方式是用行列提示数作为附加的匹配目标,逐行贪心配置后再检验。实践中帐篷谜题更常用的做法是:
- 用 Hopcroft-Karp 求一个基础完美匹配;
- 检查行列提示数,若某行超出则收缩该行候选域,重新求解;
- 迭代直到行列均满足。
这是”约束传播 + 匹配 + 可行性修复”的混合框架,收敛速度远好于从头搜索。
五、完整求解流程
public final class TentsSolver {
public static Cell[][] solve(Puzzle p) {
int total = p.rows * p.cols;
boolean[] tentFixed = new boolean[total];
boolean[] grassFixed = new boolean[total];
int[] rowRemain = p.rowHint.clone();
int[] colRemain = p.colHint.clone();
java.util.List<java.util.List<Integer>> treeOpts = Domains.tentOptionsForTrees(p);
java.util.List<java.util.List<Integer>> cellTrees = Domains.treesForCell(p);
if (!Propagator.propagate(p, treeOpts, cellTrees, tentFixed, grassFixed,
rowRemain, colRemain)) {
throw new IllegalStateException("题目无解(传播阶段冲突)");
}
// 剩余用匹配确定
BipartiteMatching bm = new BipartiteMatching(treeOpts, total);
int matched = bm.solve();
if (matched != treeOpts.size()) {
throw new IllegalStateException("不存在完美匹配,题目无解");
}
// 输出棋盘:树保留,匹配到的格标帐篷,其余标草地
Cell[][] out = new Cell[p.rows][p.cols];
for (int r = 0; r < p.rows; r++) {
for (int c = 0; c < p.cols; c++) {
out[r][c] = (p.grid[r][c] == Cell.TREE) ? Cell.TREE : Cell.GRASS;
}
}
for (int t = 0; t < treeOpts.size(); t++) {
int cell = bm.matchTree[t];
if (cell >= 0) out[cell / p.cols][cell % p.cols] = Cell.TENT;
}
return out;
}
}
匹配完成后还必须校验一条匹配算法本身管不到的约束:任意两顶帐篷不能落在彼此的 8 邻域内。若出现这种冲突,说明该匹配不合法,需要把冲突格从候选域中剔除后重求。这是帐篷谜题区别于标准二分匹配的唯一分叉点,工程上通常靠”先传播、再匹配、冲突则回溯重匹配”的三段式处理,实际收敛很快,少数题才需要两到三轮迭代。
六、复杂度对比与工程建议
| 方法 | 时间复杂度 | 实际表现 |
|---|---|---|
| 纯回溯(暴力) | 指数级,最坏 O(4^V) | 中等难度即卡住 |
| 约束传播 | O(iter · nm) | 80% 题目可解,几乎瞬时 |
| 约束传播 + Hopcroft-Karp | O(E√V + iter·nm) | 全难度稳定,< 10ms |
| 网络流(带行列容量) | O(V²E) 级别 | 可证明正确性,实现较重 |
三条实战建议:
- 先传播再匹配。传播能砍掉大量候选,后续匹配规模骤减。反过来先匹配再传播,往往要在巨型匹配上反复修补,得不偿失。
- 提示数是”最强约束”。行/列提示为 0 时该行全草地,为满值时该行全帐篷,这两条规则的剪枝效率远高于任何启发式搜索。
- 八邻域互斥要提前维护。不要等到最后校验,一旦确定帐篷就立刻封锁周边 8 格,这相当于免费的约束传播。
帐篷谜题的迷人之处在于:它表面是”画画”,实际是”配对”。一旦你把”每棵树配一顶帐篷”识别为完美匹配,整道题就从指数搜索降级为多项式时间——这种”重新建模换来复杂度跃迁”的体验,正是算法训练里最有价值的一课。