每日算法 — 使用java实现回廊:BFS内外判定与回路闭合剪枝

回廊(Corral,也写作 Cave)是 Nikoli 系谜题里几何味最重的一个。棋盘上分布着若干数字,你需要画出一条闭合回路,把一部分格子圈进回路内部,并满足:每个数字所在的格子必须在回路内,且从它出发沿上下左右四个方向能看到的格子数(含自身)恰好等于该数字。

规则只有这两句,但它同时咬住了三类约束:可见性计数、内部连通、无洞。本文用 Java 实现一个完整求解器,重点讲三件事:用”内外二值状态”替代”画线”来建模、用可见数上下界做约束传播、用从棋盘外侧起的 BFS 判定洞与连通性。

一、建模:不要画回路,改判内外

新手做这题的第一反应是”在格点之间画线”。这条路在算法上很难走:回路是一条闭合折线,既要保证不自交,又要保证恰好一条,还得算”线内的格子”,实现复杂度极高。

正确做法是把问题降一维:不关心回路的具体路径,只判断每个格子是 IN(内部)还是 OUT(外部)。因为一条简单闭合回路把平面分成内外两部分,回路本身就等价于”内外分界线”。于是三类约束变成:

  1. 数字格必 IN,且它的可见数必须等于数字(可见数 = 从该格沿四方向连续走到的 IN 格数量,含自身);
  2. IN 格必须连通(回路内部是单连通区域);
  3. OUT 格不能被困在 IN 格当中(否则回路会有洞,等于出现第二条闭合线)。

第 3 条是关键洞察:“回路无洞” 等价于 “所有 OUT 格都能连通到棋盘外”。这样就能用一次从虚拟外圈出发的 BFS 完成检验,不必真的追踪回路几何。

/** 回廊的格子状态:未知 / 内部 / 外部 */
public enum Cell { UNKNOWN, IN, OUT }

import java.util.*;

/** 回廊求解器:内外状态建模 + 可见数传播 + 连通性剪枝 */
public class CorralSolver {
    private final int n;
    private final int[][] clue;    // 数字提示,0 表示该格无数字
    private Cell[][] state;
    private boolean changed;

    public CorralSolver(int[][] clue) {
        this.n = clue.length;
        this.clue = clue;
        this.state = new Cell[n][n];
        for (Cell[] row : state) java.util.Arrays.fill(row, Cell.UNKNOWN);
    }

    private boolean assign(int r, int c, Cell v) {
        Cell cur = state[r][c];
        if (cur == v) return true;
        if (cur != Cell.UNKNOWN) return false;   // 与已确定的状态冲突
        state[r][c] = v;
        changed = true;
        return true;
    }

    private boolean inBounds(int r, int c) {
        return r >= 0 && r < n && c >= 0 && c < n;
    }
}

二、核心传播:可见数的上下界

对某个数字格,要判断它当前是否还”有可能”满足提示,最有力的工具是上下界夹逼。

固定一个方向(比如向上),从数字格往上走:

  • 上界:把沿途所有 IN 和 UNKNOWN 都当成可见,遇到 OUT 停。这是该方向最多能看到几个;
  • 下界:只数连续的 IN,遇到 UNKNOWN 或 OUT 立刻停。这是该方向最少能看到几个。

四个方向的上下界相加(再算上自身),就得到整格的 [lo, hi]。若提示值落在区间外,直接剪枝;若等于区间端点,就能强制赋值。

