news 2026/9/20 18:18:25

期望搜索算法实战:用Python构建爱因斯坦棋AI

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
期望搜索算法实战:用Python构建爱因斯坦棋AI

简介:基于期望搜索与Python实现的爱因斯坦棋对战软件,面向人工智能算法学习者和Python游戏开发人员,重点演示博弈树搜索、状态评估与剪枝优化在棋类对战中的综合运用。压缩包共1258个文件,总体积60.4MB,文件类型涵盖核心Python程序、pyd与dll扩展运行库、pyc编译文件、png和gif界面图形、xml规则配置等,同时包含大量时区数据与示例样本,目录结构完整,解压后可直接运行体验。目前已有534人学习下载。通过源码研读和实际运行,可深入掌握爱因斯坦棋的棋盘建模、走法生成、翻转逻辑、评估函数设计,并理解期望搜索、Alpha-Beta剪枝、蒙特卡洛树搜索等经典博弈算法的工程实现思路,对毕业设计、课程项目以及Python与人工智能算法入门均具有较高参考价值,不仅可用于算法对比验证,也可作为二次开发的基础框架。 写棋类AI写过不少,从井字棋到五子棋再到国际跳棋,思路基本都逃不出Minimax搜索加Alpha-Beta剪枝那套。直到朋友丢给我一个“爱因斯坦棋”的题目,我才发现自己被确定性博弈的思维框住了——这棋每走一步之前都要掷骰子,骰子决定你这一回合到底能动哪颗棋子。这种情况下,传统搜索里“对手一定会走最狠的那步”的假设直接失效,必须改用期望搜索(Expectimax)来处理随机性。

这篇文章就把我这次用Python落地爱因斯坦棋对战软件的过程完整拆开:规则怎么约定、期望搜索的原理是什么、棋盘和棋子该怎么建模、AI搜索树怎么实现、界面怎么做、以及我在调优过程中踩过的一堆坑。适合那些已经写过一点棋类程序、想尝试带随机性博弈AI的读者,也适合想了解Expectimax到底怎么落地的人。

1. 为什么爱因斯坦棋逼我用期望搜索而不是Minimax

1.1 6×6棋盘与六枚棋子的基本规则

爱因斯坦棋的版本很多,这个项目里我采用了一套比较常见且适合程序实现的简化规则:棋盘是6×6,双方各有6个棋子,正好对应国际象棋里的王、后、车、象、马、兵各一枚。初始排列很简单,黑白双方完全同型,如下:

黑方(row 0): R N B K Q P row 1: . . . . . . row 2: . . . . . . row 3: . . . . . . row 4: . . . . . . 白方(row 5): R N B K Q P

棋子走法基本沿用国际象棋:车走直线、象走斜线、后走直线加斜线、马走日字、王走一步。兵做了简化,白兵只能向上走一格,斜前方吃子;黑兵向下走一格,斜前方吃子。没有升变、没有吃过路兵、没有王车易位。

胜负条件也简化成“吃掉对方王即获胜”,不引入将军和应将的概念。这个规则改动看似很细,但对搜索树影响很大:少了“将军”判断,代码量能少三分之一,而且棋局依然能打出攻防节奏。

1.2 骰子打破确定性博弈假设的关键细节

这棋最核心的机制是:每回合开始前掷一颗六面骰子,点数1到6分别对应兵、马、象、车、后、王。也就是说,你只能移动骰子指定的那类棋子。如果对应棋子已经被吃掉了,就改为只能移动王。

这条规则直接让博弈从确定性变成了随机性博弈。你规划得再好,可能因为掷出一个不利点数,被迫移动一个原本不想动的棋子。而且这种随机性不是那种“小概率扰动”,它是每回合都必须面对的核心机制,直接影响每一步的合法走法集合。

我之前写Minimax搜索时,核心假设是“对手永远会选择对自己最有利的走法”。但在爱因斯坦棋里,玩家在骰子掷出之前根本无法选择要动哪类棋子,只能在一个由概率决定的范围里做决策。搜索时必须把“骰子会掷出哪个面”这件事纳入计算,而不是只取最大最小值。

1.3 Minimax在随机节点上的失效案例

举个具体例子:当前局面里,对方的皇后暴露在我方车的攻击范围内,而我的王在另一个位置很安全。如果按Minimax的思路,AI会直接选择“用车吃皇后”,然后高高兴兴地把这个分支的评估值打到很高。

