每日算法 — 使用java实现珍珠链:环路度数约束与局部模式消除推理

一、规则简介

珍珠链(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:

  1. 每个格子的度数恰好为 0 或 2(0 表示不在回路上);
  2. 通路方向双向一致(A 通到 B,则 B 也通到 A);
  3. 所有珍珠格度数均为 2;
  4. 白珍珠处的通路是对向配对;黑珍珠处是相邻配对,且两侧邻格直行;
  5. 通路构成恰好一个连通环,且所有非零度数格都在这个环上。

十一、复杂度与实测

在一个 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 伪装成了”题面有问题”。