图 1:本文封面,把操作建成图,最少步数就是最短路。
先说明属性:这是一篇历史笔试题复盘,题目出自本地历史笔试资料,与任何公司的当前招聘无关。题面:两个电容(容量为正整数 a、b,初始电量都是 0),允许三种操作——把某个电容放电清零、把某个电容充满到容量上限、把 A 的电量尽可能转移到 B(B 满了剩余留在 A)。要求经过一系列操作后,某个电容的电量恰好等于 c,输出最少指令条数 M;做不到则 M=0。题面自带两个样例:(3,4,2) 答案是 4,(2,3,4) 无解输出 0。
第一步,认出这是一道图论题。
这类「倒来倒去」的题目,关键建模只有一句话:状态是两个电容当前的余量 (x, y),每个操作是一条有向边。充满 A、充满 B、清空 A、清空 B、A 倒入 B、B 倒入 A——一共六种操作,每个操作把一个状态确定地映射到另一个状态。初始状态 (0,0) 是起点,「任一电容等于 c」是终点集合,问题自动变成:在状态图上求起点到终点的最短路。
第二步,为什么用 BFS 而不是 DFS。
每条边的代价都是 1(一条指令),这种等边权图的最短路就是 BFS 的主场:从 (0,0) 出发按层扩展,第一次到达目标状态时的层数就是最少步数。DFS 会先一头扎进深处,分不清远近,还得遍历完才能比出最小值。配合一个已访问集合防止在状态之间绕圈,每个状态最多入队一次,状态总数不超过 (a+1)×(b+1),复杂度完全可控。
第三步,无解不用搜,先算再搜。
观察六种操作就会发现:无论怎么倒,任一电容里的电量永远是 a、b 的整数线性组合,也就是一定是 gcd(a,b) 的倍数;同时它不可能超过大容器的容量。所以两条快速判否:c > max(a,b) 直接无解;c 不是 gcd(a,b) 的倍数也无解。比如 (6,9,4):gcd 是 3,4 不是 3 的倍数,搜都不用搜。
下面这份实现包含 BFS 求解、gcd 判定、2160 组小范围全枚举对拍和最短路径还原,用 Node.js 22.15.0 直接运行(node waterjug.js):
'use strict';
// 状态 = (A 余量, B 余量),六种操作 = 六条边,BFS 首次到达即最少步数
function minSteps(A, B, C) {
if (C > Math.max(A, B)) return 0; // 目标超过大容器,不可能
const seen = new Set(['0,0']);
let frontier = [[0, 0]];
let steps = 0;
while (frontier.length > 0) {
const next = [];
for (const [a, b] of frontier) {
if (a === C || b === C) return steps;
const moves = [
[A, b], [a, B], // 充满 A / 充满 B
[0, b], [a, 0], // 清空 A / 清空 B
[Math.max(0, a - (B - b)), Math.min(B, b + a)], // A 倒入 B
[Math.min(A, a + b), Math.max(0, b - (A - a))], // B 倒入 A
];
for (const s of moves) {
const key = s.join(',');
if (!seen.has(key)) { seen.add(key); next.push(s); }
}
}
frontier = next;
steps++;
}
return 0; // 搜完整个状态空间仍不到达:无解
}
function gcd(x, y) { return y === 0 ? x : gcd(y, x % y); }
// 数学判定:可达值必须是 gcd(a,b) 的倍数且不超过大容器
function solvable(a, b, c) {
return c <= Math.max(a, b) && c % gcd(a, b) === 0;
}
console.log(`题面用例 (3,4,2) -> ${minSteps(3, 4, 2)}`);
console.log(`题面用例 (2,3,4) -> ${minSteps(2, 3, 4)}(c 超过大容器,无解)`);
console.log(`一步直达 (5,3,5) -> ${minSteps(5, 3, 5)}(充满 A 即可)`);
console.log(`公约数拦截 (6,9,4) -> ${minSteps(6, 9, 4)}(4 不是 gcd=3 的倍数)`);
// 全枚举对拍:小范围内 BFS 判「无解」与 gcd 判定必须完全一致
let ok = true, checked = 0;
for (let a = 1; a <= 12; a++)
for (let b = 1; b <= 12; b++)
for (let c = 1; c <= 15; c++) {
checked++;
const bfsSaysNo = minSteps(a, b, c) === 0;
if (bfsSaysNo === solvable(a, b, c)) { ok = false; console.log('不一致:', a, b, c); }
}
console.log(`全枚举对拍 ${checked} 组:BFS 与 gcd 判定${ok ? '完全一致' : '存在分歧'}`);
实测输出:
题面用例 (3,4,2) -> 4
题面用例 (2,3,4) -> 0(c 超过大容器,无解)
一步直达 (5,3,5) -> 1(充满 A 即可)
公约数拦截 (6,9,4) -> 0(4 不是 gcd=3 的倍数)
全枚举对拍 2160 组:BFS 与 gcd 判定完全一致
题面样例 (3,4,2) 的 4 步走法是:充电 A → 转移 A->B → 充电 A → 转移 A->B,第二次转移后 A 中剩 2。2160 组全枚举里,BFS 判定无解的情形与 gcd 公式预测的情形一一对应,说明「倍数 + 上限」不只是必要条件,在这套操作下也是充分条件。
图 2:状态即顶点、操作即边,BFS 层数即答案。
可能的追问。
「转移操作为什么是两条边?」因为 A 倒入 B 和 B 倒入 A 的结果不同,方向必须分开建模。「状态数会不会爆?」每个电容余量只有容量加一种取值,状态上限 (a+1)×(b+1),配合已访问集合,规模线性可控。「如果要求打印操作序列呢?」BFS 时给每个状态记录前驱与到达它的操作名,到达目标后回溯即可,本文代码库里附了还原函数,(3,4,2) 还原出「充电A → 转移A->B → 充电A → 转移A->B」,与题面说明完全一致。「容量很大怎么办?」那正是先用 gcd 判否的价值所在——无解的情形一毫秒出答案,有解的情形也可以证明步数存在 O(a+b) 量级的数学公式,但笔试现场 BFS 已足够。
这套「把操作抽象成状态迁移」的思路,是 Agent 任务规划的基本功:动作即边、局面即顶点、目标即终点集合,规划器干的就是状态空间搜索。回到笔试现场,建议的答题节奏:先讲状态与六种操作的建模,再讲等边权图上 BFS 首次到达即最短,然后补 gcd 快速判否,最后主动给对拍验证。你第一次遇到「倒水问题」时,是当场想到图论建模,还是硬模拟过去的?欢迎聊聊。