简介:面向计算机博弈大赛选手与棋类爱好者,这份资源围绕爱因斯坦棋提供了一套完整的博弈软件实现方案,适用于算法学习、教学演示和比赛备战。项目采用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 视角下的期望值,turn=1 白方视角,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, depth=3): 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,时间超了就停,用上一层的完整最佳走法兜底。下表是我常用的参数组合:
| 参数 | 推荐值 | 影响 |
|---|---|---|
depth | 3 或 4 | 深度每加 1,节点量约乘 6 到 8 倍 |
time_budget | 0.8 至 2.0 秒 | 超时用上层搜索结果兜底 |
cache_max | 100000 条 | 超过后直接清空,防止内存膨胀 |
use_skip | True | 无对应棋子时跳过回合,避免复杂分支 |
import time def iterative_best_move(board, turn, dice, max_depth=4, time_budget=1.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, depth=d) 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%,优先检查对手期望值取负和骰子节点层级这两处。
深度扫描是提升棋力最直接的手段。把相同局面分别用 depth=2、depth=3、depth=4 各跑一遍,比较每步的落子和评估值。一般深度 3 到深度 4 的差异非常显著,深度 4 开始能看到“主动堵住对手关键通道”这类战术,而不是无脑往前冲。扫描时注意控制时间预算,别让 depth=4 把内存吃满。
进阶方向有两个:一是把评估函数里的固定行距奖励换成预计算 BFS 距离表,二是开局阶段加入随机化策略,避免 AI 下出完全一致的棋谱。BFS 距离表要在初始化时算好,每个格子到对方底线的实际最小步数,搜索深度较浅时这个启发式比行号差准确得多。随机化则可以在best_move里对价值差不超过 1% 的走法做随机选择,降低对局可预测性。
我最后的习惯是给命令行版加一个--replay参数,每次 AI 走完后把整个局面序列写进文件,随时可以回放某个骰面下 AI 的犹豫。这个功能帮我追到过两次缓存键漏掉depth的问题,成本极低,却很管用。期望搜索本身不复杂,复杂的从来都是随机节点位置、视角翻转和概率计算这些容易自我欺骗的细节,希望帮到你。
本文还有配套的精品资源,点击获取