简介:伯克利大学AI吃豆人项目的Python解决方案包,以经典Pacman游戏为实践场景,面向希望系统掌握人工智能搜索算法的高校学生和自学者。资源完整覆盖宽度优先搜索、深度优先搜索、A*(A星)搜索、Dijkstra算法等基础路径规划方法,并进一步引入Minimax对抗搜索、α-β剪枝和Q-learning强化学习策略,帮助读者从零理解游戏角色在动态迷宫中的智能决策过程。压缩包共23个文件,整体大小仅67KB,其中20个Python源码文件构成了主体,涵盖游戏引擎、图形与文本显示、地图布局、自动评分器、幽灵智能体等核心模块,另有README说明文档、命令文件与许可证文件,目录结构清晰,可直接运行或按需修改。已有564人浏览学习,包内不仅包含各搜索算法的核心实现,还自带八数码、八皇后等附加练习,配合自动评分工具,便于逐一验证不同搜索策略的正确性与效率,是课程作业参考和AI入门进阶的实用素材。
1. 吃豆人自己找路:AI-Pacman-Project_Search 到底在做什么
第一次在终端里看到吃豆人自己走出迷宫时,你多半会愣一下:地图不大,豆子不多,但每一步动作都来自一段只有几十行的搜索函数。这个项目是某海外高校经典 AI 课程的第一部分,坊间简称 AI-Pacman Project,核心文件是 search.py 和 searchAgents.py。你不需要训练任何模型,也不碰神经网络,要做的是把深度优先、广度优先、一致代价和 A* 这四种搜索算法写出来,再用它们解决吃豆人的单目标寻路、角落访问和食物收集问题。适合的人群很明确:正在学算法理论却不知道怎么写代码的人,以及想验证自己是否真的理解搜索本质的从业者。读完这一篇,你能得到一个能跑通全部测试卡的实现思路,也知道每一步踩坑时的应对办法。
2. 先把环境跑起来:从最小命令到评分器通过
2.1 项目结构和三个必须记住的对象
拿到打包代码后,你首先面对的是好几个 py 文件。最常见布局里,search.py 放着四个算法函数的空壳,searchAgents.py 放着各种搜索代理和启发式函数,util.py 提供栈、队列、优先队列,pacman.py 是主入口,负责解析命令行并驱动游戏循环,game.py 定义地图、状态和代理交互,剩下的是渲染相关的图形模块。
你可以先打开 search.py 看一眼函数签名。项目不会告诉你用什么数据结构和搜索策略,只留了函数名。这其实是刻意的:把算法选择权还给你,然后靠自动评分器判断优劣。util.py 里给出的三个容器是你在作业里被允许使用的数据结构,它们的 API 都不复杂,无非是 push、pop、isEmpty。PriorityQueue 的 push(item, priority) 会把 item 按优先级排序,pop() 返回当前优先级最小的 item。
search.py 中的 SearchProblem 接口有三个方法,所有算法都建立在这上面:
| 方法 | 作用 | 你要小心的点 |
|---|---|---|
| getStartState() | 返回起始状态 | 状态不一定是坐标,可能是元组 |
| isGoalState(state) | 判断是否到达终点 | 角落和食物问题中目标条件不同 |
| getSuccessors(state) | 返回可行动作及代价 | 返回三元组 (nextState, action, stepCost) |
这就是项目最核心的抽象。你写 DFS、BFS、A* 时并不需要知道地图长什么样,只需要迭代调用这三个方法。理解这一点后,你的算法可以直接套用到任何 SearchProblem 上,这也是为什么这个项目在面试里经常被拿出来当考点。
2.2 最小运行命令:从 tinyMaze 到 bigMaze
在写任何算法之前,先用自带的搜索代理跑一遍,确认环境没问题。我自己一般会这样做:
python pacman.py -l tinyMaze -p SearchAgent -a fn=dfs-l 指定地图文件,tinyMaze 是一个几乎一眼看穿的小地图;-p SearchAgent 表示使用搜索代理;-a fn=dfs 是传给代理的参数,告诉它调用 search.py 里的 depthFirstSearch。没写任何算法时也能看到吃豆人原地不动或乱走,这很正常,因为空函数返回空动作列表。
然后换中等地图验证可视化:
python pacman.py -l mediumMaze -p SearchAgent -a fn=bfs如果图形窗口没有弹出,很可能是机器缺少图形依赖。在纯服务器环境里,可以改用非图形回放:
python pacman.py -l mediumMaze -p SearchAgent -a fn=bfs -n 1 -q-n 1 表示只跑一局,-q 关闭图形界面。输出里能看到动作序列和最终得分。这里有个细节:-a 后面的键值对会被代理解析成 Python 函数名,所以 fn=dfs 映射到 search.depthFirstSearch。命名对应关系如果错了,会报出 AttributeError,这是第一个常见的启动失败原因。
2.3 用评分器确认四个函数签名没写歪
python autograder.py -q q1这条命令只测第一题,也就是 DFS。通过后继续测:
python autograder.py -q q2 python autograder.py -q q3 python autograder.py -q q4如果你在 Python 3 环境下运行,有些旧版代码会在 print 或 reduce 上报错。常见做法是把 print 改成函数调用,或者直接用社区适配过的 Python 3 分支。不要一上来就自己重写 util,它内部的 PriorityQueue 实现和评分器绑得很死,自己造轮子容易在 tie-breaking 上翻车。
评分器把每个搜索函数当成黑匣子,只看你返回的动作序列是否从起点走到终点,以及展开节点数是否在要求范围内。这意味着你不需要写任何对外接口,只要保持函数名和返回值格式不变就行。
3. 实现 DFS、BFS、UCS:三种盲目搜索的代码与取舍
3.1 为什么盲目搜索也要维护 visited 集合
在树搜索里,我们可以不记录已访问节点,但图搜索必须有。吃豆人的地图本质是一个无向图,而且大量通道是双向的,很多格子能绕回自己。如果不记录,吃豆人会在两个相邻格子之间来回弹跳,直到把栈或队列撑爆。
visited 记录的是已经扩展过的状态,不是“已经压入过的状态”,这里区别很关键。我见过不少人在压栈时就立刻标记,结果漏掉同层的其他分支,返回的路径不是最短。后面代码我会采用“弹出时判断并标记”的方式,虽然会多占一点内存,但语义清晰,出错率低。搜索规模是几十个格子时,这点内存完全不是问题。
3.2 DFS:用栈把地图走成一条道走到黑
def depthFirstSearch(problem): # 用栈作为 open 表,后进先出 from util import Stack start = problem.getStartState() frontier = Stack() frontier.push((start, [], 0)) visited = set() while not frontier.isEmpty(): state, actions, cost = frontier.pop() # 同一个格子可能被多次压栈,遇到已访问的直接跳过 if state in visited: continue visited.add(state) if problem.isGoalState(state): return actions for nextState, action, stepCost in problem.getSuccessors(state): if nextState not in visited: # 保存完整动作序列,避免回溯时去查父节点 frontier.push((nextState, actions + [action], cost + stepCost)) return []这段代码把状态、动作序列、累计代价打包成一个元组压进栈。为什么不在记录父节点后做路径回溯?因为那样需要额外维护一张父表,而且顺序容易搞错。直接在压栈时复制 actions,实现最简单,动作顺序天然正确,唯一的缺点是每次拷贝有微小开销,但对作业地图来说完全可忽略。
DFS 不保证最短路径。比如在 mediumMaze 上,它可能先冲进一条死胡同再退回来,返回的动作序列足够长,评分器只要求你“找到一个解”,DFS 可以过第一题。但它不适合大图上找最优解。
3.3 BFS:用队列保证单位代价下的最短路径
def breadthFirstSearch(problem): # 把栈换成队列,先入先出,天然按层扩展 from util import Queue start = problem.getStartState() frontier = Queue() frontier.push((start, [], 0)) visited = set() while not frontier.isEmpty(): state, actions, cost = frontier.pop() if state in visited: continue visited.add(state) if problem.isGoalState(state): return actions for nextState, action, stepCost in problem.getSuccessors(state): if nextState not in visited: frontier.push((nextState, actions + [action], cost + stepCost)) return []BFS 和 DFS 几乎一样,只有栈换成队列。因为队列先入先出,节点按离起点的距离分层扩展,第一次弹出目标时,动作序列一定是最短路径。这里有个前提:所有边代价相同,也就是 stepCost 恒定是 1。如果地图里移动代价不均等,BFS 就不再保证最优,那是 UCS 的活。
关于 visited 的时机,我在弹出时才标记。这样同一层的两个兄弟节点都指向同一个未访问子节点时,那个子节点可能被压入两次,但第二次弹出时会被 visited 拦掉。代价是多一点内存,换来的是逻辑不会错。如果你想优化,可以在压入前标记,但必须保证不是第一次遇到该状态的路径才会被压入,比较容易写错。
3.4 UCS:把栈换成优先队列,处理不同代价的移动
def uniformCostSearch(problem): # UCS 的核心:优先级是累计代价 g,不是深度 from util import PriorityQueue start = problem.getStartState() frontier = PriorityQueue() frontier.push((start, [], 0), 0) visited = {} # 记录“到达该状态的最小代价”,而不是简单集合 while not frontier.isEmpty(): state, actions, cost = frontier.pop() # 如果当前代价不是到达该状态的最小代价,跳过 if state in visited and visited[state] <= cost: continue visited[state] = cost if problem.isGoalState(state): return actions for nextState, action, stepCost in problem.getSuccessors(state): newCost = cost + stepCost # 只有找到更小代价路径时才压入 if nextState not in visited or newCost < visited[nextState]: frontier.push((nextState, actions + [action], newCost), newCost) return []UCS 的 visited 必须是一个字典,因为同一个格子可能通过不同代价到达。简单集合没法判断“这次是不是更好”,会导致要么重复展开、要么漏掉更优路径。每次压入前都检查 newState 的已知最小代价,只有更小才更新,这保证了扩展顺序就是按真实代价从低到高展开。
代价非负是 UCS 正确工作的前提。这个项目里移动代价默认是 1,但接入自定义 SearchProblem 时一定要确认没有负权边。如果出现负边,UCS 不再可靠,得换 Bellman-Ford 一类的算法,不过这不是本项目的讨论范围。
三种盲目搜索的对比可以收在一张表里:
| 算法 | 数据结构 | 保证最优 | 适用条件 |
|---|---|---|---|
| DFS | Stack | 否 | 快速找到一个解 |
| BFS | Queue | 是 | 所有边代价相同 |
| UCS | PriorityQueue | 是 | 非负代价 |
4. A* 与启发式:让吃豆人少走弯路的正确姿势
4.1 一个 aStarSearch 模板
def aStarSearch(problem, heuristic=nullHeuristic): # 优先级从 g 换成 f = g + h,其余骨架和 UCS 几乎一样 from util import PriorityQueue start = problem.getStartState() frontier = PriorityQueue() startCost = 0 frontier.push((start, [], startCost), startCost + heuristic(start, problem)) visited = {} while not frontier.isEmpty(): state, actions, cost = frontier.pop() # 和 UCS 一样只保留最小代价路径 if state in visited and visited[state] <= cost: continue visited[state] = cost if problem.isGoalState(state): return actions for nextState, action, stepCost in problem.getSuccessors(state): newCost = cost + stepCost if nextState not in visited or newCost < visited[nextState]: priority = newCost + heuristic(nextState, problem) frontier.push((nextState, actions + [action], newCost), priority) return []A* 和 UCS 的区别只有一处:压入优先队列时,优先级从 newCost 变成 newCost + heuristic(nextState, problem)。这意味着扩展顺序不再只看已花掉的代价,而是“已花代价 + 预估剩余代价”。nullHeuristic 就是一个返回 0 的函数,A* 带上它时退化成 UCS。
实现里要小心 heuristic 的调用时机。每访问一个 nextState 就调用一次,所以如果启发式函数本身很贵,比如要跑一次完整寻路,整体性能会崩。作业里启发式基本是曼哈顿距离这种 O(1) 计算,但在自定义问题里要留意这个开销。
4.2 可采纳性:为什么曼哈顿距离能用
可采纳性的定义很简单:启发式值 h(state) 永远不能超过从 state 到目标的真实最短距离。如果 h 高估了,A* 就可能错过真正的最优路径,把吃豆人引向一条看似近实则远的岔路。
曼哈顿距离在四方向网格地图上是可采纳的,因为真实移动至少需要横向差加纵向差那么多步,墙壁只会让路径变长,不可能让路径变短。所以你用曼哈顿距离时,A* 一定返回最优解。
def manhattanHeuristic(position, problem, info={}): # 只适用于有单一 goal 坐标的问题 xy1 = position xy2 = problem.goal return abs(xy1[0] - xy2[0]) + abs(xy1[1] - xy2[1])注意 problem.goal 是 SearchAgent 内部属性,不是所有 SearchProblem 都有。角落问题里目标是“访问完所有角落”,没有单一坐标,直接访问 problem.goal 会报 AttributeError。这时段距离启发式不能直接搬过去用,需要重新设计。
4.3 角落问题和食物问题:不能写死目标点
角落问题的状态是一个元组 (position, visitedCorners),visitedCorners 是四个布尔值,标记四个角落是否被访问过。目标状态不是到达某个点,而是所有布尔值都变成 True。比如 cornersHeuristic 的一个可采纳版本是:取当前位置到所有未访问角落距离的最大值,因为吃豆人必须走到这些角落,路径长度不会低于其中最远的一个。
def cornersHeuristic(state, problem): corners = problem.corners pacman = state[0] visited = state[1] unvisited = [c for i, c in enumerate(corners) if not visited[i]] if not unvisited: return 0 # 一个可采纳但偏松的下界:到最远未访问角落的曼哈顿距离 return max(manhattanDistance(pacman, c) for c in unvisited)这段代码能过评分器,但展开节点数可能不够好。要拿到更紧的下界,可以预计算所有角落两两之间的最短迷宫距离,再用“当前位置到某个角落的距离 + 该角落到其他未访问角落的最短连接”做更严格估计。这本质上是在解一个最小生成树的下界问题。
食物问题类似,状态里多了一个剩余食物坐标列表。最简单的可采纳启发式是:当前位置到所有剩余食物坐标距离的最大值。因为要吃光所有食物,必须至少走到离当前位置最远的那个食物。更紧的版本会考虑食物两两之间的迷宫距离,把问题建模成旅行商的下界。
4.4 启发式质量怎么评估:看 nodesExpanded
作业评分器会报告展开节点数 nodesExpanded,这是衡量启发式质量最直接的指标。同一个地图上,nullHeuristic 展开几千个节点,用曼哈顿距离可能降到几百个,再换成更好的启发式可能只需要几十个。
python pacman.py -l mediumMaze -p SearchAgent -a fn=astar,heuristic=manhattanHeuristic运行后终端会打印找到解的花费、动作数和展开节点数。你可以把 heuristic 参数依次换成 nullHeuristic 和 manhattanHeuristic,观察节点数变化。注意 heuristic 参数必须是 searchAgents.py 里定义的函数名,直接写 lambda 或匿名函数不会被命令行解析器接受。
5. 常见问题与避坑:五个我亲眼见过的翻车现场
5.1 死循环:打开任务管理器才发现 CPU 被吃满
现象:pacman.py 跑起来后没有报错,也没有输出,进程一直在跑,CPU 占用接近 100%。
原因:最常见的是忘记维护 visited,或者虽然维护了,但标记时机不对。比如在 getSuccessors 返回的新状态中,如果不去重,两个格子之间会反复互相压栈,形成环。
解决:在 while 循环开头检查 state 是否已在 visited 中,没有则加入,再继续扩展。如果你改成压入时标记,务必确认所有分支共享同一个 visited,并且不会漏掉同一层的其他路径。调试时可以先用 tinyMaze 跑,不管算法好坏它都很快结束,能快速暴露死循环。
5.2 返回空路径:目标判断写错了状态
现象:评分器显示搜索成功,但返回的 actions 是空列表,吃豆人原地不动,得分很低。
原因:isGoalState 判断使用的状态和实际状态格式对不上。比如在角落问题里,目标是 visited 全为 True,而不是当前位置等于某个坐标。如果你写成“到达左下角就结束”,那吃豆人访问第一个角落后就停了。
解决:打印每种问题下的 state 结构。SearchAgent 的目标状态在不同地图里长得不一样,先跑一次python pacman.py -l tinyCorners -p SearchAgent -a fn=bfs,在 isGoalState 里临时加 print 看 state 的样子。确认后再写判断条件。
5.3 超时:DFS 在 bigMaze 上彻底失踪
现象:tinyMaze 和 mediumMaze 都能过,但换到 bigMaze 后长时间不结束,最后评分器超时。
原因:DFS 深度优先的特性会让它沿一条分支走到黑,如果地图里有一条很长的死路,它会在里面越走越深,visited 拦不住已经走过的路,但它会遍历完整个无环分支才回头。bigMaze 里这种分支很多。
解决:换 BFS 或 UCS。单位代价地图上 BFS 不仅能保证最短路径,展开顺序也按层推进,不会一头扎进死胡同。如果你必须用 DFS,可以考虑限制最大搜索深度,但这是偏门做法,作业不会这么要求。
5.4 节点爆炸:启发式高估导致 A* 变愚蠢
现象:A* 跑出来的路径不是最短的,或者展开节点数比 UCS 还多。
原因:启发式函数不可采纳。常见错误是把食物数量当距离,比如 h = 最近食物距离 + 剩余食物数。剩余食物数是一个没有单位的数字,可能大于真实剩余距离,导致高估。A* 一旦高估,就失去了最优性保证,优先级排序被误导,节点数可能暴涨。
解决:回归可采纳性定义。对任意状态,手算一下真实最小剩余距离,再把启发式值代进去比较。比如在地图上选一个角落状态,用 BFS 算一遍真实到目标的距离,确保 h <= 真实距离。写一个简单的验证脚本,随机采样几百个状态逐一比对。
5.5 评分异常:Python 版本和 util 实现问题
现象:所有算法看起来都对,但 autograder 报错,或者本地跑和评分器跑的结果不一致。
原因:最常见是 Python 2 和 Python 3 的语法差异。老代码里的 print 语句、xrange 等在 Python 3 下会直接报错。另一个原因是有人重写了 util.py 里的 PriorityQueue,改变了相同优先级下的出队顺序,导致 tie-breaking 不同。
解决:优先用项目自带的 util.py,不要重写。Python 版本问题,检查报错信息里的异常类型,逐个把 print 改成 print(),把 xrange 改成 range。如果你不确定环境,先用python --version确认版本,再决定是否要切换到 Python 3 适配分支。
6. 进阶技巧:从寻路到全局规划,验证你的解法值得信
搜索算法写完了,怎么知道它真的可靠?我习惯的做法是做一个对比实验:同一张 mediumMaze,分别用 DFS、BFS、UCS、A* 跑一遍,记录动作数和展开节点数。BFS 和 UCS 的动作数应该相同,因为边代价全是 1;DFS 的动作数通常会更高;A* 的展开节点数应该明显少于 UCS。如果 BFS 和 UCS 结果不一致,说明你的 UCS 代价更新逻辑有 bug,这是最简单的交叉验证。
可视化调试里,我一般会在 getSuccessors 里临时加一个计数器,看每个状态被访问的次数。如果某个 state 被访问了几十次,说明 visited 没生效,或者压入逻辑里有重复路径。改完后删除计数器,别留在提交代码里。
启发式函数的迁移能力是这个项目最值钱的部分。你写好的 cornersHeuristic 和 foodHeuristic 不只是为吃豆人服务的,它们展示的是如何把一个约束复杂的组合优化问题拆成可采纳的下界估计。我做路径规划相关的项目时,会把同样的思路用在多目标巡检、仓库机器人取货顺序上:先把目标点之间的两两距离预计算好,再用最小生成树下界指导 A* 搜索。
还有一个实用技巧:用文本输出替代图形界面调试。服务器上没有显示器时,用 -q 跑完全局后,把动作序列打印出来,手动在地图上走一遍。我踩过最深的坑是启发式返回了浮点数,和整数代价混在一起导致比较顺序不稳定,后来统一转成整数才算安心。这个项目让我养成了一个习惯:任何搜索算法上线前,先在小地图上暴力验证一遍最优解,再放大规模,永远不要默认自己的 visited 写对了。希望帮到你。
本文还有配套的精品资源,点击获取