1. 从一道经典智力题说起:5L桶和3L桶怎么装出4L水
第一次被问到“5L的桶和3L的桶如何装4L的水”这个问题,是在一个技术社区的线下聚会上。当时提问的人说这是某公司面试的压轴题,答不出来直接淘汰。在场十几个人,有人拿纸笔开始画流程图,有人掏出手机想搜答案,还有人直接说“把5L桶斜着装,对角线刚好4L”——这个答案其实也没错,但面试官想听的显然不是这种取巧解法。
这道题之所以经典,是因为它同时考察了三样东西:逻辑推理能力、状态空间搜索思维、以及把抽象问题转化为可执行步骤的能力。前两者是算法工程师的日常,第三者则是所有技术从业者的基本功。你可能会觉得这就是个脑筋急转弯,但实际上它背后藏着状态机建模、广度优先搜索、倒水问题的数学本质等一整套知识体系。我后来在带新人的时候,经常拿这道题当热身,因为它足够简单,谁都能听懂规则,又足够深,能一路延伸到图论和算法设计。
这篇文章我会从三个层面来拆解:第一,手动推导的标准解法,把每一步为什么这么做讲透;第二,用代码建模求解,把状态空间搜索的完整实现写出来;第三,聊聊这类问题在实际工程中的影子,比如资源调度、容器编排里的类似场景。不管你是刚学算法的新手,还是想找个案例给团队做分享的老手,应该都能从里面挖到点东西。
2. 手动推导:两种标准解法及其背后的逻辑
2.1 解法一:从5L桶倒向3L桶的路径
先把规则说清楚:有两个桶,一个容量5L,一个容量3L,没有刻度,只能装满或者倒空,可以互相倒水直到一个空或一个满。目标是在某个桶里得到恰好4L水。水无限供应,也可以倒掉。
最直观的解法是从5L桶开始,反复往3L桶倒。我把每一步的状态记下来,格式是(5L桶水量,3L桶水量):
- 装满5L桶:(5,0)
- 5L桶倒水进3L桶,直到3L桶满:(2,3)
- 倒空3L桶:(2,0)
- 5L桶剩下的2L倒进3L桶:(0,2)
- 再次装满5L桶:(5,2)
- 5L桶往3L桶倒水,3L桶已有2L,只能再装1L:(4,3)
此时5L桶里恰好剩下4L。总共6步。
这个解法的核心逻辑是什么?每次倒水都在制造一个新的余数。5和3的最大公约数是1,所以通过反复的“装满-倒出-转移”操作,可以得到1到5之间任意整数的水量。第2步得到2L,第4步把2L转移到3L桶,第6步就得到了4L。本质上这是在用5和3做加减法:5-3=2,3-2=1,5-1=4。每一步的余数都在为下一步做铺垫。
注意:很多人卡在第4步,觉得“把2L倒进3L桶有什么意义”。这一步的意义在于改变3L桶的初始状态,让下一次从5L桶倒水时,只能倒进去1L,从而在5L桶里留下4L。这是整个解法最巧妙的地方。
2.2 解法二:从3L桶倒向5L桶的路径
换一个方向,从3L桶开始往5L桶倒,也能得到4L,但步骤更多:
- 装满3L桶:(0,3)
- 3L桶倒进5L桶:(3,0)
- 再装满3L桶:(3,3)
- 3L桶往5L桶倒,5L桶已有3L,只能再装2L:(5,1)
- 倒空5L桶:(0,1)
- 3L桶剩下的1L倒进5L桶:(1,0)
- 装满3L桶:(1,3)
- 3L桶倒进5L桶:(4,0)
8步得到4L。比解法一多两步,但逻辑同样成立。这条路径的余数序列是3-2=1,然后1+3=4。两条路径本质上是对称的,只是起点不同。
2.3 为什么只有这两种本质解法
有人可能会问:有没有别的路径?比如先倒掉一半之类的。答案是没有,因为规则限制了只能“装满”或“倒空”,不能凭感觉倒。在这个约束下,状态空间是有限的。5L桶的水量只能是0到5的整数,3L桶只能是0到3的整数,总共24种状态。从初始状态(0,0)出发,能到达的状态是确定的。两条标准路径分别对应了状态空间中的两条不同搜索路径,最终都到达了目标状态(4,0)或(0,4)。
这里有个数学结论:如果两个桶的容量互质,那么可以得到1到最大容量之间的任意整数水量。5和3互质,所以4L必然可达。如果换成6L和4L,最大公约数是2,那就只能得到偶数升的水,4L可以但3L不行。这个结论在数论里叫裴蜀定理的直观体现。
3. 用代码建模:状态空间搜索的完整实现
3.1 状态定义与转移规则
手动推导能解决问题,但如果桶的容量变成13L和7L,目标变成10L,手动推就很容易乱。这时候用代码来搜索才是正解。我习惯用广度优先搜索(BFS),因为BFS能保证找到最短路径,而且实现起来很直观。
先把问题抽象一下。状态可以表示为一个元组(a, b),其中a是5L桶的水量,b是3L桶的水量。初始状态是(0, 0),目标状态是a == 4 or b == 4。每一步允许的操作有六种:
- 装满5L桶:
(5, b) - 装满3L桶:
(a, 3) - 倒空5L桶:
(0, b) - 倒空3L桶:
(a, 0) - 5L倒向3L:倒的量是
min(a, 3-b),新状态是(a - min(a, 3-b), b + min(a, 3-b)) - 3L倒向5L:倒的量是
min(b, 5-a),新状态是(a + min(b, 5-a), b - min(b, 5-a))
这六种操作覆盖了所有合法动作。BFS从初始状态开始,每次取出一个状态,尝试六种操作,如果得到新状态就加入队列,直到找到目标。
3.2 Python实现与路径回溯
下面是我常用的一个实现模板,加了路径记录,方便看到每一步是怎么走的:
from collections import deque def water_jug(cap_a, cap_b, target): # 状态用 (a, b) 表示,同时记录路径 start = (0, 0) if start[0] == target or start[1] == target: return [] visited = {start} queue = deque([(start, [])]) while queue: (a, b), path = queue.popleft() # 六种操作 operations = [ (cap_a, b, f"装满{cap_a}L桶"), (a, cap_b, f"装满{cap_b}L桶"), (0, b, f"倒空{cap_a}L桶"), (a, 0, f"倒空{cap_b}L桶"), ] # 倒水操作单独算 pour_ab = min(a, cap_b - b) operations.append((a - pour_ab, b + pour_ab, f"{cap_a}L桶倒入{cap_b}L桶 {pour_ab}L")) pour_ba = min(b, cap_a - a) operations.append((a + pour_ba, b - pour_ba, f"{cap_b}L桶倒入{cap_a}L桶 {pour_ba}L")) for new_a, new_b, action in operations: new_state = (new_a, new_b) if new_state in visited: continue new_path = path + [action + f" -> ({new_a}, {new_b})"] if new_a == target or new_b == target: return new_path visited.add(new_state) queue.append((new_state, new_path)) return None # 无解 # 跑一下 result = water_jug(5, 3, 4) for i, step in enumerate(result, 1): print(f"{i}. {step}")跑出来的结果就是前面手动推导的6步路径。代码的好处是,换任何容量和目标都能直接跑,不用重新推。比如把容量改成13和7,目标10,BFS会很快给出最短路径。
3.3 复杂度分析与优化思路
状态空间的大小是(cap_a + 1) * (cap_b + 1),对于5和3就是24个状态,BFS瞬间完成。如果容量很大,比如1000L和999L,状态空间就是100万个,BFS还是能跑,但内存占用会上去。这时候可以用双向BFS,从起点和终点同时搜索,相遇时合并路径,能把搜索深度减半。
另一个优化是数学直接求解。前面说过,如果两个容量互质,目标水量一定可达,而且可以通过扩展欧几里得算法直接算出需要多少次倒水操作。不过对于面试场景,面试官通常想看的是搜索思路,而不是数学公式,所以BFS是更稳妥的答案。
实操心得:写BFS的时候,
visited集合一定要在入队时就标记,而不是出队时标记。否则同一个状态可能被多次加入队列,导致重复计算甚至死循环。这个坑我在早期写迷宫问题时踩过,后来养成了入队即标记的习惯。
4. 这道题在真实工程中的影子
4.1 资源调度中的“倒水”逻辑
你可能觉得这就是个面试题,实际工作中用不上。但仔细想想,容器编排里的资源分配和倒水问题非常像。比如有两个节点,一个剩余5GB内存,一个剩余3GB内存,现在要调度一个需要4GB内存的任务。你不能直接把两个节点的内存加起来用,只能在一个节点上腾挪。这时候的调度策略就和倒水一模一样:把任务先放到小节点,占满后溢出到大节点,反复调整直到某个节点恰好有4GB连续可用空间。
再比如数据库连接池的分配。连接池有最大连接数和最小空闲连接数两个参数,应用请求连接时,池子会动态调整。这个调整过程和倒水问题中的状态转移几乎同构:请求连接相当于“倒水”,释放连接相当于“倒空”,池子的上下限就是两个桶的容量。
4.2 状态机建模的通用套路
倒水问题最值钱的地方,是它展示了一套状态机建模的通用方法:定义状态、枚举操作、搜索路径。这套方法可以套用到很多场景:
| 场景 | 状态定义 | 操作集合 | 目标 |
|---|---|---|---|
| 倒水问题 | (5L桶水量, 3L桶水量) | 装满、倒空、互倒 | 某桶为4L |
| 迷宫寻路 | (x, y)坐标 | 上下左右移动 | 到达终点 |
| 八数码 | 3x3棋盘布局 | 空格与相邻块交换 | 还原目标布局 |
| 任务调度 | (已分配资源, 待分配队列) | 分配、释放、迁移 | 满足资源需求 |
你会发现,只要能把问题抽象成“状态+操作+目标”,就能用同一套BFS/DFS框架去解。这就是为什么面试官爱考这道题——它考的不是倒水,而是你有没有把实际问题抽象成状态搜索的能力。
4.3 面试中的答题策略
如果面试时被问到这道题,我的建议是分三步走:
- 先确认规则:问清楚能不能倒掉水、能不能凭感觉倒一半、目标是在哪个桶里得到4L。这些边界条件决定了答案的方向。
- 手动推一遍:不要急着写代码,先用嘴说或者用笔画出6步路径,证明你有基本的逻辑推理能力。
- 再抽象成算法:如果面试官继续追问,就把状态空间和BFS的思路讲出来,最好能说出“状态数有限、BFS保证最短路径、互质则任意水量可达”这几个关键点。
注意:有些面试官会故意说“桶没有刻度”,然后观察你会不会提出“斜着放”这种取巧解法。如果你提了,面试官可能会笑一下,然后说“假设桶是规则圆柱体,斜着放确实可以,但我们今天不讨论几何解法”。这时候你要立刻回到逻辑推理的正轨上,不要纠缠。
5. 常见问题与排查技巧实录
5.1 为什么我的BFS跑不出结果
最常见的原因是状态去重没做好。比如从(5,0)装满3L桶得到(5,3),从(2,3)装满5L桶也得到(5,3),如果不做visited判断,同一个状态会被反复处理。另一个原因是操作枚举不全,比如漏掉了“倒空”操作,导致某些状态无法到达。
排查方法很简单:在BFS循环里打印每次出队的状态和尝试的操作,看看是不是在某个状态上卡住了。如果发现队列空了还没找到目标,那就是状态空间没覆盖全,或者目标根本不可达(比如容量6和4,目标3)。
5.2 手动推导时容易卡在哪一步
根据我带新人的经验,大部分人卡在第4步:把5L桶剩下的2L倒进3L桶。这一步的直觉是“3L桶里已经有2L了,再倒进去不是浪费吗”。但实际上这一步是在重置3L桶的状态,让它从“空”变成“有2L”,这样下一次从5L桶倒水时,只能倒进去1L,从而在5L桶里留下4L。
如果你也卡在这里,建议把每一步的状态写下来,盯着数字看:5→2→2→0→5→4。你会发现2这个数字出现了两次,第一次是5-3的结果,第二次是3-1的结果。余数在传递,这就是整个解法的灵魂。
5.3 容量互质与不互质的区别
前面提过,容量互质时任意水量可达,不互质时只能得到公约数的倍数。这个结论可以用裴蜀定理严格证明,但直观理解更简单:每次倒水操作本质上是在做加减法,能得到的水量一定是两个容量的线性组合。如果gcd(5,3)=1,那1的任意倍数都能组合出来;如果gcd(6,4)=2,那只能组合出偶数。
| 容量组合 | 最大公约数 | 可达水量 | 4L是否可达 |
|---|---|---|---|
| 5L和3L | 1 | 1,2,3,4,5 | 是 |
| 6L和4L | 2 | 2,4,6 | 是 |
| 7L和5L | 1 | 1-7 | 是 |
| 8L和6L | 2 | 2,4,6,8 | 是 |
| 9L和6L | 3 | 3,6,9 | 否 |
所以如果面试官把题目改成“9L桶和6L桶装4L水”,答案就是无解。这时候你要能说出“因为9和6的最大公约数是3,4不是3的倍数”,而不是硬推。
5.4 代码实现中的边界条件
写代码的时候有几个边界要特别注意:
- 目标水量大于最大桶容量时,直接返回无解
- 目标水量为0时,初始状态就满足,返回空路径
- 两个桶容量相等时,只能得到0或该容量,其他都无解
- 倒水操作中,
min(a, cap_b - b)的cap_b - b可能为0,此时倒水量为0,新状态和旧状态相同,应该跳过
这些边界在面试中经常被忽略,但写出来能体现你的严谨性。
6. 从倒水问题延伸出去的思考
这道题我前前后后给不同的人讲过几十遍,每次都能发现新的角度。有一次一个做分布式系统的朋友说,这其实就是两阶段提交的简化版:5L桶和3L桶相当于两个节点,倒水相当于事务提交,目标状态相当于一致性达成。虽然类比不完全严谨,但思路是通的。
还有一次一个做游戏AI的朋友说,倒水问题的状态空间搜索和寻路算法一模一样,只是把坐标换成了水量。他后来用类似的BFS框架写了一个资源采集AI,让游戏角色在多个资源点之间来回搬运,直到满足某个建造需求。代码结构几乎没改,只是把状态定义从(a, b)换成了(资源A数量, 资源B数量)。
所以你看,一道看似简单的智力题,背后连着的是状态空间搜索这个算法设计的核心范式。把它吃透,比刷十道LeetCode题都管用。下次再有人问你5L和3L怎么装4L,你可以先给出6步解法,然后补一句“其实用BFS可以搜出最短路径,而且如果容量互质,任意水量都可达”。这一套组合拳下来,对方基本就知道你是有货的。