简介:这份文档围绕经典的「狼、羊、白菜与农夫过河」逻辑推理题展开,面向计算机科学与人工智能入门学习者、算法练习者以及需要讲授搜索算法的教师。内容以四元组状态表示为核心,把农夫、狼、羊、菜的位置抽象为可判定的状态,给出初始状态与目标状态,并定义左右岸的带物操作符与安全约束条件,进而梳理出两条可行策略及其完整推导过程,最后落到状态转移图与深度优先、广度优先搜索的求解思路上,可作为状态空间分析与图论练习的参考材料。压缩包内为1个doc文档,大小约371KB,便于直接阅读与打印。该资源已有3852人学习,适合希望从手工推演过渡到程序化搜索的读者,用于理解约束判定、回溯尝试与状态图构建的完整脉络。
1. 先带羊不是脑筋急转弯:狼、羊、白菜、农夫过河到底在搜索什么
狼、羊、白菜、农夫过河看起来像一道口算题,真正反直觉的地方是:程序并不需要“理解”狼会吃羊,它只把每个岸上的共处关系翻译成合法状态集合,然后在图上找一条从 0000 到 1111 的路径。换句话说,这道题是有限状态机、状态空间搜索和约束校验的最小完整样本,也是很多人第一次把业务规则写成状态转移条件的地方。
很多人手推时盯着“先带谁”,程序更关心“哪些状态根本不该存在”。一旦把非法状态提前删掉,剩下的搜索空间只有十个节点,BFS 和 DFS 的差别、访问集的作用、路径重建的方向,都会在这十节点的小图上暴露得很清楚。后面拆四元组建模、操作符、BFS/DFS 和几处常见的翻车点。
2. 状态空间建模:四元组 S=(L,J,M,N) 为什么刚好够用
2.1 从故事到四元组:每个实体只有两个位置
这道题里真正会移动的实体只有四个:农夫、狼、羊、菜。每个实体在任意时刻只有两种位置,左岸或右岸。用 0 表示左岸、1 表示右岸,一个状态就可以写成四元组 S=(L,J,M,N),其中 L 是农夫,J 是狼,M 是羊,N 是菜。初始状态是 (0,0,0,0),目标状态是 (1,1,1,1)。
这里没有把“船”单独做成一个变量,因为船由农夫划,船的位置永远跟农夫一致。建模时删掉这种冗余维度,状态空间才会从“看起来有很多组合”收缩成 2^4=16 种。16 种里再剔除非法状态,剩下 10 个合法状态。这个数字很小,但足够把状态转移、搜索和路径重建的机制讲透。
食物链关系菜→羊→狼不增加状态维度,它只影响“哪些状态合法”。也就是说,四元组描述的是位置,合法性由额外谓词判断。很多搜索题翻车,不是因为搜索写错,而是把约束塞进了状态维度,导致状态爆炸。农夫过河这个例子刚好相反:维度极简,约束单独校验,适合看清分层。
2.2 操作符 L(i)/R(i) 与“翻转农夫位”的等价写法
原文用 L(i) 表示农夫带东西到右岸,R(i) 表示农夫带东西到左岸,i=0 表示农夫自己,i=1 狼,i=2 羊,i=3 菜。手推时这个记号很直观,因为每一步方向明确。但写成程序时,方向其实不用单独传参:农夫当前在左岸,下一步只能去右岸;当前在右岸,下一步只能回左岸。方向由状态里的 L 位决定。
常见的实现思路是把“带谁”作为唯一选择,然后翻转农夫位和目标物品位。下面这段代码就是这种写法,item=0 表示空手,item=1..3 分别对应狼、羊、菜。先检查物品是否和农夫同岸,再翻转两位,最后用 is_safe 过滤非法状态。
# 位序:L 农夫, J 狼, M 羊, N 菜;0 左岸,1 右岸 START = (0, 0, 0, 0) GOAL = (1, 1, 1, 1) ITEMS = {0: "农夫空手", 1: "狼", 2: "羊", 3: "菜"} def is_safe(state): L, J, M, N = state # 只看没有农夫的一岸:狼羊同岸或羊菜同岸就非法 if L == 0: # 农夫在左岸,检查右岸 if J == 1 and M == 1: return False if M == 1 and N == 1: return False else: # 农夫在右岸,检查左岸 if J == 0 and M == 0: return False if M == 0 and N == 0: return False return True def next_states(state): L = state[0] for item in range(4): # 非空手时,所带物品必须和农夫当前同岸 if item != 0 and state[item] != L: continue nxt = list(state) nxt[0] ^= 1 # 农夫一定到对岸 if item != 0: nxt[item] ^= 1 # 被带的物品一起到对岸 nxt = tuple(nxt) if is_safe(nxt): yield nxt, item这段代码的关键参数只有两个:state 和 item。state 是四元组,item 取 0 到 3。返回值里的 item 用于路径重建时说明“这一步带了什么”。注意 next_states 不接收方向参数,因为方向已经藏在 L 里。这样做的好处是少一个分支,坏处是手推时不够直观,所以原文的 L(i)/R(i) 记号仍然适合写在纸上对照。
2.3 合法状态表:16 种组合里为什么只剩 10 个
判断合法性的谓词只检查一件事:没有农夫看管的那一岸,不能同时出现狼和羊,也不能同时出现羊和菜。狼和菜单独待在一起没问题,因为狼不吃菜。这个谓词不是检查“所有岸”,而是只检查农夫不在的那一岸;农夫在的岸有人看管,暂时安全。
把 16 种组合全部枚举一遍,合法状态是下面 10 个。表格里的含义按“农夫、狼、羊、菜”的顺序读,0 在左岸,1 在右岸。
| 状态 | 含义 |
|---|---|
| 0000 | 初始:全部在左岸 |
| 0001 | 农夫、狼、羊在左,菜在右 |
| 0010 | 农夫、狼、菜在左,羊在右 |
| 0100 | 农夫、羊、菜在左,狼在右 |
| 0101 | 农夫、羊在左,狼、菜在右 |
| 1010 | 农夫、羊在右,狼、菜在左 |
| 1011 | 农夫、羊、菜在右,狼在左 |
| 1101 | 农夫、狼、菜在右,羊在左 |
| 1110 | 农夫、狼、羊在右,菜在左 |
| 1111 | 目标:全部在右岸 |
剩下 6 个非法状态是 0011、0110、0111、1000、1001、1100。它们要么让狼羊在没有农夫的一岸独处,要么让羊菜在没有农夫的一岸独处。原文手工列出的“约束状态”里有 (1,0,0,1) 这样的四元组,并标注“狼、羊在左岸”。按位序读,这个状态是农夫在右、狼在左、羊在左、菜在右,左岸没有农夫却有狼和羊,属于非法状态。手工推演时很容易把“约束状态”“非法状态”“合法状态”混着写,这正是后面要用谓词生成、而不是手抄状态表的原因。
states = [(L, J, M, N) for L in (0, 1) for J in (0, 1) for M in (0, 1) for N in (0, 1)] legal = [s for s in states if is_safe(s)] print(len(legal)) # 输出 10 print(legal)这段枚举代码不依赖任何手工标签,只依赖 is_safe。它的价值在于可复现:换一道类似的约束题,只要改 is_safe,合法状态集合会自动重算。状态空间小的时候手抄还能对,状态一多,手抄表就是血泪经验的来源。
3. 搜索算法底层:BFS 为什么能给出最短过河序列
3.1 队列、访问集与层序扩展
把合法状态看成图的节点,把一次过河看成一条边,这道题就变成无权图上的最短路。BFS 用先进先出队列按层扩展:第 0 层是初始状态,第 1 层是走一步能到的状态,第 2 层是走两步能到的状态。第一次弹出目标状态时,经过的边数一定最少,因为所有边权都是 1。
下面这段 BFS 用 parent 字典同时做访问集和路径记录。parent 的键是当前状态,值是一个二元组 (前驱状态, 所带物品)。入队前检查 parent,避免同一个状态重复入队。路径重建时从 GOAL 反向追到 START,再把路径反转。
from collections import deque def bfs(): q = deque([START]) parent = {START: (None, None)} # state -> (prev_state, item) while q: cur = q.popleft() if cur == GOAL: break for nxt, item in next_states(cur): if nxt not in parent: # 入队前标记,避免重复入队 parent[nxt] = (cur, item) q.append(nxt) if GOAL not in parent: return None path = [] cur = GOAL while parent[cur][0] is not None: prev, item = parent[cur] path.append((prev, item, cur)) cur = prev path.reverse() return path参数说明:q 是待扩展状态队列,parent 既记录前驱又充当 visited 集合。item 为 0 表示农夫空手过河,1 到 3 表示带狼、羊、菜。路径里保存的是 (前驱, 物品, 当前) 三元组,这样打印时能直接看出“从哪个状态带了什么到哪个状态”。BFS 的层序扩展在这道题上非常短:第 0 层 0000,第 1 层 1010,第 2 层 0010,第 3 层 1110 和 1011,第 4 层 0100 和 0001,第 5 层 1101,第 6 层 0101,第 7 层 1111。
这里有一个容易被忽略的细节:BFS 的“最短”依赖边权相等。农夫过一次河算一步,带不带东西都算一步,所以边权都是 1。如果题目改成“带不同的东西消耗不同体力”,BFS 就不能直接保证最短,需要换成 Dijkstra 或带权 BFS。很多人看到“最短路径”就上 BFS,忽略了边权前提,这是搜索题里很常见的翻车点。
3.2 DFS 回溯:递归栈里到底存了什么
DFS 走的是另一条路:沿着一个分支一直深入,走不通再退回上一个状态换分支。递归实现时,递归栈里存的是当前状态、当前路径和 visited 集合。visited 用来防止在环里打转;path 用来记录从起点到当前状态的边。找到目标就返回 path 的副本,没找到就弹出当前边再返回上一层。
def dfs(cur, visited, path): if cur == GOAL: return path[:] # 返回副本,避免后续回溯修改结果 visited.add(cur) for nxt, item in next_states(cur): if nxt not in visited: path.append((cur, item, nxt)) ans = dfs(nxt, visited, path) if ans is not None: return ans path.pop() # 回溯:撤销这一步 return None这段 DFS 的 visited 是全局剪枝,不撤销。对于“找一条路径”的图搜索,这样做没问题:一个状态访问过一次,后面再从别的路径到达它,也不会产生更优解,因为 DFS 不保证最短。如果题目要求枚举所有路径,visited 就不能这样用,要么只记录当前递归栈上的状态,要么在回溯时撤销标记。这个差别在小型图上不明显,状态一多就会导致漏解或重复解。
DFS 在这道题上也能找到 7 步解,但它先找到哪一条取决于 next_states 的遍历顺序。BFS 第一次到达目标时就是最短层数,DFS 没有这个保证。原文提到“递归或者迭代都可以”,从机制上看,递归 DFS 更像手推:先试一条路,不行就退回来。BFS 更像水位上涨:一层一层往外扩,先碰到目标的那条路最短。
3.3 路径重建与状态去重:两条最短路径为什么在层 5 汇合
这道题有两条对称的最短路径,都是 7 步。第一条是先带羊,空手回,带狼,带羊回,带菜,空手回,带羊。状态序列是 0000 → 1010 → 0010 → 1110 → 0100 → 1101 → 0101 → 1111。第二条是先带羊,空手回,带菜,带羊回,带狼,空手回,带羊。状态序列是 0000 → 1010 → 0010 → 1011 → 0001 → 1101 → 0101 → 1111。
两条路径在第 3 层分叉:一条走 1110,一条走 1011;到第 5 层又汇合到 1101。BFS 的 parent 字典在这里体现出去重的作用:1101 第一次被记录后,后面再从另一条路径到达它,不会重复入队。路径重建只保留第一次到达它的前驱,所以最终输出的是其中一条最短路径,而不是所有最短路径。要输出所有路径,parent 的值要改成前驱列表,路径重建也要改成回溯所有前驱。
状态去重不是“可优化项”,而是 BFS 正确性的一部分。没有 visited,队列里会反复出现 0000、1010、0010 这些状态,层数被无限拉长。访问集在入队前标记还是出队后标记,会直接影响队列里有多少重复状态。入队前标记更稳,因为一个状态一旦被发现,就已经有了一条从起点到它的路径,后面再发现它不会让路径更短。
4. 避坑与排查:为什么程序会输出死循环、错误解或多走一步
4.1 坑一:把手工列出的“约束状态”当成合法状态
现象:程序一开始就把初始状态判成非法,或者 BFS 输出一条看起来“多绕了一圈”的路径。原因:原文手工列举的约束状态里,有些四元组的位序和标签对不上,比如 (1,0,0,1) 被标注成“狼、羊在左岸”,但按 L,J,M,N 读,它是农夫在右、狼在左、羊在左、菜在右,左岸没有农夫却有狼和羊,属于非法状态。手推时把“禁止出现的状态”和“允许出现的状态”写在同一张表里,很容易混。
解决:不要手抄合法状态表,写 is_safe 谓词,把 16 种组合全部跑一遍,打印合法状态和非法状态。合法状态应该是 10 个,非法状态 6 个。每次改规则,先看合法状态数量有没有变,再看具体状态。这个习惯能省掉很多“表抄错了但代码看起来没错”的排查时间。
4.2 坑二:漏掉农夫空手移动,路径凭空多两步
现象:BFS 返回 None,或者路径里农夫每次都必须带东西,最后卡在某个状态出不去。原因:next_states 只遍历了 item=1..3,忘了 item=0。农夫空手过河是合法操作,而且在这道题里很关键:带羊到右岸后,农夫要空手回左岸,否则没法继续带狼或带菜。
解决:item 从 0 开始遍历,0 表示空手,只翻转 L 位。检查 next_states 时,可以打印当前状态的所有邻居,确认每个状态的空手邻居有没有出现。比如 1010 的空手邻居是 0010,1110 的空手邻居是 0110,但 0110 非法,所以 1110 不能空手回左岸。这个细节能帮助理解“为什么有些状态只能带东西回”。
4.3 坑三:访问集在出队时才标记,队列里塞满重复状态
现象:队列长度迅速膨胀,程序跑很久不结束,或者路径重建时前驱被覆盖。原因:visited 在 popleft 之后才标记,同一个状态可能被多个前驱重复入队。小图上还能跑,状态一多就是灾难。BFS 的访问集应该在入队前标记,因为入队意味着已经发现了从起点到该状态的一条路径。
解决:用 parent 字典的存在性判重,nxt not in parent 时才记录前驱并入队。这样每个状态最多入队一次。如果确实需要记录多条路径,就不要用单一 parent,改成 parent 列表,并且明确要不要保留所有前驱。两种需求不能混着写,否则路径重建会拿到错误的前驱。
4.4 坑四:只检查移动后的岸,忘了农夫不在的那一岸
现象:程序允许狼和羊在没有农夫的一岸待着,输出一条“狼没吃羊”的假解。原因:is_safe 只检查了目标岸,或者只检查了农夫所在岸。真正的约束是“没有农夫看管的那一岸不能出现狼羊或羊菜”。农夫在的岸有人看管,不需要检查;农夫不在的岸才需要检查。
解决:先看 L 的值,确定哪一岸没有农夫,再检查那一岸的 J/M/N 组合。也可以写成通用谓词:对左岸和右岸分别检查,如果农夫不在该岸且该岸同时有狼羊或羊菜,就返回 False。两种写法等价,但“只看无农夫岸”更贴近题意,也少一次分支。
4.5 坑五:路径重建方向反了,动作和状态对不上
现象:输出从目标状态走到初始状态,或者动作说明里的“带狼”对
本文还有配套的精品资源,点击获取