简介:围绕经典“狼、羊、白菜、农夫过河”逻辑推理题整理的一份学习文档,面向人工智能、算法设计与离散数学初学者,也可作为搜索算法、状态空间分析的入门练习材料。内容以有限状态机为主线,给出题目描述、四元组状态表示、L/R操作符定义、约束状态列举,并比较先带羊或先带菜两类过河策略,帮助读者把自然语言规则转化为可搜索的状态转移图。压缩包仅含1个doc文件,约371KB,结构集中,便于直接阅读、课堂演示或自行整理笔记。已有3852人学习,读者可从中获取完整题目拆解、合法状态判断、DFS/BFS及动态规划求解思路,以及回溯排错与方案验证的参考,也可为课程作业、面试题讲解和算法可视化提供基础素材。
1. 农夫过河不是脑筋急转弯:先把状态空间这层窗户纸捅破
很多人第一次看到“狼 羊 白菜 农夫过河”是在面试题或者逻辑题集里,凭直觉试几遍就能凑出答案,于是把它归类成“脑筋急转弯”。但如果你真的动手把它写成代码,会发现它其实是人工智能课程里最干净的状态空间搜索样例:状态怎么定义、操作符怎么枚举、约束怎么判、搜索怎么剪枝,四件事一个不少。这份资源的价值就在这里——它给的不是一段背下来的答案,而是一套可复现的四元组建模方法和手工推演的完整路径,适合正在学搜索算法、准备课程设计,或者想找个最小可跑案例理解 DFS/BFS 的从业者。读完之后你至少能把它改写成任意语言的可执行程序,而不是只会默写那七步。
这道题的条件很克制:农夫、狼、羊、白菜四者位置用 0/1 二值表示,船随农夫移动且每次最多带一样,狼羊不能独处、羊菜不能独处。约束看起来只有两条,但它对搜索顺序极其敏感——先带谁、回程带谁,直接决定你是七步收工还是一头扎进死循环。下面按“建模 → 手工推演 → 代码搜索 → 踩坑 → 验证”的顺序拆开讲,中间会给可直接抄的 Python 版本。
2. 四元组建模:把四样东西塞进一个元组里
2.1 为什么是(农夫,狼,羊,菜)而不是画图
面对这类问题,最常见的误用是先画一张河岸示意图,然后靠肉眼判断“这边剩狼和羊,不行”。图形直观,但不可计算。资源里给出的做法是把整个局面压成一个四元组 S=(L,J,M,N),L 是农夫,J 是狼,M 是羊,N 是菜,取值只有 0(左岸)和 1(右岸)。整个问题的状态总数是 2 的 4 次方共 16 个,去掉非法状态后就剩 10 个,搜索空间小到可以完全枚举。
这个建模的关键在于:船的位置不需要单独存。因为船必须由农夫划,农夫在哪一岸,船就在哪一岸,所以“船的位置”是 L 的函数,冗余状态会白白翻倍搜索量。同理,“当前轮到谁动”也不需要存,因为永远只有农夫能动。把可变维度压到最少,是状态空间建模的第一条血泪经验:能由其他字段推导出来的量,一律不进状态元组,否则你的 visited 集合会漏判。
初始状态 S=(0,0,0,0),目标 G=(1,1,1,1)。注意目标不是“所有东西都在右岸”这一句中文,而是四个分量同时为 1,这个精确性在写终止条件时非常要命——见过不少人写成if sum(state) >= 3,结果提前判胜。
2.2 约束条件的等价写法与那六个非法状态
原文列了六个约束状态,比如 (1,0,0,1) 表示狼羊在左岸,这个局面非法,因为农夫不在左岸(L=1),狼会吃羊。把这六条转成代码时,有两种写法:枚举黑名单,或者写判定函数。我一般会选判定函数,因为它的语义更清楚,改题目(比如加一只鸡)时不用重列组合。
判定逻辑只有一条主干:如果农夫不在某一岸,那么这一岸上不能同时出现「狼和羊」或「羊和菜」。翻译成代码就是检查羊和狼是否同侧且都与农夫异侧,羊和菜同理。
# 非法状态判定:农夫不在的岸上,不能留下互为食物的组合 def is_valid(state): L, J, M, N = state # 狼吃羊:狼和羊同侧,且农夫在另一侧 if J == M and L != J: return False # 羊吃菜:羊和菜同侧,且农夫在另一侧 if M == N and L != M: return False return True逻辑说明:第一个条件J == M表示狼和羊在同一岸,L != J表示农夫不在这岸,两者同时成立就是羊被吃。第二个条件同理处理羊和菜。参数说明:state 必须是长度为 4 的元组或列表,元素只能是 0 或 1,顺序固定为农夫、狼、羊、菜,顺序一旦在代码里定死就不要在中途调换,否则判定会静默出错——这类 bug 不会报异常,只会让你搜出一堆错误解。
用这个函数筛一遍 16 个状态,能留下 10 个合法状态。这 10 个就是状态转移图上的全部节点,后面无论 BFS 还是 DFS 都只在这 10 个点上跑。手工推演时也可以只盯这 10 个点,比在纸上画四条船快得多。
2.3 操作符 L(i) 与 R(i) 的枚举边界
原文明确定义了 L(i) 表示农夫带东西去右岸,R(i) 表示去左岸,i=0 是自己走,i=1/2/3 分别是狼、羊、菜。这里有一个容易翻车的细节:不是所有 i 在当前状态下都可用。要带狼走,狼必须和农夫在同一岸;要空手走,永远可用。所以生成后继状态时,得先判断“目标物品是否和农夫同侧”。
另一个细节是方向。L 是去右岸,前提是农夫当前在左岸(L=0);R 是去左岸,前提是农夫当前在右岸(L=1)。如果你的代码里出现“农夫已经在右岸还执行 L 操作”,状态会变成 L=1 却试图把左岸的东西带过去,产生一批根本不存在于物理世界的状态。这类非法后继如果进了队列,表现就是搜索永远不收敛或者返回一条荒谬路径。
# 依据当前状态生成所有合法后继 def successors(state): L, J, M, N = state items = [J, M, N] result = [] # 农夫去对岸:先确定方向,再决定带谁 new_L = 1 - L # i = -1 表示空手;0/1/2 对应狼/羊/菜 for i in [-1, 0, 1, 2]: if i == -1: # 空手过河,直接改农夫位置 nxt = (new_L, J, M, N) else: # 只能带和自己同岸的那一样 if items[i] != L: continue moved = list(items) moved[i] = new_L nxt = (new_L, moved[0], moved[1], moved[2]) if is_valid(nxt): result.append(nxt) return result逻辑说明:new_L = 1 - L是二值取反,保证方向永远合法。循环里的-1分支处理空手,items[i] != L这一行是核心过滤——只有和农夫同岸的物品才能被带走。参数说明:如果你把物品顺序改成(狼,菜,羊),那么后面所有涉及索引的地方都要同步改,建议在文件开头写一行注释固定顺序,别靠记忆。
跑一下这函数你会看到从 (0,0,0,0) 出发只有两个合法后继:(1,0,1,0) 即带羊过去,以及空手过去后的 (1,0,0,0)。后者其实是死路,因为下一步无论怎么走都会回到起点或者造出羊菜单独留下的局面。这个“看起来能走其实是坑”的分支,正是搜索算法要替我们筛掉的东西。
3. 手工推演的两种解法:为什么第一步只能是羊
3.1 选择一的逐步状态表
原文给了两条路径,本质上是对称的:先带羊,然后要么先运狼、要么先运菜。先把选择一完整走一遍,用四元组记录每一步,比看文字描述清楚得多。
| 步骤 | 动作 | 状态 (L,J,M,N) | 说明 |
|---|---|---|---|
| 0 | 初始 | (0,0,0,0) | 全在左岸 |
| 1 | 带羊去右岸 | (1,0,1,0) | 左岸剩狼菜,安全 |
| 2 | 空手回左岸 | (0,0,1,0) | 右岸只有羊 |
| 3 | 带狼去右岸 | (1,1,1,0) | 右岸狼羊,农夫在场 |
| 4 | 带羊回左岸 | (0,1,0,0) | 不能把羊狼留下 |
| 5 | 带菜去右岸 | (1,1,0,1) | 左岸只剩羊 |
| 6 | 空手回左岸 | (0,1,0,1) | 右岸狼菜,安全 |
| 7 | 带羊去右岸 | (1,1,1,1) | 到达目标 |
七步,四元组从全 0 走到全 1。注意第 4 步是整条路径的枢纽:到达 (1,1,1,0) 后,右岸是狼和羊加农夫,如果农夫空手回左岸,右岸就剩狼羊独处,非法;所以只能把羊再带回去。这一步是很多人手工推演时卡住的地方,直觉上“东西都运过去了为什么还要带回来”,但约束就是这么逼的。
第 6 步也同样:运完菜之后右岸是狼和菜,这俩互不捕食,所以农夫可以空手回,此时左岸只剩羊,再带一次就到终点。整条路径里羊被运了三次,是全场最忙的角色。
3.2 选择二与状态转移图的对称性
选择二是先带羊、再运菜、把羊带回、运狼、空手回、运羊。对应的状态序列是 (0,0,0,0) → (1,0,1,0) → (0,0,1,0) → (1,0,1,1) → (0,0,0,1) → (1,1,0,1) → (0,1,0,1) → (1,1,1,1)。和选择一对比会发现,两条路径的第 3 到 6 步是狼和菜互换位置,结构完全镜像。
这个对称性不是巧合:在约束条件里,狼和菜从未直接冲突,它们都只和羊冲突,所以从图的视角看,狼和菜是两个对称节点。理解这一点之后,你可以把状态转移图画出来:10 个合法状态做节点,后继关系做边,会发现它是一张有环图,且存在多条到终点的路径。手工推演之所以能成功,是因为你无意中做了深度优先搜索并靠直觉剪掉了环;一旦状态数涨到几十个,直觉就不够用了,必须上算法。
提示:手工推演时建议强制记录每一步的完整四元组,不要只写“带羊过去”。只写动作的话,走到第六步很容易忘记羊到底在哪一岸,然后整条路径重来。
3.3 手工解法的适用边界
这两条七步路径是最短解,用 BFS 能证明没有更短的。但手工推演的方法本身有明确边界:它依赖你每次都能看出“这个局面非法”,而人眼判断在四元组上并不可靠。我在纸上试过把第 5 步的状态写成 (1,1,1,0) 和 (1,0,1,1) 混淆,结果后面全错。所以手工推演的定位应该是对照组——先用它拿到一条已知正确的路径和步数,再用程序跑,两者对上才算模型建对了。反过来只信程序输出,模型写错了你也看不出来,因为程序会一本正经地给你一条错误路径并宣称那是最优解。
4. 用 BFS 与 DFS 把解搜出来:代码结构与剪枝
4.1 BFS 求最短路径的完整实现
七步是最短,这个结论靠手工数步数不可靠,得让 BFS 来说。BFS 的性质是首次到达目标时的路径必然最短,前提是每条边权重相同——这里每步操作代价都是 1,正好符合。
from collections import deque START = (0, 0, 0, 0) GOAL = (1, 1, 1, 1) def bfs(): # 队列存 (状态, 路径),路径用于回溯输出 q = deque([(START, [START])]) visited = {START} while q: state, path = q.popleft() if state == GOAL: return path for nxt in successors(state): # visited 必须在这里判,入队时就标记 if nxt not in visited: visited.add(nxt) q.append((nxt, path + [nxt])) return None path = bfs() for i, s in enumerate(path): print(i, s)逻辑说明:队列元素带完整路径,虽然多占一点内存,但省掉了父指针回溯的代码,十来步的规模完全没必要优化。visited在入队时标记而不是出队时标记,这是 BFS 的经典易错点,出队才标记会导致同一状态被多次入队,在有环图上直接炸内存。
参数说明:successors里已经做了合法性过滤,所以这里不需要再判is_valid。如果你把过滤挪到 BFS 内部,记得两处只留一处,重复判不影响正确性但会让代码难读。运行结果会打印 8 行(含初始状态),也就是 7 次移动,和手工推演的选择一或选择二对应——具体哪条取决于successors里 i 的枚举顺序,这属于实现细节,不影响最优性。
4.2 DFS 与递归版本的差异
DFS 也能解,但它不保证最短,只保证找到一条。用递归写的时候,回溯的时机很关键:进入递归前标记 visited,递归返回后不要取消标记。这一点和排列组合类回溯题相反,因为这里求的是“能否到达”,一个状态被访问过就说明从它出发的所有路径都已经探索过,重复进入只会绕环。
def dfs(state, path, visited): if state == GOAL: return path for nxt in successors(state): if nxt not in visited: visited.add(nxt) # 进入前标记 res = dfs(nxt, path + [nxt], visited) if res: return res # 注意:这里不 remove,保持全局已访问 return None print(dfs(START, [START], {START}))逻辑说明:if res: return res是找到即返回的短路写法,避免继续搜索其他分支。参数说明:visited 作为参数传递而不是全局变量,方便多次调用互不污染。DFS 在这道题上大概率也会给出 7 步解,因为状态空间太小且环不多,但这不能当作“DFS 也能求最短”的证据——换个状态数多一点的问题立刻打脸。选型建议是:要最短路径用 BFS,要判断可达性或做回溯枚举用 DFS,别混着用。
4.3 用位运算压缩状态
10 个合法状态用四元组存完全够,但如果想把代码写得紧凑一点,可以把状态压成一个 4 位整数:第 0 位是农夫,第 1 位狼,第 2 位羊,第 3 位菜。取位用(s >> k) & 1,改位用异或。这样 visited 可以退化成大小为 16 的布尔数组,判定 O(1)。
def to_bits(state): L, J, M, N = state return L | (J << 1) | (M << 2) | (N << 3)逻辑说明:移位拼装,顺序和四元组一致,读的时候按位拆。参数说明:位运算版本可读性下降明显,我一般只在状态维度更高(比如 8 个物品)时才用,四个物品的情况下用元组更不容易写错。这也是一个选型判断:优化要匹配规模,状态数只有 16 的时候压缩纯属自嗨,反而给调试加难度。
5. 避坑与排查:五个让程序“看起来对其实错”的地方
5.1 现象:搜索返回的路径里农夫瞬移
原因通常是生成后继时没校验方向,直接对所有 i 生成 L 和 R 两个操作,导致农夫可以在右岸又执行一次“去右岸”。四元组里 L 被写成 1 之后,下一次操作又把它写成 1,中间物品位置却变了,看起来就像瞬移。
解决:在successors里先用new_L = 1 - L定死方向,不要分别写 L 和 R 两套逻辑。一套逻辑加一个取反,比两套镜像代码少一半出错面。
5.2 现象:BFS 跑出 9 步甚至 11 步的解
原因有两个常见来源。一是visited标记时机错了,出队才标记,导致同一状态在不同路径下重复展开,路径记录被污染;二是终止判断写成了“三个物品都在右岸”,忽略了农夫也必须过去,于是多出两步空跑。
解决:入队即标记;终止条件严格用state == GOAL,不要用部分匹配。判断标准很简单,把 BFS 结果的步数和手工推演的 7 步对一下,对不上就是这两处之一。
5.3 现象:is_valid把合法状态判成非法
原因多半是约束写反了。正确语义是“农夫不在的那一岸不能有食物链组合”,有人写成“农夫在的那一岸不能有”,于是 (1,1,1,0) 这种农夫和狼羊同岸的合法局面被误杀,搜索直接无解。还有一种是把J == M写成J != M,条件恒真,所有状态都非法,BFS 立刻返回 None。
解决:拿 10 个合法状态做单测,逐个断言is_valid返回 True;再拿那 6 个非法状态断言 False。这一步花五分钟,能省掉两小时瞎找。
5.4 现象:路径输出顺序反了或重复首尾
原因在路径拼接。path + [nxt]每次都新建列表是对的,但如果图省事用path.append(nxt)再传进去,所有分支共享同一个列表,最后打印出来是一锅粥。另一种是把初始状态在递归和主函数里各加了一次,输出时首行重复。
解决:拼接一律新建列表;打印前先检查path[0] == START和path[-1] == GOAL,两个断言加上去,顺序问题当场暴露。
5.5 现象:换语言重写后结果不一致
Python 版跑通后翻译成 C 或 Java,结果步数不同。原因通常是集合语义差异:Python 的 set 判重基于哈希,某些语言里如果用数组模拟 visited 却忘了初始化成 false,或者用0表示未访问但状态编码本身可能是 0,就会把起点误判为已访问。
解决:visited 的初始值要和状态编码空间错开,比如用 -1 表示未访问;跨语言重写时先跑那 10 个状态的合法性单测,再跑 BFS,两层都过再信结果。
注意:这五条里有四条不会抛异常,只会给你一个“能跑但错”的结果。过河问题的解太短,肉眼很容易觉得“差不多对”,所以断言和手工对照组不是可选项。
6. 把它当模板用:状态空间搜索的三个迁移技巧
真正值得带走的是建模套路,不是这七步。第一个技巧是先算状态总数再决定算法。四样东西二值,2^4=16,合法状态 10 个,这种规模 BFS 和 DFS 随便选;但如果你把题目扩成农夫、狼、羊、菜、鸡五样,状态数翻到 32,合法状态二十多个,这时候还靠手推就不现实了,得让程序枚举。判断标准我一般用一条:合法状态超过 20 个,手工推演就只用来验证首尾两步,中间交给搜索。
第二个技巧是把约束写成独立函数并配单测。is_valid是整个程序里唯一承载题目语义的地方,也是最容易写反的地方。资源里那六个非法状态就是现成的测试用例,抄进测试文件,一条 assert 一个,改题目时先改测试再改实现。这套习惯我在其他状态空间题上也一直用,比如倒水问题、猴子摘香蕉,约束函数一旦独立,换题只是换函数体。
第三个技巧是用可达性反推题目是否有解。把所有合法状态和合法转移建成图,从起点做一次连通性检查,如果终点不在同一个连通分量里,说明这题无解,再怎么调搜索参数都白搭。狼羊白菜这题是有解的,所以这条更多是防御性检查,但在改约束(比如规定船必须载满)之后,很可能就无解了。下面这段就是连通性检查的骨架:
def reachable_count(): seen = {START} stack = [START] while stack: s = stack.pop() for nxt in successors(s): if nxt not in seen: seen.add(nxt) stack.append(nxt) # 合法状态总数应为 10,可达数可用来判断图是否连通 return len(seen), GOAL in seen print(reachable_count())逻辑说明:DFS 遍历全图,返回可达状态数量和终点是否可达。参数说明:如果输出是(10, True),说明 10 个合法状态全部连通且终点可达;如果数量小于 10,说明你的successors漏了某些操作,图被切断了,这时候别急着调 BFS,先去查后继生成。
最后说一个验证习惯。我每次写完这类搜索,都会强制把结果路径逐状态回放一遍,用is_valid对路径上的每个状态做断言,再检查相邻状态之间是否恰好差一个物品的位移。这两条过了,解就是真的对,而不是“看着像对”。这套回放检查我坚持了很多年,起因就是早期有一次把is_valid里的等号写反,程序返回了一条七步路径,我拿去交作业才发现第一步就非法——从那以后我每次跑完搜索都强制走一遍回放断言,宁可多写十行代码,也不信“跑通了就是对的”。希望这些能帮到你,把这道小题当成状态空间搜索的练兵场,而不是一道背答案的脑筋急转弯。
本文还有配套的精品资源,点击获取