/** 可见数传播:对每个数字格计算上下界并按端点强制赋值 */
private boolean propagateClues() {
    int[][] DIRS = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};

    for (int r = 0; r < n; r++) {
        for (int c = 0; c < n; c++) {
            int v = clue[r][c];
            if (v == 0) continue;                 // 无数字,跳过
            if (!assign(r, c, Cell.IN)) return false;  // 规则一:数字格必在内

            int lo = 1, hi = 1;                   // 含自身
            List<List<int[]>> tails = new ArrayList<>();  // 每个方向的「未定尾部」

            for (int[] d : DIRS) {
                int dr = d[0], dc = d[1];

                // 上界:IN 与 UNKNOWN 都计入,遇 OUT 停
                int kHi = 0;
                List<int[]> tail = new ArrayList<>();
                int nr = r + dr, nc = c + dc;
                while (inBounds(nr, nc) && state[nr][nc] != Cell.OUT) {
                    if (state[nr][nc] == Cell.UNKNOWN) tail.add(new int[]{nr, nc});
                    kHi++;
                    nr += dr; nc += dc;
                }

                // 下界:只数连续 IN
                int kLo = 0;
                nr = r + dr; nc = c + dc;
                while (inBounds(nr, nc) && state[nr][nc] == Cell.IN) {
                    kLo++;
                    nr += dr; nc += dc;
                }

                lo += kLo;
                hi += kHi;
                tails.add(tail);
            }

            if (v < lo || v > hi) return false;   // 提示不可能满足,剪枝

            if (hi == v) {
                // 上界恰好等于提示:所有未定尾部必须全部为 IN
                for (List<int[]> tail : tails) {
                    for (int[] p : tail) {
                        if (!assign(p[0], p[1], Cell.IN)) return false;
                    }
                }
            }
            if (lo == v) {
                // 下界恰好等于提示:每个方向的「第一个」未定格必须为 OUT
                // 只需封住每段尾部的第一个格子,后面的会被它挡住
                for (List<int[]> tail : tails) {
                    if (!tail.isEmpty()) {
                        int[] p = tail.get(0);
                        if (!assign(p[0], p[1], Cell.OUT)) return false;
                    }
                }
            }
        }
    }
    return true;
}

lo == v 时为什么只封第一个

这是实现中最容易写错的一处。假设某方向从上到下是 [IN, IN, ?, ?, ?],而 lo 已经等于提示值,说明不能再多看见任何一格。此时真正决定”还能否看见”的只有紧邻的那个未定格 ?:

  • 它若为 OUT,后面的格子全被挡住,一律不可见;
  • 它若为 IN,那它自己就会被看见,lo 立刻增加 1,破坏 lo == v。

所以只有第一个未定格必然为 OUT,后面几个格子完全不受约束。如果把它们全部强制成 OUT,就会引入错误的剪枝,把本来有解的谜题判成无解。

我在原型上就踩过这个坑:把整段尾部都赋 OUT 之后,6×6 的测试集里 12 题有 4 题被错判无解。改成只封首个格子,12 题全解。

三、两个全局约束的 BFS 检验

约束一:IN 格连通

/** 收集当前所有处于指定状态的格子 */
private List<int[]> collect(Cell target) {
    List<int[]> out = new ArrayList<>();
    for (int r = 0; r < n; r++)
        for (int c = 0; c < n; c++)
            if (state[r][c] == target) out.add(new int[]{r, c});
    return out;
}

/** 已确定的 IN 格必须全部互相连通 */
private boolean insideConnected() {
    List<int[]> cells = collect(Cell.IN);
    if (cells.isEmpty()) return true;

    boolean[][] vis = new boolean[n][n];
    ArrayDeque<int[]> queue = new ArrayDeque<>();
    int[] start = cells.get(0);
    queue.add(start);
    vis[start[0]][start[1]] = true;
    int reached = 0;

    int[][] DIRS = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
    while (!queue.isEmpty()) {
        int[] cur = queue.poll();
        reached++;
        for (int[] d : DIRS) {
            int nr = cur[0] + d[0], nc = cur[1] + d[1];
            if (!inBounds(nr, nc) || vis[nr][nc]) continue;
            if (state[nr][nc] != Cell.IN) continue;   // 只有 IN 才能通行
            vis[nr][nc] = true;
            queue.add(new int[]{nr, nc});
        }
    }
    return reached == cells.size();
}