但问题是,这一回合骰子可能只允许你动马。如果你硬把“吃皇后”当作可选走法去搜索,那搜索树里的决策空间就跟真实棋规对不上了。更麻烦的是,Minimax会把“吃皇后”后的局面当成必然发生的分支,而实际上这个分支可能只有1/6的概率会出现——前提还得是骰子恰好掷到车。

所以对于爱因斯坦棋,搜索算法必须区分两类节点:一类是玩家真正能做选择的“决策节点”,另一类是掷骰子决定行动范围的“机会节点”。后者需要做概率加权,这正是Expectimax处理的典型场景。

2. Expectimax的核心逻辑:决策节点与机会节点的拆分

2.1 从Minimax到Expectimax的公式变化

Minimax的递归逻辑很简单:轮到己方时取子节点最大值,轮到对方时取子节点最小值。它的博弈树里只有两层节点,MAX和MIN交替。

Expectimax在MAX和MIN之外多了一种节点,叫机会节点(CHANCE)。机会节点的值不是取某个孩子节点的分数,而是所有孩子节点分数的概率期望。公式写出来就是这样:

V(s) = terminal时: U(s) MAX节点: max over a of V(apply(s, a)) MIN节点: min over a of V(apply(s, a)) CHANCE节点: Σ_i p_i * V(child_i)

对应到爱因斯坦棋:骰子每回合掷一次,六个面各占1/6概率。所以每个回合可以理解为外层是一个六分支的机会节点,骰子掷出后,玩家在这个骰子结果对应的合法走法集合里做一次决策。从我方视角看,我方回合的表达式是:

V(s) = Σ_{d=1..6} (1/6) * max_{a ∈ legal(s,d)} V(apply(s,a))

对方回合类似,只是里面的max要换成min。这个公式就是整个AI搜索的核心。

2.2 一颗骰子六个面的概率期望处理

落实到代码里,递归函数的顶层循环就是穷举六个骰子面。每面对应一个走法集合,然后在这个集合内部做一次最大或最小选择。

比如我方是白方,轮到白方掷骰:

total = 0.0 for dice in range(1, 7): piece = DICE_MAPPING[dice] moves = generate_moves_for_dice(board, WHITE, piece) if not moves: moves = generate_king_moves(board, WHITE) best = -float('inf') for mv in moves: board.apply(mv) score = expectimax(board, depth - 1, BLACK) board.undo(mv) best = max(best, score) total += (1.0 / 6.0) * best return total

注意这里moves为空时的处理:规则约定对应棋子被吃后改为移动王。但如果王也不存在,那就已经是终局,不会走到这段逻辑。这个边界条件后面踩坑部分会再展开。

2.3 为什么不能直接套用Alpha-Beta剪枝

Alpha-Beta剪枝之所以有效,是因为在MAX/MIN节点里,一旦发现某个分支已经不可能影响父节点的取值,就可以直接砍掉后面的子树。但在机会节点里,即使某一个骰子面下的最优走法分数很低,它依然有1/6的概率权重要算进期望里,你不能因为这个低分分支“没有前途”就把它整棵剪掉。

不过也不是完全没法剪。如果你把期望节点内部的每个骰子面单独看成子博弈,在每个子博弈的决策层内部还是可以套一层Alpha-Beta剪枝的。也就是说,MAX节点对每个骰子面各自维护alpha、beta边界,层与层之间通过期望值汇总。这样做复杂度略高,但确实能减少搜索时间。我在实际项目里先跑通了不带剪枝的版本,确认正确性之后才考虑优化。

另外一个更实用的替代思路是限制分支数量:对于每个骰子面,只挑评估值最高的前k个走法展开,相当于做beam search。因为6×6棋盘的分支本身不大,k取3到4已经能保留大部分棋力。

3. Python数据结构选择:从棋盘编码到走法生成

3.1 8×8数组加棋子枚举:省掉边界判断的小技巧

爱因斯坦棋的棋盘只有6×6,但我在底层用了一个8×8的二维数组,真正可用的区域是中间6×6。多出来的这一圈边界统一填充为“墙”,这样走子生成时就不需要反复判断下标是否越界,遇到墙直接停止扩展即可,代码简洁很多。

棋子枚举用整数表示:

EMPTY = 0 PAWN = 1 KNIGHT = 2 BISHOP = 3 ROOK = 4 QUEEN = 5 KING = 6 WHITE = 1 BLACK = -1

