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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_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]满足 mck 且 mc 或 m0 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]。以 N3、K2 为例程序会输出若干条可行路径最后一行是总共有X条路径。这个 X 就是该参数下的全部解数量。你可以改 N 和 K 观察变化K2 时解是有限的把 K 调大动作集合变大路径数量也会变多。如果输出里出现负数人数或者某条路径中途卡住基本可以定位到ok函数或动作生成条件写错了。想验证单个状态转移是否正确可以手动算一遍状态[3, 3, 0, 0, 1]执行动作[0, 2]按公式得到[3-1*0, 3-1*2, 01*0, 01*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)是否作为某个状态的子节点出现过。人数出现负数动作生成时没有限制mck或者状态转移公式里船位符号写反。船在左岸是 1左岸人数做减法、右岸做加法船到右岸变 -1方向整体反过来。输入非整数直接崩int(input())遇到字母会抛ValueError。脚本外层有try/except兜底打印异常但更稳妥的做法是在输入处加循环校验。排查时如果懒得逐行读可以把报错信息和相关函数贴到 TaoToken 的模型对话页面让它帮你定位是哪一行触发的。入口在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite 同一套 Key 就能用。6. 把统一 Key 接进你的调试流程如果你只是偶尔问几句代码问题直接在模型对话页面粘贴即可不用写任何调用代码。但如果你想把 AI 辅助调试固化进日常流程比如写个脚本自动把报错发给模型、或者给编辑器配一个统一的补全后端那就需要走 API。API 基址是 https://taotoken.net/api 请求时带上你在控制台生成的 Key 即可具体参数格式看接入文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。长期做算法题、刷搜索类作业的同学可以考虑 Coding Plan 这类面向持续编码场景的方案把 Key 配一次之后写 DFS、BFS、A* 都能复用同一通道省去每个工具单独配置的麻烦https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite 。Key 管理统一在控制台的 API Keys 页面https://taotoken.net/console/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite 。回到这道题本身DFS 跑通之后你可以顺手做两件事一是把递归改成显式栈对比两种写法的路径顺序差异二是把graph打印出来手动数一数状态空间到底有多少个合法节点。这两个练习比单纯抄代码更能帮你理解状态空间搜索的本质。脚本里那个建图耗时打印别删改参数时它能直观告诉你状态规模涨得有多快。