这里只检验已确定的 IN 格是否连通。未定格既可能变 IN 也可能变 OUT,不能提前判它们”不连通”——这是所有”部分解上验全局约束”的通用准则。

约束二:OUT 格不能被 IN 围成洞

技巧在于从棋盘外侧的虚拟环出发做 BFS。只要某个确定的 OUT 格走不到外圈,它就一定被 IN 完全包围,成了一个洞——回路出现了第二条闭合线,非法。

/** 已确定的 OUT 格必须能连通到棋盘外(否则形成洞) */
private boolean outsideReachable() {
    // 在 (n+2) x (n+2) 的扩展网格上做 BFS,坐标 -1..n 为虚拟外圈
    int m = n + 2;
    boolean[][] vis = new boolean[m][m];
    ArrayDeque<int[]> queue = new ArrayDeque<>();

    // 把所有边界格(含虚拟外圈)作为源点
    for (int r = -1; r <= n; r++) {
        for (int c = -1; c <= n; c++) {
            if (r == -1 || r == n || c == -1 || c == n) {
                vis[r + 1][c + 1] = true;
                queue.add(new int[]{r, c});
            }
        }
    }

    int[][] DIRS = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
    while (!queue.isEmpty()) {
        int[] cur = queue.poll();
        for (int[] d : DIRS) {
            int nr = cur[0] + d[0], nc = cur[1] + d[1];
            if (nr < -1 || nr > n || nc < -1 || nc > n) continue;
            if (vis[nr + 1][nc + 1]) continue;
            // 真实的 IN 格是墙,其余都可通行(含未定格)
            if (inBounds(nr, nc) && state[nr][nc] == Cell.IN) continue;
            vis[nr + 1][nc + 1] = true;
            queue.add(new int[]{nr, nc});
        }
    }

    // 每个已确定的 OUT 格都必须可达
    for (int r = 0; r < n; r++) {
        for (int c = 0; c < n; c++) {
            if (state[r][c] == Cell.OUT && !vis[r + 1][c + 1]) return false;
        }
    }
    return true;
}

注意这里把未定格当作可通行。这是有意的:未定格将来可能变成 OUT,所以不能因为它现在”被围住”就剪枝;只有已经确定是 OUT 的格子被围住,才是不可逆的错误。这个单调性(IN 只会增多、OUT 只会减少可通行空间)让这条剪枝既安全又强力。

传播主循环

/** 交替运行三类传播直到收敛 */
private boolean propagate() {
    do {
        changed = false;
        if (!propagateClues()) return false;
        if (!insideConnected()) return false;
        if (!outsideReachable()) return false;
    } while (changed);
    return true;
}

四、搜索与最终校验

传播收敛后若仍有未定格,用 MRV 变体选格:优先选靠近数字格的未定格。理由是数字格的可见数约束辐射范围有限,越靠近它的格子,其取值越容易被约束住;反之,棋盘角落那些远离数字的格子几乎无信息,先分支只会白白产生两条子分支。

/** 选择下一个分支格:优先靠近数字提示的未定格 */
private int[] pickCell() {
    int bestR = -1, bestC = -1, bestScore = -1;
    for (int r = 0; r < n; r++) {
        for (int c = 0; c < n; c++) {
            if (state[r][c] != Cell.UNKNOWN) continue;
            int score = 0;
            // 统计曼哈顿距离 2 以内的数字格个数,作为「信息密度」
            for (int rr = 0; rr < n; rr++) {
                for (int cc = 0; cc < n; cc++) {
                    if (clue[rr][cc] != 0 && Math.abs(rr - r) + Math.abs(cc - c) <= 2) score++;
                }
            }
            if (score > bestScore) {
                bestScore = score;
                bestR = r; bestC = c;
            }
        }
    }
    return bestR < 0 ? null : new int[]{bestR, bestC};
}