棋盘的格子存的是color * piece_type。白车存4,黑车存-4,空位存0。判断某格子是不是己方棋子,就判断值和当前玩家的乘积是否大于0。这套编码虽然不如位棋盘高效,但胜在直观好调试。

3.2 走法生成器怎么处理“骰子指定棋子类型”

我为每类棋子准备了一组方向向量,生成走法就是沿向量一路扫描,遇到己方棋子或者墙就停,遇到对方棋子就作为可吃落点加入走法列表。

ROOK_DIRS = [(1,0), (-1,0), (0,1), (0,-1)] BISHOP_DIRS = [(1,1), (1,-1), (-1,1), (-1,-1)] KNIGHT_JUMPS = [(2,1), (2,-1), (-2,1), (-2,-1), (1,2), (1,-2), (-1,2), (-1,-2)] def generate_moves_for_piece(board, player, piece): moves = [] for row in range(ROWS): for col in range(COLS): val = board.grid[row][col] if val == player * piece: moves.extend(expand_moves(board, row, col, piece)) return moves

难点在兵。白兵向上走一格,斜前方吃子;黑兵向下走一格,斜前方吃子。因为棋盘只有6行,兵走到底就是强弩之末,本来价值就低。实现的时候记得兵不能跳到空格再吃子,所以要把“空走”和“斜吃”分开处理。

走法对象我直接用元组表示:(from_row, from_col, to_row, to_col, piece, captured_piece)。执行和撤销都很方便。

3.3 基于棋子价值与位置表的局面评估函数

搜索必须依赖一个评估函数,把局面转成一个分数。我的评估函数由两部分组成:基础棋力价值和位置奖励。

棋子基础价值取国际象棋的经典比例:兵100、马300、象310、车500、后900、王100000。王设成大数是为了让AI永远优先保王,但这个数不能设得太大,否则会用浮点数精度问题,而且会导致AI在优势局面下也不敢靠近对方王,后面踩坑部分细说。

位置奖励我做了个很简单的实现:

  • 马、象、后、王越靠近棋盘中心,位置分越高;
  • 兵越深入对方腹地,位置分越高。

从己方视角看,最终评估值是己方总分数减对方总分数。为了让评估更稳定,位置奖励的权重只占基础价值的5%左右。如果权重太大,AI会为了“占中心”而白白送子。

4. 期望搜索树的落地实现与性能取舍

4.1 博弈树节点类型判断的递归结构

这个项目里,每个回合先掷骰子、再决策,所以我把机会节点和决策节点合并成一个递归函数。递归函数的职责是:从“当前玩家即将掷骰”的状态开始,计算这个状态的期望得分。

递归终止条件有两个:一是搜索深度到0,直接返回评估值;二是棋盘进入终局,返回一个极大或极小分数。终局只判定吃王,所以很好判断——任意一方的王不存在了,就返回对应方向的胜负分。

4.2 关键代码:期望层包决策层的循环写法

核心递归函数如下,我保留了完整的细节:

def expectimax(board, depth, current_player): terminal_score = board.terminal_score(current_player) if terminal_score is not None: return terminal_score if depth == 0: return evaluate(board, current_player) total = 0.0 for dice in range(1, 7): piece_type = DICE_MAPPING[dice] moves = board.generate_moves_for_dice(current_player, piece_type) if not moves: moves = board.generate_moves_for_piece(current_player, KING) if not moves: # 极端情况:王也被吃,或全部棋子被困住,按终局处理 if board.has_king(current_player): total += (1.0 / 6.0) * NEG_INF else: total += (1.0 / 6.0) * NEG_INF continue if current_player == WHITE: best = NEG_INF for mv in moves: board.apply(mv) score = expectimax(board, depth - 1, BLACK) board.undo(mv) best = max(best, score) else: best = POS_INF for mv in moves: board.apply(mv) score = expectimax(board, depth - 1, WHITE) board.undo(mv) best = min(best, score) total += (1.0 / 6.0) * best return total

这个函数里有个很容易错的地方:每次apply之后,递归调用传入的当前玩家必须翻转。因为棋规是每步之前掷骰,所以递归的下一层自然就是对方玩家掷骰。我把决策逻辑放在掷骰循环内部,相当于在同一层里完成了“掷骰子→选择走法”这两个动作的合并。

还有一个细节:如果某个骰子面下没有合法走法,我用NEG_INF处理。但实际对局中这种情况很少,因为规则保证了至少还能移动王。我加这个分支只是为了程序健壮性,避免死循环。

