用期望搜索(Expectimax)为爱因斯坦棋编写AI博弈算法

发布时间:2026/10/31 13:21:36
用期望搜索(Expectimax)为爱因斯坦棋编写AI博弈算法 简介面向计算机博弈大赛选手与棋类爱好者这份资源围绕爱因斯坦棋提供了一套完整的博弈软件实现方案适用于算法学习、教学演示和比赛备战。项目采用Python编写核心是基于期望搜索算法对棋局进行评估并生成高水准应对同时借助Pygame构建了直观的用户界面和实时反馈功能能够帮助使用者理解博弈树搜索、评估函数设计及界面交互逻辑。压缩包共159个文件大小约7.38MB内容以PNG图片、XML配置、示例样本、字体文件与Python源码为主另有部分Git元数据文件目录结构清晰便于按需查阅和二次开发。已有122人浏览/学习具备较好的参考价值。使用者可通过源码与配套素材深入掌握期望搜索在棋类博弈中的落地思路并将算法模块迁移至国际象棋、围棋等场景兼顾比赛实战与学习研究。1. 给爱因斯坦棋写 AI第一步先分清随机节点在哪给爱因斯坦棋写一套博弈软件算法选型的第一道坎就落在“随机”二字上。这盘棋每回合先掷一颗六面骰掷出几点才有资格动几号棋子运气成分很重但 AI 不能只拼运气。常见的最小最大搜索假设局面完全确定遇到骰子后无从下手真正匹配的是期望搜索。它在博弈树里加了一层“随机节点”把每个骰点下的最佳应手用概率加权再往上取最大。这套方案适合两类人一是想给爱因斯坦棋补一个人机对战模式的开发者二是拿小棋盘练手、想理顺随机博弈搜索的算法新手。下面从规则建模讲到可运行的 Python 实现参数和翻车点都会出现。2. 棋盘建模与走子生成4×4 棋盘、骰子编号和终局判断搜索算法再漂亮也要先落到一套能准确描述局面和走法的数据结构上。爱因斯坦棋的棋子不多棋盘也很小但“骰子点数决定能走哪个子”这条规则天然地把走子生成分成了两段先按骰面找候选棋子再按方向找合法落点。我实现时全部用整数表示棋盘不做字符串拼接这样评估函数和缓存表都能跑得更快。2.1 用整数编码棋盘和棋子省掉后续所有字符串开销我用一维数组表示 4×4 棋盘下标从 0 到 15每行 4 个格子。棋子用带符号整数编码正数表示白方棋子数值就是骰子编号 1 到 6负数表示黑方棋子。0 表示空格。这样“骰到 3白方只能动编号为 3 的白子”就可以直接写成board[i] turn * dice不需要任何字符串匹配。ROWS, COLS 4, 4 SIZE ROWS * COLS # board[i] 的取值约定 # 0 表示空格 # 1-6 表示白方棋子数字是骰子编号 # -1~-6 表示黑方棋子数字是骰子编号 def new_board(): board [0] * SIZE # 白方在下方两行黑方在上方两行 # 每方 6 个棋子编号 1-6落在两行共 8 个位置里 white_cells [12, 13, 14, 15, 8, 9, 11] black_cells [0, 1, 2, 3, 4, 5, 7] for i in range(6): board[white_cells[i]] i 1 board[black_cells[i]] -(i 1) return board这段初始化把白方码放在下方黑方放在上方摆位本身就是骰子编号的乱序。乱序是故意保留的避免搜索模型里出现“编号和位置一对一”这种过强先验。实际对局平台如果采用固定开局只需要替换成平台给的下标列表搜索部分不用改。2.2 走子生成骰子点数先筛棋子再按方向筛落点爱因斯坦棋的常见移动规则是每回合掷一颗六面骰骰到几就走编号为几的棋子棋子只能向对手方向、左方或右方移动一格不能后退落点为空或者为对方棋子时可以进入进入对方棋子位置即吃掉对方。白方从棋盘下方往上走黑方从上方往下走。代码里我用dr表示前进方向白方是-1黑方是1。def legal_moves(board, turn, dice): # turn: 1 白方, -1 黑方 # dice: 当前掷出的骰子点数只对应编号 dice 的棋子 moves [] dr -1 if turn 1 else 1 for r in range(ROWS): for c in range(COLS): idx r * COLS c if board[idx] ! turn * dice: continue for dc in (-1, 0, 1): nr, nc r dr, c dc if not (0 nr ROWS and 0 nc COLS): continue nidx nr * COLS nc # 不能走进自己的棋子 if board[nidx] ! 0 and board[nidx] // abs(board[nidx]) turn: continue moves.append((idx, nidx)) return moves这个函数把“骰子先筛编号”和“棋盘再筛方向”合在一起了。board[idx] ! turn * dice是第一个过滤器确保只有当前骰点对应的棋子能进候选dc循环是第二层过滤器处理左右和前移。判断己方棋子用的是符号比较而不是直接比较编号这样即使以后改成可堆叠的规则这里的符号位逻辑仍然成立。需要注意dr的作用。白方在下方目标是上方底线所以白方向是-1黑方在下方方向相反。如果新手把方向写反走子生成器会立刻把整个搜索树带偏而且这种错误在自博弈里很难一眼看出来因为双方都按同一套错误规则在玩输赢依然有分布但棋形完全不对。2.3 终局判断和无子可走两个容易漏的状态爱因斯坦棋的终局判断比较简单任意一方把一个棋子走到对方底线即获胜。白方的底线是第 0 行黑方的底线是第 3 行。但“无子可走”这个分支处理不好AI 会在某些骰面下直接返回异常值甚至把搜索树炸掉。我采用的规则是如果当前骰子点数对应的棋子一个都不在棋盘上或者对应棋子被己方棋子堵死则跳过这一回合。这样期望搜索里每个骰面的分支都有确定返回值。def terminal_result(board): # 返回 10000 表示白胜-10000 表示黑胜None 表示未终局 for idx in range(SIZE): val board[idx] if val 0: if idx // COLS 0: return 10000 elif val 0: if idx // COLS ROWS - 1: return -10000 return None def can_skip(board, turn): # 当前骰面下没有合法动作允许跳过回合 for d in range(1, 7): if legal_moves(board, turn, d): return False return Trueterminal_result的检查只需要扫一次棋盘代价极低。在期望搜索的热点路径上这个函数会被反复调用所以尽量写成 O(SIZE) 而不是每次都生成移动列表。can_skip只用来处理极端堵塞局面正常博弈中很少触发但必须保留否则搜索到死角时会没有返回值。这里还要强调一个规则差异有些版本规定“骰到的编号棋子被吃光后可以任选一个棋子走”有些版本则直接跳过。两种规则都能做但期望搜索的概率计算完全不同。我下面整套代码按“跳过”实现如果你手上的平台用“任选”需要把chance_node里对无效骰面的处理改成调用一次不受骰子限制的全棋子走法搜索。3. 期望搜索怎么算Min-Max 之外还有一层“掷骰子”的期望值这一章回答的核心问题是搜索树里多出来的随机节点到底该放在哪一层价值该怎么算。很多人把 Min-Max 改巴改巴就套到爱因斯坦棋上结果发现 AI 总是在“赌”某一个骰面棋风非常不稳定根本原因就是随机节点放错了位置。3.1 为什么 Min-Max 解决不了带骰子的博弈Min-Max 的树结构里只有两种节点我方行动时取最大值对方行动时取最小值。这个模型成立的前提是双方行动时面临的世界状态完全确定。爱因斯坦棋的问题是骰子点数在行动之前才揭晓玩家看到点数后才会选择走哪颗棋子。如果硬把骰子去掉等于默认 AI 每次都能选到对全局最有利的骰点这显然过高估计了当前局面的价值。另一个容易踩的坑是把骰子当作“对手”来处理。表面上骰子会从六个点数里选一个像是随机的对手但骰子没有利益诉求它不会刻意恶心你。用 Min-Max 的min节点去选“最坏骰面”会让 AI 过度保守明明 5/6 概率能推进的局面它却选择原地防御。骰子的正确建模方式不是对手而是概率环境也就是 Expectimax 中的 chance 节点。3.2 期望搜索的价值传递先算期望再取极值期望搜索的树有三类节点掷骰子节点负责算期望行动节点负责取最大。它和 Min-Max 的关键差异是min节点被替换成一堆概率固定的 chance 节点。在爱因斯坦棋里骰子从 1 到 6 分布均匀每个点数的概率都是 1/6但每个骰面能生成多少合法走法却不一样。这个不均匀性必须在行动节点里自然体现而不是在 chance 层强行加权重。WIN 10000.0 def chance_node(board, turn, depth): # turn 视角下的期望值turn1 白方视角turn-1 黑方视角 done terminal_result(board) if done is not None: return done if turn 1 else -done if depth 0: return evaluate(board, turn) exp_value 0.0 for d in range(1, 7): moves legal_moves(board, turn, d) if moves: # 掷到这个点数时action_node 返回 turn 视角下的最优值 exp_value (1.0 / 6.0) * action_node(board, turn, d, depth) else: # 无子可走跳过回合局面价值不变但概率仍然 1/6 exp_value (1.0 / 6.0) * evaluate(board, turn) return exp_value def action_node(board, turn, dice, depth): moves legal_moves(board, turn, dice) if not moves: return evaluate(board, turn) best -10_000_000.0 for src, dst in moves: child_board board.copy() child_board[src] 0 child_board[dst] turn * dice done terminal_result(child_board) if done is not None: # 自己走到底线立刻赢 val WIN if turn 1 else -WIN elif depth - 1 0: val evaluate(child_board, -turn) # evaluate 返回的是下一步行动方视角的价值要翻转回 turn 视角 val -val else: # 递归进入对方回合的 chance 节点 val -chance_node(child_board, -turn, depth - 1) best max(best, val) return best这里最容易抄错的是val -chance_node(...)。chance_node(child_board, -turn, depth - 1)返回的是对手视角的价值正数代表对对手有利。当前action_node站在turn视角必须取负才能继续比较。我在第一次实现时漏掉这个负号结果 AI 白方和黑方的棋力明显不对称同一起点下黑方胜率异常高查了半天才发现是评估视角没有统一。depth的单位是“行动次数”不是节点层数。一次行动包含一个骰子节点的枚举和最多几个行动分支。深度为 3 意味着 AI 往后看 3 次走子实际展开的节点数大约是 6 个骰面乘以平均 2 个落点再乘以下一层量级大概是6×2×6×2×6也就是几千个叶子盘面速度完全可以接受。3.3 评估函数设计把“离赢线近”写进数值里期望搜索到叶子节点时需要一个启发式函数。如果只用“吃子数”做评估AI 会变成贪吃蛇疯狂换子却忘了往底线推进。爱因斯坦棋的胜利条件是任意棋子到达对方底线所以评估函数的主项应该是距离而不是棋子数量。def evaluate(board, turn): score 0 for idx in range(SIZE): r idx // COLS c idx % COLS val board[idx] if val 0: # 白棋越靠近第0行越好 score (ROWS - r) * 10 # 给侧翼位置一点小奖励鼓励制造优势 score (COLS - 1 - abs(c - 1.5)) * 2 elif val 0: # 黑棋越靠近第3行越好 score - (r 1) * 10 score - (COLS - 1 - abs(c - 1.5)) * 2 return score if turn 1 else -score距离权重每格 10 分侧翼奖励每格 2 分差距拉得足够大搜索才会优先推进而不是在中间来回横跳。这里的距离用的是行号差没有考虑左右绕路棋子在边界时实际要走更多步但作为启发式已经够用。如果后续想提升棋力可以改成 BFS 计算每个棋子的最短到达步数但搜索前要预计算整张距离表否则评估函数本身会成为性能瓶颈。4. 动手写博弈软件核心expectimax 主循环、评估函数与迭代加深第三章的递归骨架已经有了这一章把它组装成能跑的人机对弈程序。除了搜索本身博弈软件还需要处理“当前轮到谁”“玩家怎么输入走法”“AI 想多久”这三个工程问题。我把它们拆成三个可替换的模块盘面逻辑、搜索逻辑、输入输出。这样以后换界面层或者换规则集不需要重写搜索。4.1 搜索根节点从所有合法动作里挑一个最大期望行动chance_node返回的是局面价值不能直接告诉 AI 该走哪一步。根节点需要单独写一个循环遍历当前骰面下的所有合法走法对每个走法调用一次chance_node取期望值最高的那一步。注意根节点也要站在当前行动方视角所以对手视角的值同样需要取负。def best_move(board, turn, dice, depth3): moves legal_moves(board, turn, dice) if not moves: return None best_val -10_000_000.0 best_mv moves[0] for src, dst in moves: child_board board.copy() child_board[src] 0 child_board[dst] turn * dice done terminal_result(child_board) if done is not None: return src, dst, WIN if turn 1 else -WIN val -chance_node(child_board, -turn, depth - 1) if val best_val: best_val val best_mv (src, dst) return best_mv[0], best_mv[1], best_val在一个实际的博弈软件里骰子由界面层或服务端掷出用户能看到骰子点数然后调用best_move时传入这个点数。如果对局平台要求 AI 自己掷骰那只需要在外面包裹一层随机数生成再把生成的dice传给best_move即可。根节点的深度我一般设成 3 或 4深度 5 以上在纯 Python 版里响应会超过一秒体验会明显变差。4.2 用缓存表砍掉重复局面棋盘小重复多爱因斯坦棋的棋盘只有 16 格棋子 12 颗搜索过程中从不同顺序到达同一个局面的情况非常常见。比如先动左棋再动右棋与先动右棋再动左棋如果落子顺序不影响最终位置就可能撞出同一个局面。给chance_node加一个简单的字典缓存能省掉大量重复计算。cache {} def chance_node_cached(board, turn, depth): key (tuple(board), turn, depth) if key in cache: return cache[key] done terminal_result(board) if done is not None: val done if turn 1 else -done cache[key] val return val if depth 0: val evaluate(board, turn) cache[key] val return val exp_value 0.0 for d in range(1, 7): moves legal_moves(board, turn, d) if moves: exp_value (1.0 / 6.0) * action_node_cached(board, turn, d, depth) else: exp_value (1.0 / 6.0) * evaluate(board, turn) cache[key] exp_value return exp_value缓存键必须包含depth因为同一局面在深度 2 和深度 3 时的回溯价值不一样。turn也不能省白方视角和黑方视角的评估值互为相反数混用会让搜索结果出现符号错误。缓存表不要设置得无限大一局棋的搜索量在百万节点量级时用限制条目数的OrderedDict或直接定期清空都行否则长时间对局后内存会缓慢上涨。4.3 迭代加深与时间预算深度 2、3、4 的取舍固定深度搜索有个毛病不知道当前机器能跑多快。同样的深度 4在开发机上 0.3 秒返回在用户老机器上可能要 3 秒。博弈软件里更稳妥的做法是迭代加深先算深度 1再算深度 2时间超了就停用上一层的完整最佳走法兜底。下表是我常用的参数组合参数推荐值影响depth3 或 4深度每加 1节点量约乘 6 到 8 倍time_budget0.8 至 2.0 秒超时用上层搜索结果兜底cache_max100000 条超过后直接清空防止内存膨胀use_skipTrue无对应棋子时跳过回合避免复杂分支import time def iterative_best_move(board, turn, dice, max_depth4, time_budget1.0): best_result None start time.time() for d in range(1, max_depth 1): if time.time() - start time_budget * 0.8: break cache.clear() result best_move(board, turn, dice, depthd) best_result result return best_result时间预算设在1.0秒时我通常留 20% 的余量给缓存清理和棋盘复制开销。cache.clear()放在每次完整搜索之前而不是每次递归之前否则缓存完全失去意义。迭代加深的真正价值在于深度 4 搜到一半超时退出时上次深度 3 的完整结果仍然可用用户不会感到 AI 忽然变蠢。5. 避坑记录期望搜索最容易翻车的 5 个细节这一章写我自己在实现过程中真切踩过的坑以及从命令行对局里反推出来的问题。每一条都按“现象、原因、解决”三步整理照着排查能少走很多弯路。5.1 骰子节点放错层AI 像开了透视眼现象是 AI 的棋风看起来很激进常常选择一条“无论下次骰到几都还能走”的路线但实际对局胜率并不高。原因是搜索结构写成了先行动、后掷骰等于让 AI 在不知道骰点时预先决定了走法。真实规则是先掷骰再行动玩家看到骰面后才做决策。解决方法是严格按“chance 节点在外、行动节点在内”的嵌套关系展开也就是chance_node里枚举骰子点数action_node里只负责取最大。5.2 对手期望值没取负白方黑方实力失衡现象是同一套代码白方先手胜率明显高于黑方或者反过来。原因是action_node递归进入chance_node(child, -turn, depth - 1)后返回值是对方视角的期望直接拿来max比较会让己方错误地“喜欢”对己方不利的局面。解决办法是在递归调用前面加一个负号并且写注释标明val -chance_node(...)。这条规则在 Min-Max 里因为显式写了min不容易错在 Expectimax 里全用max时特别容易漏。5.3 带剪枝的期望搜索期望值被算歪现象是搜索速度变快但棋力反而下降尤其是落后局面下不防守。原因是在 chance 节点上直接套用了 Alpha-Beta 的剪枝逻辑把某个骰子点数的概率剪成了 0。期望节点的价值是所有分支概率加权不能因为某个分支暂时不好就剪掉它发生的概率是规则给定的不是对手选出来的。解决方法是只在action_node的max循环里做普通剪枝chance 层必须完整遍历所有骰面。5.4 深度单位混乱搜索树膨胀到卡死现象是深度从 3 调到 4单步思考时间从 0.5 秒暴涨到几十秒。原因是把“骰子枚举层”也计入了 depth每多一层深度就多展开 6 个骰面同时行动分支再加倍总节点数成倍率极其吓人。解决方法是约定depth表示“玩家行动次数”骰子枚举不计入 depth 递减。代码里chance_node收到depth后递归传给action_node时不减只有action_node走完一步后才把depth - 1传回下一个chance_node。5.5 评估函数只看子力AI 会围着中间转圈现象是 AI 永远不会主动往底线冲总是在找吃子机会。原因是评估函数里吃子奖励权重过高推进奖励过低。爱因斯坦棋的胜利条件是到达底线棋子数量只影响阻碍能力不能决定胜负。解决方法是把距离分成主项侧翼奖励只做微调吃子优势通过“对方棋子减少推进阻力变小”间接体现而不是直接给吃掉一颗加 50 分。6. 验证与进阶自博弈回放、深度扫描与“先算哪一步”的取舍写完搜索和界面后最怕的是两个 bug 互相抵消单局看起来正常胜率统计却完全无意义。我的验证顺序是先做固定种子回放再做自博弈批量对局最后做深度扫描。回放时把每一步的骰子点数、走法、搜索值打印在同一行能直观看出 AI 是否在同一个局面下重复犯错。自博弈则用命令行批量跑 200 局统计白方胜率正常情况应该在 60% 上下浮动因为先手有推进优势如果白方胜率只剩 45%优先检查对手期望值取负和骰子节点层级这两处。深度扫描是提升棋力最直接的手段。把相同局面分别用 depth2、depth3、depth4 各跑一遍比较每步的落子和评估值。一般深度 3 到深度 4 的差异非常显著深度 4 开始能看到“主动堵住对手关键通道”这类战术而不是无脑往前冲。扫描时注意控制时间预算别让 depth4 把内存吃满。进阶方向有两个一是把评估函数里的固定行距奖励换成预计算 BFS 距离表二是开局阶段加入随机化策略避免 AI 下出完全一致的棋谱。BFS 距离表要在初始化时算好每个格子到对方底线的实际最小步数搜索深度较浅时这个启发式比行号差准确得多。随机化则可以在best_move里对价值差不超过 1% 的走法做随机选择降低对局可预测性。我最后的习惯是给命令行版加一个--replay参数每次 AI 走完后把整个局面序列写进文件随时可以回放某个骰面下 AI 的犹豫。这个功能帮我追到过两次缓存键漏掉depth的问题成本极低却很管用。期望搜索本身不复杂复杂的从来都是随机节点位置、视角翻转和概率计算这些容易自我欺骗的细节希望帮到你。本文还有配套的精品资源点击获取