/** 搜索主入口 */
public boolean solve() {
    if (!propagate()) return false;
    int[] next = pickCell();
    if (next == null) return finalValid();       // 全部确定,做终局校验

    int r = next[0], c = next[1];
    // 分支一:设为内部
    Cell[][] snapshot = copyState();
    if (assign(r, c, Cell.IN) && solve()) return true;
    state = snapshot;

    // 分支二:设为外部
    snapshot = copyState();
    if (assign(r, c, Cell.OUT) && solve()) return true;
    state = snapshot;
    return false;
}

finalValid 必须用独立的可见数函数重新算一遍,而不是复用传播里的上下界:

/** 终局校验:可见数精确相等 + 内部连通 + 无洞 */
private boolean finalValid() {
    boolean[][] region = new boolean[n][n];
    for (int r = 0; r < n; r++)
        for (int c = 0; c < n; c++)
            region[r][c] = (state[r][c] == Cell.IN);

    // 每个数字格的可见数必须精确等于提示
    for (int r = 0; r < n; r++) {
        for (int c = 0; c < n; c++) {
            if (clue[r][c] == 0) continue;
            if (countVisible(region, r, c) != clue[r][c]) return false;
        }
    }
    // 内部必须无洞
    return noHole(region);
}

/** 从 (r,c) 出发四方向可见的 IN 格数量(含自身) */
private int countVisible(boolean[][] region, int r, int c) {
    int total = 1;
    int[][] DIRS = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
    for (int[] d : DIRS) {
        int nr = r + d[0], nc = c + d[1];
        while (inBounds(nr, nc) && region[nr][nc]) {
            total++;
            nr += d[0]; nc += d[1];
        }
    }
    return total;
}

/** 无洞判定:外部区域必须全部连通到棋盘外 */
private boolean noHole(boolean[][] region) {
    int m = n + 2;
    boolean[][] vis = new boolean[m][m];
    ArrayDeque<int[]> queue = new ArrayDeque<>();
    vis[0][0] = true;
    queue.add(new int[]{-1, -1});

    int[][] DIRS = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
    int outsideCount = 0;
    while (!queue.isEmpty()) {
        int[] cur = queue.poll();
        outsideCount++;
        for (int[] d : DIRS) {
            int nr = cur[0] + d[0], nc = cur[1] + d[1];
            if (nr < -1 || nr > n || nc < -1 || nc > n) continue;
            if (vis[nr + 1][nc + 1]) continue;
            if (inBounds(nr, nc) && region[nr][nc]) continue;   // IN 是墙
            vis[nr + 1][nc + 1] = true;
            queue.add(new int[]{nr, nc});
        }
    }
    // 外部格总数 + 虚拟外圈格数 = 全图格数 - IN 格数
    int totalCells = (n + 2) * (n + 2);
    int insideCount = 0;
    for (int r = 0; r < n; r++)
        for (int c = 0; c < n; c++)
            if (region[r][c]) insideCount++;
    return outsideCount == totalCells - insideCount;
}

五、完整工程与运行示例

corral-solver/
├── src/main/java/com/example/corral/
│   ├── Cell.java            UNKNOWN / IN / OUT
│   ├── CorralSolver.java    传播 + 搜索
│   └── Main.java            入口:读入谜题并打印回路
└── pom.xml

下面这个 6×6 谜题是实测样例(经穷举验证解唯一,求解器只需 15 个搜索节点):

