简介:这是一份面向人工智能初学者的 UC Berkeley AI Pacman 项目搜索算法解决方案,使用 Python 编写,完整覆盖经典吃豆人游戏中的路径规划与智能决策问题,尤其适合正在学习 CS188 或人工智能导论课程的学生。压缩包共 23 个文件:20 个 Python 脚本构成核心代码,涵盖搜索代理、图形显示、游戏逻辑、自动评分等模块;另有 1 份 Markdown 文档、1 份命令文本及 1 份开源许可证,整体仅 67KB,轻量易部署。目前已有 561 人浏览学习,实践反馈良好。代码中实现了 BFS、DFS、A*、Dijkstra 等经典搜索算法,并进一步扩展至 Minimax、α-β 剪枝与 Q-learning 等进阶策略;配套的 searchAgents.py、graphicsDisplay.py 等文件支持直接运行与可视化调试,还附带八数码、八皇后等经典搜索问题的扩展实现。无论是完成课程作业、竞赛准备,还是深入理解 AI 决策原理,这份资源都能提供可参考的完整示例与实现思路。
1. 这不是游戏代打:UC Berkeley 的 Search 项目到底要你交什么
先说结论:这份 UC Berkeley AI Pacman 项目的 Search 解决方案,是一整套 CS188 第一周作业的完整实现,不是给游戏写外挂的脚本。它的核心是四个搜索算法——DFS、BFS、UCS、A*,外加角落问题(CornersProblem)和食物问题(FoodSearchProblem)两套自定义建模与启发式函数,全部落在 Python 写的官方框架里。你拿到手能直接在 tinyMaze、mediumMaze、bigMaze、trickySearch 这些官图上一行命令跑起来,并用 autograder 对照 q1–q7 自查得分。适合三种人:正在啃 AI 导论、对着模板发呆的留学生;自学搜索算法想找个可视化 playground 的开发者;以及想抄一套干净实现当 AI 应用开发面试底稿的求职者。
2. 四个搜索算法一次写对:栈、队列、优先队列与路径还原
官方项目里需要你动笔的只有两个文件:search.py 和 searchAgents.py。search.py 里四个空函数对应 autograder 的 q1–q4,searchAgents.py 里的三个启发式对应 q5–q7。很多第一次做的人卡在同一个地方:算法思路都懂,但不知道 SearchProblem 这个抽象类到底给了什么,返回值到底该是什么。这一章先把框架讲透,再给能直接抄的四个函数,最后给一套验证命令。
2.1 SearchProblem 抽象类:三个接口决定一切
你写的每个搜索函数都只接收一个 problem 参数,它长这样:
class SearchProblem: def getStartState(self): # 返回初始状态,例如吃豆人的起始坐标 (x, y) ... def isGoalState(self, state): # 判断某个状态是否到达目标 ... def getSuccessors(self, state): # 返回 [(nextState, action, stepCost), ...] # action 是 'North' / 'South' / 'East' / 'West' 这样的字符串 ...重点是返回值约定:四个搜索函数最后都要返回一个动作列表,比如['North', 'East', 'East'],而不是状态坐标列表,也不是总代价。为什么这么设计?因为 SearchAgent 这个 AI Agent 每帧要决定往哪走,pacman.py拿到动作列表后按顺序消费;autograder 验证的也是「从起点按这些动作走,能不能到目标」。
另一个容易忽略的点:getSuccessors 返回的 action 是字符串,cost 是数值,搜索节点里每一步是谁、走的什么方向都由这个三元组携带。我见过有人把 cost 丢了的版本,写到 UCS 才发现要回头补,所以一开始就在节点里带上 cost 最省事。
2.2 DFS 和 BFS:同一个模板,换一种容器
from util import Stack, Queue def depthFirstSearch(problem): frontier = Stack() # LIFO,后进先出 frontier.push((problem.getStartState(), [])) visited = set() while not frontier.isEmpty(): state, actions = frontier.pop() if problem.isGoalState(state): return actions # 返回动作序列 if state not in visited: visited.add(state) # 弹出时才标记 for nxt, action, stepCost in problem.getSuccessors(state): if nxt not in visited: frontier.push((nxt, actions + [action])) return [] def breadthFirstSearch(problem): frontier = Queue() # FIFO,先进先出 frontier.push((problem.getStartState(), [])) visited = set() while not frontier.isEmpty(): state, actions = frontier.pop() if problem.isGoalState(state): return actions if state not in visited: visited.add(state) for nxt, action, stepCost in problem.getSuccessors(state): if nxt not in visited: frontier.push((nxt, actions + [action])) return []两个函数结构完全一样,唯一的差别是Stack()换成了Queue(),这就是 DFS 与 BFS 的全部区别。逻辑说明:每次从容器里弹出一个节点,先判目标、再判 visited、然后展开后继。visited 统一在弹出时加入,同时 push 前再做一次去重过滤——这是一套双保险:push 前过滤防止重复状态在容器里堆积,弹出时再确认是为了兜住「两个不同父节点把同一个子节点同时压入」的边界情况。
参数说明:actions + [action]是新建列表而不是 append,因为多条分支必须各自持有独立路径,共享一个列表会互相污染。在 PositionSearchProblem 里所有 stepCost 都是 1,所以同一张图上 UCS 和 BFS 表现几乎一致,q3 真正考的是你在非等权图上怎么处理累计代价与弹出顺序。
2.3 UCS 和 A*:g 与 h 怎么进优先队列
from util import PriorityQueue def uniformCostSearch(problem): frontier = PriorityQueue() start = problem.getStartState() frontier.push((start, [], 0), 0) visited = {} # state -> 目前为止的最好代价 while not frontier.isEmpty(): state, actions, cost = frontier.pop() if problem.isGoalState(state): return actions if state in visited and visited[state] <= cost: continue # 这个状态已经以更优代价展开过 visited[state] = cost for nxt, action, stepCost in problem.getSuccessors(state): newCost = cost + stepCost if nxt not in visited or newCost < visited[nxt]: frontier.push((nxt, actions + [action], newCost), newCost) return []逻辑说明:优先队列按 priority 弹出最小元素,UCS 的 priority 就是累计代价 g。这里故意没有用「标记-更新」式的闭包表,因为 util 里的 PriorityQueue 只提供 push/pop,没有 decrease-key;常见做法是允许同一个状态重复进队列,靠 visited 字典在弹出时裁掉更差的版本。
A* 与 UCS 的差异只有一行:
def aStarSearch(problem, heuristic=nullHeuristic): frontier = PriorityQueue() start = problem.getStartState() frontier.push((start, [], 0), heuristic(start, problem)) visited = {} while not frontier.isEmpty(): state, actions, cost = frontier.pop() if problem.isGoalState(state): return actions if state in visited and visited[state] <= cost: continue visited[state] = cost for nxt, action, stepCost in problem.getSuccessors(state): newCost = cost + stepCost if nxt not in visited or newCost < visited[nxt]: priority = newCost + heuristic(nxt, problem) frontier.push((nxt, actions + [action], newCost), priority) return []A* 的 priority 变成newCost + heuristic(nxt, problem),其中 newCost 是已经付出的 g,h 是到目标的估计。search.py 顶部的nullHeuristic返回 0,把它接进来,A* 就自动回落到 UCS——这也是为什么 q3、q4 可以共用同一套验证链。
2.4 命令行验证:从 tinyMaze 到 openMaze
模板自带的 pacman.py 是完整可运行的游戏框架,验证搜索算法的命令格式是:
python pacman.py -l 地图名 -p SearchAgent -a "fn=算法名,heuristic=启发式名"参数说明:
| 参数 | 作用 | 常用值 |
|---|---|---|
| -l | 指定 layouts 目录下的 .lay 地图 | tinyMaze / mediumMaze / bigMaze / openMaze / trickySearch |
| -p | 指定吃豆人 Agent | SearchAgent |
| -a | 传给 Agent 的参数 | fn=dfs, fn=astar, heuristic=..., prob=... |
| -q | 静默模式,只打印路径代价与统计 | — |
| --frameTime | 动画帧间隔,0 为最快 | 0 或更小数值 |
一套标准验证命令按梯度跑:
python pacman.py -l tinyMaze -p SearchAgent -a fn=dfs python pacman.py -l mediumMaze -p SearchAgent -a fn=bfs python pacman.py -l bigMaze -p SearchAgent -a fn=astar,heuristic=manhattanHeuristic python pacman.py -l openMaze -p SearchAgent -a fn=ucs对照关系很直观:tinyMaze 上四个算法结果一样,闭着眼都能过;mediumMaze 用 BFS 拿到最短步数,DFS 虽然也找到解但路径肉眼可见地绕;bigMaze 没有曼哈顿启发式的裸 A* 会慢慢爬,带heuristic=manhattanHeuristic通常一秒内出结果。跑的时候我建议一律加-q,别让动画拖慢验证节奏;真要开可视化,把--frameTime调小,默认值在复杂地图上非常劝退。
3. 启发式函数决定成败:曼哈顿、角落问题与食物问题的下界设计
q5–q7 全部在 searchAgents.py 里完成:q5 要一个 PositionSearchProblem 用的曼哈顿启发式,q6 要 CornersProblem 的角落启发式,q7 要 FoodSearchProblem 的食物启发式。前两个是白送分,真正拉开差距的是第三个。这一章把三个启发式的下界思路拆开讲。
3.1 可采纳性与一致性:A* 最优性的两条命脉
先把两个词钉死。可采纳性(admissible)要求 h(state) 永远不超过从 state 到目标的真实最短代价,这是 A* 返回最优解的必要条件;一致性(consistency)要求 h(A) ≤ c(A, B) + h(B),也就是说沿边前进时启发式不能跳崖式下降,它保证图搜索版本下每个节点最多被展开一次。
我记这两个概念的方法:可采纳性是「不许高估」,一致性是「不许突然变乐观」。如果启发式高估,A* 就会像生成式模型产生幻觉一样,自信满满地朝一条次优路径冲过去,最后返回一个非最优的动作序列,autograder 直接判 fail。所以每次写完启发式,先在小地图上验证路径代价是否等于 BFS 的基准值,再谈展开节点数。
3.2 cornersHeuristic:四个角都要去,下界怎么算
CornersProblem 的状态是二元组(吃豆人位置, visitedCorners),visitedCorners 是四个布尔值,表示四个角各去过没有,目标是把四个角全部打卡。这里最容易写出不可采纳的启发式:把到所有未访问角的曼哈顿距离直接求和。以吃豆人在 (5,5)、角在 (0,0)、(0,10)、(10,0)、(10,10) 为例,四条距离加起来就是严重高估——真实路径是一条串行走过的折线,不是从当前位置向四个角放射。
def cornersHeuristic(state, problem): pos, visitedCorners = state # visitedCorners 是 (bool, bool, bool, bool),下标对应 problem.corners unvisited = [problem.corners[i] for i in range(4) if not visitedCorners[i]] if not unvisited: return 0 def md(a, b): return abs(a[0] - b[0]) + abs(a[1] - b[1]) # 下界1:当前位置到最近的未访问角 nearest = min(md(pos, c) for c in unvisited) # 下界2:未访问角之间的最小生成树(Prim,曼哈顿距离作边权) nodes = unvisited[:] mst_cost = 0 in_tree = {nodes[0]} rest = set(nodes[1:]) while rest: edge = min((md(a, b), a, b) for a in in_tree for b in rest) mst_cost += edge[0] in_tree.add(edge[1]) rest.remove(edge[1]) return nearest + mst_cost逻辑说明:任何可行路径都必然先到达第一个未访问角,这段距离至少是 nearest;之后要把剩余未访问角全串起来,把所有未访问角连通的最小代价就是它们之间的最小生成树。所以 nearest + MST 是真实最优代价的合法下界。
参数说明:为什么用曼哈顿距离做 MST 边权?因为迷宫里的真实距离一定不少于曼哈顿距离,用更小的边权算出来的 MST 只会更小,下界依然成立,而且不需要在启发式里反复跑 BFS。四个角的规模下,Prim 的开销可以忽略。problem.corners是构造 CornersProblem 时传入的四个角坐标列表,顺序必须和 visitedCorners 下标一一对应,写反了启发式会变得不可采纳。
3.3 foodHeuristic:把 trickySearch 的节点数压进 700
FoodSearchProblem 的状态是(吃豆人位置, foodGrid),foodGrid 是一个布尔网格,用foodGrid.asList()直接拿到所有剩余食物的坐标。q7 的硬指标是:用fn=astar, prob=FoodSearchProblem, heuristic=foodHeuristic跑 trickySearch,要返回最优解且展开节点数少于 700。写 nullHeuristic 的话 A* 退化成 UCS,在这个地图上节点数会膨胀到几千,直接丢分。
def foodHeuristic(state, problem): position, foodGrid = state food = foodGrid.asList() if not food: return 0 def md(a, b): return abs(a[0] - b[0]) + abs(a[1] - b[1]) # 下界1:当前位置到最近食物的距离 nearest = min(md(position, f) for f in food) # 下界2:所有食物之间的最小生成树(曼哈顿边权) nodes = food[:] mst_cost = 0 in_tree = {nodes[0]} rest = set(nodes[1:]) while rest: edge = min((md(a, b), a, b) for a in in_tree for b in rest) mst_cost += edge[0] in_tree.add(edge[1]) rest.remove(edge[1]) return nearest + mst_cost逻辑说明:和角落问题同一个套路——先到最近食物,再用食物之间的 MST 覆盖剩余食物,两个下界相加。代码也几乎是从 3.2 平移过来的,唯一区别是节点集合从「未访问角落」换成「剩余食物」。参数说明:Prim 每轮找当前树到剩余节点的最短边,食物数量在 trickySearch 上是几十的量级,单次启发式计算是 O(m²)(m 为剩余食物数),配合 700 节点的限制整体耗时完全可控。
真实复现经验:曼哈顿边权版本在 trickySearch 上通常能把展开节点数压进 700。如果你本地跑出来刚好卡线,另一个常见做法是把曼哈顿换成「对每对食物和位置预计算 BFS 真实距离」再算 MST,下界更紧,节点数能再降一个量级;代价是初始化时要跑几十次 BFS,地图越大越划算。
3.4 三个启发式的对比选择
| 问题 | 常用启发式 | 下界构成 | 典型效果 |
|---|---|---|---|
| PositionSearchProblem(q5) | manhattanHeuristic | 到单一目标点的曼哈顿距离 | bigMaze 上节点数远低于裸 BFS |
| CornersProblem(q6) | cornersHeuristic | 最近角距离 + 角间 MST | mediumCorners 节点数降一个量级以上 |
| FoodSearchProblem(q7) | foodHeuristic | 最近食物距离 + 食物间 MST | trickySearch 可过 700 节点硬指标 |
选择逻辑:启发式越紧,A* 展开越少,但每次计算越贵。曼哈顿距离是 O(1),四个角的 MST 是常数,食物间 MST 是 O(m²)。作业这个规模直接用曼哈顿版本即可;在更大的自定义地图上才需要换 BFS 距离并预计算。
还有一个小技巧:如果节点数刚好卡线,可以把优先队列的 priority 从单值改成元组,例如(newCost + h, -h),同分时优先展开 h 大的节点,通常能再压掉一批。另外我一般会先跑 tinyCorners、trickySearch 这类小图验证正确性,再上 medium 和 big 图,别把搜索过程当黑匣子直接跑大图,翻车了分不清是算法问题还是启发式问题。
4. 避坑指南:Search 项目最常见的五个翻车现场
这一章全是血泪经验。我拆这套项目时把最常见的五类问题按「现象 → 原因 → 解决」列出来,你照着对号入座。
4.1 返回的不是动作序列,autograder 直接报错
现象:autograder 输出 FAIL,提示 "path does not end at a goal state",或者图形界面里吃豆人沿错误方向移动、原地打转。 原因:search.py 的四个函数要求返回动作列表(如['North', 'East']),有人却返回了状态坐标列表,或者把 (state, actions, cost) 整个三元组返回。autograder 从返回结果里取动作序列,类型和内容都对不上。 解决:检查 return 的对象必须是actions,且每一项是 getSuccessors 返回的那个字符串。拿不准就在 return 前 print 一下前两个动作,确认是 'North'/'South'/'East'/'West' 这类值。
4.2 同一个状态反复入栈,mediumMaze 卡成 PPT
现象:DFS 跑 mediumMaze 时展开节点数暴涨,动画一帧一帧挪,最后路径还特别长。 原因:只做弹出时的 visited 检查,push 前没去重。mediumMaze 这种有多个回路的图,同一个格子会被不同路径反复压入,栈里堆积大量重复节点。 解决:push 前加if nxt not in visited过滤,同时保留弹出时的if state not in visited兜底。前一道拦截防堆积,后一道防两个父节点把同一个子节点同时压入。这套双保险在四个算法里通用。
4.3 A* 的 priority 漏掉 g,退化成贪心还浑然不觉
现象:bigMaze 上 A* 秒出路径,但路径代价比 BFS 的最优值大一截;换几张图路径肉眼可见地绕。 原因:push 时 priority 写成了heuristic(nxt, problem),把已经走过的 g 丢了。A* 的 priority 必须是newCost + heuristic(nxt, problem),缺了 g 就是贪心最佳优先搜索。 解决:对照 2.3 的模板逐行检查 priority 那一行。有个自查技巧:把 heuristic 换成 nullHeuristic,如果展开节点数和路径代价值和 UCS 完全一致,说明 g 的部分没错,问题只可能出在启发式本身。
4.4 启发式高估:启发式「幻觉」导致路径非最优
现象:foodHeuristic 或 cornersHeuristic 在小图上能出解,但 autograder 的 q6/q7 判 "heuristic is not admissible",或者路径代价高于基准值。启发式调参在早期我看来基本是玄学,直到搞懂可采纳性才不亏分。 原因:最常见的写法是把到所有剩余目标的距离求和。角落问题的例子在 3.2 已经说过:四条曼哈顿距离相加是典型的 AI 幻觉式高估——真实路径是串行折线,不是从当前位置向多个目标放射。 解决:改用「最近目标距离 + 剩余目标间 MST」的下界结构,参考 3.2 和 3.3 的代码。验证方法:先跑一次 BFS 拿到最优代价,再跑 A* 对比,两者不一致就一定是启发式的问题;再快一点的检查是手算几个状态,确认 h 确实小于等于真实代价。
4.5 无显示环境跑可视化:窗口秒退或直接卡死
现象:在服务器或远程终端里执行 pacman.py,要么报 no display,要么图形窗口一闪而过,要么整条命令卡住不动。 原因:graphicsDisplay 依赖本地图形环境(tkinter),headless 机器上没有 X 服务。很多人以为是代码写错了,其实只是环境问题。 解决:验证逻辑一律用-q静默模式,只读输出的路径代价和节点统计;要看搜索过程就在本机跑,并加--frameTime 0加速。本机缺 tkinter 的就装上对应系统包,比如 Ubuntu 下sudo apt install python3-tk。注意无显示环境下千万别去掉-q,否则 autograder 也会因为创建不了窗口而挂掉。
5. 把 autograder 当回归测试:验收、自定义地图与复盘习惯
5.1 autograder 与节点数硬指标
官方评分脚本是一等一的回归测试工具:
python autograder.py -q q1 # 只测 DFS python autograder.py -q q7 # 只测 foodHeuristic python autograder.py # 全量跑 q1-q7每个 question 内部有多条测试用例,会检查正确性、最优性和展开节点数。我的习惯是每写完一个函数就跑一次对应 -q,全绿再动下一个;改 searchAgents.py 之前先全量跑一遍存基线,防止调启发式时把 search.py 带崩。
5.2 自己造一张 layout,验证不再靠猜
官方 layouts 目录下的 .lay 是纯文本,符号约定:%是墙、空格是空地、P是吃豆人起点、.是食物、G是幽灵起点。手搓一张测角落启发式的小图:
%%%%% % % %.P % % . % %%%%%保存成 layouts/myTest.lay。注意两个硬要求:每一行长度必须一致,不足补空格;四周必须用 % 围死,否则 pacman.py 解析会出错或者越界。然后:
python pacman.py -l myTest -p SearchAgent -a "fn=astar,prob=CornersProblem,heuristic=cornersHeuristic"从那以后我每次写完启发式都强制走一遍流程:autograder 全量基线 → 小图人工验证 → 大图压测节点数 → 再提交。这套流程帮我省掉的返工次数比任何教程都值,特别是 q7 卡 700 节点的阶段,没有基线根本分不清是下界松了还是算法写坏了。希望帮到你。
本文还有配套的精品资源,点击获取