简介:围绕人工智能经典状态空间搜索问题展开的「狼、羊、白菜与农夫过河」学习文档,面向计算机科学初学者、算法课程学习者及需要理解搜索与约束建模的读者。内容从题目描述与食物链约束入手,梳理先带羊或先带菜两类过河策略,并用四元组表示农夫、狼、羊、菜的位置,给出初始状态、目标状态及L/R操作符定义,便于理解状态转移、回溯与可行解验证。资源包共1个doc文件,约371KB,适合作为课堂例题、算法练习或复习笔记使用。已有3852人学习,文档中整理了约束状态列表、分步推理和状态空间图构建思路,可帮助读者把DFS、BFS、动态规划等搜索方法落到具体问题上,也能为图论与有限状态机练习提供可对照的案例。
1. 农夫过河到底在考什么:一句话拆开这道经典题
“农夫带狼、羊、白菜过河”是一道流传很广的经典题。农夫有一条小船,每次只能带一样东西,人不在场时狼会吃羊、羊会吃菜,怎么让三者都安全到对岸?很多人第一次听到会觉得像脑筋急转弯,试了两步就卡住:带羊过去,回来带狼,狼又和羊凑到一起。其实它考的不是急智,而是一个计算机科学里极常见的思路——把局面写成“状态”,用“约束”筛掉危险局面,再靠“搜索”一步步试出一种走法。搞懂这道题,你会更容易看懂人工智能里路径规划、任务调度、自动推理这些名词在说什么,而不是把它们当成玄学。它真正有价值的地方,是给了一个小到可以手推、又完整到能装下搜索算法全部要素的模型。
2. 为什么第一步只能带羊:约束条件才是题眼
很多人拿到这道题,第一反应是“先带谁过去”。这个问法本身就偏了。真正决定第一步的,不是带什么过去方便,而是带完之后,留在原岸的组合会不会出事。换句话说,约束条件才是题眼。
2.1 食物链构成的“危险组合”
题目给出的关系很明确:狼吃羊,羊吃菜,狼不吃菜。这三句话翻译成组合,就是两组危险搭配和一组安全搭配。
| 同在无人看管的一岸 | 后果 |
|---|---|
| 狼 + 羊 | 羊被吃 |
| 羊 + 白菜 | 白菜被吃 |
| 狼 + 白菜 | 相安无事 |
注意表头里的限定词——“无人看管的一岸”。农夫在场的时候,狼和羊关在一个笼子边上也没事,因为有人拦着。所以判断危险,永远要同时看两件事:哪两样东西在一起,以及农夫在不在那一岸。只看组合不看农夫位置,是后面一系列误区的源头。
这条规则还有一个容易忽略的对称性:农夫在左岸,要检查的是右岸;农夫在右岸,要检查的是左岸。检查的对象永远是“农夫不在的那一岸”,不是“东西多的那一岸”,也不是“刚放东西的那一岸”。
2.2 每一次往返都在解除一个定时炸弹
把第一步的所有可能摊开看,只有四种动作:农夫空手走、带狼走、带羊走、带白菜走。逐个检查留在左岸的组合。
农夫空手走,左岸剩狼、羊、白菜,狼吃羊、羊吃菜,同时踩两个雷,不行。带狼走,左岸剩羊和白菜,羊吃白菜,不行。带白菜走,左岸剩狼和羊,狼吃羊,不行。带羊走,左岸剩狼和白菜,这两个搭配安全,行。
所以第一步不是“优选带羊”,而是“只能带羊”。这一步没有第二个合法选项,任何号称先带狼或先带菜的说法,都漏掉了留在原岸的那一组。
羊到了右岸,农夫要回去继续运东西。这时如果农夫把羊再带回去,等于原地转圈,所以只能空手回。空手回的那一刻,右岸只有羊,没有农夫,但羊单独待着不会出事;左岸有狼、白菜和农夫,狼和白菜也不冲突。这个局面是安全的,第二步就成立了。
接下来运狼还是运白菜,才是真正出现分岔的地方。假设第三步带狼到右岸,右岸变成狼、羊、农夫同在。农夫只要一离开右岸,狼和羊就会独处,所以农夫回左岸时必须把羊捎回去。于是第四步是“带羊回左岸”,右岸剩狼,左岸有羊、白菜和农夫。这时左岸的羊和白菜都在农夫眼皮底下,安全。第五步农夫带白菜到右岸,右岸变成狼、白菜、农夫。第六步农夫空手回左岸,右岸的狼和白菜安全,左岸只剩羊。第七步带羊到右岸,全部到位。
把这条路径写成动作序列:
带羊到右岸 → 空手回左岸 → 带狼到右岸 → 带羊回左岸 → 带白菜到右岸 → 空手回左岸 → 带羊到右岸一共七次单程移动。如果第三步换成带白菜,后面就是“带羊回、带狼去、空手回、带羊去”,同样七步,只是狼和白菜的出场顺序对调。两条路径结构完全对称,差别只在先运哪一个。
这里有个细节值得停一下:第六步为什么可以空手回?因为那时左岸只剩羊,右岸是狼加白菜,两边都没有触发危险组合。空手回不是偷懒,而是当前局面下唯一不会制造新危险的走法。整道题的节奏就是这样——每一步都在解除一个即将引爆的定时炸弹,同时避免制造下一个。
2.3 四元组状态表示:把局面写成机器能读的格式
手工推演靠的是眼睛和直觉,要让机器接手,得先把“局面”翻译成符号。题目给出的办法是四元组:
S = (L, J, M, N)四个位置分别代表农夫、狼、羊、白菜,0 表示在左岸,1 表示在右岸。初始状态是(0, 0, 0, 0),目标状态是(1, 1, 1, 1)。每移动一次,就有一个或两个分量的值翻转。
操作符也做了编号:L(i)表示农夫带东西去右岸,R(i)表示农夫带东西回左岸,i=0是农夫自己,i=1是狼,i=2是羊,i=3是白菜。这个编号看起来琐碎,实际很有用——它把“带谁”变成了一个可以循环遍历的整数,代码里一个for循环就能枚举所有动作。
题目还列了一串“约束状态”,比如(0,1,1,0)、(0,0,1,1)、(1,0,0,0)。拿(0,1,1,0)来说,农夫在左岸,狼和羊在右岸,农夫不在右岸而狼羊同在右岸,羊会被吃。再看(1,0,0,0),农夫在右岸,狼、羊、白菜全在左岸,左岸无人看管且三样俱全,两个危险组合同时成立。这些四元组的共同点是:它们都活不过一次安全检查。
注意:原材料把这些组合叫“约束状态”,但从上下文看,它们其实是“被约束排除掉的状态”。这个叫法上的模糊,是初学者最容易栽跟头的地方,后面会单独展开。
2.4 为什么“先带菜”这个写法经不起推敲
有些手写笔记在列举分支时会写“选择二:先带白菜到右岸”。单看这一句没问题,但把它放回约束里检查就露馅了:白菜被带走的瞬间,左岸剩下狼和羊,农夫在右岸,狼立刻吃羊。这一步在物理上做得到,在规则上不合法。
这个写法之所以流传,多半是因为记录的人只盯着“到了右岸的东西”,忘了回头看“留在左岸的东西”。过河题的每一步都要同时检查两岸,只检查目的岸,等于只做了一半的安检。
把这一点理清之后,两条对称路径就好理解了:它们不是“第一步不同”,而是“第一步之后先运谁不同”。第一步永远是羊,这一点没有商量余地。
3. 状态空间搜索:人脑推理和机器试错的同一件事
手工推演到第七步,靠的是对局面的持续跟踪。机器没有眼睛,它靠的是一张表和一个循环:把所有合法状态列出来,把状态之间的合法跳转连成边,然后从起点开始一条条试。人脑和机器做的其实是同一件事,只是机器不会累、不会漏。
3.1 状态转移图长什么样
四个物体各有两种位置,理论上最多十六种组合。用“农夫不在的那一岸不能同时出现狼羊或羊菜”这把筛子过一遍,只剩十个合法状态。
| 状态 (L,J,M,N) | 含义 |
|---|---|
| (0,0,0,0) | 全部在左岸,起点 |
| (0,0,0,1) | 农夫、狼、羊在左,白菜在右 |
| (0,0,1,0) | 农夫、狼、白菜在左,羊在右 |
| (0,1,0,0) | 农夫、羊、白菜在左,狼在右 |
| (0,1,0,1) | 农夫、羊在左,狼和白菜在右 |
| (1,1,1,1) | 全部在右岸,终点 |
| (1,1,1,0) | 农夫、狼、羊在右,白菜在左 |
| (1,1,0,1) | 农夫、狼、白菜在右,羊在左 |
| (1,0,1,1) | 农夫、羊、白菜在右,狼在左 |
| (1,0,1,0) | 农夫、白菜在右,狼和羊在左 |
这十个状态就是整张图的全部节点。边是操作:农夫从自己所在的岸出发,带一样东西或者空手,到对岸去,落点必须还在这十个状态里。以起点(0,0,0,0)为例,空手走、带狼走、带白菜走都会落到表外的非法组合,只有带羊走能落到(1,0,1,0)。所以起点只有一条出边,这也是“第一步唯一”在图上的直观体现。
从(1,0,1,0)出发,农夫在右岸,可以空手回左岸落到(0,0,1,0),也可以带羊回左岸落回起点。前者是前进,后者是倒退。搜索算法要做的事情,就是在不重复踩点的前提下,从起点一路走到(1,1,1,1)。
这张图还有一个很实用的性质:它是对称的。把每个四元组里的 0 和 1 全部翻转,得到的还是一个合法状态,而且起点和目标正好互换。这意味着从终点倒着搜和从起点正着搜,工作量差不多。
3.2 DFS 和 BFS 在这道题上的不同走法
深度优先搜索(DFS)的思路是“一条道走到黑”。用一个栈存待探索的状态,每次取最新压入的那个,能往前就往前,走不动了再退回来换一条。放到过河题上,它可能先沿着“带羊、空手回、带狼、带羊回、带白菜”这条链一路走到底,发现到终点了就停。如果中途走进死胡同,就弹出栈顶,回到上一个分岔点。
DFS 的优点是省内存,只存当前路径上的节点。缺点是它找到的路径不一定最短。如果动作顺序换一换,它可能先走一条绕远的路线,照样能到终点,但步数更多。在过河题这种小图上,绕远也绕不了几步,问题不大;换成大一点的图,DFS 可能一头扎进很深的角落,半天回不来。
广度优先搜索(BFS)的思路是“一圈一圈往外扩”。用一个队列存待探索的状态,先看离起点一步能到哪些状态,再看两步能到哪些,依此类推。第一次碰到目标状态时,走过的步数一定是最少的。过河题的最短解是七步,BFS 第一次抵达(1,1,1,1)时,路径长度必然是七。
BFS 的代价是内存。它要把每一层的状态都存下来,状态一多,队列就会胀得很快。在这道题里,十个状态塞不满一个队列,随便怎么跑都行。
两种搜索还有个共同的前提:必须记录“已经访问过的状态”。不记录的话,农夫带羊过去、再带羊回来,状态回到原点,搜索就会在这两个状态之间来回打转,永远走不到新地方。这个去重动作,行话叫“剪枝”或者“避免重复扩展”,是搜索算法里最便宜也最有效的一招。
3.3 用一段代码把搜索跑起来
把上面的规则翻译成代码,核心只有三块:判断一个状态安不安全、生成从当前状态出发的所有合法下一步、按队列顺序往外扩展。
from collections import deque # 状态 (L, J, M, N):0 表示在左岸,1 表示在右岸 # L 农夫,J 狼,M 羊,N 白菜 START = (0, 0, 0, 0) GOAL = (1, 1, 1, 1) def is_safe(state): L, J, M, N = state far = 1 - L # 农夫不在的那一岸 if J == far and M == far: return False # 狼和羊独处,羊被吃 if M == far and N == far: return False # 羊和白菜独处,白菜被吃 return True def next_states(state): L, J, M, N = state result = [] # item:0 农夫自己,1 狼,2 羊,3 白菜 for item in range(4): pos = [L, J, M, N] if item != 0 and pos[item] != L: continue # 东西不在农夫身边,带不走 nxt = pos[:] nxt[0] = 1 - L # 农夫换到对岸 if item != 0: nxt[item] = 1 - L nxt = tuple(nxt) if is_safe(nxt): result.append((nxt, item)) return result def bfs(): queue = deque([(START, [START])]) seen = {START} # 记录已访问状态,防止来回打转 while queue: state, path = queue.popleft() if state == GOAL: return path for nxt, item in next_states(state): if nxt not in seen: seen.add(nxt) queue.append((nxt, path + [nxt])) return None path = bfs() for step, state in enumerate(path): print(step, state)is_safe只查农夫不在的那一岸,这是整段代码里最容易被写错的地方。next_states里的item=0对应农夫空手过河,这个分支不能省,因为第六步就是空手回。seen集合负责去重,没有它,搜索会在“带羊过去、带羊回来”之间死循环。
如果想把问题改复杂一点,比如加一只额外的动物,只需要改状态元组的长度和is_safe里的判断规则,搜索骨架一行都不用动。这也是这类写法值得学的原因:规则和流程是分开的,换题不换框架。
参数上唯一值得留意的是动作顺序。for item in range(4)先试空手、再试狼、再试羊、再试白菜,BFS 找到的会是“先运狼”那条路径。把顺序调成先试白菜,找到的就是另一条对称解。路径不同,步数一样。
4. 常见误区与易混概念:五组最容易混的说法
初学者在这道题上翻车,很少是因为算不明白,多数是因为某个概念的第一印象偏了,后面越推越拧。下面五组说法,是手推和写代码时最容易混的。
4.1 误区一:只要农夫在场,怎么放都安全
易混点:把“危险”理解成“两样东西不能待在一起”,忽略了农夫位置这个前提。
为什么混:日常生活里,狼和羊放一块就是危险,这个印象太强,容易把“无人看管”这个条件自动省略掉。一省略,就会觉得“狼和羊都在左岸,农夫也在左岸,这有什么问题”。
怎么区分:危险组合只在农夫离开那一岸时成立。农夫在左岸,要检查的是右岸;农夫在右岸,要检查的是左岸。同一条“狼+羊”,农夫在左岸时它发生在右岸才危险,发生在左岸反而不危险。判断安全永远问一句:农夫不在的那一岸,有没有狼羊或者羊菜。
4.2 误区二:约束状态就是合法状态
易混点:原材料列了一串四元组,叫“约束状态”,有人读成“满足约束的状态”,以为这些是允许出现的局面。
为什么混:“约束”这个词太中性。它既可能指“被约束保护的状态”,也可能指“被约束排除的状态”,单看字面分不出来。
怎么区分:看上下文。那串四元组后面跟着的解释是“狼、羊在左岸”“羊、菜在左岸”,全是会出事的组合。它们真正的作用是当反面清单用:搜索时碰到这些状态,直接扔掉。叫“非法状态”或“危险状态”更不容易误会。
| 四元组 | 农夫位置 | 无人看管岸上的组合 | 判定 |
|---|---|---|---|
| (0,1,1,0) | 左岸 | 右岸有狼和羊 | 非法 |
| (0,0,1,1) | 左岸 | 右岸有羊和白菜 | 非法 |
| (1,0,0,1) | 右岸 | 左岸有狼和羊 | 非法 |
| (1,1,0,0) | 右岸 | 左岸有羊和白菜 | 非法 |
| (1,0,0,0) | 右岸 | 左岸三样俱全 | 非法 |
| (0,1,1,1) | 左岸 | 右岸三样俱全 | 非法 |
把这六行当成黑名单记住,比背“约束状态”这个模糊叫法管用得多。
4.3 误区三:先带羊和先带菜是两条对称的起点
易混点:看到两条路径都能到终点,就以为第一步也有两个选择,一个先带羊,一个先带菜。
为什么混:只盯着“到达右岸的东西”,忘了看“留在左岸的东西”。带白菜过去这个动作本身很顺,船上有位置,白菜也搬得动,看起来完全可行。
怎么区分:第一步带什么,取决于左岸剩下什么。带狼走,左岸羊加白菜;带白菜走,左岸狼加羊;两种都触发危险组合。只有带羊走,左岸狼加白菜,才安全。所谓的两条路径,是“带羊之后先运狼”和“带羊之后先运白菜”,分岔点在第三步,不在第一步。把分岔点前移一格,整个推理就顺了。
4.4 误区四:这题和迷宫最短路完全一样
易混点:都叫“搜索”,都在一张图上找一条从起点到终点的路径,看起来是同一类问题。
为什么混:形式上确实像。迷宫有格子、有墙、有起点终点;过河题有状态、有操作、有初始和目标。两者都能用 BFS 跑,代码骨架几乎一样。
怎么区分:迷宫里的“墙”是事先画好的,格子之间的连通关系固定不变;过河题的“墙”是算出来的,每一步都要拿当前局面去过一遍安全规则。迷宫搜的是空间位置,过河题搜的是局面组合。更关键的是,过河题里存在“物理上走得到、规则上不允许”的动作,比如带白菜第一步,这种动作在迷宫模型里没有对应物。写代码时,迷宫只需要判断“下一格是不是墙”,过河题需要判断“下一个状态是不是非法”,后者是动态的、依赖上下文的。
4.5 误区五:答案只有一个,搜索没意义
易混点:手推七步就能推出来,写程序显得多此一举。
为什么混:这道题的规模太小,人脑够用,程序的优势显不出来。加上网上流传的答案通常只给一条路径,更容易让人觉得“标准答案唯一”。
怎么区分:这道题至少有两条对称解,先运狼和先运白菜各一条,步数都是七。程序能一次把两条都找出来,人手推很容易推完一条就收工。更重要的是,这套写法的价值不在这一道题,而在它换个规模还能用。把狼、羊、白菜换成三台设备、两个互斥条件、五个待办任务,人手推几天,搜索几秒钟。学的是把局面写成状态、把规则写成函数、把试错交给循环,不是背一条七步路径。
5. 从玩具题到真实系统:状态空间方法还能用在哪儿
过河题的状态只有十个,小到可以画在纸上。但它的结构——状态、动作、约束、目标——和很多真实系统是同构的。区别只在规模和规则复杂度。
5.1 什么场景适合用状态搜索建模
判断一个场景适不适合套这套方法,可以看四个条件。局面能不能离散成有限个状态;动作是不是有限且可枚举;有没有明确的安全规则或合法性规则;目标能不能用一组条件判定。四条都满足,状态搜索就能上场。
常见的例子:拼图还原,每个排列是一个状态,移动一块是一步;八皇后,每行放一个皇后是一个状态,检查列和对角线是约束;任务调度,每个任务的完成情况是一个状态,依赖关系是约束;物流装箱,每件货物的位置是一个状态,承重和体积是约束;棋类走子,棋盘局面是状态,走法规则是动作。
拿调度来说,假设有一组设备需要按顺序启停,某些设备不能同时运行,某些设备必须在另一台之后启动。把每台设备的开/关写成 0/1,整个局面就是一个二进制串;把“不能同时运行”写成类似is_safe的检查函数;把“启动一台”或“停止一台”写成动作。剩下的就是让 BFS 从初始串走到目标串。过河题里的狼羊白菜,在这里变成了设备之间的互斥关系,结构一模一样。
5.2 什么情况下这套方法会“算不动”
状态搜索的软肋是规模。每一步有 b 种选择,走 d 步,搜索树的节点数大致是 b 的 d 次方。过河题里 b 最多是 4,d 是 7,组合数小得可以忽略。换成真实系统,这两个数字都会往上蹿。
按二进制状态估算:10 个物体各有两种位置,一共 1024 种组合;20 个物体是一百多万种;30 个物体是十亿种;40 个物体就到了一万亿。这还只是“每个物体只有两种状态”的乐观估计,如果每个物体有三种以上状态,数字涨得更快。BFS 要把这些状态塞进队列,内存在中途就会告急。
还有一种更隐蔽的“算不动”:状态表示选得不好,把本质相同的局面记成了不同状态。比如两个可互换的物体,交换位置后其实还是同一个局面,如果状态元组把它们当成两个不同分量,搜索就会在等价状态之间反复横跳,白白烧掉算力。这类问题不报错,只是慢,排查起来很费劲。
5.3 状态压缩与剪枝:让搜索不爆炸的小技巧
第一招是状态压缩。把四元组压成一个整数,用位运算存取,比存元组省内存,哈希也更快。
# 把 (L, J, M, N) 压成一个 4 位整数 def encode(state): L, J, M, N = state return (L << 3) | (J << 2) | (M << 1) | N # 从整数还原回四元组 def decode(code): return ((code >> 3) & 1, (code >> 2) & 1, (code >> 1) & 1, code & 1)encode把四个 0/1 分量按位拼起来,decode再拆回去。四个物体只占四位,换成三十个物体也只占三十位,一个整数就能装下整个状态。这一步在状态多的时候收益很明显。
第二招是去重。每扩展一个新状态,先查它有没有被访问过,访问过就跳过。过河题里这招省不了多少,但在大图上,它能砍掉大量回头路。
第三招是利用对称性。过河题里狼和白菜在规则上并不对称,不能随便互换;但很多真实系统里存在完全等价的物体,把它们归成一类,状态数会成倍下降。
第四招是双向搜索。从起点和终点同时往外扩,两边在中间碰头。过河题的正反两张图结构对称,双向搜索能把扩展量压到原来的一半左右。
第五招是启发式搜索。给每个状态估一个“离目标还有多远”的分数,优先扩展分数低的。A* 算法就是这一类,只要估价函数不夸大真实距离,找到的仍然是最短路径。过河题里可以拿“还没过河的东西数量”当估价,效果一般,但能说明思路。
提示:这几招不是越多越好。小规模问题上加太多优化,代码会变得难读,收益却看不出来。先把朴素的 BFS 跑通,确认结果对,再按需要往上加。
6. 什么时候用、什么时候别用:这套思路的边界
状态搜索不是万能锤。它适合的局面有明确特征,不适合的局面也有明确信号。把边界划清楚,比多背几个算法名有用。
| 场景特征 | 适合用状态搜索 | 不适合硬套 |
|---|---|---|
| 状态形态 | 离散、有限、可枚举 | 连续变量、无限取值 |
| 规模 | 组合数在可控范围内 | 维度高、组合爆炸 |
| 规则 | 明确、可写成函数 | 模糊、依赖外部判断 |
| 目标 | 可用条件判定 | 目标本身需要协商 |
| 路径要求 | 需要一条可解释的操作序列 | 只关心最终结果、不关心过程 |
举一个具体例子。某自动化脚本要安排一组设备的启停顺序,设备之间有互斥关系:A 运行时 B 不能运行,C 启动前必须等 D 停止。把每台设备的开/关写成 0/1,整个局面是一个二进制串;把互斥和依赖写成检查函数;把“切换某台设备”写成动作。这套结构和过河题几乎一样,BFS 能找出一组最短的切换序列,而且每一步都解释得清为什么这么切。
但如果设备数量到了几十台,状态数会指数上涨,队列撑不住,这时候就该换约束求解器或者启发式规划,而不是硬把 BFS 往上堆。判断标准很简单:先数一遍状态规模,估算一下内存和耗时,再决定要不要上搜索。数不出来,就先拿小规模跑一遍,看状态增长曲线。
还有一种情况要避开:规则本身说不清。过河题的规则只有两条,白纸黑字。真实系统里经常出现“一般情况下不能这样,但特殊情况可以”的条款,这种模糊规则写不进is_safe,硬写进去只会得到一堆看似合法、实际没法执行的路径。规则没理清之前,先别急着写搜索。
从那以后我每次遇到“看起来像过河题”的需求,都先做三件事:把局面写成状态元组,把危险组合列成黑名单,估算一下状态总数。三步走完,能不能用搜索、该用哪种搜索,基本就有答案了。希望帮到你。
本文还有配套的精品资源,点击获取