深蓝黑背景封面:左侧大标题「两个容器,最少几步」,副标题「把倒来倒去建成状态图,BFS 求最短路」;右侧原创示意图——两个量杯形容器(一高一矮),中间一条双向箭头表示互相倾倒,右侧一棵 BFS 搜索树从初始状态 (0,0) 展开三层,目标状态节点高亮;左上标注「面试经典 · 算法」,右上标注「Day 05 · 11-02」 图 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 公式预测的情形一一对应,说明「倍数 + 上限」不只是必要条件,在这套操作下也是充分条件。

建模拆解图:左上「状态与操作」——状态用二元组 (A 余量, B 余量) 表示,六种操作是两条边集:充满 A、充满 B、清空 A、清空 B、A 倒入 B、B 倒入 A,每个操作把一个状态映射到另一个状态;右上「BFS 逐层扩展」——从 (0,0) 出发按层推进,队列旁标注已访问集合防止绕圈,第一次到达目标状态时的层数就是最少步数。左下「无解判定」——目标值必须不超过大容器容量且是两容器容量的公约数倍数,不满足直接判无解,不用搜。右下「为什么 BFS 而不是 DFS」——边权都为 1 的图上,BFS 首次到达即最短;DFS 会先扎到深处分不清远近。底部总结:这类题的通用套路是把「操作」抽象成「状态迁移」,问题自动变成图最短路 图 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 快速判否,最后主动给对拍验证。你第一次遇到「倒水问题」时,是当场想到图论建模,还是硬模拟过去的?欢迎聊聊。