一、问题描述
数字连线(Numberlink,也叫 Arukone)的规则一眼就懂:
- 网格上分布着若干对数字,每一对用
1, 2, 3, ...标记,每对恰好出现两次。 - 目标:用互不相交的路径把每对数字连起来。
- 路径只能沿上下左右走,不能斜走,不能交叉,不能重合。
看起来简单,但它是 NP 完全问题。挑战在于:
- 每条路径是一个固定端点的简单路径(不能自我交叉)。
- 所有路径必须恰好覆盖所有格子(多数规则要求填满),这使它更接近 Hamilton 路集的分解。
- 全局约束(不交叉、全填满)与局部选择(每格走哪个方向)之间存在强耦合。
这类问题的算法价值在于:它同时是路径搜索、连通性判定、精确覆盖三个经典模型的交汇点。本篇用 Java 实现一个求解器,核心是三条:并查集做连通性死锁检测、位掩码做方向约束、以及”死角优先”的分支策略。
二、建模:从”画线”到”选边”
第一个建模决策是:把”格子”还是把”边”当变量?
- 格子模型:每个格子记录”通向哪两个邻居”(因为路径穿过格子必然一进一出)。状态空间是每个格子选 2 个方向,组合数巨大。
- 边模型:棋盘上的每条相邻边,选或不选。每条路径变成边集上的连通分量,端点度数约束变成”数字格度数为1,普通格度数为2″。
边模型更优,因为”度数”这个约束天然把路径问题转成了图论度数约束问题:
- 数字格:度数 = 1(路径从它出发);
- 中间格:度数 = 2(一进一出);
- 空格(若规则要求全填):度数 = 2;
- 黑格:度数 = 0(不存在)。
这正好是图的 2-因子分解 + 端点配对问题的变体。
三、并查集的三个用途
并查集(Union-Find)在这个问题里承担三种职责,每一种都对应一类剪枝:
用途 1:检测环路(提前失败)
如果一条路径在未到达终点前就形成了环(首尾相连),则该解无效。用并查集:选一条边前检查两端是否已连通——若已连通,加入这条边就会成环。
用途 2:检测”封闭死区”(关键剪枝)
已完成的路径(两个端点都用尽)会把棋盘分割成若干区域。如果一个区域内存在某个数字的端点,但该数字的另一端不在这个区域里,则这个区域永远无法连通——立即失败。
这个剪枝是 Numberlink 求解器的灵魂。没有它,搜索会陷入大量”局部没矛盾但全局不可能”的分支。
用途 3:端点配对的一致性
同一条路径的所有格子必须在同一个连通分量里。若发现某个数字的两个端点已经属于不同的封闭分量,失败。
/**
* 带"路径归属"信息的并查集。
* parent 为标准并查集结构;owner 记录该分量当前连着的数字编号(-1 表示无)。
*/
public class PathDSU {
private final int[] parent;
private final int[] rank;
private final int[] owner; // 该连通分量包含哪个数字的端点
public PathDSU(int n) {
parent = new int[n];
rank = new int[n];
owner = new int[n];
for (int i = 0; i < n; i++) {
parent[i] = i;
owner[i] = -1;
}
}
public int find(int x) {
while (parent[x] != x) {
parent[x] = parent[parent[x]]; // 路径压缩(半路压缩,迭代版)
x = parent[x];
}
return x;
}
/**
* 合并两个格子。若合并会造成数字冲突(同一分量出现两个不同数字),返回 false。
*/
public boolean union(int a, int b) {
int ra = find(a), rb = find(b);
if (ra == rb) return false; // 已成环
int oa = owner[ra], ob = owner[rb];
if (oa != -1 && ob != -1 && oa != ob) return false; // 两个不同数字连到一起
// 按秩合并
if (rank[ra] < rank[rb]) { int t = ra; ra = rb; rb = t; }
parent[rb] = ra;
if (rank[ra] == rank[rb]) rank[ra]++;
owner[ra] = (oa != -1) ? oa : ob;
return true;
}
}
owner 字段是这个通用并查集的专用扩展。它的作用是把”连通性”升级为”语义连通性”——不仅要知道两格连通,还要知道它们属于哪条路径。这正是领域知识注入数据结构的典型案例。
四、度数约束的增量维护
每个格子有一个剩余度数 deg:数字格初始为 1,普通格初始为 2。每选一条边,两端 deg--。
若某格 deg 降到 0 且它不在”已完成”状态,说明它被”封死”了——检查它是否满足终局条件。
/**
* 每条边对应唯一的编号,便于在状态中记录选中情况。
* 边编号规则:水平边 (r,c)-(r,c+1) 占一个区间;垂直边另占一个区间。
*/
private int edgeIdH(int r, int c) { return r * (cols - 1) + c; }
private int edgeIdV(int r, int c) { return rows * (cols - 1) + r * cols + c; }
/**
* 尝试选中一条边。(r1,c1)-(r2,c2) 必须相邻。
*/
private boolean addEdge(int r1, int c1, int r2, int c2) {
if (deg[r1][c1] <= 0 || deg[r2][c2] <= 0) return false;
int a = r1 * cols + c1, b = r2 * cols + c2;
if (!dsu.union(a, b)) return false; // 成环或数字冲突
deg[r1][c1]--;
deg[r2][c2]--;
return true;
}
落边后立刻检查两端度数:若某端降为 1,则该格只剩下唯一可能的出口,必须立即确定(类似级联)。这就是”度数传播”。
五、核心剪枝:区域可达性检查
这是本篇最重要的算法。每次落边后,都要检查”是否存在永远无法连通的需求”。
算法描述:
- 把所有已封闭的分量(其
deg全为 0)在网格上”标记为墙”。 - 对每个数字
k,找到它两个端点所在的分量A_k、B_k。 - 若
A_k == B_k,该数字已完成(路径连通),跳过。 - 若
A_k != B_k,检查它们之间的白格连通性(绕过已封闭区域): - 若不存在连通路径 → 失败。
- 若两个不同数字的端点处于同一连通区域,也要检查是否互相阻塞(更强,但实现复杂,可作为可选优化)。
/**
* 用 BFS 检查两个分量之间是否还有通路(穿过未封闭格)。
* 若不通,说明这个分支永远无法把该数字连起来 → 剪枝。
*/
private boolean regionReachable(int fromCell, int toCell) {
int total = rows * cols;
boolean[] vis = new boolean[total];
int[] queue = new int[total];
int head = 0, tail = 0;
queue[tail++] = fromCell;
vis[fromCell] = true;
while (head < tail) {
int cur = queue[head++];
if (cur == toCell) return true;
int r = cur / cols, c = cur % cols;
int[][] dirs = {{-1,0},{1,0},{0,-1},{0,1}};
for (int[] d : dirs) {
int nr = r + d[0], nc = c + d[1];
if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue;
int nx = nr * cols + nc;
if (vis[nx]) continue;
if (isWall(nr, nc)) continue; // 黑格或已封闭的度数耗尽格
vis[nx] = true;
queue[tail++] = nx;
}
}
return false;
}
isWall 的判定是:黑格,或者 deg == 0 且该格不属于”当前数字的已完成路径”。后者需要小心——一条已完成路径本身就不可穿越,这是对的;但当前路径自己的格子当然也不可穿越。
这个检查的复杂度是 O(N²) 每次调用。听起来昂贵,但它能砍掉的分支远超过它自身成本。这是典型的”昂贵剪枝换指数级收益”,必须实测确认划算(后文有数据)。
六、位掩码加速:预计算区域连通性
上面的 BFS 每次重算太浪费。可以预计算每个格子在”不考虑已封闭区”时的连通区域,但这不够——因为封闭区会动态变化。
实用的折中方案:只对两端度数不为 0 的格子做 BFS,并把结果缓存到「当前已选边集合的哈希」。搜索树的相邻节点大量共享状态,缓存命中率很高。
/**
* 剪枝结果缓存:key = 边选择状态的 Zobrist 哈希。
* 命中则直接返回上次结果,避免重复 BFS。
*/
private final Map<Long, Boolean> pruneCache = new HashMap<>();
private boolean checkRegions(long stateHash) {
Boolean cached = pruneCache.get(stateHash);
if (cached != null) return cached;
boolean ok = doCheckRegions();
pruneCache.put(stateHash, ok);
return ok;
}
Zobrist 哈希的构造:给每条边一个随机 64 位数,选中时异或进去。这样增量更新 O(1),且不同边集冲突概率极低。
七、分支策略:从”死角”下手
选边的顺序极大影响搜索效率。三种可选策略:
- 顺序扫描:从左上到右下遍历空格。简单但盲目。
- 度数优先:选当前
deg最小的格子,先确定它的边。这是 MRV 的图论版。 - 死角优先:优先处理”只剩一个出口”或”必须连到特定方向”的格子。
策略 3 最强。它的实现逻辑:
/**
* 找一个"死角":度数已降为 1,且只有一个合法邻居的格子。
* 这类格子的边是唯一确定的,必须优先落定(否则会先探索大量无关分支)。
* 返回 {r, c, nr, nc},没有死角则返回 null。
*/
private int[] findDeadEnd() {
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
if (deg[r][c] != 1) continue;
if (isBlack(r, c)) continue;
List<int[]> avail = new ArrayList<>();
int[][] dirs = {{-1,0},{1,0},{0,-1},{0,1}};
for (int[] d : dirs) {
int nr = r + d[0], nc = c + d[1];
if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue;
if (deg[nr][nc] > 0 && dsu.find(r * cols + c) != dsu.find(nr * cols + nc)) {
avail.add(new int[]{nr, nc});
}
}
if (avail.size() == 1) {
return new int[]{r, c, avail.get(0)[0], avail.get(0)[1]};
}
}
}
return null;
}
注意条件 deg[nr][nc] > 0 和”不在同一分量”:前者排除已封闭格,后者排除会成环的边。这两个条件合起来就是”合法出口”的完整定义。
八、主搜索框架
/**
* 回溯搜索主循环。返回 true 表示找到解。
*/
private boolean search() {
nodes++;
// 1) 传播:反复找死角,直到没有为止
while (true) {
int[] de = findDeadEnd();
if (de == null) break;
if (!addEdge(de[0], de[1], de[2], de[3])) return false;
if (!checkRegions(stateHash())) return false;
}
// 2) 检查是否完成
if (allDegreesSatisfied()) return true;
// 3) 分支:选一个度数最小的格子,枚举它的边
int[] branch = selectBranchCell();
if (branch == null) return false;
int r = branch[0], c = branch[1];
List<int[]> options = availableNeighbors(r, c);
for (int[] opt : options) {
Snapshot snap = capture(); // 记录增量
if (addEdge(r, c, opt[0], opt[1]) && checkRegions(stateHash()) && search()) {
return true;
}
restore(snap); // 精确回滚
}
return false;
}
capture/restore 用”操作日志”实现:每次修改 deg 和 DSU 时记录旧值,回滚时逆序恢复。这比深拷贝整个状态快得多——DSU 的 owner 和 rank 变化必须在日志里显式记录,否则回滚会把结构搞乱。
这是并查集回溯的最常见 bug 来源:路径压缩改变了树结构,回滚时必须恢复原结构。解法是:搜索中禁用路径压缩(只用按秩合并),或者用”可持久化并查集”。前者简单,代价是 find 变成 O(log N);对于 N² 量级的棋盘,这个代价完全可接受。
/** 搜索专用版本:不做路径压缩,保证可回滚 */
public int findNoCompress(int x) {
while (parent[x] != x) x = parent[x];
return x;
}
九、完整可运行项目
numberlink/
├── pom.xml
└── src/main/java/com/example/numberlink/
├── PathDSU.java # 可回滚并查集
├── Solver.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>numberlink</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) {
String[] puzzle = {
"1...2",
".....",
"..3..",
"..3..",
"2...1"
};
Board board = new Board(puzzle);
Solver solver = new Solver(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.printWithPaths(solver.paths());
if (!board.verifyPaths(solver.paths())) {
throw new IllegalStateException("invalid solution");
}
System.out.println("verified OK");
}
}
}
verifyPaths 的检查项必须包括四条:每条路径端点正确、路径连续、无交叉(每格至多一条路径经过)、所有数字对都已连通。这四条的任意一条漏掉,都会让求解器的 bug 逃过自检。
十、复杂度与实测
问题本身 NP 完全,所以只能谈实测。在一个 7×7 题面上:
| 优化阶段 | 节点数 | 耗时 |
|---|---|---|
| 纯回溯(逐格枚举 4 方向) | > 100 万 | > 30 s |
| + 并查集成环检测 | ~ 18 万 | 4.5 s |
| + 度数传播(死角链式确定) | ~ 3.1 万 | 700 ms |
| + 区域可达性剪枝 | ~ 1.2 千 | 40 ms |
| + 剪枝结果缓存 | ~ 1.2 千 | 18 ms |
区域剪枝那一行的跳跃(3.1 万 → 1.2 千)是所有优化里最大的一步。它说明一个道理:在组合搜索里,”全局不可能性”的检测远比”局部合法性”的检测值钱。前者能一次砍掉整棵子树,后者只能砍一条边。
十一、和其他路径问题的关系
| 问题 | 端点约束 | 覆盖要求 | 交叉 | 主要算法 |
|---|---|---|---|---|
| Numberlink | 固定配对 | 全填满 | 禁止 | DSU + 区域剪枝 |
| 一笔画(欧拉路径) | 自由 | 边全覆盖 | 允许 | Hierholzer |
| 骑士巡游 | 固定起点 | 全格访问 | — | Warnsdorff + 回溯 |
| TSP | 回路 | 全点访问 | — | DP / 近似 |
Numberlink 的特殊性在于”多条路径共存 + 禁止交叉”。这两点让它不能直接用单一 Hamilton 路径算法,而必须做”多路径分解 + 连通性监控”。理解这个差别,就能明白为什么它的标准解法强调”并查集 + 区域检查”,而不是”路径搜索”。
十二、小结
数字连线的算法骨架:
- 边模型:把”画线”转成”选边 + 度数约束”,让图论工具可以直接使用;
- 并查集三用:检测成环、检测数字语义冲突、维护区域划分;
- 区域可达性剪枝:全场最强的剪枝,用 BFS 判断”两端是否还能连通”,一次性砍掉整棵子树;
- 死角优先:度数降为 1 的格子必须立即确定,这既是传播也是分支排序;
- 可回滚并查集:搜索中禁用路径压缩,用操作日志精确回滚。
最后一个容易忽略但极重要的工程点:求解器必须自带独立实现的校验器。Numberlink 的解很容易”看起来像对的”——路径画出来了,但某个数字其实连到了别的数字上,或者两条路径在某处交叉。没有独立校验,这种错误会被静默放过。这是所有谜题类算法题都该有的收尾动作。