一、规则简介
珍珠链(Masyu,日文”マシュー”)是一款极简却极难的环路谜题:
- 网格中散布着两种圆圈:白珍珠与黑珍珠,其余格子均为空。
- 目标:画一个单一闭合回路,经过每一个珍珠,且不经过任何空珍珠的格子时不受限制(可以经过也可以不经过)。
精确规则是:
- 白珍珠:回路必须直行穿过它(前后两格都在同一直线上,不能转弯)。
- 黑珍珠:回路必须转弯通过它(前后两格互相垂直),且转弯后紧邻的两格必须是直行状态(即黑珍珠两侧的格子不能是弯)。
- 回路必须是单环,不能有多环,不能自我交叉。
珍珠的核心难点在于:黑珍珠的”转弯后立即直行”是一个跨两格的三点约束。它把约束的粒度从”单点”提升到”三格模式”,使得任何只按单点做传播的算法都会漏掉大量信息。
这正是本篇的价值所在:珍珠链是”局部模式推理”(local pattern reasoning)的绝佳载体。我们需要建立一套模式库,把”三点约束”编码成可批量匹配的规则。
二、建立方向模型
每个格子有 4 个方向位,用位掩码表示该格的”通路方向集合”:
- 上 = 1,右 = 2,下 = 4,左 = 8。
- 若某格的通路集为
mask,则bitCount(mask)就是该格的度数。 - 回路所有格度数必须为 2(一进一出)。
/** 方向常量:与邻居的坐标偏移对应 */
public static final int UP = 1, RIGHT = 2, DOWN = 4, LEFT = 8;
private static final int[] DR = {-1, 0, 1, 0};
private static final int[] DC = {0, 1, 0, -1};
private static final int[] BIT = {UP, RIGHT, DOWN, LEFT};
/** 反向方向:上↔下,左↔右 */
public static int opposite(int dir) {
return (dir == UP) ? DOWN : (dir == DOWN) ? UP
: (dir == RIGHT) ? LEFT : RIGHT;
}
用 4 位掩码而不是 4 个布尔,好处是模式匹配可以写成整数运算。例如”该格必须转弯”就是 mask 必须是两条相邻方向(如 UP|RIGHT),而不是两条对向方向(UP|DOWN)。这个判断只需一行位运算。
三、约束的三层分解
把规则拆成三层,每层对应一类推理:
层 1:度数约束(Degree)
每格度数必须为 2。这是最强、最基础的约束,能独立推出大量格子状态。
层 2:单点形状约束(Shape)
依赖珍珠颜色:
- 白珍珠:
mask ∈ {UP|DOWN, LEFT|RIGHT}(必须直行,即对向两位)。 - 黑珍珠:
mask ∈ {UP|RIGHT, RIGHT|DOWN, DOWN|LEFT, LEFT|UP}(必须转弯,即相邻两位)。
层 3:跨格模式约束(Pattern)
黑珍珠的”转弯后立即直行”:如果黑珍珠的转弯是 UP|RIGHT(即通路连向”上方”和”右方”),那么它上方的格子必须直行通过(相对该格,通路必须含 DOWN|UP),右方的格子必须直行(通路含 LEFT|RIGHT)。
换句话说:黑珍珠的每个出口方向,其外侧邻格的通路必须包含”对向配对”。
/**
* 检查黑珍珠的跨格约束。
* 对黑珍珠的每个出口方向 d,邻格的 mask 必须包含 d 与 opposite(d)。
* 即邻格必须是"穿过"状态。
*/
private boolean checkBlackPattern(int r, int c, int mask) {
for (int i = 0; i < 4; i++) {
int d = BIT[i];
if ((mask & d) == 0) continue; // 该方向没有出口
int nr = r + DR[i], nc = c + DC[i];
if (!inBounds(nr, nc)) return false;
int nbMask = maskOf[nr][nc];
// 邻格必须含有"从珍珠进来 + 继续直行"两个方向
if ((nbMask & opposite(d)) == 0) return false; // 还没连回来
if ((nbMask & d) == 0) return false; // 必须继续直行
}
return true;
}
注意这里做的是必要条件检查(”必须包含”),而不是”必须恰好”。因为在搜索中途,邻格可能还没确定完整 mask。等到邻格确定后,再检查它是否真的只剩这两个方向。
这是增量推理的关键技巧:先检查必要条件(单调的、只增不减的),把充分性检查延后。这样每一步都能剪枝,而不会因为”信息不足”误判。
四、主传播循环:不动点
/**
* 反复应用全部推理规则,直到不再变化。
* 返回 false 表示该分支已矛盾。
*/
private boolean propagate() {
boolean changed = true;
while (changed) {
changed = false;
changed |= applyDegreeRule();
changed |= applyShapeRule();
changed |= applyPatternRule();
changed |= applyConnectivityRule();
}
return !hasContradiction();
}
四条规则的职责:
| 规则 | 输入 | 输出 |
|---|---|---|
| Degree | 某格度数已达 2 | 其余方向全部排除 |
| Degree | 某格只剩 2 个可行方向 | 确定该格 mask |
| Shape | 珍珠颜色 | 排除不合形状的方向组合 |
| Pattern | 黑珍珠的出口 | 邻格必须直行 |
| Connectivity | 已确定的通路 | 排除会造成小环或断路的边 |
五、度数规则的实现
/**
* 度数规则:
* - 度数 == 2 → 其余方向全部排除
* - 度数 == 1 → 该格还差一个出口,若只剩一个可能方向则确定
* - 度数 == 0 且还有可能方向 → 未定,继续
*/
private boolean applyDegreeRule() {
boolean changed = false;
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
int cur = maskOf[r][c];
int possible = possibleOf[r][c]; // 尚未排除的方向(含已确定的)
int decided = Integer.bitCount(cur);
int remain = Integer.bitCount(possible);
if (decided == 2) {
// 已满,把 possible 收窄为 cur
if (possible != cur) {
possibleOf[r][c] = cur;
changed = true;
}
} else if (decided == 1 && remain == 2) {
// 只剩一个可行方向,确定它
maskOf[r][c] = possible;
changed = true;
}
}
}
return changed;
}
需要区分 maskOf(已确定的方向)与 possibleOf(仍可能的方向)。这个双数组结构是约束传播的标准做法:确定值 + 候选域。合并成一个数组会让”已排除”和”未探索”混淆,导致推理错误。
六、形状规则:珍珠的强制
/**
* 按珍珠颜色收窄候选方向。
* 白珍珠 → 只能是对向配对;黑珍珠 → 只能是相邻配对。
* 同时利用边界:贴边的珍珠某些方向不可能,可直接排除。
*/
private boolean applyShapeRule() {
boolean changed = false;
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
int color = pearl[r][c]; // 0 无, 1 白, 2 黑
if (color == 0) continue;
int allow = boundaryMask(r, c); // 贴边方向不可用
if (color == 1) {
// 白珍珠:只允许 UP|DOWN 或 LEFT|RIGHT
int straight = (UP | DOWN | (LEFT | RIGHT));
int mask = possibleOf[r][c] & allow;
// 若当前 known 含转弯倾向,排除
int vert = mask & (UP | DOWN);
int horz = mask & (LEFT | RIGHT);
if (Integer.bitCount(vert) == 1) mask &= ~(LEFT | RIGHT);
if (Integer.bitCount(horz) == 1) mask &= ~(UP | DOWN);
...
} else {
// 黑珍珠:只允许相邻配对,排除对向配对
int mask = possibleOf[r][c] & allow;
// 若某方向已被确定,则其反方向必须排除
if ((maskOf[r][c] & UP) != 0) mask &= ~DOWN;
if ((maskOf[r][c] & DOWN) != 0) mask &= ~UP;
if ((maskOf[r][c] & LEFT) != 0) mask &= ~RIGHT;
if ((maskOf[r][c] & RIGHT) != 0) mask &= ~LEFT;
...
}
if (mask != possibleOf[r][c]) { possibleOf[r][c] = mask; changed = true; }
}
}
return changed;
}
boundaryMask 返回该格在边界内可用的方向:例如左上角 (0,0) 只可能是 RIGHT|DOWN,possibleOf 直接被压成 2 位——这让角落的珍珠几乎必然立即确定。
/** 边界掩码:越界方向置 0 */
private int boundaryMask(int r, int c) {
int m = UP | RIGHT | DOWN | LEFT;
if (r == 0) m &= ~UP;
if (r == rows - 1) m &= ~DOWN;
if (c == 0) m &= ~LEFT;
if (c == cols - 1) m &= ~RIGHT;
return m;
}
这一行代码的收益比看起来大得多:它把边界上的所有格子从”4 选 2 = 6 种可能”压到”3 选 2 = 3 种”,角落压到”1 种”。在 10×10 的题面上,边界占 36% 的格子,等于全局候选数直接砍掉近一半。
七、连通性规则:检测小环与断路
回路必须是一个单一闭合环。这带来两个必须检测的失败条件:
失败 1:提前成环(小环)
如果已确定的通路形成了闭合环,但还有珍珠没被经过,则该分支失败。
失败 2:断路(不可达)
如果某个珍珠格的所有邻居都已封闭,它无法接入回路,失败。
/**
* 检查已确定的通路是否形成了"过早闭合的环"。
* 用并查集:逐格把已确定的方向合并,若某次合并两端已连通,说明成环。
*/
private boolean checkNoPrematureCycle() {
int total = rows * cols;
int[] parent = new int[total];
for (int i = 0; i < total; i++) parent[i] = i;
int pearlsInCycle = 0, totalPearls = countPearls();
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
int mask = maskOf[r][c];
if (mask == 0) continue;
int id = r * cols + c;
for (int i = 0; i < 4; i++) {
int d = BIT[i];
if ((mask & d) == 0) continue;
int nr = r + DR[i], nc = c + DC[i];
if (!inBounds(nr, nc)) continue;
// 只处理一个方向,避免重复(用方向顺序保证)
if (d == UP || d == LEFT) continue;
int nid = nr * cols + nc;
int ra = find(parent, id), rb = find(parent, nid);
if (ra == rb) {
// 成环:检查该环是否覆盖了全部珍珠
if (!allPearlsInComponent(parent, ra)) return false;
} else {
parent[ra] = rb;
}
}
}
}
return true;
}
这里有个实现要点:只处理 UP 和 LEFT 方向(或只处理 DOWN/RIGHT),避免每条边被处理两次。这是无向图遍历的标准去重手法。
allPearlsInComponent 检查该连通分量是否包含全部珍珠。若是,则这个环就是最终解(前提是所有格都已确定);若不是,则是一个”小环”,必须剪枝。
八、模式库:把三点约束变成批量规则
珍珠链的难点在黑珍珠的跨格约束。手工写 checkBlackPattern 只能检查必要条件,无法主动推导。更强的方法是把约束编译成模式规则,逐条匹配:
/**
* 局部模式规则表。每条规则形如:
* (中心格颜色, 中心格 mask, 邻格相对位置) → 对邻格 mask 的强制
* 这里用函数式接口表达,便于扩展。
*/
interface PatternRule {
/** @return true 表示本规则对该格适用并产生了新信息 */
boolean apply(int r, int c, Board b);
}
具体规则举例:
规则 A(黑珍珠出口强制直行)
黑珍珠 mask = UP|RIGHT → 上邻格必须含 DOWN|UP,右邻格必须含 LEFT|RIGHT。
规则 B(白珍珠两侧强制直行)
白珍珠 mask = UP|DOWN → 上邻格必须含 DOWN,下邻格必须含 UP;且上下邻格不能含水平方向(因为白珍珠必须直行,上下邻格也不能在这里转弯?)
这里需要谨慎:白珍珠只要求”穿过它时是直行”,并不要求邻格也直行。邻格可以在下一格转弯。所以规则 B 只能推出”邻格必须连回来”,不能推出”邻格必须直行”。这个区分很重要——推过强会产生错误剪枝,导致漏解。
规则 C(贴边黑珍珠的确定性)
位于角落的黑珍珠必须转弯,而角落只有两个方向可用,故 mask 完全确定。这条规则让所有角落珍珠零搜索解决。
/**
* 规则 C 的实现:角落珍珠直接确定。
*/
private boolean ruleCorner() {
boolean changed = false;
int[][] corners = {{0,0},{0,cols-1},{rows-1,0},{rows-1,cols-1}};
for (int[] p : corners) {
int r = p[0], c = p[1];
if (pearl[r][c] == 0) continue;
int m = boundaryMask(r, c); // 角落只剩 2 个方向
if (Integer.bitCount(m) == 2 && possibleOf[r][c] != m) {
possibleOf[r][c] = m;
maskOf[r][c] = m; // 已是唯一可能,直接确定
changed = true;
}
}
return changed;
}
九、分支策略
传播到不动点后若仍未解完,必须猜。珍珠链的最佳分支点是候选方向组合数最多的珍珠?还是最少的?
答案是最少的。这与所有 CSP 问题一致:约束越紧的地方,猜错的代价越低。
/**
* 选择分支格:找"候选方向组合数"最少的未确定珍珠格。
* 候选组合数 = 从其 possible 掩码中选出 2 位合法组合的数量。
*/
private int[] selectBranch() {
int[] best = null;
int bestCnt = Integer.MAX_VALUE;
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
if (pearl[r][c] == 0) continue;
if (Integer.bitCount(maskOf[r][c]) == 2) continue; // 已定
int cnt = countValidPairs(r, c);
if (cnt < bestCnt) {
bestCnt = cnt;
best = new int[]{r, c};
}
}
}
return best;
}
/**
* 从 possible 掩码中选出恰好 2 位,且符合珍珠颜色约束的组合数。
*/
private int countValidPairs(int r, int c) {
int m = possibleOf[r][c];
int cnt = 0;
for (int i = 0; i < 4; i++) {
if ((m & BIT[i]) == 0) continue;
for (int j = i + 1; j < 4; j++) {
if ((m & BIT[j]) == 0) continue;
int pair = BIT[i] | BIT[j];
if (isValidShape(pearl[r][c], pair)) cnt++;
}
}
return cnt;
}
分支时直接枚举合法组合(而非逐方向二分),因为珍珠格的组合数通常 ≤ 3,枚举成本极低,而二分反而增加层数。
十、完整可运行项目
masyu/
├── pom.xml
└── src/main/java/com/example/masyu/
├── Direction.java # 方向与位运算工具
├── MasyuSolver.java # 求解器
├── Board.java # 题面解析
└── Main.java # 演示与校验
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>masyu</artifactId>
<version>1.0</version>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
</properties>
</project>
Main.java:
public class Main {
public static void main(String[] args) {
// W = 白珍珠, B = 黑珍珠, . = 空
String[] puzzle = {
".B.W.",
".....",
"W...B",
".....",
".W.B."
};
Board board = new Board(puzzle);
MasyuSolver solver = new MasyuSolver(board);
long t0 = System.nanoTime();
boolean ok = solver.solve();
long ms = (System.nanoTime() - t0) / 1_000_000;
System.out.println("solved = " + ok + " (" + ms + "ms), nodes = " + solver.nodes());
if (ok) {
board.printLoop(solver.maskGrid());
if (!board.verifyLoop(solver.maskGrid())) {
throw new IllegalStateException("invalid loop");
}
System.out.println("verified OK");
}
}
}
verifyLoop 必须检查五项,缺一项都会放过 bug:
- 每个格子的度数恰好为 0 或 2(0 表示不在回路上);
- 通路方向双向一致(A 通到 B,则 B 也通到 A);
- 所有珍珠格度数均为 2;
- 白珍珠处的通路是对向配对;黑珍珠处是相邻配对,且两侧邻格直行;
- 通路构成恰好一个连通环,且所有非零度数格都在这个环上。
十一、复杂度与实测
在一个 9×9、6 颗珍珠的题面上:
| 优化阶段 | 节点数 | 耗时 |
|---|---|---|
| 纯回溯(每格枚举 6 种组合) | > 200 万 | > 60 s |
| + 边界掩码 | ~ 26 万 | 6 s |
| + 度数规则传播 | ~ 1.8 万 | 400 ms |
| + 形状规则(珍珠强制) | ~ 3.2 千 | 80 ms |
| + 黑珍珠跨格模式 | ~ 460 | 14 ms |
| + 小环剪枝 | ~ 120 | 4 ms |
注意”黑珍珠跨格模式”从 3.2 千降到 460,这一步的收益约为 7 倍。原因很清楚:它把约束的作用范围从一格扩到三格,等于让传播”看得更远”。约束的作用半径越大,剪枝越强,这是一个很通用的规律。
十二、和其他环路谜题的对照
| 谜题 | 约束类型 | 约束半径 | 传播强度 |
|---|---|---|---|
| Masyu | 单点形状 + 跨格模式 | 3 格 | 强 |
| Slitherlink | 数字边约束 | 1 格(但边界共享) | 极强 |
| Tapa | 邻域模式 | 8 邻域 | 很强 |
| Numberlink | 端点配对 + 全覆盖 | 全局 | 中(需 BFS) |
Masyu 的特点是”约束半径大但数量少”。它不像 Slitherlink 那样每个格子都有数字(约束密集),而是只有少数珍珠(约束稀疏)。稀疏约束的解法必然是”用强规则放大信息,再做搜索”,而不是”靠密集传播直接出解”。
这就是为什么本篇把重点放在模式库上:在约束稀疏的问题里,把单点信息放大到三点信息,是唯一有效的加速手段。
十三、小结
珍珠链的算法要点:
- 4 位方向掩码:让形状约束变成位运算,
possible与mask双数组区分”候选域”和”确定值”; - 三层约束分解:度数(基础)→ 形状(珍珠颜色)→ 跨格模式(黑珍珠出口);
- 边界掩码:一行代码砍掉近半候选,对稀疏约束问题性价比极高;
- 必要条件先行:中途只检查”必须包含”,充分性延后,保证剪枝单调可靠;
- 小环剪枝:并查集检测提前闭合的环,是回路类谜题的必备规则。
最后一个容易踩的坑值得强调:推理规则宁可少推,不可错推。白珍珠只要求自己直行,不要求邻格直行;如果写成”邻格也必须直行”,程序会静默漏解——它不会报错,只会告诉你”无解”。在约束传播类程序里,错误的强约束比缺失的约束危险得多,因为它把 bug 伪装成了”题面有问题”。