
简介一个基于期望搜索与Python语言实现的爱因斯坦棋对战软件面向人工智能算法学习者及棋类游戏开发者。项目以Python实现完整对弈逻辑包含棋盘状态管理、合法落子判断、棋子翻转处理并重点采用期望搜索算法进行决策该算法通过递归遍历所有可能走法、结合对手最优反应的期望值评估局面同时引入Alpha-Beta剪枝减少搜索分支还可扩展蒙特卡洛树搜索以提升竞争力适用于课程设计、毕业设计或博弈算法实战。压缩包共1258个文件约60.4MB包含py/pyc源码、dll动态库、pyd扩展模块、png图标与语言配置文件等解压后可直接运行和二次开发。目前已有534人学习下载。通过该项目可系统掌握博弈树搜索、评估函数设计、剪枝优化及Python游戏界面开发方法是理论与实践结合的优质学习资料。 写一个能陪你下爱因斯坦棋的AI程序听起来像是搞算法的人才该干的事但拆开看核心就三样一张棋盘、一个掷骰子的规则、一段期望搜索的递归代码。去年我被桌游圈的朋友问了一句“这游戏带骰子应该不能直接套minimax吧”当场有点不服气于是花了三个周末用Python把这个人机对战软件完整写了出来。今天把这套实现从算法选型、评估函数设计到踩坑记录全部捋一遍适合正在学博弈树搜索、想给棋类游戏写AI、或者单纯好奇骰子游戏怎么做决策的朋友参考。1. 项目拆解爱因斯坦棋为什么不能用普通搜索1.1 带骰子的棋类博弈与规则要点爱因斯坦棋EinStein würfelt nicht!是一款非常典型的“带随机性”的抽象棋类。我用的是最主流的简化规则棋盘做成6x6双方各有编号1到6的六枚棋子开局摆在各自角落区域谁先把自己任意一枚棋子走到对方起始格就获胜。每回合掷一个六面骰这是整个游戏最特别的地方。如果骰子点数为n并且编号n的棋子还在棋盘上那这一回合就必须移动这枚棋子如果编号n的棋子已经被对方吃掉就可以自由选择还在场上的任意一枚棋子移动。棋子移动规则也简单向周围相邻格走一步八个方向都算只能走进空格或者走进编号比它小的对方棋子所在格走进去就相当于吃掉对方。编号相同的棋子互不相吃大编号棋子也不能主动走进更小编号的自己棋子所在的格。这个“骰子决定棋子”的机制让每一回合的决策都分成两个阶段先接受一个不可控的随机事件再在自己可控的行动集合里做选择。我一开始也想图省事直接把minimax搬过来结果很快发现完全不对劲。1.2 为什么minimax在这里不好使minimax的核心假设是“双方轮流做最优决策”每一步都是确定性的搜索树上只有我方节点和对方节点。但爱因斯坦棋完全不同每个回合中间还夹着一个骰子骰子点数不由任何玩家控制它是一个概率事件。举个例子当前局面下我明明评估出“移动编号5的棋子”是最优的但骰子偏偏掷出3那我就只能干瞪眼去移动3号棋。如果搜索时忽略了骰子这一层AI给出的行动在真实对局里根本没有意义因为对手和你自己都被骰子限制了可行动范围。这就引出了期望搜索Expectimax的适用场景在搜索树中引入“机会节点”chance node把骰子的每个点数看成一种概率分支把每种分支下的收益按概率加权求和。说白了就是人不能控制骰子但可以控制“面对每种骰子点数时选哪个行动”那AI要优化的就是所有骰子结果下的期望收益。我对比过两种方案的差异给新同学一个直观感受对比维度minimax期望搜索Expectimax节点类型max节点、min节点max节点、min节点、chance节点随机性处理无法建模按概率加权求期望适用游戏围棋、国际象棋等纯确定性博弈带骰子、纸牌等随机元素的博弈计算开销分支因子^深度分支因子^深度×骰子面数决策含义找必胜或最优路径找期望收益最大的策略如果强行用minimax就得把骰子结果硬编码成“最坏情况”比如假设每次都掷出对对手最有利的点数。这样AI会变得极度保守实际体验非常差很多明明值得冒险的制胜机会全被错过了。2. 核心算法期望搜索的细节与评估函数2.1 expectimax的三种节点与回溯流程期望搜索在实现上并不复杂核心就是递归函数里区分三种节点max节点轮到AI行动AI在所有合法行动中选评估值最大的。min节点轮到对手行动对手会选让AI评估值最小的行动换句话说就是AI视角下的最坏情况。chance节点轮到骰子“行动”遍历1到6每个点数每个点数按1/6的概率加权求和。我最初实现时踩了一个逻辑坑千万不要把chance节点里骰子之后的对手决策也简单当成“取平均”。骰子点数只决定了“这一轮能移动哪些棋子”在这个约束下对手依然会选对他最有利的那一步。所以chance节点的正确展开方式是对每个骰子点数先枚举该点数下的所有合法行动取对手最优值再乘上1/6的概率加权。核心递归伪代码长这样def expectimax(board, depth, node_type): # 到达搜索深度或分出胜负时返回评估值 if depth 0 or board.winner() is not None: return evaluate(board) if node_type max: # AI回合选最大 return max( expectimax(board.apply(move), depth - 1, chance) for move in board.legal_moves() ) if node_type min: # 对手回合对手会选让AI最难受的走法 return min( expectimax(board.apply(move), depth - 1, chance) for move in board.legal_moves() ) if node_type chance: # 骰子回合每个点数概率1/6 total 0.0 for die in range(1, 7): moves board.legal_moves_with_die(die) if not moves: # 该点数无棋可动局面不变继续递归 total (1.0 / 6.0) * expectimax(board, depth - 1, max) else: # 骰子之后轮到另一方因此是min节点 sub min( expectimax(board.apply(m), depth - 1, max) for m in moves ) total (1.0 / 6.0) * sub return total第一次做AI决策时不要直接返回分数而是要在AI可能走的所有行动里选出评估值最高的那一步再把对应的move返回给主程序。2.2 评估函数把棋局翻译成数字如果期望搜索是大脑的决策框架那评估函数就是大脑的价值观。同一个局面评估函数写的不好深度再多也是白搭。我的评估函数从四个维度打分棋子价值编号越大越值钱因为大编号能吃掉更多小编号的棋子我按编号本身作为基础分。位置进度每枚棋子到对方起始格的距离距离越近分数越高。这是最关键的维度因为这是获胜目标。吃子威胁当前棋子周围有没有可以吃掉的对方小编号棋子有就加分。暴露风险反过来当前棋子周围有没有对方大编号棋子可以吃掉自己有就减分。最终总分用加权求和def evaluate(board): score 0.0 for piece in board.my_pieces(): score piece.number * 1.5 # 子力价值 score progress(piece) * 8.0 # 到目标格的距离越近越高 score threat_bonus(piece) * 2.0 # 能吃到对手棋子的奖励 score - danger_penalty(piece) * 2.5 # 被对手吃掉的惩罚 return score权重不是一次性调好的。我实验过很多次最大的体会是“位置进度”的权重必须压倒其他项。原因很直接这游戏获胜条件就是到达对方起始格你子力再强、吃子再多推进不上去也没用。最开始我把子力权重调得特别高结果AI经常绕路去吃小编号棋子明明两步就能冲到终点非要贪一口吃的气得我直接重构了权重。2.3 搜索深度、时间预算与难度分级期望搜索因为多了骰子这一层搜索树的规模比普通minimax大不少。以我方行动为根往后推一层要展开“合法行动数 × 6种骰子点数 × 对方合法行动数”这么多个局面。实测下来6x6棋盘上双方各有6枚棋子时一层完整展开就有几万个节点两层基本是百万级别。这就逼着我在深度和速度之间找平衡。我给对战软件设了三档难度难度搜索深度时间预算适用场景简单2层1秒内新手熟悉规则普通3层2-3秒日常娱乐困难4层8-10秒挑战模式深度并不是越大越好因为评估函数本身有误差搜到4层以上之后边际收益开始下降耗时却指数上升。我实测过同一份代码、同一批测试棋局深度3对深度2的胜率大概是65%左右深度4对深度3的胜率只有58%左右但单步耗时从不到1秒涨到了8秒以上。对普通玩家来说8秒等一步已经很不耐烦了所以默认档我放在深度3。3. 从零搭建环境准备、代码实现与对战界面3.1 Python环境安装与项目依赖写这个项目我全程用的Python 3.10代码里没用什么新语法3.8以上都能跑。如果你机器上还没装Python先去官网下载对应系统的安装包。Windows用户安装时一定要记得勾选“Add Python to PATH”这个选项否则后面命令行里敲python会提示找不到命令。安装完之后打开终端验证一下python --version pip --version我建议用虚拟环境隔离依赖避免把系统Python环境搞乱python -m venv venv # Windows激活 venv\Scripts\activate # macOS / Linux激活 source venv/bin/activate本项目依赖只有两个numpy负责棋盘状态计算和向量化评估pygame负责画界面和接收鼠标事件。装起来很简单pip install numpy pygame如果你只是关心AI算法完全可以先不做界面直接用命令行打印棋盘来测试界面放到最后再说。我就是这么干的先把逻辑全跑通再套界面调试起来省事得多。3.2 项目模块划分与核心代码实现整个项目我拆了四个文件每个文件的职责非常明确main.py # 程序入口负责控制流程和界面刷新 board.py # 棋盘规则层包含落子、吃子、合法行动枚举 ai.py # 期望搜索AI包含搜索函数和评估函数 gui.py # pygame界面绘制棋盘与棋子处理鼠标点击board.py 是整个项目的基石。它内部维护一个状态对象核心方法是生成合法行动。这里有个工程上的重要决定不要在主搜索循环里现算合法行动而是在棋盘状态改变时就维护好候选列表。因为期望搜索的递归调用次数非常频繁每个节点都要枚举6个骰子点数的行动现算不仅慢而且容易写出重复bug。class Board: def __init__(self): self.grid {} # (x, y) - piece self.current_player 0 self.die None def legal_moves_with_die(self, die): target self.piece_by_number(die) if target is None or target.player ! self.current_player: # 骰子点数的棋子已被吃或不在己方自由选择任意己方棋子 pieces self.my_pieces(self.current_player) else: pieces [target] # 枚举每个棋子的移动目标格返回 (from_pos, to_pos) 列表 moves [] for piece in pieces: for to_pos in self.neighbors(piece.pos): if self.can_move(piece, to_pos): moves.append((piece.pos, to_pos)) return movesai.py 里的核心搜索函数就是上面那一版expectimax我在实际项目中用functools.lru_cache做了局面缓存from functools import lru_cache lru_cache(maxsize2**20) def expectimax(state_key, depth, node_type): board Board.from_key(state_key) # ... 和上面伪代码相同这里有个关键点lru_cache要求参数可哈希所以我把棋盘状态编码成一个不可变元组包含双方棋子的位置和编号、当前行动方、骰子点数。这个缓存对搜索速度的提升非常明显尤其在相同局面可能通过不同走法路径再次到达时能省掉大量重复计算。3.3 对战界面与人机交互体验pygame界面我做得比较朴素6x6网格双方棋子画出不同底色上面写编号。轮到玩家时鼠标点击自己的棋子程序高亮显示所有能走的格子再点一下目标格就落子。轮到AI时界面先显示“思考中”AI计算完成后自动落子并刷新。这里有一个体验上的重要细节AI计算不能放在主线程里。否则计算那几秒窗口会直接变成白屏无响应视觉效果极差。我通过Python的threading模块把AI搜索丢到后台线程计算完成后发消息给主线程主线程再更新棋盘def ai_turn(): def work(): move ai.choose_move(board, depth) event_queue.put((ai_move, move)) threading.Thread(targetwork, daemonTrue).start()骰子动画我也加了一个小延迟掷骰结果先展示给玩家看停顿0.5秒再亮出可移动棋子这样玩家能清楚理解AI为什么会走某一步棋而不是一脸懵地看着棋子自己乱跳。4. 常见问题与排查技巧实录4.1 搜索太慢换位表与行动排序实战第一个版本跑起来后我的深度3搜索单步要将近6秒完全没法玩。排查下来两个瓶颈一是Python函数调用开销太大递归节点动不动上百万次二是没有任何缓存相同局面反复计算。先用lru_cache我评估的命中率大概在40%-60%之间速度直接快了接近一倍。然后加行动排序在搜索每个节点前先根据评估函数对行动列表做个粗略排序优先搜索“吃子”和“向终点推进”的行动。这样做的好处是虽然期望搜索不能像minimax那样做严格的alpha-beta剪枝但排序后更容易提前找到高价值分支后续分支可以跳过明显低价值的节点工程上叫软剪枝。做完这两步深度3的单步耗时降到了1.5秒左右深度4大约7秒基本可以接受。如果你还想更快可以考虑numba加速评估函数或者把搜索核心用C扩展重写但是收益不大了这个规模的项目没必要。4.2 骰子规则与概率处理的坑这个游戏的规则细节很容易踩坑尤其是“骰子点数对应的棋子已经被吃”的情况。规则规定此时玩家可以自由选择任意己方棋子这相当于给了一个“万能移动”选项。我在第一版里漏了这种分支结果AI一旦某编号棋子被吃那一整层搜索直接少了一个骰子点数的可能性概率分布完全错了。处理方式是在legal_moves_with_die里先判断目标棋子是否存在、是否属于当前行动方不存在就返回所有己方棋子的行动。但注意概率加权仍然是1/6不能因为自由选择就调整概率每一面骰子出现的概率始终一样。还有一个相关坑骰子点数对应的棋子虽然是己方的但如果它在当前局面下无路可走周围全是不能进的格此时也算“无合法行动”不能把这一步直接跳过或重新掷骰。正确做法是局面保持不变交给对方行动。如果你把这种状态整个丢弃搜索树就会漏掉真实对局中会出现的情况。4.3 AI表现古怪评估函数失灵诊断AI下出“蠢棋”90%的情况出在评估函数上。我最惨痛的一次教训是AI在领先时突然开始原地转圈怎么都不往终点走。查了半天才发现评估函数里有一个负向惩罚项写反了符号导致棋子越靠近目标反而扣分越多AI自然拼命绕路。如果你也遇到AI行为诡异我建议做一个“单步分析模式”给定任意局面让AI打印出搜索第一层每个行动的评估值肉眼看看数值是否合理。比如某个行动已经把棋子移动到距离目标只剩一格的位置评估值却低于其他行动那肯定是评估逻辑有问题。另一个常见问题是局部振荡AI在两个相近局面之间来回摆始终无法推进。这通常是评估函数缺少全局目标项导致的我把“任意一枚棋子到终点的最小距离”单独拿出来乘以一个较大的权重加进总分这个问题基本就消失了。因为即使整体棋形分数暂时变差只要有一枚棋子离终点更近了总分也会上涨。4.4 界面卡顿与测试方法论界面白屏卡顿的问题前面已经说过了用线程解决。这里再说一个很多人容易忽略的点AI计算线程里不要直接操作pygame的绘制对象否则会出现画面闪烁、控件错乱。我的做法是线程只计算一个move结果通过queue传给主线程由主线程统一做界面更新。至于AI棋力评估千万不要用“我下几盘感觉变强了”来判断。我自己测试时用自对弈让两个不同参数、不同深度的AI对战100局统计胜率和平均步数。这样能比较客观地知道权重的调整到底有没有效果。比如我把威胁奖励从1.5调到2.0对比测试100局后发现胜率从60%涨到74%我就知道这次调参是有效的。5. 写在最后调试AI的一点真心话我在这项目上投入最大的不是写代码而是调参和排坑。期望搜索本身原理不复杂真正花时间的全是这些边界情况骰子点数的棋子被吃了怎么处理、无路可走怎么递归、评估函数权重怎么才不会让AI“贪吃误事”。这套程序现在跑起来困难难度下我已经很难赢它了但我知道它还有很多可以提升的地方。如果后面有空我打算再给它加上自对弈的进化式调参让权重自己在对战数据里优化那样棋力应该还能再上一个台阶。做这类AI项目最有意思的地方就在这里每次你填上一个坑就会立刻发现下一个更深的坑然后你的AI就真的变聪明了一点。本文还有配套的精品资源点击获取