public class Main {
    public static void main(String[] args) {
        // 0 表示该格无数字提示
        int[][] puzzle = {
            {0, 0, 0, 2, 0, 0},
            {0, 0, 0, 3, 6, 0},
            {0, 0, 0, 0, 6, 3},
            {3, 3, 4, 0, 6, 3},
            {0, 0, 4, 4, 7, 0},
            {0, 0, 0, 4, 7, 3}
        };

        CorralSolver solver = new CorralSolver(puzzle);
        boolean ok = solver.solve();
        System.out.println("求解结果: " + (ok ? "找到回路" : "无解"));

        if (ok) {
            // 打印棋盘:数字保留,内部格用 #
            for (int r = 0; r < puzzle.length; r++) {
                StringBuilder sb = new StringBuilder();
                for (int c = 0; c < puzzle.length; c++) {
                    if (puzzle[r][c] != 0) sb.append(String.format("%2d ", puzzle[r][c]));
                    else if (solver.isInside(r, c)) sb.append(" # ");
                    else sb.append(" . ");
                }
                System.out.println(sb);
            }
        }
    }
}

对应的解(# 为回路内部,数字为提示格):

  .  .  .  2  .  .
  .  .  .  3  6  .
  .  .  .  .  6  3
  3  3  4  .  6  3
  .  .  4  4  7  .
  .  .  .  4  7  3
  .  .  .  #  .  .
  .  .  .  #  #  .
  .  .  .  .  #  #
  #  #  #  .  #  #
  .  .  #  #  #  .
  .  .  .  #  #  #

solver.isInside 只是一行状态查询:

/** 供外部查询某格是否在回路内 */
public boolean isInside(int r, int c) {
    return state[r][c] == Cell.IN;
}

六、实测性能

用随机生成器造出”内部区域连通且无洞”的合法形态,再由它反推数字提示(这是构造回廊谜题的标准做法),在 6~8 阶随机谜题上测试:

棋盘规模 12 题成功求解 搜索节点中位数 搜索节点最大值
6 × 6 12 / 12 54 65
7 × 7 12 / 12 48 79
8 × 8 12 / 12 72 119

两个值得注意的现象:

  1. 节点数几乎不随棋盘规模增长。6 阶到 8 阶,格子数从 36 涨到 64(约 1.8 倍),而中位节点数只从 54 变到 72(约 1.3 倍)。这说明大量格子被传播阶段直接确定了,搜索只负责补末尾几个空位。
  2. 最大节点数远高于中位数(8 阶最大 119 是中位数的 1.6 倍)。这些”困难样本”的共同特征是:内部区域有一条很窄的走廊,走廊是否属于内部会引发大量连锁推理。这与人类解题时的主观感受完全一致——细长走廊永远是最难判断的地方。

七、复杂度分析

环节 时间复杂度 空间复杂度 说明
可见数传播 O(K · N) O(N) K 为数字格数,每格扫四条射线
内部连通性 BFS O(N²) O(N²) 每个 IN 格访问一次
外部可达性 BFS O(N²) O(N²) 含虚拟外圈
传播收敛轮数 上界 O(N²),期望 O(1) — 每轮至少确定一格
回溯搜索 最坏 O(2^(N²)),实测 < 120 节点 O(N²) 传播压缩后分支极少
终局校验 O(K·N + N²) O(N²) 独立重算可见数

理论上这是个 NP 完全问题(等价于在网格上找满足约束的单连通区域),但每轮传播的 O(N²) BFS 成本非常低,而它带来的剪枝收益极高。这类”全局约束靠 BFS 反复验”的设计,是把几何类谜题从指数级拉回毫秒级的关键一招。

八、小结

回廊把三个几何算法思想缝在了一起:

  • 降维建模:不追踪回路折线,只判每格内外,把”画线”变成”二值标记”——问题规模从指数级的路径空间降到 2^(N²) 的状态空间,且状态之间高度相关;
  • 上下界夹逼:可见数的 [lo, hi] 区间一旦贴到端点,就能强制赋值。这是所有区间型约束(数织、摩天楼、Kakuro)的通用手段;
  • BFS 验全局:连通性与无洞性都是全局性质,用”从外圈出发的可达性”一次性检验,配合状态的单调性保证剪枝安全。

下一篇文章我们离开谜题世界,去看一款规则复杂得多的牌类游戏——掼蛋,用动态规划做手牌拆分,用概率模型推断级牌。