2026/10/31 8:44:55

狼羊菜农夫过河:状态空间搜索与BFS/DFS实现

狼羊菜农夫过河:状态空间搜索与BFS/DFS实现 简介这份DOC文档围绕经典的“狼、羊、白菜与农夫过河”逻辑推理题展开面向人工智能、算法入门学习者及计算机专业低年级学生用于理解状态空间搜索、约束判断与可行路径求解。文档以四元组表示农夫、狼、羊、白菜在两岸的位置给出初始状态、目标状态和左右岸移动操作符并梳理需要排除的不安全状态进而说明如何构建状态转移图借助深度优先、广度优先或递归回溯寻找方案。内容还比较先带羊与先带菜两类策略逐步分析子状态、回退条件与约束验证适合课堂演示、作业参考和手工推演也可作为编写搜索程序的题目原型。压缩包仅含1个DOC文件约371KB篇幅集中、便于查阅。目前已有3849人学习适合希望用小型案例夯实状态表示、搜索过程与算法设计基础的读者。1. 不是脑筋急转弯把“狼羊菜”拆成状态空间搜索“农夫过河”这道题常被归到逻辑推理游戏里但它真正的价值是一个极小的状态空间搜索沙盘四对象、两河岸、一条只能载一件货的船逼着你在十几个合法状态里找一条七步路径。手推一次能得出答案换成“三狼三羊两菜”立刻失控所以人工智能教材愿意拿狼羊白菜农夫过河开刀——它足够小小到能在纸上画完状态图又足够典型典型到 BFS、DFS、约束检查、路径回溯这四板斧一个都少不了。这份资源围绕四元组 S(L,J,M,N) 给出了完整的状态建模和操作符定义我把它当成一个可复现的 Python 小工程重写了一遍状态怎么编码、约束怎么写成布尔表达式、BFS 为什么能保证最短解、DFS 为什么容易漏解以及那些“能跑但答案不对”的血泪经验都在下面几章里。2. 状态建模把“狼羊菜”压成 4 位二进制状态建模这一步决定了后面所有代码的复杂度。很多人第一次写会直接用字符串拼状态比如农夫左岸-狼左岸-羊右岸-菜左岸能跑但一旦要做去重、比较、哈希就立刻变慢。资源里用的四元组 S(L,J,M,N) 是最小可用的编码方式我在此基础上再推一层能不能把它压成一个 015 的整数答案是能而且压完之后 BFS 的 visited 可以直接用布尔数组不需要字典。2.1 四元组、位编码与整数编码的选型先把语义对齐。L、J、M、N 分别对应农夫、狼、羊、菜0 表示左岸1 表示右岸。初始状态 S(0,0,0,0)目标 G(1,1,1,1)。这个语义在资源正文里已经写清楚了关键是它带来的三个性质状态总数有限4 个变量各 2 种取值一共 2^4 16 个候选状态去掉不合法的最多十几个状态可以直接比较元组相等就是状态相等Python 里tuple tuple是常数级开销状态可以哈希元组可哈希直接塞进set或dict不需要额外序列化。如果换成整数编码比如把 (L,J,M,N) 按位拼成L3 | J2 | M1 | N好处是可以用一个bytearray(16)当 visited访问速度更快代价是每次判断某个对象在哪一岸都要做位移和掩码代码可读性下降。我一般会这么选题目对象不超过 8 个、状态总数不超过 2^12 的时候用元组够用且好调试对象数量上去了再切整数编码。资源正文里对象只有 4 个用四元组是正解别为了“性能”提前把代码写成位运算的黑匣子。还有一个隐含的选择状态里要不要带“船在哪一岸”。在这个问题里船跟着农夫走所以船的位置等于农夫的位置不需要单独一个维度。如果题目改成“船可以自己漂”那就得加第五个变量状态空间直接翻倍。写这类题之前先确认“哪些对象的位置是独立的”能省掉一个维度就省掉一个维度。2.2 约束条件写成布尔表达式不要写成一串 if资源正文里把约束状态列成了六条(1,0,0,1)、(1,1,0,0)、(0,1,1,0)、(0,0,1,1)、(1,0,0,0)、(0,1,1,1)。这是“穷举非法状态”的写法能表达清楚但代码里如果真按这六条写if维护成本很高——换一道题比如加一只狐狸就得重新列一遍。更稳的写法是把食物链关系抽象成谓词农夫不在的那一岸不能同时出现狼和羊也不能同时出现羊和菜。翻译成代码就是两条判断若sheep wolf且farmer ! sheep非法若sheep cabbage且farmer ! sheep非法。注意这里用的是“农夫是否和它们在同一岸”而不是“农夫在左岸还是右岸”这是把具体岸抽象掉的关键。写成函数之后加新对象只需要在谓词里加对应的“捕食关系”对不用重写整段逻辑。提示这条谓词等价于“羊要么和农夫在一起要么单独待着”。很多初学者把“狼和羊不能独处”写成“狼和羊必须分开”方向正好反了程序能跑但会生成一堆非法中间状态。2.3 用 Python 枚举全部合法状态做交叉验证在写搜索之前先把 16 个候选状态全部枚举一遍打印出合法的那几个人工对一遍资源正文里的六条约束能提前发现谓词写错。下面这段代码就是干这个的from itertools import product # 状态顺序固定为 (farmer, wolf, sheep, cabbage)0左岸1右岸 def is_safe(state): f, w, s, c state # 农夫不在的岸上狼和羊不能独处 if s w and f ! s: return False # 农夫不在的岸上羊和菜不能独处 if s c and f ! s: return False return True # 把 2^4 16 个候选状态全部枚举过滤出合法状态 legal [st for st in product((0, 1), repeat4) if is_safe(st)] print(f合法状态数{len(legal)} / 16) for st in legal: print(st)逻辑说明product((0,1), repeat4)按字典序生成 16 个四元组is_safe逐条过滤。跑完会得到 10 个合法状态比资源正文里列的六条非法状态多出来的四个正好是“农夫和羊在一起”的那些中间态——手工枚举时最容易漏掉的就是这类“看起来没事”的状态。参数上唯一可调的是状态顺序只要你后面所有函数保持一致顺序本身不影响解但顺序一旦在某个函数里写反比如把(f,w,s,c)和(f,s,w,c)混用就会出现“明明合法却被判非法”的玄学 bug这一点后面避坑章节还会专门讲。3. 搜索骨架BFS 求最短解、DFS 求全部解状态和约束都定好之后搜索本身就是套模板。资源正文里用 L(i) 和 R(i) 两个操作符表示农夫带东西去右岸/左岸i0 表示农夫自己i1 到 3 分别对应狼、羊、菜。我把它改成一个更通用的next_states(state)函数不区分左右方向只看“农夫当前在哪一岸、能带走哪些东西”方向由状态本身决定。这样 BFS 和 DFS 可以共用同一份转移逻辑改一处两边都生效。3.1 操作符定义与状态转移函数先把next_states写出来。它的输入是一个四元组输出是一个列表列表里每个元素是(新状态, 带走的物品编号)物品编号 0 表示空手。核心约束有两条农夫只能带“和自己同一岸”的物品转移后的状态必须合法。def next_states(state): f, w, s, c state nf 1 - f # 农夫换到对岸 out [] # 选项一农夫空手过河 nxt (nf, w, s, c) if is_safe(nxt): out.append((nxt, 0)) # 选项二农夫带一件物品过河物品必须和农夫在出发岸 for item in (1, 2, 3): if state[item] ! f: continue # 物品不在农夫这一岸带不走 moved [f, w, s, c] moved[0] nf moved[item] nf nxt tuple(moved) if is_safe(nxt): out.append((nxt, item)) return out逻辑说明nf 1 - f是“换岸”的位运算写法0 变 1、1 变 0。空手过河单独处理是因为它不涉及state[item]的判断。带物品那段循环里state[item] ! f是关键过滤——这一步不做的话程序会生成“农夫带一个不在自己岸上的物品过河”的非法转移表现出来就是路径里出现“狼凭空从右岸跑到左岸”这种离谱结果。参数item的取值 1、2、3 要和(f, w, s, c)的顺序严格对齐建议在文件头写一行注释把顺序钉死后面所有函数引用同一个常量。3.2 BFS 队列骨架与 visited 去重BFS 的骨架就三件事队列里存(状态, 路径)、用visited集合防止回头、出队时判断是否到达目标。资源正文里说“沿着边进行搜索直到找到目标状态”BFS 的特点是按步数分层扩展第一次碰到目标状态时路径一定最短这是它能给出 7 步解的原因。from collections import deque def bfs(start(0, 0, 0, 0), goal(1, 1, 1, 1)): queue deque([(start, [])]) visited {start} # 入队即标记避免同一状态重复入队 expand_count 0 while queue: state, path queue.popleft() expand_count 1 if state goal: return path, expand_count for nxt, item in next_states(state): if nxt in visited: continue visited.add(nxt) # 路径里记录“带了什么 到达哪个状态”便于后面回放 queue.append((nxt, path [(item, nxt)])) return None, expand_count逻辑说明visited用的是“入队即标记”而不是“出队再标记”这一点和 DFS 正好相反。原因是 BFS 同一层可能有多个节点指向同一个后继入队不标记会让队列里出现重复节点虽然结果不会错但膨胀系数会变大。expand_count是调试用的计数器扩展节点数能侧面反映你的状态建模是否冗余——同一个解扩展节点数越少说明剪枝越干净。参数上start和goal可以换成任意合法状态函数本身不依赖具体题目这也是我建议把搜索函数和题目参数分离的原因。3.3 DFS 回溯模板与路径记录DFS 和 BFS 最大的区别在 visited 的用法。BFS 里 visited 是全局的一个状态访问过就再也不进DFS 里如果也用全局 visited会漏掉“到达同一状态但路径不同”的解。正确做法是维护一个“当前路径集合”on_path进入节点时加入、回溯时移除这样才能枚举出所有不重复状态的路径。def dfs_all(start(0, 0, 0, 0), goal(1, 1, 1, 1), max_depth20): solutions [] def backtrack(state, path, on_path): if state goal: solutions.append(list(path)) # 深拷贝别把引用存进去 return if len(path) max_depth: # 防止在环里无限绕 return for nxt, item in next_states(state): if nxt in on_path: continue # 当前路径已走过跳过 path.append((item, nxt)) on_path.add(nxt) backtrack(nxt, path, on_path) on_path.remove(nxt) # 回溯把本层加进去的标记撤掉 path.pop() backtrack(start, [], {start}) return solutions逻辑说明on_path是“当前这条路径上出现过的状态集合”和 BFS 的全局 visited 是两回事。path.append和path.pop必须成对出现中间夹着递归调用这是回溯模板的标准形态。max_depth是后悔药——如果状态建模写错导致图里有环没有这个上限递归会直接爆栈。solutions.append(list(path))里的list()不能省直接 appendpath会让所有解指向同一个列表最后打印出来全一样这个坑后面还会细说。3.4 把 7 步解跑出来两个搜索函数写完之后跑一遍对比结果。下面这段是完整调用path, expanded bfs() print(fBFS 扩展节点数{expanded}解长度{len(path)} 步) for step, (item, st) in enumerate(path, 1): label 空手 if item 0 else {1: 狼, 2: 羊, 3: 菜}[item] print(f第 {step} 步农夫带{label} - {st}) sols dfs_all() print(fDFS 找到 {len(sols)} 条不重复状态的路径)跑出来的 BFS 解是 7 步带羊过去、空手回来、带狼过去、带羊回来、带菜过去、空手回来、带羊过去。中间那两步“带羊回来”是最容易被忽略的因为直觉上会觉得羊已经过去了为什么还要带回来——但如果不带回来狼和羊或者羊和菜就会在农夫不在的岸上独处。DFS 会找到比 7 步更多的路径因为它在环里绕出来的长路径也算解实际使用时建议加一个“路径长度不超过最短解 2”的过滤否则输出会很长。资源正文里把两种策略先带羊、先带菜分开列出来用 BFS 跑一遍你会发现先带菜那条分支其实是先带羊那条的对称解最终步数一样。4. 避坑排查程序能跑但答案不对的五个原因搜索代码写出来通常都能跑但“能跑”和“答案对”之间隔着一堆细节。下面五条是我自己踩过、也见过别人踩的坑按现象、原因、解决三段写照着排查基本能覆盖 90% 的错答场景。4.1 现象5 步就“过完了河”路径却缺东西现象BFS 输出的路径只有 5 步每步看起来都合法但终点状态不是(1,1,1,1)而是某个只搬了一半的状态。原因目标判断写成了“农夫到达右岸”而不是“四元组全部为 1”。资源正文里目标 G(1,1,1,1)四个分量一个都不能少。如果只判断state[0] 1农夫自己空手过去也算到达搜索会提前终止。解决在 BFS 和 DFS 里统一用state goal做判断goal定义为常量元组不要在函数里临时拼。顺手加一条断言assert goal (1,1,1,1)改题目时忘了改目标会立刻报错。4.2 现象路径里出现“狼从右岸瞬移到左岸”现象解里某一步农夫空手过河但下一状态里狼的位置变了或者农夫带了一个不在自己岸上的物品。原因next_states里漏了state[item] ! f的过滤。空手过河那条分支没问题带物品那条分支如果直接改moved[item] nf不管物品原来在哪一岸都会把它“传送”到对岸。解决带物品之前先判断state[item] f不等就continue。另外写一个assert_transition(state, nxt, item)的小函数做校验如果item ! 0必须满足state[item] state[0]且nxt[item] nxt[0]每次生成后继时调用一次能把这类错误在开发期就拦住。4.3 现象DFS 只找到一条解和预期不符现象BFS 能跑出 7 步解DFS 却只返回一条路径明明状态图里有多条。原因DFS 里用了全局visited集合而不是“当前路径集合”。全局 visited 会让 DFS 退化成“每条状态只访问一次”一旦某个状态在一条路径上被走过其他路径就再也进不去自然只剩一条解。解决把visited换成on_path进入递归前add、递归返回后remove。判断条件是if nxt in on_path: continue不是if nxt in visited。这个改动很小但语义差别很大建议在函数注释里写清楚“这是路径集合不是全局去重”。4.4 现象所有解打印出来一模一样现象solutions里存了 3 条解打印出来三条完全相同连最后一步都一样。原因solutions.append(path)存的是列表引用后面的path.pop()会把已经存进去的内容也改掉。等搜索结束再打印所有条目都指向同一个被清空的列表或者都指向最后一次的状态。解决改成solutions.append(list(path))或者solutions.append(path[:])做一次浅拷贝。这个坑在 Python 里非常常见凡是“把路径存进结果集”的地方都要检查一遍。更稳的写法是存元组tuple(path)从类型上彻底断掉后续修改。4.5 现象扩到 6 件物品后搜索卡死现象把题目从“狼羊菜”扩成“两狼两羊两菜”程序跑了十几秒还没出结果内存一路涨。原因状态数从 2^4 16 涨到 2^6 64看似不大但 DFS 不加剪枝时会枚举所有排列路径长度一长就是指数级。另外如果状态里用了可变对象比如 list当 visited 的元素哈希失效去重完全不起作用。解决一是状态一律用元组或整数不用 list二是 DFS 加max_depthBFS 加“层数上限”三是对象变多之后优先用 BFS 而不是 DFSBFS 找到第一条最短解就停不会像 DFS 那样把整棵树翻一遍。对象数量继续往上走就该考虑双向 BFS 或者 A* 了启发式函数可以用“还没过河的对象数量”做下界这也是后面进阶章节要展开的方向。5. 验证演示把搜索结果锁进断言和步进表代码跑出解只是第一步真正让人放心的是“每一步都可验证”。这一章做三件事把每个中间状态打印成人能看懂的左右岸快照、用断言把最短步数锁住、把操作序列导成一张可以直接贴进文档的步进表。做完之后这份代码就可以当作模板复用换题目只改状态定义和约束谓词。5.1 逐帧回放每一步的左右岸快照光看四元组(1,0,1,0)很难直观判断对错把它翻译成“左岸有谁、右岸有谁”会清晰很多。下面这个函数接收一个状态返回一行可读描述NAMES {0: 农夫, 1: 狼, 2: 羊, 3: 菜} def snapshot(state): left [NAMES[i] for i in range(4) if state[i] 0] right [NAMES[i] for i in range(4) if state[i] 1] left_str 、.join(left) if left else 空 right_str 、.join(right) if right else 空 return f左岸[{left_str}] || 右岸[{right_str}] # 把 BFS 路径逐帧打印出来 for step, (item, st) in enumerate(path, 1): label 空手 if item 0 else NAMES[item] print(f第 {step} 步 带{label} {snapshot(st)})逻辑说明NAMES字典把编号映射成中文snapshot按 0/1 分流。这里有个细节——状态里 0 表示左岸但NAMES[0]是“农夫”两套编号容易混所以函数里用range(4)遍历下标、用state[i]判断岸别不要直接把 0 当成“农夫”去查表。跑出来的效果是每一步都能看到“左岸还剩谁、右岸新增了谁”中间那两步“带羊回来”一眼就能看出必要性如果不带回来某一岸就会同时出现狼和羊。5.2 用断言和单元测试锁住最短步数手工核对容易漏写成断言就能一劳永逸。下面这几条是我一般会加的def validate(path, start(0, 0, 0, 0), goal(1, 1, 1, 1)): assert path, 路径为空 # 1. 起点正确 first_state path[0][1] assert next_states(start), 起点没有合法后继 # 2. 每一步都是合法转移 prev start for item, st in path: legal_next [n for n, _ in next_states(prev)] assert st in legal_next, f非法转移{prev} - {st} prev st # 3. 终点正确 assert path[-1][1] goal, 终点不是目标状态 validate(path) assert len(path) 7, f最短解应为 7 步实际 {len(path)} 步 print(路径校验通过最短步数锁定为 7)逻辑说明validate拆成三段——第一步检查起点有没有后继防止状态建模写错导致起点是死胡同第二步逐步比对next_states的输出这一步等价于把整个转移函数当 oracle 用第三步检查终点。最后那条assert len(path) 7是回归测试以后改约束谓词或者改状态顺序只要最短步数变了就会立刻失败不用每次手动数。参数上唯一要注意的是start和goal要和调用 BFS 时传的一致建议提到模块级常量别在两个地方各写一遍。5.3 把操作序列导成可读的步进表最后一步是把结果整理成表格方便贴进实验报告或者代码注释。用 Markdown 表格手动写太累用代码生成def to_markdown_table(path, start(0, 0, 0, 0)): rows [| 步数 | 农夫动作 | 到达状态 | 左岸 | 右岸 |, | --- | --- | --- | --- | --- |] prev start for step, (item, st) in enumerate(path, 1): action 空手 if item 0 else f带{NAMES[item]} left p a hrefhttps://download.csdn.net/download/zeroLing/2189103 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p