4.3 搜索深度、迭代加深与置换表优化

爱因斯坦棋每回合分支因子大概是“六个骰子面 × 每种棋子2到4个走法”,整体约15到20个分支。这个体量比国际象棋小得多,所以深度可以做得比较深。我在一台普通笔记本上用Python 3.10实测了不同深度的表现:

搜索深度单步耗时棋力表现
2约20ms只会贪吃眼前子,看不出战术
4约0.5秒能看出2个回合后的威胁,攻防正常
6约6秒能主动布陷阱,会控制中心位置

我实际对战用的默认深度是4,因为单步0.5秒体验比较好,棋力也够用。深度6虽然更强,但等待时间太明显,不太适合人机对战。

如果要进一步提升速度,可以做两件事:一是迭代加深,先搜深度2,再逐步加深,把上次搜索的最佳走法作为这次的首选走法,能显著提升剪枝效率;二是Zobrist哈希加置换表,因为6×6棋盘的合法局面数远小于国际象棋,置换表的命中率很可观。这两个我建议作为后续优化,不要第一版就上,否则bug排查会很痛苦。

5. 人机对战全流程:从命令行到Pygame显示

5.1 玩家输入流程与骰子结果展示

我第一版只做了命令行界面。对局流程是这样的:先打印当前棋盘,然后模拟掷骰子,展示骰子点数对应的棋子类型。玩家随后在所有“该类型棋子可走的格子对”里选一个起点和终点,程序校验合法性后执行走法,再进入AI回合。

玩家输入环节有个交互细节:玩家可能不熟悉这6种棋子的英文缩写。我在命令行版里把骰子结果和当前可走棋子的位置都列出来,比如:

当前轮到 白方 掷骰结果: 4 -> 车 可移动的车: [(5,0) -> (2,0), (5,0) -> (5,3)] 请输入起点和终点 (如 50 20):

这种提示能避免玩家一边查棋子规则一边操作。

5.2 Pygame版界面的事件循环结构

命令行版能跑通AI逻辑,但真要拿去跟朋友展示,还得有个图形界面。我用Pygame做了个简易版,核心事件循环结构是:

  1. 维护一个game_state对象,里面保存棋盘、当前玩家、上一次骰子结果;
  2. 每次事件循环先检查是不是玩家回合,如果是就等待鼠标点击;
  3. 玩家先选中一颗骰子指定类型的棋子,再点击目标格子,程序执行走法;
  4. 进入AI回合时,界面显示“AI思考中”,后台线程跑Expectimax搜索,搜完自动落子并更新棋盘。

绘制棋盘就是画6×6方格,交替填色,然后在格子里画棋子字符。骰子结果我直接显示一个大的数字和对应棋子简写,比如“5 → Q”。你不需要做成多漂亮的UI,关键是让玩家能看清当前骰子限制。

5.3 不同搜索深度AI的实际对局体验

我把深度2、4、6的AI都跑了几十局,观察下来差异非常明显。深度2的AI经常在明明能吃掉对方后的时候,因为骰子掷到兵就随便走一步兵,完全看不到潜在威胁。深度4的AI已经会主动把马跳到中心位置,也会在对手王的附近制造双车配合的杀势。深度6的AI则表现出一种“耐性”,它会在局面不利于进攻时主动退守,等待骰子面好转,这种打法在深度2里根本看不到。

如果你希望AI“看起来保守一点”,可以把搜索深度固定为4,同时把分支筛选打开,限制每个骰子面只展开前3个走法。实测下来棋力损失不大,但单步耗时能压到0.2秒以内。

6. 调优踩坑:随机性博弈AI最容易忽略的五件事

6.1 掷到被吃掉的棋子时规则必须写死

这个坑是我在联调时发现的。有一局我这边皇后早被吃了,但程序掷骰子掷到5(皇后),走法生成器返回空列表。由于我没写替代规则,AI直接返回了NEG_INF,导致它认为这一步怎么走都是死局,开始胡乱走兵送死。

修法就是在generate_moves_for_dice返回空时,强制改为生成王的走法。这条规则一定放在走法生成函数里,不要放在expectimax递归里,否则所有用到生成走法的地方都会漏。

6.2 评估函数里的王的价值不是越大越好

我曾经把王的价值设成1000000,结果AI变得极其胆小:即使已经领先一个后加一个车,它也不愿意靠近对方王,因为评估函数里任何可能导致王附近出现空格的走法,都会被巨大惩罚压下去。棋风变得非常被动,很多赢棋局面生生拖成和棋。

