引言
三壶谜题(Water Jug Problem)是数学史上著名的智力题:有三个容量分别为 $a$、$b$、$c$ 升的水壶,其中 $c$ 壶 initially装满水,$a$ 和 $b$ 壶为空。问如何通过倒水的操作,使某个壶中恰好获得指定容量的水?本文将用Java实现三壶谜题的通用求解器,讲解BFS状态空间搜索的核心思想,并深入分析基于最大公约数(GCD)的可解性判定定理。
一、问题定义与数学建模
1.1 操作规则
给定三个水壶,容量分别为 $(A, B, C)$。允许的操作:
- 倒满:将任意水壶倒满水(从无限水源)
- 倒空:将任意水壶的水全部倒掉
- 互倒:将一个壶的水倒入另一个壶,直到倒空或倒满
1.2 状态表示
用三元组 $(x, y, z)$ 表示三个壶中的当前水量,其中:
- $0 \leq x \leq A$
- $0 \leq y \leq B$
- $0 \leq z \leq C$
- $x + y + z = C$(总水量守恒)
由于总水量守恒,实际上只需要记录 $(x, y)$ 两个维度,$z = C – x – y$。
1.3 GCD可解性定理
定理:三壶谜题中,通过倒水操作能够得到的所有水量都是 $\gcd(A, B, C)$ 的倍数。
证明概要:每次倒水操作只涉及水量的加减或容器容量的加减。设 $d = \gcd(A, B, C)$,则初始状态 $(0, 0, C)$ 中每个分量都是 $d$ 的倍数。任何允许的操作(加减 $A$、$B$、$C$ 或其子集)都不会改变”每个分量是 $d$ 的倍数”这一性质。因此,任何可达状态中每个壶的水量都是 $d$ 的倍数。
推论:若目标水量 $target$ 不是 $d$ 的倍数,则问题无解。
二、核心算法设计
2.1 BFS状态空间搜索
BFS(广度优先搜索)是求解最短操作序列的最优算法。由于每次操作代价相同,BFS保证找到的操作步数最少。
2.2 状态转移
从状态 $(x, y, z)$ 出发,可能的后续状态包括:
- 将壶1倒满:$(A, y, z – (A – x))$(若 $z \geq A – x$)
- 将壶1倒空:$(0, y, z + x)$
- 将壶1倒入壶2:$(x – \min(x, B – y), y + \min(x, B – y), z)$
- … 共 $6 \times 2 = 12$ 种基本操作
2.3 可解性预判定
在启动BFS之前,先用GCD判定目标是否可达,避免无效搜索。
三、Java完整实现
import java.util.*;
/**
* 三壶谜题 — BFS状态空间搜索与GCD可解性判定
*
* 核心设计:
* 1. 状态压缩:用两个int表示(x, y),z由守恒推导
* 2. BFS最短路径搜索
* 3. GCD预判定避免无效搜索
* 4. 路径回溯输出操作序列
*/
public class ThreeJugsPuzzle {
private final int A, B, C; // 三个壶的容量
private final int target; // 目标水量
// 操作类型枚举
enum Operation {
FILL_1("将壶1倒满"), FILL_2("将壶2倒满"), FILL_3("将壶3倒满"),
EMPTY_1("将壶1倒空"), EMPTY_2("将壶2倒空"), EMPTY_3("将壶3倒空"),
POUR_1_TO_2("将壶1倒入壶2"), POUR_1_TO_3("将壶1倒入壶3"),
POUR_2_TO_1("将壶2倒入壶1"), POUR_2_TO_3("将壶2倒入壶3"),
POUR_3_TO_1("将壶3倒入壶1"), POUR_3_TO_2("将壶3倒入壶2");
final String desc;
Operation(String desc) { this.desc = desc; }
}
// 状态节点,用于BFS和路径回溯
static class State {
int x, y, z; // 三个壶的当前水量
State parent; // 父状态(用于回溯路径)
Operation op; // 从父状态到当前状态的操作
int step; // 到达当前状态的步数
State(int x, int y, int z, State parent, Operation op, int step) {
this.x = x; this.y = y; this.z = z;
this.parent = parent;
this.op = op;
this.step = step;
}
// 状态唯一标识(用于哈希表去重)
String key() {
return x + "," + y;
}
boolean hasTarget(int target) {
return x == target || y == target || z == target;
}
}
public ThreeJugsPuzzle(int a, int b, int c, int target) {
this.A = a;
this.B = b;
this.C = c;
this.target = target;
}
/**
* 欧几里得算法求GCD
*/
static int gcd(int a, int b) {
while (b != 0) {
int tmp = a % b;
a = b;
b = tmp;
}
return a;
}
static int gcd(int a, int b, int c) {
return gcd(gcd(a, b), c);
}
/**
* 可解性判定
*
* 条件:
* 1. 目标水量必须是三个壶容量GCD的倍数
* 2. 目标水量不能超过最大壶的容量
*/
public boolean isSolvable() {
if (target < 0 || target > Math.max(A, Math.max(B, C))) {
return false;
}
int d = gcd(A, B, C);
return target % d == 0;
}
/**
* BFS求解最短操作序列
*
* 算法流程:
* 1. 初始状态入队
* 2. 循环取出队首状态
* 3. 检查是否达到目标
* 4. 生成所有合法后续状态
* 5. 未访问过的状态入队
* 6. 队列为空仍未找到则返回无解
*/
public List<State> solve() {
if (!isSolvable()) {
System.out.println("预判定:目标 " + target + " 不可达(GCD判定)");
return null;
}
Queue<State> queue = new LinkedList<>();
Set<String> visited = new HashSet<>();
// 初始状态:壶1和壶2为空,壶3装满
State start = new State(0, 0, C, null, null, 0);
queue.offer(start);
visited.add(start.key());
while (!queue.isEmpty()) {
State cur = queue.poll();
// 检查是否达成目标
if (cur.hasTarget(target)) {
return reconstructPath(cur);
}
// 生成所有可能的后续状态
List<State> nextStates = generateNextStates(cur);
for (State next : nextStates) {
if (!visited.contains(next.key())) {
visited.add(next.key());
queue.offer(next);
}
}
}
System.out.println("BFS搜索完毕,未找到解。");
return null;
}
/**
* 从当前状态生成所有可能的后续状态
*/
List<State> generateNextStates(State cur) {
List<State> results = new ArrayList<>();
int x = cur.x, y = cur.y, z = cur.z;
int step = cur.step + 1;
// 1. 倒满操作
if (x < A) results.add(new State(A, y, C - A - y, cur, Operation.FILL_1, step));
if (y < B) results.add(new State(x, B, C - x - B, cur, Operation.FILL_2, step));
if (z < C) results.add(new State(x, y, C, cur, Operation.FILL_3, step));
// 2. 倒空操作
if (x > 0) results.add(new State(0, y, C - y, cur, Operation.EMPTY_1, step));
if (y > 0) results.add(new State(x, 0, C - x, cur, Operation.EMPTY_2, step));
if (z > 0) results.add(new State(x, y, 0, cur, Operation.EMPTY_3, step));
// 3. 互倒操作:壶1 -> 壶2
if (x > 0 && y < B) {
int pour = Math.min(x, B - y);
results.add(new State(x - pour, y + pour, z, cur, Operation.POUR_1_TO_2, step));
}
// 壶1 -> 壶3
if (x > 0 && z < C) {
int pour = Math.min(x, C - z);
results.add(new State(x - pour, y, z + pour, cur, Operation.POUR_1_TO_3, step));
}
// 壶2 -> 壶1
if (y > 0 && x < A) {
int pour = Math.min(y, A - x);
results.add(new State(x + pour, y - pour, z, cur, Operation.POUR_2_TO_1, step));
}
// 壶2 -> 壶3
if (y > 0 && z < C) {
int pour = Math.min(y, C - z);
results.add(new State(x, y - pour, z + pour, cur, Operation.POUR_2_TO_3, step));
}
// 壶3 -> 壶1
if (z > 0 && x < A) {
int pour = Math.min(z, A - x);
results.add(new State(x + pour, y, z - pour, cur, Operation.POUR_3_TO_1, step));
}
// 壶3 -> 壶2
if (z > 0 && y < B) {
int pour = Math.min(z, B - y);
results.add(new State(x, y + pour, z - pour, cur, Operation.POUR_3_TO_2, step));
}
// 过滤无效状态(水量不守恒的)
results.removeIf(s -> s.x + s.y + s.z != C || s.x < 0 || s.y < 0 || s.z < 0);
return results;
}
/**
* 回溯重构操作路径
*/
List<State> reconstructPath(State end) {
LinkedList<State> path = new LinkedList<>();
State cur = end;
while (cur != null) {
path.addFirst(cur);
cur = cur.parent;
}
return path;
}
/**
* 打印求解结果
*/
public void printSolution(List<State> path) {
if (path == null || path.isEmpty()) {
System.out.println("无解。");
return;
}
System.out.println("\n=== 求解成功 ===");
System.out.println("最少操作步数: " + (path.size() - 1));
System.out.println("\n操作序列:");
System.out.printf("%-4s %-15s %-10s %-10s %-10s%n", "步数", "操作", "壶1", "壶2", "壶3");
System.out.println("-".repeat(55));
for (int i = 0; i < path.size(); i++) {
State s = path.get(i);
String opStr = (s.op == null) ? "初始状态" : s.op.desc;
System.out.printf("%-4d %-15s %-10d %-10d %-10d%n", i, opStr, s.x, s.y, s.z);
}
// 指出哪个壶达到了目标
State end = path.get(path.size() - 1);
if (end.x == target) System.out.println("\n最终:壶1中恰好有 " + target + " 升水。");
else if (end.y == target) System.out.println("\n最终:壶2中恰好有 " + target + " 升水。");
else System.out.println("\n最终:壶3中恰好有 " + target + " 升水。");
}
/**
* 求解多个目标,返回每个目标的最短步数统计
*/
public void analyzeAllTargets() {
System.out.println("=== 所有可达水量分析 ===");
System.out.printf("壶容量: A=%d, B=%d, C=%d%n", A, B, C);
System.out.println("GCD=" + gcd(A, B, C));
for (int t = 0; t <= Math.max(A, Math.max(B, C)); t++) {
ThreeJugsPuzzle solver = new ThreeJugsPuzzle(A, B, C, t);
List<State> path = solver.solve();
if (path != null) {
System.out.printf("目标 %2d: 可达,最少 %d 步%n", t, path.size() - 1);
} else {
System.out.printf("目标 %2d: 不可达%n", t);
}
}
}
public static void main(String[] args) {
// 经典问题:3升、5升、8升壶,目标是得到4升水
System.out.println("===== 经典三壶谜题 =====");
System.out.println("壶容量: 3升、5升、8升,初始8升壶满,目标得到4升水\n");
ThreeJugsPuzzle puzzle = new ThreeJugsPuzzle(3, 5, 8, 4);
List<State> path = puzzle.solve();
puzzle.printSolution(path);
// 再测试一个例子
System.out.println("\n===== 扩展问题 =====");
System.out.println("壶容量: 4升、7升、10升,目标得到6升水\n");
ThreeJugsPuzzle puzzle2 = new ThreeJugsPuzzle(4, 7, 10, 6);
List<State> path2 = puzzle2.solve();
puzzle2.printSolution(path2);
// 分析所有可达目标
System.out.println("\n===== 可达性分析 =====");
ThreeJugsPuzzle puzzle3 = new ThreeJugsPuzzle(3, 5, 8, 0);
puzzle3.analyzeAllTargets();
}
}
四、算法复杂度分析
| 操作 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| GCD可解性判定 | $O(\log(\min(A, B, C)))$ | $O(1)$ | 欧几里得算法 |
| BFS搜索 | $O(A \times B)$ | $O(A \times B)$ | 状态空间大小 |
| 单状态扩展 | $O(1)$ | $O(1)$ | 固定12种操作 |
| 路径回溯 | $O(S)$ | $O(S)$ | $S$为最短路径长度 |
对于经典问题 $(3, 5, 8)$,状态空间仅有 $4 \times 6 = 24$ 个状态,BFS瞬间完成。
五、算法扩展与变体
5.1 $N$ 壶推广
将状态从二维扩展到 $N-1$ 维,BFS框架保持不变。状态空间大小变为 $\prod_{i=1}^{N-1} (A_i + 1)$。
5.2 带权操作
若不同操作有不同代价(如倒水比倒空更费力),可使用Dijkstra算法替代BFS求解最小代价路径。
5.3 双向BFS
对于大容量壶(如 $A=100, B=200$),状态空间较大时可使用双向BFS:从初始状态和目标状态同时搜索,在中间相遇,可将搜索空间从 $O(A \times B)$ 降至 $O(\sqrt{A \times B})$。
六、总结
本文完整实现了三壶谜题的求解器,核心算法亮点包括:
- GCD可解性定理:在搜索前快速排除不可解情况,避免无效计算
- BFS最短路径:保证找到的操作序列步数最少
- 状态空间压缩:利用水量守恒将三维状态降维到二维
- 路径回溯:完整输出从初始状态到目标状态的操作序列
读者可以尝试修改壶容量和目标水量,观察不同参数下可达状态的分布规律,进一步理解数论与图论在组合问题中的深刻联系。