简介:一份围绕人工智能中“狼、羊、白菜、农夫过河”经典逻辑推理题展开的doc文档,面向计算机科学初学者、算法课程学习者以及准备搜索算法练习的读者,可用于理解状态空间表示、约束判断与求解路径设计。压缩包内仅含1个doc文件,大小约371KB,以题目描述、题目分析和四元组状态建模为主线,整理了两类可行策略及状态转移过程,便于对照阅读和课堂演示。目前已有3849人学习下载。文档把农夫、狼、羊、菜的位置抽象为(L,J,M,N)四元组,给出初始状态、目标状态、L(i)与R(i)操作符及约束状态,并围绕先带羊或先带菜两条思路逐步推演,能够帮助读者掌握深度优先搜索、广度优先搜索和回溯思想在有限状态机问题中的落地方式,也可作为算法作业与图论入门练习的参考材料。
1. 从纸笔推演到状态空间:这套过河方案到底交付什么
很多同学拿到狼、羊、白菜、农夫过河这道题,第一反应是拿纸笔推演,推出七八步就以为完事了。我见过一份课程设计就是这么交的:纸上画了三张岸边草图,写着“先带羊过去,空手回来,带狼过去,把羊带回来……”,步骤大致对,但被追问一句“你怎么证明这是最短解”“换成四样东西你还推得出来吗”就卡住了。这道题真正的交付物不是一组答案,而是一套能自动求解的状态空间搜索方案:把岸边情况编码成状态,把撑船动作编码成操作符,用 BFS 或 DFS 在状态图上找出从初始状态到目标状态的路径,再配一套能验证结果的办法。这套结构可以直接迁移到八数码、水壶倒水、传教士与野人这类经典搜索题,也是人工智能课程设计里最常被要求讲清楚的一段。
2. 状态建模:用四元组压平岸边,先定死十个合法状态
2.1 为什么选四元组,而不是“物品集合”
建模的第一步是决定“状态长什么样”。最直觉的写法是记录左岸有哪些东西,比如{"狼","羊"},但这种变长集合在工程上有两个麻烦:一是做哈希、塞进visited集合时开销更大,二是判断“某物在哪一岸”要写in判断,代码里到处都是隐含分支。四元组 S=(L, J, M, N) 把每个角色的位置直接摊平成四个二进制位,L 表示农夫,J 表示狼,M 表示羊,N 表示菜,0 在左岸、1 在右岸。初始状态 S=(0,0,0,0),目标状态 G=(1,1,1,1)。
这样写的好处是状态本身就是一个可哈希的元组,可以直接当字典键用,回溯路径时不需要额外编码。四个布尔位一共 2⁴=16 种组合,暴力枚举也就十几行,这对答辩时“现场证明状态空间是有限的”很关键——有限状态机这个说法不是修辞,是真的只有 16 个格子。
提示:不要用字符串拼接表示状态,比如
"0,1,0,0"。看着直观,但比较、做键、做位运算时到处都要转换,出错概率高且难排查。
另外一个常被忽略的点是“状态是否需要包含船的位置”。这道题里船由农夫划,农夫在哪岸船就在哪岸,所以船的位置是冗余信息,写进状态只会让状态数翻倍而不增加任何表达力。判断一个字段该不该进状态的标准很简单:它能不能被其它字段唯一推导出来。能推导的就不进。
2.2 合法性判定:永远检查农夫不在的那一岸
食物链关系是菜被羊吃、羊被狼吃,所以任何时候只要“狼和羊单独相处”或者“羊和菜单独相处”就出事。关键在于“单独相处”的判定条件不是“它们在同一岸”,而是“它们在同一岸且农夫不在那一岸”。这一句话是整个方案里最容易写错的地方,也是后面避坑章节要重点讲的。
把规则翻译成代码,只有四条判断:
def is_safe(state): """判断状态是否合法:只需检查农夫不在的那一岸""" farmer, wolf, sheep, veg = state if farmer == 1: # 农夫在右岸,左岸无人看管 if wolf == 0 and sheep == 0: # 狼和羊都留在左岸 return False if sheep == 0 and veg == 0: # 羊和菜都留在左岸 return False else: # 农夫在左岸,右岸无人看管 if wolf == 1 and sheep == 1: # 狼和羊都留在右岸 return False if sheep == 1 and veg == 1: # 羊和菜都留在右岸 return False return True参数说明:state是四元组,下标 0~3 依次对应农夫、狼、羊、菜;返回值True表示这一状态可以存在。函数里没有去检查“农夫所在那一岸有没有冲突”,因为农夫在的地方他看得住,冲突不会发生。这个设计把四条判断压缩成两条分支,逻辑上更紧凑,也避免了“两边都检查”带来的误判。
拿初始状态 (0,0,0,0) 走一遍:农夫在左,右岸全是 0,两个if都不成立,返回True。再拿 (1,0,0,0) 走一遍:农夫在右,左岸狼和羊都是 0,第一条命中,返回False——这正是“农夫自己过河,把狼和羊留在左岸”的情况,程序正确地把它判死了。
2.3 十个合法状态列成表,方案就有了地基
把 16 种组合逐个过一遍is_safe,剩下 10 个合法状态。这一步建议手工做完再交给程序验证,因为答辩时导师很可能让你“不跑代码,说说有几种可能”,答不上来会很难看。
| 序号 | 状态 (L,J,M,N) | 农夫位置 | 左岸 | 右岸 | 说明 |
|---|---|---|---|---|---|
| 1 | (0,0,0,0) | 左 | 狼、羊、菜 | 空 | 初始状态 |
| 2 | (0,0,0,1) | 左 | 狼、羊 | 菜 | 农夫看着狼羊 |
| 3 | (0,0,1,0) | 左 | 狼、菜 | 羊 | 狼菜不冲突 |
| 4 | (0,1,0,0) | 左 | 羊、菜 | 狼 | 农夫看着羊菜 |
| 5 | (0,1,0,1) | 左 | 羊 | 狼、菜 | 狼菜不冲突 |
| 6 | (1,0,1,0) | 右 | 狼、菜 | 羊 | 第一步的唯一去向 |
| 7 | (1,0,1,1) | 右 | 狼 | 羊、菜 | 农夫看着羊菜 |
| 8 | (1,1,0,1) | 右 | 羊 | 狼、菜 | 狼菜不冲突 |
| 9 | (1,1,1,0) | 右 | 菜 | 狼、羊 | 农夫看着狼羊 |
| 10 | (1,1,1,1) | 右 | 空 | 狼、羊、菜 | 目标状态 |
这 10 个状态全部可达,也就是说从 (0,0,0,0) 出发,沿着合法动作走,能把 10 个状态挨个走遍。这个结论不是靠感觉,后面第三章会生成邻接表来验证。有了这张表,整个方案的地基就打好了:状态总数有限、合法性规则明确、初始与目标都在表内,剩下的事就是怎么在图上找路。
注意:如果单看“岸边组合”而不管农夫位置,很容易把 (0,0,1,1) 或 (1,1,0,0) 这类状态误当成合法。判定时必须绑定农夫位置,否则合法状态会数成 12 个甚至更多。
3. 搜索策略选型:BFS 拿最短解,DFS 拿内存
3.1 为什么优先选 BFS:7 步最短解的“层”保证
这道题的标准答案是 7 步,而且最短解不止一条——先带羊过去、之后分叉成“先带狼”和“先带菜”两支对称路线,两支都是 7 步。要让程序“保证”输出 7 步而不是 9 步或 11 步,靠的就是 BFS 逐层扩展的性质:它按步数从小到大访问状态,第一次碰到目标状态时的深度就是最短步数。
用 BFS 还有一个现实好处:状态只有 10 个,队列最多装十几个元素,内存开销可以忽略不计,根本不需要考虑优化。所以在这道题上,BFS 是“选它没有代价、选别的却有风险”的那个选项。血泪经验是,很多同学一上来就写递归 DFS,结果找到的解绕了 11 步,被问“为什么多走两步”答不上来。
BFS 的核心约束有两条,必须在写搜索层之前就定好:一是每个状态只入队一次,靠visited(或者前驱字典)保证;二是入队时要检查合法性,非法状态不能进队列。这两条听起来理所当然,但少写一条就会出现重复扩展或路径断裂。
3.2 DFS 加访问标记:能用,但别指望它给最短解
DFS 不是不能用,它的优势在于内存——只需要一条从根到当前的路径,不需要维护整层队列。在状态空间很大、目标很深的问题上(比如某些路径规划),DFS 或者迭代加深更合适。但在这道 10 个状态的题里,它的缺点比优点明显:找到的第一条路径不一定最短,而且递归写法如果没有访问标记,会在 (0,0,0,0) → (1,0,1,0) → (0,0,0,0) 这种来回动作上打转,直接栈溢出。
如果非要用 DFS,最少要加两样东西:
def dfs(start=(0, 0, 0, 0), goal=(1, 1, 1, 1)): """深度优先搜索:靠 visited 剪掉重复状态,靠栈回溯""" visited = set() path = [] def walk(state): if state in visited: # 关键:重复状态直接剪枝 return False visited.add(state) path.append(state) if state == goal: return True for nxt, _act in next_states(state): if walk(nxt): return True path.pop() # 回溯:当前分支走不通就退出 return False return path if walk(start) else None参数说明:visited是全局访问集合,path用列表模拟调用栈,walk返回布尔值表示这条分支是否通向目标。path.pop()那一行是回溯的关键,少了它路径会越积越长,最后打印出来的状态数对不上实际步数。
即便加了剪枝,DFS 在这道题上找到的路径也可能不是 7 步,因为它的扩展顺序取决于next_states里动作的遍历次序。如果想让 DFS 也给最短解,得换成迭代加深(IDDFS),那就等于绕了一圈回到 BFS 的复杂度。所以我的习惯是:凡是状态数在几千以内、又要求最短的题,直接上 BFS,别纠结。
3.3 把状态图打印成邻接表,手工核对每条边
写到这一步,强烈建议先把状态图打出来看一眼,再动手写求解器。做法是枚举 10 个合法状态,对每个状态生成所有合法后继,输出成邻接表。这一步花不了十分钟,但它能提前暴露大部分建模错误。
def build_graph(): """生成完整状态转移图:{状态: [(后继状态, 动作编号), ...]}""" graph = {} for l in (0, 1): for j in (0, 1): for m in (0, 1): for n in (0, 1): s = (l, j, m, n) if is_safe(s): graph[s] = list(next_states(s)) return graph这里的next_states就是第二章规则的具体落地:农夫只能带自己所在那一岸的东西,动作编号 0 表示空手,1~3 表示狼、羊、菜;带完之后新状态必须过is_safe。跑出来的结果里,(0,0,0,0) 的出边应该只有一条,指向 (1,0,1,0),也就是“只能先带羊”。如果跑出来有三条出边,说明is_safe写错了;如果一条都没有,说明动作生成漏了空手这一项。
这 10 个状态、二十来条边的图,就是整份方案的全部“知识点”。答辩时能白板画出这张图并解释每条边的含义,比贴两百行代码有说服力得多。
提示:把邻接表打印出来存成文本,和论文里的“状态转移图”章节对得上。图里的节点编号建议直接沿用第二章表格的序号 1~10,读者对照着看不会乱。
4. 代码实现:分四层把求解器写成一个能演示的小系统
4.1 模块划分:状态层、搜索层、校验层、展示层
单文件一把梭也能跑,但课程设计通常要求“有结构”,而且分层之后每层都能单独讲,答辩时更好展开。我一般分成四层:状态层负责四元组、合法性判定、动作生成;搜索层负责 BFS 主循环和路径回溯;校验层负责独立回放解路径,不信任求解器的输出;展示层负责把状态翻译成人话打印出来。
这样分的好处是每层都能独立测试。比如只跑状态层,验证 10 个合法状态;只跑搜索层,验证返回的路径长度是 7;只跑校验层,拿手工推演的答案喂进去看能不能通过。任何一层出问题,排查范围立刻缩小到几十行代码。
工程上还有一个细节:动作编号 0~3 必须有统一的常量定义,不要在各处硬编码数字 2 表示羊。
# 动作编号与角色名称的唯一定义处,改一处全链路生效 FARMER, WOLF, SHEEP, VEG = 0, 1, 2, 3 NAMES = ["农夫", "狼", "羊", "菜"] START, GOAL = (0, 0, 0, 0), (1, 1, 1, 1)4.2 搜索层与路径回溯的完整实现
搜索层用deque做队列,用两个字典分别记录前驱状态和“到达该状态用了哪个动作”。只用一个visited集合是不够的,因为最后要打印路径,必须知道每一步是怎么来的。
from collections import deque def bfs(start=START, goal=GOAL): """广度优先搜索,返回 [(动作, 状态), ...],找不到返回 None""" queue = deque([start]) parent = {start: None} # 前驱表:用于回溯路径 action = {start: None} # 动作表:记录到达该状态用的动作编号 while queue: cur = queue.popleft() if cur == goal: return rebuild(parent, action, cur) for nxt, act in next_states(cur): if nxt in parent: # 已访问过,跳过(BFS 首次到达即最短) continue parent[nxt] = cur action[nxt] = act queue.append(nxt) return None # 队列空了还没到,说明状态图不连通 def rebuild(parent, action, state): """从目标状态沿前驱指针回溯,再反转成从起点开始的顺序""" path = [] while state is not None: path.append((action[state], state)) state = parent[state] return path[::-1] # 反转,得到从初始状态开始的序列bfs的两个字典分工明确:parent决定“从哪来”,action决定“怎么来的”。回溯时先逆序收集再整体反转,比递归回溯更省心,也不会因为深度问题翻车。nxt in parent这一句承担了两件事——去重和判访问,比单独维护一个visited集合少一次哈希查找。
如果目标不可达,函数返回None,调用方要显式判断。在这个问题里目标一定可达,所以None实际上是一个“模型写错了”的信号,后面避坑章节会专门讲怎么用它定位问题。
4.3 校验层:不信任求解器,独立回放每一步
求解器自己说“我找到 7 步解”,这句话本身不能当证据。校验层的作用是拿解路径重新走一遍,用完全独立的判断逻辑确认每一步都合法。这层代码不长,但答辩时非常加分,因为它证明你考虑过“程序输出错了怎么办”。
def verify(path): """独立校验解路径:不依赖搜索层的任何中间结果""" assert path and path[0][1] == START, "路径起点必须是初始状态" for idx in range(1, len(path)): prev_state = path[idx - 1][1] act, cur_state = path[idx] farmer = prev_state[0] # 1) 农夫必须换到对岸,否则这一步没有意义 assert cur_state[0] == 1 - farmer, f"第{idx}步农夫没有换岸" # 2) 被带的物品必须换岸,其余物品必须原地不动 for i in (WOLF, SHEEP, VEG): expect = 1 - prev_state[i] if i == act else prev_state[i] assert cur_state[i] == expect, f"第{idx}步{i}号物品位置异常" # 3) 到达的新状态必须满足食物链约束 assert is_safe(cur_state), f"第{idx}步出现非法状态:{cur_state}" assert path[-1][1] == GOAL, "路径终点必须是目标状态" return True三个断言分别对应三类错误:农夫没动、物品被顺手多带了一个、状态非法。第三条是最容易被漏掉的,因为前两步都通过时,人很容易以为后面也没问题。实际调试中,这一步拦下过“带羊过去之后又带羊回来”这种零位移的无效动作。
4.4 运行结果与参数说明
展示层把状态翻译成岸边清单,输出的长度正好是 8 行(初始状态加 7 步动作),每行都能和第二章的状态表对上号。
def show(path): for step, (act, state) in enumerate(path): if act is None: desc = "初始状态:全部在左岸" else: direction = "右岸" if state[0] == 1 else "左岸" who = "空手" if act == 0 else NAMES[act] desc = f"农夫带{who}去{direction}" left = "/".join(NAMES[i] for i in range(4) if state[i] == 0) or "空" right = "/".join(NAMES[i] for i in range(4) if state[i] == 1) or "空" print(f"第{step}步 {desc:<16} 左岸[{left:<10}] 右岸[{right}]")运行后得到的是这样一组输出(状态按 (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) | 空 | 农夫/狼/羊/菜 |
这里要强调第 2 步和第 6 步:它们都是“空手返回”。很多人手工推演时会下意识认为“过河一定要带东西”,结果在第 2 步就卡住。程序里动作编号 0 专门表示空手,就是为了把这一步显式建模出来。
想改参数的话,主要改三处:START和GOAL决定问题实例,next_states里的角色数量决定题目规模(加一样东西就是五元组),is_safe里的约束条件决定规则。三处是解耦的,扩展新题目不用动搜索层,这也是分层最实际的价值。
5. 避坑与常见问题排查:五个让程序“看着对但过不了河”的地方
5.1 现象:程序直接输出“无解”
现象很明确:bfs返回None,队列跑空也没碰到目标状态。原因通常只有一个——初始状态的某个后继被误判成非法,导致搜索树在第一层就断了。最常见的具体错误是把is_safe的检查方向写反:农夫在左岸时去检查左岸,农夫在右岸时去检查右岸,等于把“农夫看着的那一岸”当成了冲突来源,结果初始状态的后继全被判死。
解决方法是加一个单步打印,把 START 的所有后继和它们的is_safe结果列出来,肉眼确认只有 (1,0,1,0) 一条合法出路。如果列出来三条五条,说明判定过于宽松;一条都没有,说明过于严格。这个手段比打断点快,也比加日志清爽。
5.2 现象:解是找到了,但要走 9 步甚至 11 步
步数对不上,多半是搜索策略选错了。用 DFS 或者没用队列而用了栈,都会优先往深处钻,第一次摸到目标时的路径长度没有保证。另一种可能是动作生成顺序有问题,比如把“空手”排在最后遍历,某些分支会先绕远路再回来。
解决办法分两步:先确认用的是deque.popleft()而不是list.pop(),前者是队列、后者是栈;再确认parent字典的写入时机在入队时而非出队时,写入晚了一步会让同一个状态被多次扩展,深度计算随之失真。改完这两处,输出应该稳定在 7 步。
5.3 现象:单看每步都合法,连起来走却出事
这种最玄学的情况,通常是状态判定里漏了“农夫不在的那一岸”。比如写成了“只要狼和羊在同一岸就返回 False”,那 (0,0,0,0) 初始状态就会被判非法,程序直接崩在第一步;反过来,如果只检查农夫所在岸,那 (0,0,1,0) 这种“农夫在左、右岸只有羊”的状态会被误判非法,搜索空间被砍掉一大块。
排查方法是把 16 种组合全跑一遍is_safe,人工数出True的个数,必须是 10 个。多了少了都说明规则有问题。这个自检脚本五分钟能写完,建议直接放进单元测试里。
5.4 现象:动作生成里少了“空手”,整条路走不通
如果不把“农夫自己过河”建模成一个动作,搜索会卡在第 2 步:带羊到了右岸,想回左岸却没得带(右岸只有羊,带回去就等于回到起点)。程序会认为没有合法后继,然后回溯找别的路,最后报无解。
解决方式是把动作编号 0 显式定义为“空手”,并且在next_states里允许i == 0时不检查“物品是否与农夫同岸”这个前置条件。这一点在代码里就是一句if i != 0 and state[i] != farmer: continue,i != 0那半句就是给空手开的后门,删掉它整道题就废了。
5.5 现象:路径打印出来是从终点往起点倒着走的
回溯逻辑写反了。rebuild里从目标状态沿parent往上走,收集到的天然是逆序,必须[::-1]反转一次。忘了反转的话,第一步会打印成“带羊去左岸”,看着像程序疯了。
另一个变体是第一步被打印成“农夫带羊”,漏了“初始状态”这一行,原因是循环里parent[state] is not None的终止条件没处理好。改成先收集到parent为None为止,最后统一反转,两个问题一起解决。
6. 验收与答辩:一张清单加一次现场回放
验收这件事,我一般让同学按“能不能被第三方复现”来准备,而不是按“我跑通了”来准备。具体是三步:先跑一遍校验层,确认verify对程序输出的解返回True;再把手工推演的 7 步答案喂进去,确认也能通过;最后故意改错一处(比如把初始状态改成 (0,1,0,0)),确认程序能明确报错而不是静默出错。三步都过,这套方案才算能交。
| 验收项 | 判定方法 | 通过标准 |
|---|---|---|
| 合法状态数 | 枚举 16 种组合过is_safe | 恰好 10 个 |
| 首步唯一性 | 打印 START 的全部后继 | 只有 (1,0,1,0) 合法 |
| 解的最短性 | BFS 返回的路径长度 | 等于 7 |
| 解的合法性 | verify(path)全程断言 | 不抛异常 |
| 双手推演一致 | 手工 7 步喂入verify | 同样通过 |
| 异常可检测 | 篡改一处状态 | 断言失败并指出步号 |
答辩时的讲解顺序建议反过来:先给结论表(上面那张 7 步输出),再讲状态怎么编码、合法性怎么判定,最后才翻代码。因为听众最想知道的永远是“你到底解决了没有”,代码是实现细节。如果时间允许,现场把is_safe的 16 种组合枚举结果投出来,让人看到从 16 到 10 的筛选过程,这个片段通常最能体现你有没有真正理解题目。
还有一个容易被问的点:为什么合法状态全可达,却只展示了其中 8 个?这时候要能说清楚两条对称解的存在——先带狼和先带菜是对称分支,走的是不同的中间状态,但步数都是 7。BFS 只会返回一条,想展示两条就把next_states的遍历顺序调一下再跑一次。能主动说出这一点,说明你不是背答案,而是真的把状态图看透了。
从那以后我每次做状态空间类的课程设计,都会先花半小时手画状态图和邻接表,再动手写代码——图对了,代码只是翻译;图错了,再漂亮的代码也过不了河。这套习惯帮我省下的返工时间,比任何调试技巧都多。希望帮到你。
本文还有配套的精品资源,点击获取