
简介基于Python实现MINIMAX自动吃豆人的课程设计资源主要面向学习人工智能、博弈论与算法设计的本科学生及游戏AI开发者适用于课程设计、算法实验或项目演示场景。包内含4个文件压缩包大小仅442KB包含核心Python源码、项目报告、README说明文档及许可证文件其中PY文件实现了游戏状态表示、MINIMAX递归搜索、α-β剪枝优化及评估函数设计等关键模块PDF报告系统讲解了算法流程与实验分析MD文档便于快速上手。目前已有430人学习下载。通过深入阅读源码与报告读者可掌握如何为双人零和游戏构建决策树、预测对手最优策略并在吃豆人环境中完成智能体控制同时也能学习Python面向对象编程与算法优化方法为后续扩展更复杂的博弈AI打下基础。 如果你学过人工智能导论或者只是对博弈类游戏AI感兴趣大概率听过MINIMAX最小最大算法的大名。课件里通常把倒推值、搜索树、Alpha-Beta剪枝这些概念讲得清清楚楚但真到自己动手写一个能自动玩游戏的AI立刻就会撞上一堆教材里没写的实际问题。这篇文章记录的是我用Python实现自动吃豆人的完整过程吃豆人不需要任何人为控制完全由MINIMAX算法决定每一步往哪走怎么绕开幽灵、怎么吃豆、吃到能量豆之后怎么反追。项目规模不大但把对抗搜索、状态评估、性能优化这几块硬知识全串了起来特别适合想亲手验证AI算法、又不想一上来就啃大工程的人参考。需要先说明一下这里的MINIMAX是博弈论里的经典决策算法很多资料管它叫极小极大搜索跟最近网上那个叫Minimax的生成式大模型不是一回事。前者解决两个对手轮流行动时我该选哪一步的问题正是吃豆人这种追逃对抗的天然解法。1. 项目整体设计为什么吃豆人能用MINIMAX来玩1.1 吃豆人对幽灵天然的对抗搜索场景在动手写任何代码之前得先理解一件事吃豆人游戏本质上是一个零和博弈。所谓零和就是吃豆人的收益和幽灵的收益加起来等于零——吃豆人多吃一个豆子、多活一秒钟对幽灵来说就是失败反过来被幽灵抓住一次吃豆人就失去一条命。MINIMAX算法就是为这类场景设计的我方MAX玩家每一步都试图让最终得分最大化对方MIN玩家每一步都试图让得分最小化。放到吃豆人里MAX玩家是吃豆人MIN玩家就是那些幽灵。唯一要注意的是原始吃豆人里有四只幽灵严格来说这是多人博弈但实际落地时可以做一个合理简化把四只幽灵看作一个整体它们在MIN层里共同选择一组移动目标统一为让评估分数变低。这个简化在工程上非常关键否则搜索空间会膨胀到完全不可用。能量豆的存在会让攻守关系短暂互换这也恰好能塞进MINIMAX的框架里在评估函数里加一个恐惧状态的判定能量豆生效时间内幽灵对吃豆人的威胁权重变成负值AI自然会选择追着幽灵跑。这就是一个把游戏规则翻译成算法参数的典型过程。1.2 核心思路把一局游戏展开成决策树MINIMAX的思路可以概括成一句话把所有可能走到的情况展开成一棵树从叶子节点往回倒推最优解。举一个具体例子。假设当前轮到吃豆人决策它面临四个动作上下左右。对每一个动作游戏都会进入一个新的局面。在这个新局面上轮到幽灵决策每只幽灵又可能有四个动作于是局面继续分叉。就这样吃豆人一次、幽灵一次地交替展开直到达到预设的搜索深度或者游戏结束。到叶子节点时拿评估函数打一个分然后从下往上回溯MAX层取子节点最大值MIN层取子节点最小值最终根节点选出的那个动作就是当前局面下AI认为的最优走法。这张树的规模有多大直接决定了整个项目的技术选型。地图一行假设15个格子一列15个格子吃豆人的分支因子是4很少刚好四个方向都被墙堵死幽灵分支因子满打满算也是4。如果没有剪枝深度为d的搜索树节点数大约是(4×4)^d 16^d。深度4的时候已经接近65536个节点深度6就是1600万。这还是单只幽灵的情况下四只幽灵一起算深度4就是4^(4×4)43亿。所以项目里必须做几个限制搜索深度控制在4到6之间幽灵在MIN层作为一个组合动作来枚举同时配合Alpha-Beta剪枝把无效分支砍掉。1.3 选型对比为什么不用A*或强化学习在确定MINIMAX方案之前我也认真考虑过另外两条常见路线这里把对比结论写出来方便后来人少走弯路。A是经典的单智能体寻路算法适合有明确终点、没有对手捣乱的场景。如果直接用A驱动吃豆人最短路算法会算出通往最近豆子的路径但它完全无视幽灵的位置结果就是吃豆人经常一头撞进被幽灵堵住的死胡同。当然你可以改成先避开幽灵再找豆子但这本质上已经是在手工设计优先规则正是评估函数要解决的问题。强化学习比如Q-learning或深度Q网络是另一个热门选项。它的优点是理论上不需要手工设计评估函数让智能体自己从奖励信号里学策略缺点是训练周期长调超参数很折磨人而且吃豆人这种动作反馈非常频繁的游戏随机初始化策略时大概率是连一关都撑不过去的。MINIMAX最大的好处是全程不需要训练只要状态表示正确、评估函数合理代码跑起来AI就立刻具备可玩的水平。对一个用于教学和验证算法的小项目来说这个性价比是碾压级的。2. 核心细节解析评估函数与搜索深度怎么定2.1 评估函数AI的直觉从哪来MINIMAX本身不学习它衡量局面的标准完全来自评估函数。可以把这个函数理解成AI的直觉给它一个游戏状态它返回一个数值正数代表对吃豆人有利负数代表对吃豆人不利绝对值越大倾向越明显。在实践中最常用的评估指标有这么几类最近幽灵的距离离幽灵越近越危险应该给一个惩罚项。最近豆子的距离离豆子越近越应该去吃给一个奖励项。剩余豆子数量剩余豆子越少离过关越近应该逐步加大奖励。生死状态被幽灵吃掉返回极大负值吃完所有豆子返回极大正值。能量豆剩余时间在恐惧状态下距离幽灵近反而是好事。我当时写的第一版评估函数长这样def evaluate(state): # 终局状态设最高/最低分 if state.pacman_dead: return -100000 if not state.pellets: return 100000 # 曼哈顿距离 ghost_dists [manhattan(state.pacman, g) for g in state.ghosts] min_ghost_dist min(ghost_dists) min_pellet_dist min(manhattan(state.pacman, p) for p in state.pellets) score 0.0 # 幽灵越近惩罚越大用倒数而不是负数距离 score - 3.0 / max(1, min_ghost_dist) # 豆子越近奖励越大 score 1.0 / max(1, min_pellet_dist) # 剩余豆子越少越好给一个很小的负系数 score - 0.0005 * len(state.pellets) if state.frightened_timer 0: # 能量豆生效期转向追幽灵 score 5.0 / max(1, min_ghost_dist) return score两个关键细节值得展开。第一距离项我用的是倒数而不是距离本身目的是让贴近幽灵和远离幽灵的差异放大。幽灵距离从2变成1时惩罚从1.5跳到3.0这个信号比从10变成9强烈得多。第二各个子项的权重不是拍脑袋定的是反复跑出来的。我调试时发现如果幽灵惩罚权重太低AI会对近在咫尺的死亡毫无反应如果太高AI又会变得过度保守哪怕幽灵在地图另一端它也只敢在一个安全区里打转。我最终的权重比例大约是3:1突出保命优先、吃豆其次的策略。2.2 搜索深度与分支因子性能瓶颈在哪搜索深度决定了AI能往前看几步也直接决定了计算耗时。我把不同深度的表现做了个对比搜索深度单个决策的耗时3只幽灵8x8地图实际表现1几毫秒只会躲避当前最近的幽灵经常原地摇摆210~30毫秒能预判幽灵下一步不再主动送死4100~500毫秒会走绕路吃豆路线主动远离危险区63秒以上表现接近人类新手但基本没法实时游戏这里的核心瓶颈是分支因子。吃豆人4个方向每只幽灵最多4个方向三只幽灵的分支因子就是4×4×4×4×41024吃豆人一层加幽灵组合一层。深度每加2节点数就膨胀约1000倍。所以深度6在Python这种解释型语言里基本是极限必须要靠剪枝来救场。我在代码里还做了一个非常有效的限制幽灵在MIN层不能走回头路。真实吃豆人的幽灵有禁止掉头的规则上一帧往左走下一帧不能突然向右。这个约束直接让每只幽灵的分支因子从4降到最多3树的总节点数立刻削减不少。这条规则也符合游戏真实逻辑属于既提性能又保真的一举两得。2.3 Alpha-Beta剪枝能省多少计算量Alpha-Beta是MINIMAX的标准加速方案核心思想是如果当前分支已经不可能影响最终决策结果就不要再往里搜了。用最简单的话说MAX层已经找到一个分数比较高的走法这时候MIN层在另一个分支里发现分数只会更低那这个分支的后续部分就不用看了——因为MAX玩家在更上层根本不会选这条路。我实现的是最经典的递归版本import math INF math.inf def alpha_beta(state, depth, alpha, beta, is_max): if depth 0 or state.is_terminal(): return evaluate(state) if is_max: best -INF for action in get_valid_actions(state, playerTrue): next_state state.apply(action, playerTrue) best max(best, alpha_beta(next_state, depth - 1, alpha, beta, False)) alpha max(alpha, best) if beta alpha: # 剪枝 break return best else: best INF for ghost_actions in get_ghost_action_combinations(state): next_state state.apply(ghost_actions, playerFalse) best min(best, alpha_beta(next_state, depth - 1, alpha, beta, True)) beta min(beta, best) if beta alpha: # 剪枝 break return best需要注意剪枝效果非常依赖搜索顺序。如果每一层都先搜索最有可能产生好分数的分支就能更早更新alpha或beta的界从而更早触发剪枝。我在代码里做了一步很简单的排序优化在MAX层先执行离豆子最近的动作在MIN层先执行离吃豆人最近的幽灵动作。仅仅加了这两个排序规则同一深度下的节点访问量大约减少了70%。这一招对Python这种性能不算强的语言来说比各种花哨优化都实在。3. 实操过程从地图加载到AI决策的最小闭环3.1 环境准备与地图表示开发环境用的是Python 3.10游戏渲染用的pygame算法本身不依赖任何第三方库。地图我直接用一个二维数组表示数字对应不同元素# 1 墙, 0 空地, 2 豆子, 3 能量豆, 8 玩家起点, 9 幽灵起点 MAP [ [1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 2, 2, 2, 2, 2, 2, 2, 2, 1], [1, 2, 1, 2, 1, 1, 2, 1, 2, 1], [1, 2, 2, 2, 2, 2, 2, 2, 2, 1], [1, 2, 1, 2, 1, 2, 1, 1, 2, 1], [1, 2, 2, 2, 3, 2, 2, 2, 2, 1], [1, 2, 1, 2, 1, 2, 1, 2, 1, 1], [1, 2, 2, 2, 2, 2, 2, 2, 2, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1], ]游戏状态我封装成一个GameState类里面维护几样东西玩家坐标、幽灵坐标列表、豆子集合、能量豆剩余时间、当前得分、玩家是否死亡。这里有一个很关键的实现细节MINIMAX在递归搜索中会生成大量未来状态千万不能在每一步去深拷贝整个地图数组。我改成了只保存豆子集合和坐标变化的轻量状态地图本身全局只读每走一步只改玩家和幽灵坐标、从豆子集合里删除被吃掉的豆子。这样递归一次的状态复制开销能降一个数量级是项目能跑起来的关键优化。3.2 MINIMAX核心代码实现根节点的决策逻辑和上一节给出的alpha_beta函数配套使用。吃豆人的最佳动作就是在所有合法动作里让自己走一步之后再让幽灵方走一步得到的评估值最高的那个动作def choose_best_action(state, depth4): best_score -INF best_action None alpha -INF beta INF for action in get_valid_actions(state, playerTrue): next_state state.apply(action, playerTrue) # 深度-1因为当前这一步已经展开 score alpha_beta(next_state, depth - 1, alpha, beta, False) if score best_score: best_score score best_action action alpha max(alpha, best_score) return best_action这段代码要注意一个容易犯错的小地方根节点本身在递归函数外面多包了一层循环是为了能返回具体的动作而不是只返回一个分数。alpha初始值要设置成负无穷beta初始值要设置成正无穷这是剪枝的正确起点。如果你在调试中发现剪枝结果和暴力搜不一致八成就是alpha、beta的初始值或者更新顺序写错了。get_valid_actions里面要处理越界和撞墙同时保留按兵不动这个动作。吃豆人游戏里玩家可以原地不动等待时机搜索时必须把这个选项也放进去否则AI在死局里会被迫选择一个必死的方向。我遇到过AI在一条死路尽头不断左右横跳的情况根源就是没有考虑等待这个选项。3.3 主循环与AI接入游戏主循环按帧驱动但AI不需要每帧重新决策只需要在吃豆人回合触发一次。我的做法是维护一个player_turn布尔变量。轮到吃豆人时调用choose_best_action得到动作后立即执行然后标记轮到幽灵走幽灵按自己的固定AI走完一步再切回吃豆人回合。while running: for event in pygame.event.get(): if event.type pygame.QUIT: running False if state.player_turn: action choose_best_action(state, SEARCH_DEPTH) state.apply(action, playerTrue) else: for ghost in state.ghosts: move_ghost(ghost, state) # 让幽灵按固定规则移动 state.player_turn True draw(state) clock.tick(10)时钟频率我设成了每秒10帧这是因为深度4的搜索在实际运行时需要100毫秒左右帧率再高也跑不满。如果你希望观感更流畅可以把地图缩小或者把搜索深度降到2。这个取舍在游戏AI项目里非常常见理论最优步数和实时响应速度之间需要不断调平衡。4. 常见问题与排查技巧实录4.1 幽灵预测不准确的坑第一版AI跑起来之后我观察到一种诡异现象吃豆人会在某个路口反复调整方向看起来就像在颤抖但幽灵实际上根本不在附近。排查后发现问题出在MIN层对幽灵动作的枚举方式上。真实吃豆人的幽灵移动规则里有一条幽灵不能突然掉头只能选择前方、左转、右转三种方向。但我的第一版实现偷懒了让幽灵可以朝四个任意方向移动这导致搜索树里出现了大量游戏规则根本不允许的局面。吃豆人基于这些幽灵会瞬移掉头的错误预测做出了过度反应表现为频繁转向。修正方法很简单给幽灵的状态加一个last_direction字段在get_ghost_action_combinations里过滤掉回头方向。另外不同色幽灵的性格不同红幽灵会直接追着吃豆人走粉幽灵会抄近路拦截。如果完全按幽灵也会做最优决策的思路模拟预测仍会有偏差。我采用的方案是在MIN层假定所有幽灵都朝离吃豆人最近的方向走也就是不做太多聪明的预判只按当前最危险的追法展开。这样一个保守的模型反而让AI更稳定因为吃豆人在搜索时假设最坏情况实际对局中表现更从容。4.2 AI原地摇摆与死锁问题的坑AI在小地图上转圈是另一个高发问题特别是在搜索深度只有2的时候。原因是这样的深度2意味着吃豆人只能看到自己走一步、幽灵走一步之后的一切完全靠评估函数猜。如果评估函数里靠近豆子的奖励和远离幽灵的惩罚在数值上接近就会出现两个相邻格子分数极度接近的情况AI会在这两个格子之间反复横跳。解决这个问题我用了两个办法。第一在评估函数里增加一个方向惯性惩罚项如果下一步的方向和当前方向相反给一个轻微的负分惩罚。这个办法很有效AI不会因为两个格子分差不明显就来回摇摆。第二在搜索深度上做文章从深度2提升到深度4AI能看到这个路口继续往前走有没有死路卡死的概率大幅下降。如果AI还是死锁在一个区域里我建议检查一下地图设计。有些地图存在吃豆人必须贴着幽灵走才能绕出去的隘口这种局面下评估函数的保命逻辑会阻止AI冒险通过。我在地图设计上特意让所有通道宽度不小于两格给AI留出躲避空间。这个问题在调参时很容易被忽略但它暴露的是评估函数和地图拓扑结构之间的耦合关系。4.3 搜索结果不确定和性能瓶颈实录MINIMAX的一个特性是结果确定性——同一局面下同样的深度和同样的评估函数AI的决策应该完全一致。如果你的AI在同一个局面下两次决策结果不一样大概率是某个环节引入了非确定因素常见的罪魁祸首是遍历幽灵动作组合时用了set或dict而Python的哈希顺序在不同运行间不稳定。评估函数里用了随机数作为打破平局的机制。在搜索过程中修改了全局状态导致下一次搜索的初始状态被污染。最后一条是最隐蔽的。我在调试时曾经把state.apply写成原地更新而不是返回新状态结果第一层搜索把局面改掉了后续所有分支都在错误的局面上搜索。这个bug浪费了我整整一个下午。排查方法也很直接在choose_best_action调用前后分别序列化打印state的核心字段对比是否一致。性能方面我的经验是优先做三件事给状态类实现紧凑的坐标表示、限制幽灵组合动作去掉回头路、以及做好alpha-beta剪枝。如果这三样都做了还是慢再考虑降低搜索深度或者缩小地图。不要一开始就引入numba、多进程之类的高阶优化那会让项目复杂度完全失控。做完这个小项目之后我最大的感受是MINIMAX的难点从来不在递归写法本身而在于怎么把游戏规则翻译成状态、把直觉翻译成评估函数、把算力限制翻译成深度和剪枝策略。这套思路换到任何一个回合制游戏——五子棋、井字棋、甚至象棋类游戏——都能直接复用。如果你跟我一样是从零开始接触对抗搜索可以先从1只幽灵、深度2、8x8的小地图起步看到AI跑起来之后再加深度、加幽灵、放大地图这种循序渐进的方式会平稳很多也不容易在早期就被性能问题劝退。本文还有配套的精品资源点击获取