1. 传教士与野人问题到底在搜什么
传教士与野人(Missionaries and Cannibals)是人工智能基础课里最经典的状态空间搜索案例之一。三个传教士和三个野人同在左岸,有一条最多载两人的小船,要求把所有人安全送到右岸,且任何一岸只要传教士在场,野人数就不能超过传教士数。它看起来是个脑筋急转弯,本质却是一道标准的图搜索题:把每一种合法局面当成节点,把一次摆渡当成边,然后从初始节点出发找一条通往目标节点的路径。
很多人第一次写这个题,卡的不是 DFS 本身,而是状态怎么表示、动作怎么生成、非法状态怎么剪枝。我当年做大三人工智能作业时也在这几个点上反复改,最后把状态定义成[ML, CL, MR, CR, B],也就是左岸传教士、左岸野人、右岸传教士、右岸野人、船的位置,船在左岸记 1、右岸记 -1,这样一次动作就能用统一的加减法算出下一状态。这个表示法最大的好处是:船的位置直接决定了两岸人数是增还是减,不用写两套分支逻辑。
这篇面向算法学习者和正在用 AI 工具辅助调试的同学,给出一份可以直接复制运行的 Python DFS 脚本,包含状态合法性判断、动作生成、递归搜索、路径回溯,以及用 TaoToken 统一 Key 接入 AI 工具帮你读代码、查报错的完整流程。你不需要任何额外环境,装好 Python 就能跑,跑完能拿到全部可行路径和总条数。
2. 用 TaoToken 统一 Key 给调试加个外挂
写搜索算法最烦的往往不是思路,而是细节:递归里stateList忘了 pop、建图时父子节点方向写反、路径回溯多存了一份引用。这些 bug 靠肉眼盯代码效率很低,我习惯把可疑片段丢给 AI 工具让它逐行解释,或者把报错贴进去问原因。问题是不同工具要配不同 Key、不同地址,来回切换很折腾。
TaoToken 做的事情就是把这些入口收敛成一个统一 Key 和一条 API 通道。你注册后在控制台生成一个 Key,之后无论是走 API 调模型、在模型对话页面试跑,还是给 Coding Plan 这类长期编码场景用,都是同一套凭证。对这篇的调试场景来说,最实用的两个入口是模型对话和 API Keys 管理:前者用来贴代码问问题,后者用来把 Key 配进你自己的脚本或工具里。
需要先说明的是,TaoToken 是合规的 API 聚合与调用平台,不是任何形式的网络中转工具,你按正常开发者流程注册、拿 Key、调接口即可。官网入口在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 基址是 https://taotoken.net/api ,注意 API 地址后面不加任何 UTM 参数,保持干净。
拿 Key 的路径很短:进控制台,找到 API Keys 页面,新建一个 Key 并复制保存。这个 Key 就是你后面所有调用的统一凭证,建议单独存到环境变量里,别硬编码进脚本。
3. 可复制的 DFS 脚本与关键配置
下面这份脚本把状态建模、动作生成、递归搜索、路径输出串成一条线。核心思路是:用字典graph记录状态转移关系,键是父状态元组,值是子状态列表;递归函数mapping负责从当前状态出发尝试所有动作并建图;find_path再从图里回溯出所有到终点的路径。
import time n = 0 path = [] paths = [] graph = {} stateList = [] actions = [] def ok(state): # 人数不能为负 if state[0] < 0 or state[1] < 0 or state[2] < 0 or state[3] < 0: return False # 任一岸只要传教士在场,野人不能多于传教士 if (state[0] < state[1] and state[0] != 0) or (state[2] < state[3] and state[2] != 0): return False # 建图:把当前状态挂到上一个状态下面 if len(stateList) - 1: state_b = stateList[-2][:] if tuple(state_b) in graph.keys() and tuple(state) not in graph[tuple(state_b)]: graph[tuple(state_b)].append(tuple(state)) else: graph[tuple(state_b)] = [tuple(state)] # 与历史状态重复则剪枝 for p in stateList[:-1]: if p[0] == state[0] and p[1] == state[1] and p[4] == state[4]: return False return True def mapping(state): if not ok(state): return # 到达目标状态就停止向下扩展 if state[0] == 0 and state[1] == 0: return tmp = [0] * 5 for action in actions: tmp[0] = state[0] - action[0] * state[4] tmp[1] = state[1] - action[1] * state[4] tmp[2] = state[2] + action[0] * state[4] tmp[3] = state[3] + action[1] * state[4] tmp[4] = -state[4] stateList.append(tmp[:]) mapping(tmp) stateList.pop() return def find_path(state): global n if state in path: path.append(state) return if state == (0, 0, n, n, -1): path.append(state) paths.append(path[:]) return path.append(state) for i in range(len(graph[state])): find_path(graph[state][i]) path.pop() def main(): global n n = int(input("输入各人数N:")) k = int(input("输入载客量K:")) s = [n, n, 0, 0, 1] stateList.append(s) # 生成合法动作 [m, c],满足 m+c<=k 且 m>=c 或 m==0 for i in range(1, k + 1): for j in range(i + 1): if (j >= i - j) or (j == 0): actions.append([j, i - j]) start = time.perf_counter() mapping(s) total = time.perf_counter() - start print(total) find_path(tuple(s)) num = 0 for p in paths: num += 1 print("第%d条路径:" % num) str1 = "{:^6}{:^6}{:^6}{:^6}{:^6}" print(str1.format("ML", "CL", "MR", "CR", "B")) for i in p: print(str1.format(i[0], i[1], i[2], i[3], i[4])) print("总共有%d条路径" % num) if __name__ == '__main__': try: main() except Exception as e: print(e)几个容易配错的地方单独说清楚。动作生成里j是传教士数、i-j是野人数,条件(j >= i - j) or (j == 0)保证船上要么传教士不少于野人,要么船上没有传教士,这样才不会在船上就出现被吃的情况。状态去重只比较state[0]、state[1]、state[4]三个分量,因为左右岸人数之和固定,左岸和船位确定了,右岸也就确定了,这样剪枝更彻底。建图时用元组做键,因为列表不可哈希,这点如果写成列表会直接抛TypeError。
4. 运行验证与预期输出
把脚本保存为CrossRiverDFS.py,在终端执行:
python CrossRiverDFS.py按提示输入 N 和 K,经典场景输入3和2。程序会先打印建图耗时(一个很小的浮点数),然后逐条输出路径。每条路径以表格形式展示,列头是 ML、CL、MR、CR、B,分别对应左岸传教士、左岸野人、右岸传教士、右岸野人、船的位置。你会看到路径从[3, 3, 0, 0, 1]开始,中间经过若干状态,最后停在[0, 0, 3, 3, -1]。
以 N=3、K=2 为例,程序会输出若干条可行路径,最后一行是总共有X条路径。这个 X 就是该参数下的全部解数量。你可以改 N 和 K 观察变化:K=2 时解是有限的;把 K 调大,动作集合变大,路径数量也会变多。如果输出里出现负数人数或者某条路径中途卡住,基本可以定位到ok函数或动作生成条件写错了。
想验证单个状态转移是否正确,可以手动算一遍:状态[3, 3, 0, 0, 1]执行动作[0, 2],按公式得到[3-1*0, 3-1*2, 0+1*0, 0+1*2, -1],也就是[3, 1, 0, 2, -1],和脚本输出一致就说明转换模型没问题。
5. 本篇常见报错排查
TypeError: unhashable type: 'list':建图时用了列表当字典键。graph的键和值都必须是元组,把state用tuple()包一层即可,脚本里已经处理,如果你自己改代码要注意。
RecursionError: maximum recursion depth exceeded:状态去重没生效,导致递归无限深入。检查ok里的重复判断是否比较了state[4],以及stateList.pop()是否在每次递归返回后都执行了。去重条件漏掉船的位置,就会出现来回摆渡的死循环。
输出路径为空或只有一条:多半是find_path里path.pop()的位置不对,或者graph里根本没有目标状态的前驱。可以先打印graph的键值对,确认(0, 0, n, n, -1)是否作为某个状态的子节点出现过。
人数出现负数:动作生成时没有限制m+c<=k,或者状态转移公式里船位符号写反。船在左岸是 1,左岸人数做减法、右岸做加法;船到右岸变 -1,方向整体反过来。
输入非整数直接崩:int(input())遇到字母会抛ValueError。脚本外层有try/except兜底打印异常,但更稳妥的做法是在输入处加循环校验。
排查时如果懒得逐行读,可以把报错信息和相关函数贴到 TaoToken 的模型对话页面,让它帮你定位是哪一行触发的。入口在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite ,同一套 Key 就能用。
6. 把统一 Key 接进你的调试流程
如果你只是偶尔问几句代码问题,直接在模型对话页面粘贴即可,不用写任何调用代码。但如果你想把 AI 辅助调试固化进日常流程,比如写个脚本自动把报错发给模型、或者给编辑器配一个统一的补全后端,那就需要走 API。API 基址是 https://taotoken.net/api ,请求时带上你在控制台生成的 Key 即可,具体参数格式看接入文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。
长期做算法题、刷搜索类作业的同学,可以考虑 Coding Plan 这类面向持续编码场景的方案,把 Key 配一次,之后写 DFS、BFS、A* 都能复用同一通道,省去每个工具单独配置的麻烦:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite 。Key 管理统一在控制台的 API Keys 页面:https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite 。
回到这道题本身,DFS 跑通之后你可以顺手做两件事:一是把递归改成显式栈,对比两种写法的路径顺序差异;二是把graph打印出来,手动数一数状态空间到底有多少个合法节点。这两个练习比单纯抄代码更能帮你理解状态空间搜索的本质。脚本里那个建图耗时打印别删,改参数时它能直观告诉你状态规模涨得有多快。