接水管(也 Pipe / Net)大概是所有益智小游戏里规则最简单、但背后算法最漂亮的一个:一块 n×n 的网格,每格放一段管子(直管、弯管、十字),玩家点击旋转管子,让水从入口一路流到出口,中途不漏一滴。
很多教程用 BFS 做”水流模拟”:从入口出发,一格一格推水,碰到没接上的接口就判定失败。这个做法直观,但有一个致命缺陷——它把”能不能流”当成了逐格局部判断,而水管的连通性本质上是全局的:每一段管子的接口要么接上、要么漏水,这是一条约束链,最后还要检查整张图是不是连成一体。
把这个问题用图论语言重写,你会发现它就是欧拉路径(一笔画)问题:
- 每个格子 = 图的一个顶点;
- 两格之间的接口互相对接 = 图的一条边;
- “水流能一笔流过所有管子且不重复” = 存在一条欧拉迹。
本文给出完整实现:判定部分用欧拉路径定理(连通性 + 奇度点个数),构造部分用Hierholzer 迭代算法(O(V+E)),朝向求解部分用约束传播 + MRV 回溯。所有数字都在 Python 里做了镜像复现验证,文中给出的路线、度数、统计量全部可复现。
一、先把游戏翻译成图
管件一共三种(再加一个扩展用的三通):
| 管件 | 接口数 | 不同朝向数 | 说明 |
|---|---|---|---|
直管 I |
2 | 2 | 竖直 / 水平 |
弯管 L |
2 | 4 | 四个拐角 |
十字 X |
4 | 1 | 四通,方向无所谓 |
三通 T(扩展) |
3 | 4 | 接水口分叉 |
每个格子的接口朝向用 4 位掩码表示,约定 d = 0上 / 1右 / 2下 / 3左,第 d 位为 1 表示这一侧有接口。
接口匹配约束:设格子 u 的朝向掩码为 m(u),其右邻格为 v,则
u 的右边有接口 ⟺ v 的左边有接口
即:(m(u) & (1<<1)) != 0 ⟺ (m(v) & (1<<3)) != 0
两个方向的约束必须同时成立:要么两个接口都接上,要么两个都没有。只接一半就是漏水。
把所有”互相对接”的邻接对收集起来,就得到一张无向图 G:
- 顶点 = 有管件的格子;
- 边 = 一对互相接上的接口;
deg(u) = u的度数 = 该格子被接上的接口数。
于是”水能不能一笔流过所有管道”就等价于:
G中是否存在一条欧拉迹(经过每条边恰好一次)?
二、核心定理:无向图的欧拉迹判定
定理(Euler):设 G 是有限无向连通图,则:
G存在欧拉回路(起终点相同) ⟺G中所有顶点的度数都是偶数;G存在欧拉路径(起终点不同) ⟺G中恰好有 2 个奇度顶点。
必要性(握手定理 + 回路计数):一条闭合回路在每个顶点处”进一次出一次”,贡献 2 条边,因此经过某个顶点的边数必为偶数 —— 所有顶点度数都是偶数。开路径只有起点和终点是”只进不出”或”只出不进”,各贡献奇数,因此恰好两个奇度点。
充分性(欧拉 1736 年的构造):把所有边按”能接上就接”的方式串成若干条迹;由于连通且奇度点至多两个,这些迹最终能拼成一条。更直观的构造就是下面的 Hierholzer 算法。
一个重要的结构性推论:在接水管游戏里,每种管件的接口数都是偶数(2 或 4),而”不漏水”要求每个接口都被接上,所以最终图里每个顶点的度数必然是偶数 —— 因此这个游戏里的合法解只能是欧拉回路,永远不会是开路径。只有引入了三通件(接口数 3,奇数)之后,才可能出现恰好两个奇度点、进而存在欧拉路径的局面。第四节给出一个 3×3 的具体例子。
为什么不能直接用 BFS 判连通:连通性只是必要条件。本节末尾有一个反例:一个 3×3 的布局,所有顶点度数都是偶数、图也连通,但它由两个互不相交的环组成——水在每个环里各转一圈,永远流不到另一个环里。所以除了”连通”和”度数”,还必须确认图是单一的回路结构,而这一点正是 Hierholzer 在构造过程中自动保证的。
三、Hierholzer 算法:栈式构造欧拉回路
Hierholzer 的思路只有一句话:从任意点出发像 DFS 一样走,走不动了就回退,把回退时经过的点倒序拼起来。
经典写法(递归版):
function hierholzer(u):
while u 还有未使用的出边:
取一条未使用的边 (u, v)
标记该边已使用
hierholzer(v)
把 u 加入答案
因为递归先输出”回退点”,最后整体反转,得到的就是欧拉回路。递归版在边数大时容易爆栈,所以实现用显式栈改写:
stack = [start] // 栈顶是当前所在点
path = [] // 回退时弹出的点
while stack 非空:
u = stack.top()
while u 还有出边 且 栈顶出边已被使用:
弹掉这条出边(跳过已用边)
if u 没有出边了:
path.push(stack.pop()) // 回退
else:
取一条未使用的出边 (u, v)
标记已使用
stack.push(v)
reverse(path)
关键细节:每条无向边只能被消耗一次。实现里给边编号(0..m-1),用 boolean[] used 标记,而不是简单地”访问顶点”——顶点可以被访问多次(度为 4 的十字接头会出现在路径里两次),边不行。
复杂度:O(V + E)。每个顶点进出栈一次,每条边被检查常数次。
一个必须讲清的坑:不要用”访问过的点”做去重
很多教材给无向图欧拉路径的写法是:
// 错误示范:用 visited[] 去重
boolean[] visited = new boolean[n];
这在有向图里成立,在无向图里会把 (u,v) 和 (v,u) 当成两条边各用一次,等于每条无向边被走两遍,得到的路径长度是 2E+1 而不是 E+1。本文的 EulerGraph 用带编号的边表 + used[] 数组,正是为了避开这个坑。
四、一个手工例子:两个三通 ⇒ 一条欧拉路径
构造一个 3×3 的棋盘(左列 T、中列 I、右列 T,上下两条弯管通道):
┌ ─ ┐
├ ─ ┤
└ ─ ┘
对应关系:(1,0) 和 (1,2) 是三通(3 个接口),其余 7 格都是直管或弯管(2 个接口)。全部接口两两对接、不漏水,构成一张无向图:
| 顶点 | 度数 |
|---|---|
(1,0) 三通 |
3(奇) |
(1,2) 三通 |
3(奇) |
| 其余 7 格 | 2(偶) |
边数 = (3+3+2×7) / 2 = 10。奇度点恰好 2 个 ⇒ 存在欧拉路径,端点就是两个三通。用与 Java 代码完全相同的算法(同样的边编号顺序、同样的指针展开)跑出来的路线是:
(1,0) → (0,0) → (0,1) → (0,2) → (1,2) → (1,1) → (1,0) → (2,0) → (2,1) → (2,2) → (1,2)
共 11 个节点、10 条边,每条边恰好走一次,水从左上三通流进、绕行整个网络、从右上三通流出。注意 (1,0) 和 (1,2) 各出现了两次——因为它们是三通,水要”分一次身”。
这个例子同时说明了前面那个反例:本文的生成器只生成偶度的欧拉子图,所以它产出的谜题永远只会得到欧拉回路;只有像本例这样引入三通件,才会出现奇度点、进而出现欧拉路径。
五、真正的难点:管件朝向怎么求
判定和构造都是线性的,但找出一组让所有接口两两对接的朝向才是这个游戏的计算核心。这是一个典型的约束满足问题(CSP):
- 变量 = 每个格子;
- 变量的取值 = 该管件类型的某个朝向(直管 2 种、弯管 4 种、十字 1 种、三通 4 种);
- 约束 = 任意两个相邻格子之间的接口匹配约束。
5.1 约束的表达
对相邻格子 u、v(v 在 u 的方向 d):
(m(u) & (1<<d)) != 0 ⟺ (m(v) & (1<<opp(d))) != 0
这个约束是双向的:不仅”我指着你,你必须指着我”,而且”我没指你,你也不能指着我”。很多实现只检查前一半,结果会算出一堆”半漏水”的假解。
5.2 约束传播
一旦某个格子 u 的朝向确定,它对四邻格的要求就完全确定了:
u指向v⇒v必须有一个朝向指向u;u没指向v⇒v必须没有朝向指向u。
于是 v 的候选朝向被过滤成一个集合:
- 集合为空 ⇒ 矛盾 ⇒ 回溯;
- 集合只剩 1 个 ⇒
v被强制确定,继续向四周传播。
这就是标准的 AC-3 式约束传播。传播过程中一旦发现某个格子的某个接口指向棋盘外或空白格,立刻判定矛盾(漏水)。
5.3 搜索顺序:MRV
传播结束后,挑候选数最少的格子来定(MRV,Minimum Remaining Values)。这是 CSP 里最有效的启发式之一:候选越少,说明它被约束得越死,越早确定越能早暴露矛盾。
5.4 终止条件:还要查连通性
所有格子都定向之后,还不能急着宣布成功:接口全对上了,但图可能是几个互不相交的环。必须再做一次连通性检查(从任意顶点 BFS),要求所有有管件的顶点都在同一个连通块里。
在本文的实现里,这个检查发生在搜索的最末端(所有变量都赋值时),不满足就回溯继续找。
5.5 实测搜索开销
用 Python 镜像实现(与 Java 同算法)在 5×5 棋盘上生成 100 个谜题并求解:
| 指标 | 数值 |
|---|---|
| 平均回溯搜索节点数 | 13.1 |
| 平均管件格子数 | 17.91 / 25 |
| 平均管道边数 | 18.80 |
| 平均度为 2 的顶点 | 17.02 |
| 平均十字接头(度 4) | 0.89 |
| 平均欧拉回路节点数 | 19.80 |
平均 13 个搜索节点就解出一盘棋,说明约束传播的力度远远大于回溯本身——绝大多数分支是在传播阶段就被掐死的。
六、随机拼装几乎必然无解:生成器的设计
既然求解器这么强,那谜题从哪来?最朴素的想法:每个格子随机放一种管件,然后让求解器去解。实测结果(Python 镜像实现,每种棋盘 300 次):
| 棋盘 | 随机拼装可解率 |
|---|---|
| 4×4 | 0 / 300 |
| 5×5 | 0 / 300 |
| 6×6 | 0 / 300 |
一件都解不出来。 原因不神秘:每个格子有 2~4 个接口,每个接口都要在 4 个方向上恰好找到一个配对的邻居——这是一个极强的约束系统,随机拼装几乎必然留下大量断口。现实中的接水管关卡都是设计出来的,这个数据正好解释了为什么。
所以生成器必须逆向构造:先造出一个保证有解的局面,再反推管件类型。
6.1 构造法:随机环叠加 → 欧拉子图
思路来自”欧拉回路 = 若干个环的并”这个事实:
- 在
n×n网格图上随机找一条简单环(DFS 找环,随机选起点、随机选扩展方向); - 把这条环的边加入子图;
- 重复若干次,要求新环必须与已有子图共享至少一个顶点(否则会得到互不相连的多个环);
- 最终子图里每个顶点的度数都是偶数(环的并的特征),而且是连通的。
因为整个子图本身就是一组合法的欧拉回路,只要把每个顶点的接口方向反推出来,就得到了一组管件类型:
- 度数 2 且两接口相对 ⇒ 直管;
- 度数 2 且两接口相邻 ⇒ 弯管;
- 度数 4 ⇒ 十字。
因为这个布局本身就是解,生成器 100% 产出可解谜题(实测 100/100)。
6.2 解的唯一性
有意思的是,有解不等于唯一解。统计每个生成谜题的合法解个数(用带传播的全量枚举,上限 50),100 个谜题的结果分布是:
| 解的个数 | 7 | 8 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 21 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 谜题数 | 1 | 2 | 5 | 7 | 13 | 17 | 11 | 11 | 13 | 9 | 5 | 4 | 2 |
解的个数落在 7 ~ 21 之间,中位数 14。相关系数检验给出两个结论:
- 棋盘越大,解越多:
corr(管件格子数, 解个数) ≈ 0.62; - 接头多少与解数无关:
corr(十字接头数, 解个数) ≈ -0.03。
也就是说,分叉接头不是”解变多”的原因,格子多才是——每个格子多出几个朝向的自由度,而且这些自由度之间还有耦合。
6.3 生成器为什么要”新环必须与已有子图共享顶点”
如果不加这个约束,多个环会各走各的,整张图不连通——水在每个环里各转一圈,永远流不到另一个环里。这正是第二节末尾说的那个陷阱,也是为什么求解器在最后必须补一次连通性检查:有解 ≠ 单一回路。
七、完整 Java 实现
工程结构(Maven 标准布局):
pipe-puzzle/
├── pom.xml
└── src/main/java/com/pipe/
├── PipeType.java // 管件类型与朝向枚举
├── PipeBoard.java // 棋盘
├── PipeSolver.java // 约束传播 + MRV 回溯求解朝向
├── EulerGraph.java // 建图 + 欧拉判定 + Hierholzer 构造
├── PuzzleGenerator.java // 随机环叠加生成谜题
└── Main.java // 演示与统计
pom.xml(Java 17):
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.hworld</groupId>
<artifactId>pipe-puzzle</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
</project>
7.1 PipeType.java —— 管件类型与朝向
package com.pipe;
/**
* 管件类型。
*
* 方向约定:0=上, 1=右, 2=下, 3=左。
* 接口用 4 位掩码表示,第 d 位为 1 表示该侧有接口。
*/
public enum PipeType {
/** 直管:竖直或水平两种朝向 */
STRAIGHT(0b0101),
/** 弯管:四个拐角 */
ELBOW(0b0011),
/** 三通:接口数为 3(奇数),是欧拉路径出现的关键 */
TEE(0b0111),
/** 十字:四通,只有一种朝向 */
CROSS(0b1111);
/** 基准朝向的接口掩码 */
private final int baseMask;
PipeType(int baseMask) {
this.baseMask = baseMask;
}
/** 该管件的接口数 */
public int degree() {
return Integer.bitCount(baseMask);
}
/**
* 返回该管件所有互不相同的朝向掩码。
* 直管 2 种、弯管 4 种、三通 4 种、十字 1 种。
*/
public int[] orientations() {
int[] tmp = new int[4];
int m = 0;
for (int k = 0; k < 4; k++) { // 依次旋转 0/90/180/270 度
int mask = 0;
for (int d = 0; d < 4; d++) {
if ((baseMask & (1 << d)) != 0) {
mask |= 1 << ((d + k) & 3);
}
}
boolean dup = false; // 去掉旋转后重复的朝向
for (int i = 0; i < m; i++) {
if (tmp[i] == mask) {
dup = true;
break;
}
}
if (!dup) {
tmp[m++] = mask;
}
}
int[] res = new int[m];
System.arraycopy(tmp, 0, res, 0, m);
return res;
}
}
7.2 PipeBoard.java —— 棋盘
package com.pipe;
import java.util.HashMap;
import java.util.Map;
/**
* n×n 的棋盘。(r, c) 位置要么是空白,要么放着一个管件。
* 格子用扁平下标 idx = r * n + c 表示,便于用数组存状态。
*/
public class PipeBoard {
/** 四个方向的行列偏移:上、右、下、左 */
public static final int[] DR = {-1, 0, 1, 0};
public static final int[] DC = {0, 1, 0, -1};
private final int n;
private final Map<Integer, PipeType> pieces = new HashMap<>();
public PipeBoard(int n) {
this.n = n;
}
public int size() {
return n;
}
public int index(int r, int c) {
return r * n + c;
}
public int row(int idx) {
return idx / n;
}
public int col(int idx) {
return idx % n;
}
public void put(int r, int c, PipeType type) {
pieces.put(index(r, c), type);
}
public PipeType pieceAt(int idx) {
return pieces.get(idx);
}
public boolean hasPiece(int idx) {
return pieces.containsKey(idx);
}
public Map<Integer, PipeType> pieces() {
return pieces;
}
/** 沿方向 d 走一格后的格子下标;越界返回 -1 */
public int neighbor(int idx, int d) {
int r = row(idx) + DR[d];
int c = col(idx) + DC[d];
if (r < 0 || r >= n || c < 0 || c >= n) {
return -1;
}
return index(r, c);
}
/** 方向 d 的反方向 */
public static int opposite(int d) {
return (d + 2) & 3;
}
}
7.3 PipeSolver.java —— 约束传播 + MRV 回溯
package com.pipe;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Deque;
import java.util.List;
/**
* 朝向求解器:约束传播(AC-3 风格)+ MRV 回溯。
*
* 变量:每个有管件的格子;
* 取值:该管件的某个朝向掩码;
* 约束:相邻两格之间"接口要么都接上、要么都不接"。
*/
public class PipeSolver {
private final PipeBoard board;
private final int n;
/** 每个格子的朝向掩码,-1 表示尚未确定 */
private final int[] mask;
/** 每个格子是否已经确定朝向 */
private final boolean[] decided;
/** 搜索节点计数,用于观察回溯规模 */
private int nodes = 0;
public PipeSolver(PipeBoard board) {
this.board = board;
this.n = board.size();
this.mask = new int[n * n];
this.decided = new boolean[n * n];
Arrays.fill(mask, -1);
}
public int nodes() {
return nodes;
}
/**
* 求解朝向。
*
* @return 每个格子的朝向掩码数组(空白格为 -1);无解返回 null
*/
public int[] solve() {
return dfs();
}
/** 该朝向的每个接口都要能对接:邻格存在,且邻格类型存在朝回指的姿态 */
private boolean matchable(int idx, int m) {
for (int d = 0; d < 4; d++) {
if ((m & (1 << d)) == 0) {
continue;
}
int nb = board.neighbor(idx, d);
if (nb < 0) {
return false; // 接口朝棋盘外 => 漏水
}
PipeType nt = board.pieceAt(nb);
if (nt == null) {
return false; // 接口朝空白格 => 漏水
}
boolean canPointBack = false;
for (int om : nt.orientations()) {
if ((om & (1 << PipeBoard.opposite(d))) != 0) {
canPointBack = true;
break;
}
}
if (!canPointBack) {
return false; // 邻格根本没法朝回指
}
}
return true;
}
/** 某个格子当前可取的朝向 */
private List<Integer> candidates(int idx) {
List<Integer> res = new ArrayList<>();
PipeType t = board.pieceAt(idx);
if (t == null) {
return res;
}
for (int m : t.orientations()) {
if (matchable(idx, m)) {
res.add(m);
}
}
return res;
}
/**
* 从 startCell 出发做约束传播。
* 一个格子定向后,它对四邻格的要求被完全确定:
* 我指着你 ⇒ 你必须指回来;我没指你 ⇒ 你不能指过来。
*/
private boolean propagate(int startCell) {
Deque<Integer> stack = new ArrayDeque<>();
stack.push(startCell);
while (!stack.isEmpty()) {
int cell = stack.pop();
if (!decided[cell]) {
continue;
}
int m = mask[cell];
for (int d = 0; d < 4; d++) {
int nb = board.neighbor(cell, d);
if (nb < 0 || !board.hasPiece(nb)) {
continue;
}
boolean need = (m & (1 << d)) != 0; // 我是否指向对方
int opp = PipeBoard.opposite(d);
if (decided[nb]) {
boolean theirs = (mask[nb] & (1 << opp)) != 0;
if (theirs != need) {
return false; // 矛盾
}
} else {
int chosen = -1;
int cnt = 0;
PipeType nt = board.pieceAt(nb);
for (int om : nt.orientations()) {
boolean pointsBack = (om & (1 << opp)) != 0;
if (pointsBack == need) {
cnt++;
chosen = om;
}
}
if (cnt == 0) {
return false; // 邻格无解
}
if (cnt == 1) {
mask[nb] = chosen; // 被强制确定
decided[nb] = true;
if (!matchable(nb, chosen)) {
return false;
}
stack.push(nb);
}
}
}
}
return true;
}
private int[] dfs() {
nodes++;
// MRV:挑候选最少的格子
int best = -1;
List<Integer> bestCands = null;
for (int idx = 0; idx < n * n; idx++) {
if (!board.hasPiece(idx) || decided[idx]) {
continue;
}
List<Integer> cs = candidates(idx);
if (cs.isEmpty()) {
return null; // 该格子怎么摆都不行
}
if (bestCands == null || cs.size() < bestCands.size()) {
best = idx;
bestCands = cs;
if (cs.size() == 1) {
break;
}
}
}
if (best == -1) {
// 全部定向:接口都对了,还要确认整张图连成一体
EulerGraph g = new EulerGraph(board, mask);
return g.isConnected() ? mask.clone() : null;
}
for (int m : bestCands) {
mask[best] = m;
decided[best] = true;
int[] snapMask = mask.clone();
boolean[] snapDecided = decided.clone();
if (propagate(best)) {
int[] r = dfs();
if (r != null) {
return r;
}
}
System.arraycopy(snapMask, 0, mask, 0, mask.length);
System.arraycopy(snapDecided, 0, decided, 0, decided.length);
}
return null;
}
}
7.4 EulerGraph.java —— 建图 + 欧拉判定 + Hierholzer
package com.pipe;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Deque;
import java.util.List;
/**
* 由「棋盘 + 朝向」构造出的无向图,并提供欧拉路径的判定与构造。
*
* 边用编号管理(0..m-1),配 boolean[] used,
* 保证每条无向边只被消耗一次——这是无向图欧拉路径最容易踩的坑。
*/
public class EulerGraph {
private final PipeBoard board;
private final int n;
/** 邻接表:每个顶点存 {对端顶点, 边编号} */
private final List<List<int[]>> adj = new ArrayList<>();
/** 边表:边编号 -> {u, v} */
private final List<int[]> edgeEnds = new ArrayList<>();
/** 每个顶点的度数 */
private final int[] degree;
/** 接口是否两两互惠(没有单边接口) */
private boolean consistent = true;
/** 漏水位置说明 */
private final List<String> leaks = new ArrayList<>();
public EulerGraph(PipeBoard board, int[] mask) {
this.board = board;
this.n = board.size();
this.degree = new int[n * n];
for (int i = 0; i < n * n; i++) {
adj.add(new ArrayList<>());
}
// 1) 检查每个接口是否落在棋盘内的管件上(否则漏水)
for (int idx = 0; idx < n * n; idx++) {
if (!board.hasPiece(idx)) {
continue;
}
int m = mask[idx];
for (int d = 0; d < 4; d++) {
if ((m & (1 << d)) == 0) {
continue;
}
int nb = board.neighbor(idx, d);
if (nb < 0 || !board.hasPiece(nb)) {
consistent = false;
leaks.add("(" + board.row(idx) + "," + board.col(idx) + ") 的接口朝 " + dirName(d) + " 泄漏");
}
}
}
// 2) 建边:只看「右」「下」两个方向,每条边只建一次
for (int idx = 0; idx < n * n; idx++) {
if (!board.hasPiece(idx)) {
continue;
}
for (int d : new int[]{1, 2}) {
int nb = board.neighbor(idx, d);
if (nb < 0 || !board.hasPiece(nb)) {
continue;
}
boolean a = (mask[idx] & (1 << d)) != 0;
boolean b = (mask[nb] & (1 << PipeBoard.opposite(d))) != 0;
if (a && b) {
int id = edgeEnds.size();
edgeEnds.add(new int[]{idx, nb});
adj.get(idx).add(new int[]{nb, id});
adj.get(nb).add(new int[]{idx, id});
degree[idx]++;
degree[nb]++;
} else if (a != b) {
consistent = false; // 一头接一头,接口对不上
}
}
}
}
private static String dirName(int d) {
return switch (d) {
case 0 -> "上";
case 1 -> "右";
case 2 -> "下";
default -> "左";
};
}
/** 接口是否两两互惠 */
public boolean isConsistent() {
return consistent;
}
public List<String> leaks() {
return leaks;
}
/** 所有有管件的顶点是否连成一体 */
public boolean isConnected() {
int start = -1;
int pieces = 0;
for (int i = 0; i < n * n; i++) {
if (!board.hasPiece(i)) {
continue;
}
pieces++;
if (degree[i] > 0 && start < 0) {
start = i;
}
}
if (start < 0) {
return pieces == 0;
}
boolean[] seen = new boolean[n * n];
Deque<Integer> stack = new ArrayDeque<>();
stack.push(start);
seen[start] = true;
while (!stack.isEmpty()) {
int u = stack.pop();
for (int[] e : adj.get(u)) {
if (!seen[e[0]]) {
seen[e[0]] = true;
stack.push(e[0]);
}
}
}
for (int i = 0; i < n * n; i++) {
if (board.hasPiece(i) && !seen[i]) {
return false; // 有管件却连不上
}
}
return true;
}
/** 奇度顶点个数 */
public int oddDegreeCount() {
int c = 0;
for (int i = 0; i < n * n; i++) {
if (board.hasPiece(i) && (degree[i] & 1) == 1) {
c++;
}
}
return c;
}
/** 是否存在欧拉回路(连通 + 全偶度) */
public boolean hasEulerCircuit() {
return consistent && isConnected() && oddDegreeCount() == 0;
}
/** 是否存在欧拉路径(连通 + 恰好 2 个奇度点) */
public boolean hasEulerPath() {
return consistent && isConnected() && oddDegreeCount() == 2;
}
public int edgeCount() {
return edgeEnds.size();
}
public int degreeOf(int idx) {
return degree[idx];
}
/**
* Hierholzer 迭代版:用显式栈构造欧拉迹。
*
* @param start 起点(存在欧拉路径时应是一个奇度点)
* @return 顶点序列,相邻两项之间恰好是一条被使用的边
*/
public List<Integer> eulerTrail(int start) {
int[] ptr = new int[n * n];
boolean[] used = new boolean[edgeEnds.size()];
Deque<Integer> stack = new ArrayDeque<>();
List<Integer> path = new ArrayList<>();
stack.push(start);
while (!stack.isEmpty()) {
int u = stack.peek();
// 跳过已经用过的边
while (ptr[u] < adj.get(u).size() && used[adj.get(u).get(ptr[u])[1]]) {
ptr[u]++;
}
if (ptr[u] == adj.get(u).size()) {
path.add(stack.pop()); // 无路可走,回退并记录
} else {
int[] e = adj.get(u).get(ptr[u]++);
used[e[1]] = true; // 这条边用掉了
stack.push(e[0]);
}
}
Collections.reverse(path);
return path;
}
}
7.5 PuzzleGenerator.java —— 随机环叠加生成谜题
package com.pipe;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Random;
import java.util.Set;
/**
* 谜题生成器:随机环叠加 → 连通欧拉子图 → 反推管件类型。
*
* 关键:欧拉子图本身就是一组合法解,所以生成出来的谜题 100% 可解。
*/
public class PuzzleGenerator {
private final Random rnd;
// DFS 找环用的临时状态
private List<List<Integer>> tmpAdj;
private Set<Long> tmpUsed;
private List<Integer> tmpPath;
private Set<Integer> tmpOnPath;
private int tmpStart;
public PuzzleGenerator(long seed) {
this.rnd = new Random(seed);
}
/** 无向边编码:小的放高位,保证 (u,v) 与 (v,u) 是同一个键 */
private static long edgeKey(int u, int v) {
int a = Math.min(u, v);
int b = Math.max(u, v);
return ((long) a << 32) | (b & 0xffffffffL);
}
/** 生成一个 n×n 的可解谜题 */
public PipeBoard generate(int n) {
// 1) 网格图的邻接表
List<List<Integer>> adj = new ArrayList<>();
for (int i = 0; i < n * n; i++) {
adj.add(new ArrayList<>());
}
for (int r = 0; r < n; r++) {
for (int c = 0; c < n; c++) {
int u = r * n + c;
if (c + 1 < n) {
int v = u + 1;
adj.get(u).add(v);
adj.get(v).add(u);
}
if (r + 1 < n) {
int v = u + n;
adj.get(u).add(v);
adj.get(v).add(u);
}
}
}
// 2) 随机环叠加
Set<Long> edges = new HashSet<>();
int[] deg = new int[n * n];
int target = Math.max(4, (int) (n * n * 0.6));
int used = 0;
for (int guard = 0; guard < 300 && used < target; guard++) {
List<Integer> cyc = randomCycle(adj, rnd.nextInt(n * n));
if (cyc == null || cyc.size() < 5) {
continue; // 环至少要 4 个格子
}
// 新环的边不能与已有边重复
List<long[]> fresh = new ArrayList<>();
boolean dup = false;
for (int i = 0; i + 1 < cyc.size(); i++) {
int u = cyc.get(i);
int v = cyc.get(i + 1);
long key = edgeKey(u, v);
if (edges.contains(key)) {
dup = true;
break;
}
fresh.add(new long[]{key, u, v});
}
if (dup) {
continue;
}
// 度数不能超过 4(网格顶点最大度就是 4)
int[] d2 = deg.clone();
boolean over = false;
for (long[] e : fresh) {
d2[(int) e[1]]++;
d2[(int) e[2]]++;
if (d2[(int) e[1]] > 4 || d2[(int) e[2]] > 4) {
over = true;
break;
}
}
if (over) {
continue;
}
// 连通性:新环必须与已有子图共享至少一个顶点
boolean touch = false;
for (long[] e : fresh) {
if (deg[(int) e[1]] > 0 || deg[(int) e[2]] > 0) {
touch = true;
break;
}
}
if (used > 0 && !touch) {
continue; // 否则会出现互不相连的多个环
}
for (long[] e : fresh) {
edges.add(e[0]);
}
deg = d2;
used = 0;
for (int d : deg) {
if (d > 0) {
used++;
}
}
}
// 3) 由欧拉子图反推管件类型
PipeBoard board = new PipeBoard(n);
for (int idx = 0; idx < n * n; idx++) {
if (deg[idx] == 0) {
continue; // 该格是空白
}
int m = 0;
for (int d = 0; d < 4; d++) {
int nb = neighborOf(idx, d, n);
if (nb >= 0 && edges.contains(edgeKey(idx, nb))) {
m |= 1 << d;
}
}
board.put(idx / n, idx % n, typeOf(m));
}
return board;
}
private static int neighborOf(int idx, int d, int n) {
int r = idx / n + PipeBoard.DR[d];
int c = idx % n + PipeBoard.DC[d];
if (r < 0 || r >= n || c < 0 || c >= n) {
return -1;
}
return r * n + c;
}
/** 由接口掩码反推管件类型 */
private static PipeType typeOf(int mask) {
int cnt = Integer.bitCount(mask);
if (cnt == 4) {
return PipeType.CROSS;
}
if (cnt == 3) {
return PipeType.TEE;
}
// cnt == 2:相对的是直管,相邻的是弯管
for (int d = 0; d < 4; d++) {
if ((mask & (1 << d)) != 0 && (mask & (1 << ((d + 2) & 3))) != 0) {
return PipeType.STRAIGHT;
}
}
return PipeType.ELBOW;
}
/** 在无向图上随机找一条简单环(DFS) */
private List<Integer> randomCycle(List<List<Integer>> adj, int start) {
tmpAdj = adj;
tmpUsed = new HashSet<>();
tmpPath = new ArrayList<>();
tmpOnPath = new HashSet<>();
tmpStart = start;
tmpPath.add(start);
tmpOnPath.add(start);
return dfsCycle(start) ? tmpPath : null;
}
private boolean dfsCycle(int v) {
List<Integer> nbs = new ArrayList<>(tmpAdj.get(v));
Collections.shuffle(nbs, rnd);
for (int w : nbs) {
long key = edgeKey(v, w);
if (tmpUsed.contains(key)) {
continue;
}
tmpUsed.add(key);
if (w == tmpStart) {
if (tmpPath.size() >= 4) {
tmpPath.add(tmpStart); // 闭合成环
return true;
}
tmpUsed.remove(key);
continue;
}
if (tmpOnPath.contains(w)) {
tmpUsed.remove(key);
continue;
}
tmpOnPath.add(w);
tmpPath.add(w);
if (dfsCycle(w)) {
return true;
}
tmpPath.remove(tmpPath.size() - 1);
tmpOnPath.remove(w);
tmpUsed.remove(key);
}
return false;
}
}
7.6 Main.java —— 演示与统计
package com.pipe;
import java.util.ArrayList;
import java.util.List;
/**
* 演示:
* 1) 生成一个 5×5 谜题并求解,输出欧拉回路;
* 2) 手工的 3×3 例子:两个三通 ⇒ 一条欧拉路径;
* 3) 随机拼装谜题 → 无解;
* 4) 批量统计。
*/
public class Main {
public static void main(String[] args) {
demoGenerated();
demoTwoTees();
demoRandomIsUnsolvable();
statistics(100);
}
/** 1) 生成 + 求解 + 欧拉回路 */
private static void demoGenerated() {
System.out.println("========== 1. 生成 5x5 谜题并求解 ==========");
PuzzleGenerator gen = new PuzzleGenerator(20261026L);
PipeBoard board = gen.generate(5);
printBoard(board);
PipeSolver solver = new PipeSolver(board);
int[] mask = solver.solve();
if (mask == null) {
System.out.println("无解(生成器保证有解,这里不应该出现)");
return;
}
System.out.println("搜索节点数 = " + solver.nodes());
EulerGraph g = new EulerGraph(board, mask);
System.out.println("接口互惠 = " + g.isConsistent()
+ ", 连通 = " + g.isConnected()
+ ", 奇度点 = " + g.oddDegreeCount()
+ ", 边数 = " + g.edgeCount());
System.out.println("存在欧拉回路 = " + g.hasEulerCircuit());
int start = -1;
for (int i = 0; i < mask.length; i++) {
if (board.hasPiece(i)) {
start = i;
break;
}
}
List<Integer> trail = g.eulerTrail(start);
System.out.println("水路(欧拉回路,共 " + g.edgeCount() + " 条边):");
StringBuilder sb = new StringBuilder();
for (int i = 0; i < trail.size(); i++) {
int v = trail.get(i);
if (i > 0) {
sb.append(" -> ");
}
sb.append("(").append(board.row(v)).append(",").append(board.col(v)).append(")");
}
System.out.println(sb);
System.out.println();
}
/** 2) 3x3 手工例子:两个三通 ⇒ 欧拉路径 */
private static void demoTwoTees() {
System.out.println("========== 2. 3x3:两个三通 => 欧拉路径 ==========");
PipeBoard board = new PipeBoard(3);
board.put(0, 0, PipeType.ELBOW);
board.put(0, 1, PipeType.STRAIGHT);
board.put(0, 2, PipeType.ELBOW);
board.put(1, 0, PipeType.TEE);
board.put(1, 1, PipeType.STRAIGHT);
board.put(1, 2, PipeType.TEE);
board.put(2, 0, PipeType.ELBOW);
board.put(2, 1, PipeType.STRAIGHT);
board.put(2, 2, PipeType.ELBOW);
// 手工指定朝向:bit0=上 bit1=右 bit2=下 bit3=左
int[] mask = new int[9];
mask[0] = 0b0110; // (0,0) 右+下
mask[1] = 0b1010; // (0,1) 左+右
mask[2] = 0b1100; // (0,2) 下+左
mask[3] = 0b0111; // (1,0) 上+右+下(三通)
mask[4] = 0b1010; // (1,1) 左+右
mask[5] = 0b1101; // (1,2) 上+下+左(三通)
mask[6] = 0b0011; // (2,0) 上+右
mask[7] = 0b1010; // (2,1) 左+右
mask[8] = 0b1001; // (2,2) 上+左
EulerGraph g = new EulerGraph(board, mask);
System.out.println("边数 = " + g.edgeCount() + ", 奇度点 = " + g.oddDegreeCount());
System.out.println("存在欧拉路径 = " + g.hasEulerPath());
List<Integer> trail = g.eulerTrail(3); // 从三通 (1,0) 出发
StringBuilder sb = new StringBuilder();
for (int i = 0; i < trail.size(); i++) {
int v = trail.get(i);
if (i > 0) {
sb.append(" -> ");
}
sb.append("(").append(board.row(v)).append(",").append(board.col(v)).append(")");
}
System.out.println("水路:" + sb);
System.out.println();
}
/** 3) 随机拼装几乎必然无解 */
private static void demoRandomIsUnsolvable() {
System.out.println("========== 3. 随机拼装 5x5 ==========");
java.util.Random rnd = new java.util.Random(1);
PipeType[] kinds = {PipeType.STRAIGHT, PipeType.ELBOW, PipeType.CROSS};
int solvable = 0;
for (int t = 0; t < 50; t++) {
PipeBoard board = new PipeBoard(5);
for (int r = 0; r < 5; r++) {
for (int c = 0; c < 5; c++) {
if (rnd.nextDouble() < 0.08) {
continue;
}
board.put(r, c, kinds[rnd.nextInt(kinds.length)]);
}
}
if (new PipeSolver(board).solve() != null) {
solvable++;
}
}
System.out.println("随机拼装 50 局,可解 " + solvable + " 局");
System.out.println();
}
/** 4) 批量统计 */
private static void statistics(int rounds) {
System.out.println("========== 4. 批量统计 ==========");
PuzzleGenerator gen = new PuzzleGenerator(20261026L);
long cells = 0;
long edges = 0;
long nodes = 0;
long trails = 0;
int solved = 0;
for (int i = 0; i < rounds; i++) {
PipeBoard board = gen.generate(5);
PipeSolver solver = new PipeSolver(board);
int[] mask = solver.solve();
if (mask == null) {
continue;
}
solved++;
cells += board.pieces().size();
nodes += solver.nodes();
EulerGraph g = new EulerGraph(board, mask);
edges += g.edgeCount();
int start = -1; // 起点必须是有管件的格子
for (int k = 0; k < mask.length; k++) {
if (board.hasPiece(k)) {
start = k;
break;
}
}
trails += g.eulerTrail(start).size();
}
System.out.printf("样本 %d 局,可解 %d 局%n", rounds, solved);
System.out.printf("平均管件格子 = %.2f%n", (double) cells / solved);
System.out.printf("平均管道边数 = %.2f%n", (double) edges / solved);
System.out.printf("平均搜索节点 = %.1f%n", (double) nodes / solved);
System.out.printf("平均欧拉回路节点数 = %.2f%n", (double) trails / solved);
}
/** 打印棋盘(按管件类型显示) */
private static void printBoard(PipeBoard board) {
for (int r = 0; r < board.size(); r++) {
StringBuilder sb = new StringBuilder();
for (int c = 0; c < board.size(); c++) {
PipeType t = board.pieceAt(board.index(r, c));
sb.append(t == null ? " . " : " " + symbol(t) + " ");
}
System.out.println(sb);
}
}
private static String symbol(PipeType t) {
return switch (t) {
case STRAIGHT -> "I";
case ELBOW -> "L";
case TEE -> "T";
default -> "X";
};
}
}
八、复杂度分析
| 环节 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| 建图(棋盘 → 无向图) | O(V) |
O(V+E) |
每格最多 4 个接口 |
| 连通性判定 | O(V+E) |
O(V) |
BFS |
| 奇度点统计 | O(V) |
O(1) |
一次扫描 |
| Hierholzer 构造 | O(V+E) |
O(V) |
每条边进出栈各一次 |
| 朝向求解(最坏) | O(4^V · V) |
O(V) |
CSP 理论上仍是指数的,但传播极强 |
| 朝向求解(实测) | 平均 13 个搜索节点 | — | 5×5 棋盘,100 个谜题 |
| 谜题生成 | O(V+E) 量级 |
O(V+E) |
找环是回溯,但很快命中 |
需要强调:Hierholzer 是线性的,但”找朝向”不是。一个 5×5 棋盘只有 21 个变量,传播几乎把所有分支都掐死了;棋盘放大到 8×8 以后,MRV + 传播会明显吃力,那时就需要更强的传播策略(比如把”连通性”也作为约束提前剪枝)。
九、几个值得记住的结论
- “水流模拟”是错的视角。 逐格 BFS 推水只能验证一个已经确定的布局,答不了”怎么转”这个问题。真正的结构是:接口匹配是局部二元约束,整体形态由全局图论性质(连通性 + 奇度点)决定。
- 判定用定理,构造用算法。 欧拉定理负责”有没有”(
O(V+E)),Hierholzer 负责”长什么样”(O(V+E))。两者组合起来才是完整答案。 - 无向图欧拉路径必须给边编号。 用
visited[顶点]去重会让每条边被走两遍,路径长度从E+1变成2E+1。这是实现里最常见的错误。 - 有解不等于唯一解。 实测 5×5 谜题平均有约 14 个合法解,格子越多解越多(
r ≈ 0.62),而分叉接头数量与解数几乎无关(r ≈ -0.03)。 - 随机拼装必是无解。 4×4 / 5×5 / 6×6 各 300 次随机实验,可解率全部为 0。生成器必须”先有解、再反推管件”,而”环的并”正好给出了这种构造——欧拉子图本身就是一组合法解。
下一步可以往两个方向继续玩:一是把棋盘放大到 8×8 并引入更强的剪枝(例如在搜索过程中就维护”已定向部分不能产生孤立子图”的不变量);二是把 CSP 换成整数规划或SAT,看看同一批谜题在不同求解器下的表现差异——这类”游戏即约束系统”的对照实验,往往比算法本身更有意思。