后来我把王的价值降到100000,同时给“吃王”的终局单独设一个更大的胜负分数。这样评估函数在绝大多数局面下不会因为王的价值而影响其他棋子的判断,只在终局搜索时通过终局分数触发胜败判断。

6.3 期望层顺序写反会让AI“看不懂局面”

这个坑特别隐蔽。我最早写的代码把期望层放在了决策层外面,但决策层里取max/min的判断用的是“存档状态里的当前玩家”,而不是“执行走法后的玩家”。结果AI永远在自己走棋的时候取min,在对手走棋的时候取max,棋风非常搞笑:它会主动走到对方嘴里送吃。

排查方法很简单:打印每一步的current_playerbest取值方向和首选的走法。如果发现白方AI的搜索里best一直在变小,那八成就是取max/min的方向写反了。

6.4 固定随机种子才能复现并讨论AI走法

期望搜索里有掷骰子,而Python的随机数默认不固定。调试时如果你发现AI某一步走得“极其离谱”,想复现讨论,却发现下一局它又换了种离谱方式,那根本没法分析。

我在调试代码里加了一个全局随机数种子,random.seed(42),并且在每次掷骰子前后打印骰子序列。这样跑出来的每一局棋谱都可以完整复盘。调优评估函数时,固定种子还能保证“同一局面、同一个搜索深度”下AI的应对保持一致,方便做A/B对比。

6.5 实时显示搜索信息对调试很有帮助

最后一招不算坑,但很管用。我在AI搜索结束后打印一行摘要:当前深度、期望分数、最佳走法、搜索节点数、耗时。这样一方面能确认AI计算到底有没有跑起来,另一方面也能直观看到随机性带来的分数波动。比如明明这个回合有几个走法看上去都不错,但搜索出来的期望分是负的,说明骰子限制下这个局面的确处于劣势,你就知道不是代码出错了,而是棋规本身就不给你发挥空间。

调试到后期我甚至做了个“教练模式”:把搜索到的最佳期望分直接显示在界面上,观看AI对局时能很清楚地看到哪个回合AI是在“赌”骰子面,哪个回合是“被迫”防守。如果你打算在文章或者演示里展示AI棋力,这个教练模式绝对是加分项。

如果后续想让AI再强一档,我建议往蒙特卡洛树搜索(MCTS)方向走,它在随机性博弈里的表现通常比期望搜索更好。但Expectimax这个算法本身实现简单、调试直观,作为理解和实现含随机性棋类AI的入门路径,依然非常合适。我这套代码改一改也能套用到其他带骰子的棋类项目上,关键是理解透“掷骰子→选择走法”这个层级架构,很多细节就都能顺下来了。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/20 18:17:39

FMCW雷达与蓝牙Tone信号:纯音信号原理、调试与跨领域应用

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/20 18:16:19

KFB转SVS实操指南:病理切片格式转换与批量处理全流程

简介:生物医学图像处理中,kfb格式向徕卡svs格式的批量转换是很多病理科研人员都会遇到的难题。这套KFB2SVS资源正是为解决此类格式兼容问题而设计,面向病理科室、医学影像分析人员及生物医学研究者,支持对大量kfb切片图像进行快速…

作者头像 李华
网站建设 2026/9/20 18:15:21

Ubuntu 22.04从装机到配置完全指南:镜像下载、分区驱动与常见坑

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/20 18:13:11

A100 ADC数据MATLAB信号处理与双实现验证实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/20 18:12:16

基于PLC的供料控制系统设计与调试全流程解析

简介:这是一份基于PLC的供料控制系统课程设计报告,面向自动化、电气工程等相关专业学生,也可供工业控制入门者参考。内容围绕冶炼厂皮带传输供料场景,完整呈现从需求分析、方案设计、硬件选型到梯形图编程与仿真调试的全过程&…

作者头像 李华
网站建设 2026/9/20 18:12:14

十美元U盘装Claude Code:跨平台便携化命令行工具链完全指南

把 Claude Code 塞进一个十几美元的 U 盘里带走,听起来像野路子,但实际跑通之后你会发现,这事一点都不折腾。不用做启动盘、不用进 BIOS、不用装 PE,更不需要把你手头电脑的系统环境搅一遍。我最近在两个办公点来回切换&#xff0…

作者头像 李华