简介:这份资源是UC Berkeley AI Pacman项目中Search搜索模块的完整解决方案,面向正在学习人工智能搜索算法、希望动手实践的高校学生与自学者,帮助读者理解并实现吃豆人游戏中的智能决策逻辑。压缩包共23个文件,以20个Python脚本为核心,涵盖搜索智能体、游戏主逻辑、图形与文本显示、自动评分及测试类等模块,另附README说明、命令清单与许可证文件,整体约67KB,结构紧凑、便于按模块查阅。目前已有565人学习下载,可作为课程作业或自学项目的参考实现。读者可从中获得BFS、DFS、A*、Dijkstra等基础搜索算法的落地代码,以及Minimax、α-β剪枝、状态空间搜索与Q学习等进阶策略的实现思路,并借助配套测试与评分脚本验证结果、排查问题,从而系统掌握用Python构建游戏AI的方法。
1. 吃豆人里的 AI 实验场:一份能跑起来的 Pacman 搜索算法实现
很多人第一次接触人工智能课,卡住的不是概念,而是“算法写完了,怎么知道它对不对”。吃豆人这个游戏恰好是个天然的验证台:地图是网格,幽灵和豆子的位置都是离散坐标,搜索算法的每一步都能被肉眼观察。这份 UC Berkeley AI Pacman 项目的搜索部分解决方案,做的就是这件事——把深度优先、广度优先、一致代价、A* 四种经典搜索算法,落到一个能实际运行、能看得到吃豆人走位的 Python 工程里。它适合正在学 AI 导论、想对照课程作业验证自己实现、或者想拿一个干净的小项目练手搜索算法的开发者。整个项目用 Python 写成,核心逻辑集中在几个待补全的函数里,跑通之后你能直观看到不同算法在同一张地图上的路径差异,这种反馈比看十页伪代码都管用。
2. 工程结构与运行方式:从目录到第一条命令
2.1 目录布局与各文件职责
拿到这份源码,先别急着改代码,花五分钟把目录结构摸清楚,后面定位问题会快很多。这类课程项目通常遵循固定的骨架,常见做法是下面这种布局:
pacman-search/ ├── pacman.py # 游戏主入口,负责解析参数、启动界面或文本模式 ├── game.py # 游戏状态、Agent 基类、网格与动作定义 ├── ghostAgents.py # 幽灵行为策略 ├── layout.py # 地图文件解析,把 .lay 文本转成网格 ├── search.py # 搜索算法框架,SearchProblem 与通用搜索函数 ├── searchAgents.py # 吃豆人 Agent,待补全的搜索实现都在这里 ├── util.py # 优先队列、栈、队列等数据结构 ├── layouts/ # 地图文件,如 mediumMaze.lay、bigMaze.lay └── test_cases/ # 各算法的测试用例与期望输出search.py是算法骨架,里面定义了depthFirstSearch、breadthFirstSearch、uniformCostSearch、aStarSearch这些入口函数,它们统一调用一个通用的图搜索过程。searchAgents.py才是真正要动手的地方,吃豆人如何把“我在哪、豆子在哪、墙在哪”抽象成一个搜索问题,全在这个文件里。game.py提供Agent基类和Directions常量,util.py里的PriorityQueue是 A* 和一致代价搜索的底层容器。理解这个分层,你就知道改逻辑只动searchAgents.py,别去碰game.py的引擎部分。
2.2 环境准备与启动命令
项目依赖很轻,标准 Python 3 环境即可,不需要额外装第三方库。图形界面依赖tkinter,多数 Python 发行版自带,如果启动时报找不到模块,按系统补装对应的 tk 包就行。先确认版本,再跑一个最小地图验证环境:
# 确认 Python 版本,项目在 3.6 以上均可运行 python3 --version # 文本模式跑一张小地图,不弹窗,适合远程或快速验证 python3 pacman.py -l tinyMaze -p SearchAgent -a fn=bfs # 图形界面模式,能实时看到吃豆人一步步移动 python3 pacman.py -l mediumMaze -p SearchAgent -a fn=astar,heuristic=manhattanHeuristic-l指定地图名,对应layouts/下的文件;-p指定 Agent 类;-a传参给 Agent,fn=bfs表示用广度优先,fn=astar配合heuristic指定启发函数。文本模式输出的是路径动作序列和最终得分,图形模式则把每一步渲染出来。新手建议先用tinyMaze加文本模式,输出短、好对照;确认逻辑对了再换大图看效果。
2.3 测试用例怎么用
项目自带测试用例,这是验证实现是否正确的关键。常见做法是每个算法配一个test_cases/下的输入输出对,跑完对比路径长度和扩展节点数:
# 跑单个测试用例,检查 BFS 在 tinyMaze 上的表现 python3 pacman.py -l tinyMaze -p SearchAgent -a fn=bfs -q # -q 表示安静模式,只输出结果不渲染,适合批量跑-q关掉动画后跑得快很多,批量验证时必开。测试用例里通常会校验两件事:一是最终路径是否到达目标,二是扩展节点数是否在合理范围。扩展节点数超标往往意味着你的搜索没有正确去重,或者优先队列的优先级设错了,这个指标比“能不能走到终点”更能暴露实现问题。
3. 核心模块拆解:搜索问题抽象与四种算法落地
3.1 把游戏状态抽象成 SearchProblem
所有搜索算法的前提,是把“吃豆人找豆子”翻译成一个标准搜索问题。search.py里定义了SearchProblem基类,要求子类实现几个方法:getStartState返回初始状态,isGoalState判断是否到达目标,getSuccessors返回后继状态及动作和代价,getCostOfActions计算路径总代价。searchAgents.py里的PositionSearchProblem就是具体实现,状态用吃豆人的(x, y)坐标表示。
class PositionSearchProblem(SearchProblem): def __init__(self, gameState): # 从游戏状态里取出吃豆人当前位置作为搜索起点 self.startState = gameState.getPacmanPosition() # 记录墙的布局,用于判断后继是否合法 self.walls = gameState.getWalls() self.goal = None # 目标在子类或外部指定 def getStartState(self): return self.startState def isGoalState(self, state): # 状态就是坐标,直接和目标坐标比较 return state == self.goal def getSuccessors(self, state): successors = [] x, y = state # 四个方向逐一尝试,撞墙或越界就跳过 for action, (dx, dy) in [('North', (0, 1)), ('South', (0, -1)), ('East', (1, 0)), ('West', (-1, 0))]: nx, ny = x + dx, y + dy if not self.walls[nx][ny]: # 每走一步代价为 1,返回 (后继状态, 动作, 代价) successors.append(((nx, ny), action, 1)) return successors这段代码的关键在于状态的定义粒度。这里用单个坐标,是因为目标只有一个豆子;如果目标变成“吃掉所有豆子”,状态就得扩展成(位置, 剩余豆子集合),搜索空间会指数级膨胀,这也是后续多目标问题的难点所在。getSuccessors里对墙的判断用的是self.walls[nx][ny],注意坐标顺序是先行后列,写反了会出现吃豆人穿墙的玄学现象,我第一次写的时候就栽在这。代价统一设为 1,是因为网格移动每步等长;如果地图有权重地形,这里就要改成对应代价,一致代价搜索才有意义。
3.2 通用图搜索框架与去重逻辑
四种算法共用一套图搜索骨架,差异只在“从边缘集合里取哪个节点扩展”。search.py里的通用函数大致长这样:
def graphSearch(problem, frontier): # frontier 是边缘集合,不同算法传入不同数据结构 frontier.push((problem.getStartState(), [], 0)) visited = set() # 已扩展节点集合,防止重复扩展 while not frontier.isEmpty(): state, actions, cost = frontier.pop() if problem.isGoalState(state): return actions if state in visited: continue visited.add(state) for nextState, action, stepCost in problem.getSuccessors(state): if nextState not in visited: frontier.push((nextState, actions + [action], cost + stepCost)) return []frontier的多态是这套设计的精髓:DFS 传栈(后进先出),BFS 传队列(先进先出),一致代价和 A* 传优先队列(按代价或代价加启发排序)。visited集合负责去重,注意它在节点出队时才加入,而不是入队时,这个细节决定了算法是“图搜索”还是“树搜索”。如果入队就去重,某些需要重新访问的更优路径会被错误剪掉,A* 的最优性就没了。代价累加用cost + stepCost而不是直接取stepCost,是因为一致代价搜索要比较累计代价,写错这一行,算法会退化成 BFS。
3.3 四种算法的实现差异与参数
把frontier换掉,算法就切换了。下面是对照关系,改代码时对着这张表看:
| 算法 | frontier 结构 | 排序依据 | 最优性 | 适用场景 |
|---|---|---|---|---|
| DFS | 栈 Stack | 后进先出 | 否 | 内存受限、只求可行解 |
| BFS | 队列 Queue | 先进先出 | 是(等代价) | 步数最少、无权图 |
| UCS | 优先队列 | 累计代价 g | 是 | 有权重地形 |
| A* | 优先队列 | g + h | 是(h 可采纳) | 大图、追求效率 |
A* 的启发函数manhattanHeuristic用曼哈顿距离,即abs(x - gx) + abs(y - gy)。这个启发是可采纳的,因为它永远不大于实际剩余步数(网格四方向移动下成立),所以 A* 能保证最优。如果换成欧氏距离,虽然也可采纳但更松,扩展节点会变多;如果启发过估计,比如乘了个大于 1 的系数,速度上去了但可能错过最优解,这是典型的用最优性换效率的取舍。实际调参时,我一般先用曼哈顿距离跑通,再根据地图规模决定要不要加权重。
3.4 从单目标到多目标的状态设计
单豆子搜索跑通后,真正的挑战是多目标:吃豆人要把地图上所有豆子吃完。状态不能再只是坐标,得带上剩余豆子集合:
class FoodSearchProblem(SearchProblem): def getStartState(self): # 状态 = (吃豆人位置, 剩余豆子元组) return (self.startPosition, tuple(sorted(self.food))) def isGoalState(self, state): # 剩余豆子为空即完成 return len(state[1]) == 0 def getSuccessors(self, state): position, food = state successors = [] for nextPos, action, cost in self._move(position): # 走到豆子上就把它从集合里移除 newFood = tuple(f for f in food if f != nextPos) successors.append(((nextPos, newFood), action, cost)) return successors这里把豆子集合排序后转成元组,是为了让状态可哈希,能放进visited集合去重。如果不排序,同样的剩余豆子集合因为顺序不同会被当成不同状态,搜索空间白白翻几倍。多目标问题的状态数随豆子数量指数增长,A* 的启发函数也得相应调整,常见做法是用“到最近豆子的距离”或“最小生成树下界”来估计剩余代价,前者简单但偏松,后者更紧但计算量大。这一步是搜索作业里最容易翻车的地方,状态设计错了,小地图能跑,大地图直接卡死。
4. 避坑与排查:跑不通时先看这几处
4.1 吃豆人原地打转或穿墙
现象是吃豆人在两个格子之间来回走,或者直接穿过墙壁。原因多半是getSuccessors里坐标顺序写反,walls[nx][ny]被写成了walls[ny][nx],导致墙的判断完全错位。解决方法是打印一张小地图的walls矩阵,对照layouts/tinyMaze.lay的文本布局逐格核对,确认行列索引和地图文本的行列一致。这类错误在图形模式下特别明显,文本模式反而不容易发现。
4.2 搜索能到终点但扩展节点数异常大
现象是路径正确,但测试用例报扩展节点数超标。原因通常是visited去重的时机不对,或者在getSuccessors里没有过滤已访问节点,导致同一状态被反复入队。解决方法是确认去重发生在节点出队时,并且入队前也做一次轻量过滤;同时检查优先队列的优先级是否用了累计代价而非单步代价。扩展节点数是搜索效率的体温计,它异常基本就说明去重或排序出了问题。
4.3 A* 结果不是最短路径
现象是 A* 跑出来的路径比 BFS 长。原因几乎都是启发函数不可采纳,比如用了带权重的曼哈顿距离,或者启发里混入了实际代价。解决方法是把启发函数单独拿出来,对每个状态验证h(state) <= 真实剩余代价,最直接的办法是先用h=0跑一遍,此时 A* 等价于 UCS,结果必然最优;再逐步加启发,观察路径长度是否变化。一旦变长,说明启发过估计了。
4.4 大地图运行时内存爆掉或卡死
现象是bigMaze或foodSearch跑到一半进程被系统杀掉。原因是状态空间爆炸,多目标问题里豆子集合没做规范化,或者 DFS 在深图上递归过深。解决方法是给豆子集合排序去重、用迭代代替递归、必要时给搜索加节点数上限做保护。我一般会在搜索函数里加一个扩展计数,超过阈值就打印当前状态规模,先定位是状态设计问题还是算法选择问题。
提示:改完任何搜索逻辑,先用
tinyMaze加-q跑一遍,再换mediumMaze,最后才上大图。跳过小图直接跑大图,出问题时你连是哪一步错的都定位不到。
5. 二次开发与验证:把这份代码改成自己的实验台
5.1 加一个新启发函数并对比效果
想验证不同启发对 A* 的影响,最省事的做法是在searchAgents.py里加一个函数,然后在命令行用heuristic=切换:
def euclideanHeuristic(position, problem): # 欧氏距离,比曼哈顿更松,扩展节点通常更多 goal = problem.goal return ((position[0] - goal[0]) ** 2 + (position[1] - goal[1]) ** 2) ** 0.5 def weightedManhattan(position, problem, weight=1.5): # 加权曼哈顿,weight > 1 时不再保证最优,但更快 goal = problem.goal return weight * (abs(position[0] - goal[0]) + abs(position[1] - goal[1]))加完后用同一张地图分别跑manhattanHeuristic、euclideanHeuristic、weightedManhattan,记录扩展节点数和路径长度。你会看到一条清晰的曲线:启发越紧,扩展越少;加权之后速度提升但路径可能变长。这个对比实验比任何理论推导都直观,也是我推荐给初学者的第一个改造练习。
5.2 用脚本批量验证而不是手动跑
手动一条条命令跑太慢,写个小脚本把所有地图和算法组合跑一遍,输出成表格:
import subprocess layouts = ['tinyMaze', 'mediumMaze', 'bigMaze'] algos = ['dfs', 'bfs', 'ucs', 'astar'] for layout in layouts: for algo in algos: cmd = ['python3', 'pacman.py', '-l', layout, '-p', 'SearchAgent', '-a', f'fn={algo}', '-q'] result = subprocess.run(cmd, capture_output=True, text=True) # 从输出里提取路径长度和扩展节点数,写入结果表 print(layout, algo, result.stdout.strip().splitlines()[-1])这个脚本的价值在于把“改一处、跑一次、看一眼”变成“改一处、跑全部、看差异”。二次开发时最怕的就是改了一个启发函数,只在小图上验证就以为万事大吉,结果大图上性能崩了。批量跑完把结果存成 CSV,前后对比一目了然。
5.3 改造方向与验证清单
想继续往下做,几个方向比较自然:把搜索问题从单目标扩到多目标并优化状态表示;给 A* 换更强的启发(比如最小生成树下界);把搜索过程可视化,记录每步扩展的节点并在界面上高亮。每改一处,验证清单固定走一遍:小图能到终点、路径长度符合预期、扩展节点数在合理区间、大图不崩。这套流程我踩过坑之后就一直保留着,因为搜索算法的错误往往不是“跑不起来”,而是“跑起来了但结果是错的”,没有对照验证根本发现不了。
从那以后我每次改搜索逻辑,都强制先用tinyMaze加-q跑一遍再动大图,这个习惯帮我省了无数个对着屏幕怀疑人生的夜晚。希望帮到你。
本文还有配套的精品资源,点击获取