简介:一套基于期望搜索算法的爱因斯坦棋博弈软件,面向计算机博弈大赛参赛者、棋类爱好者及高校师生。项目以Python编写,通过期望搜索分析棋局并制定策略,同时提供实时反馈与多种棋类支持,兼顾对弈和教学用途。
压缩包共159个文件、7.38MB,内含PNG界面资源、sample样本数据、XML配置、TTF字体、patch补丁及master/head等工程对象,覆盖界面渲染、数据组织与算法逻辑多个模块,结构清晰,便于直接查看源码和二次开发。
目前已有122人学习下载。软件以爱因斯坦式思维切入博弈决策,适合研究期望搜索在实际棋类对战中的落地方式,也可作为计算机博弈大赛备赛、课程设计与个人项目的参考资料。
读者可从中获取完整的Python项目工程、图形界面素材和样本数据,用于复现核心算法或改造自己的博弈程序。
1. 期望搜索与爱因斯坦棋:为什么这个组合值得你花一个周末
做了几年棋牌 AI,我越来越觉得“骰子棋”才是博弈搜索里最被低估的试炼场。普通象棋用 Minimax 就能跑得不错,但一旦引入骰子,局面不再是一棵纯对抗树,而是“随机分支 + 对抗分支”的混合体。这时候期望搜索(Expectimax)才是正统解法,而爱因斯坦棋——这是一款 5×5 棋盘、六枚带编号棋子、靠掷骰决定哪枚棋子能走的德式桌游——恰好把随机性和策略性揉到了同一盘棋里。它不像围棋那样深不可测,也不像井字棋那样一眼望穿,规则简单到新手十分钟能上手,但搜索树的形状比同规模的象棋复杂得多。这篇文章会带你从头实现一个能下完整盘棋的期望搜索引擎,包括骰子建模、动作生成、评估函数设计和五个我实际踩过的坑。适合有一定 Python 基础、想理解“随机博弈怎么用程序算清楚”的开发者,也适合想做课程设计或桌面 AI 项目但不想撞车的人。
2. 先把规则盘清楚:爱因斯坦棋的棋盘、走法与骰子建模
很多人写博弈树翻车,不是因为搜索算法写错,而是规则建模从一开始就漏了细节。爱因斯坦棋的规则看似简单,但骰子与棋子的对应关系、底线判定、吃子规则这三件事,每一件都有隐蔽的边角。这一章先把规则翻译成数据结构,再落成可执行的代码。
2.1 规则棋盘建模:坐标系的选型决定后面所有代码的写法
爱因斯坦棋的棋盘是 5×5,双方各 6 枚棋子,编号从 1 到 6。开局的摆法很讲究:白棋在左下角两行各放三枚(第 0 行和第 1 行),黑棋在右上角两行各放三枚(第 3 行和第 4 行)。每一位玩家的目标都是让任意一枚棋子冲到对方的底线——对白方来说就是第 4 行,对黑方来说是第 0 行。
我一般用(row, col)元组表示坐标,row 从 0(白方底线)到 4(黑方底线),col 从 0 到 4。白方棋子只能向 row 增大的方向前进,黑方棋子只能向 row 减小的方向前进。每枚棋子的移动方向有三个:直走一格(不改变列号)以及斜前方两格。直走可以吃子,斜走只能进入空格。这个区别非常关键,它意味着“吃子”和“位移”是两套规则,不能共用同一个移动生成函数。
方向向量的定义决定了后面所有动作枚举的写法。白方的三个方向是(1, 0)、(1, -1)、(1, 1),黑方对应的就是(-1, 0)、(-1, -1)、(-1, 1)。为什么不把方向做成黑方也用正方向?因为那样坐标换算和吃子判断都会多一层 if。让每个玩家持有自己的方向表,语义最直观。
棋子编号与位置的对应关系,我建议用字典而不是列表。原因是下落子的时候需要频繁地按编号查位置、按位置查编号,字典双向维护的成本最低。如果你追求极致性能,可以用两个长度为 7 的数组,下标就是棋子编号,这样查询是 O(1) 且没有哈希开销。我自己的实现先用字典,确认逻辑正确后再优化成数组,别一上来就写高性能代码,调试期会痛不欲生。
2.2 骰子概率表与随机节点:把“运气”变成可计算的概率分布
爱因斯坦棋每回合先掷一颗六面骰子,掷出的点数决定玩家可以移动哪一枚棋子。比如掷出 3,就只能移动编号为 3 的棋子。这里有一个容易忽略的规则变体:如果骰子点数对应的棋子已经到达对方底线(也就是已经赢了),玩家可以任选一枚棋子移动。这个“自由选择”规则在标准规则里存在,但很多教程的简化版本会把它裁掉。
从博弈树的角度看,骰子就是一个典型的 chance 节点:它有 6 个分支,每个分支的概率是 1/6。如果某个点数对应的棋子已经到达底线,则该分支内部不再继续按骰子分叉,而是变成一个由当前玩家自由选择的确定性动作。这个细节直接决定搜索树的形状——骰子节点下挂的不是 6 个等价的走法列表,而是“对应点数棋子的全部合法走法”,其中最多有一个分支会展开成多倍的动作。
我在代码里把概率表做成一个显式的列表,而不是每次现场计算:
DICE = [1, 2, 3, 4, 5, 6] DICE_PROB = {1: 1/6, 2: 1/6, 3: 1/6, 4: 1/6, 5: 1/6, 6: 1/6}这样做的理由是:期望搜索在计算期望值时需要按概率加权求和,如果你在搜索函数里硬编码1/6,将来想支持“加权骰子”或者“命运牌”这类扩展就得改搜索函数。把概率表独立出来,搜索代码只关心prob从哪里来,不关心它是 1/6 还是别的值。代码的耦合度低,调试的时候也能单独打印某个点数的概率值,验证分叉是否正确。
掷骰之后,轮到当前玩家行动,此时是一个确定性的行棋节点。所以一个完整的回合在搜索树上的形态是:chance 节点(掷骰)→ max 节点(当前玩家选动作)→ 对手的 chance 节点(对手掷骰)→ 对手的 max 节点,如此交替。这个交替关系必须画清楚,因为后面写递归函数时,is_chance_node的判断条件就依赖这个结构。
2.3 动作生成器:合法走法的枚举与去重
动作生成是每次搜索调用最频繁的函数,它的性能直接决定搜索深度。常见做法是写一个generate_moves(board, player, die_value)函数,接收当前棋盘、玩家和骰子点数,返回所有合法动作的列表。每个动作可以用一个(from_pos, to_pos)元组表示,吃子不需要额外标记,因为目标位置有敌方棋子与否已经蕴含在to_pos里了。
生成动作的核心逻辑是:先取出骰子点数对应的那枚棋子,检查它是否还在棋盘上、是否已经到达底线。如果不在棋盘上(被吃掉了),那这枚棋子无法行动——这是新手最容易忘的边界情况。规则上是“只能移动编号 X 的棋子”,但如果编号 X 的棋子被吃了,玩家不能随意选择其他棋子,而是相当于被迫放弃这一回合,直接进入对手回合。
def generate_moves(board, player, die_value): piece_pos = board.pieces[player][die_value] if piece_pos is None: return [] # 对应棋子已被吃掉,本回合无合法动作 row, col = piece_pos if row == player_goal_row(player): # 已到达底线,按自由选择规则:尝试所有棋子的合法动作 moves = [] for p in range(1, 7): pos = board.pieces[player][p] if pos is not None: moves.extend(_piece_moves(board, player, p, pos)) return moves return _piece_moves(board, player, die_value, piece_pos)_piece_moves负责单枚棋子的方向检查:遍历该玩家的三个方向向量,计算目标坐标,然后判断目标格是否越界、是否被己方棋子占据。如果目标是空格,加入动作;如果目标是敌方棋子且方向是直走(列号不变),加入动作;斜方向碰到敌方棋子则跳过。
逻辑说明:这里把“自由选择”规则的判断放在动作生成器而不是搜索函数里,是有意的。如果放在搜索函数里,你需要在 chance 节点的每个分支再做一次分支判断,逻辑分散且容易重复生成动作。动作生成器统一处理之后,搜索树的分支数在源头就收敛了。参数die_value是这次掷骰的结果,由 chance 节点传入,动作生成器本身不关心骰子的概率是多少。
参数说明:player_goal_row(player)返回白棋的 4 或黑棋的 0;_piece_moves里的方向表来自 2.1 定义的DIRECTIONS[player]。如果你后面加入“任意选子”的变体规则,只需要改generate_moves的中间分支,搜索函数完全不用动。
3. 期望搜索算法:从 Minimax 到 Expectimax 的改动量比你想象的小
如果你已经写过 Minimax,那么期望搜索的核心思路可以一句话概括:把对手节点里的最小值运算,替换成按骰子概率加权的期望值运算。但这句话落地时有三处细节要补:随机节点的终止条件、对手模型的选型、以及剪枝策略的失效。这一章从这三个点展开。
3.1 Minimax 的局限:没有随机节点的博弈树是残缺的
经典的 Minimax 假设博弈双方轮流走棋,每一步都是确定性的。这个假设在象棋、五子棋、黑白棋里成立,因为走子完全由棋手决定。但爱因斯坦棋的每一步之前都多了一个掷骰动作,而掷骰的结果不由任何一方控制。如果强行用 Minimax,你有两种粗糙的做法:一是把所有骰子结果当成等概率的对手步来枚举,然后把 6 个分支的值取平均——这实际上已经是期望搜索的雏形;二是只考虑“最可能的骰子点数”,也就是把随机性砍掉,只搜一个分支。第二种做法在实战中会输得很惨,因为对手的回合同样有骰子,你忽略随机性等于忽略对手所有的可能的应变。
更本质的问题在于 Minimax 的语义是“最小化对手的最佳收益”,而随机节点的语义是“计算所有可能结果的概率加权值”。玩家的策略要在随机结果之上取最优,对手的策略也要在随机结果之上取最优。二者像是两层夹心:玩家选动作时看的是“对手下一步掷骰后能拿到的最优值”的期望,而不是一个确定的最小值。所以搜索树的每个切面都要分清楚:当前是决策节点还是机会节点,处理方式完全不同。
3.2 Expectimax 的核心区别:把 min 节点换成 chance 节点
期望搜索的递归结构和 Minimax 几乎一致,差别就在节点类型上。我给出一个 Python 风格的伪代码实现,方便你直接对照自己的代码改:
def expectimax(board, depth, current_player, is_chance=False): # 终止条件:深度耗尽或已分胜负 if depth == 0 or board.is_terminal(): return evaluate(board, current_player) # 机会节点:掷骰阶段,对每个骰子面求期望值 if is_chance: total = 0.0 for die in DICE: prob = DICE_PROB[die] # 当前玩家掷骰,掷完后仍由当前玩家走棋 val = expectimax(board, depth, current_player, is_chance=False, die=die) total += prob * val return total # 决策节点:当前玩家选择对自己最有利的走法 moves = generate_moves(board, current_player, die) if not moves: # 无子可动:相当于跳过回合,交给对手掷骰 return expectimax(board, depth - 1, opponent(current_player), is_chance=True) best = -float("inf") for move in moves: board.apply(move) # 走完一步后,轮到对手掷骰 val = expectimax(board, depth - 1, opponent(current_player), is_chance=True) board.undo(move) best = max(best, val) return best逻辑说明:递归的入口永远从决策节点开始,因为轮到玩家行动时先要落子,落子之后对手才掷骰。函数用is_chance区分两类节点。chance 节点遍历 6 个骰子面,把子节点的值按 1/6 加权求和;决策节点遍历当前骰子点数下的全部合法走法,取最大值。die参数在决策节点是必需的,但在 chance 节点阶段它还没有被“掷出”,所以必须传下去到下一层决策节点。这也是和 Minimax 最大的结构差异:Minimax 的递归深度每次减一,而这里的depth只在走棋节点递减,掷骰节点不递减。
参数说明:depth是搜索的最大层数,这里指“决策节点的层数”。如果你想让搜索深度 4,意味着每个玩家各走 4 步,而骰子节点夹在中间不消耗深度。很多初写者把 chance 节点也减深度,结果搜索只能看 2 步棋,棋力大幅下降。evaluate函数的输入是棋盘和当前玩家,注意这里的“当前玩家”指的是正要走棋的一方,也就是评估函数的视角。评估函数的值是站在这个玩家角度算的,搜索里通过 max/min 来保证视角一致。
这段代码还有一个细节:board.apply(move)之后,子节点是opponent(current_player)的 chance 节点。因为当前玩家走完一步,轮到对手掷骰和走棋。递归回来后board.undo(move)恢复棋盘,这是搜索算法的常规内存管理方式,避免每次生成新棋盘对象导致内存爆炸。
3.3 轮到谁落子?对手模型的两种假设
期望搜索里有一个容易含糊的问题:当轮到你走棋时,你把对手当什么?在 Minimax 里,对手被建模成“总是选对我不利的走法”,所以取 min。在期望搜索里,对手同样要掷骰、走棋,但骰子的随机性应该按期望处理,而对手走棋时的“恶意”则按 min 处理。
常见做法是:对手的决策节点用 min 而不是 max。因为对于当前玩家来说,对手会选择让对手受益最大的走法,也即让当前玩家收益最小的走法。所以决策节点要区分“当前 max 玩家”和“当前 min 玩家”,而不是笼统地“走棋的人取 max”。
def expectimax(board, depth, maximizing_player, current_player, is_chance, die=None): if depth == 0 or board.is_terminal(): return evaluate(board, maximizing_player) if is_chance: total = 0.0 for d in DICE: total += DICE_PROB[d] * expectimax( board, depth, maximizing_player, current_player, False, d ) return total moves = generate_moves(board, current_player, die) if not moves: return expectimax( board, depth, maximizing_player, opponent(current_player), True, None ) if current_player == maximizing_player: best = -float("inf") for move in moves: board.apply(move) val = expectimax( board, depth - 1, maximizing_player, opponent(current_player), True, None ) board.undo(move) best = max(best, val) return best else: best = float("inf") for move in moves: board.apply(move) val = expectimax( board, depth - 1, maximizing_player, opponent(current_player), True, None ) board.undo(move) best = min(best, val) return best逻辑说明:这里用maximizing_player固定表示搜索树的根节点玩家,current_player表示当前递归到谁走棋。评估函数始终返回根节点玩家的视角分数。这样 max 节点和 min 节点的区别就是current_player == maximizing_player这个布尔判断。chance节点不区分玩家,因为骰子的概率独立于玩家身份。这段代码比上一版更完整,因为它正确处理了“双方都有骰子随机性”的交替结构。
参数说明:die在 chance 节点置为None,到决策节点时才传入具体的骰子点数。一旦某个玩家“无子可动”,它相当于跳过回合,代码直接递归到对手的 chance 节点,depth 不减——这个处理也和规则语义一致,因为跳过回合本身没有消耗玩家的行棋步数。如果你发现搜索在某个局面下异常浅,多半是 depth 被机会节点消耗了,检查点就在这里。
4. 评估函数决定棋力上限:位置分、逃生分与行动自由度的权重标定
搜索深度只能让你看到更多步,但树底部的叶子节点总得有个数值来决定取舍。如果评估函数只看“离底线还有多远”,AI 会变成一根筋往前冲,被对方堵死也不回头;如果评估函数太复杂,又会让搜索速度骤降。这一章讲清楚评估函数里应该放哪些特征、用什么样的权重结构,以及如何用手工对局来标定参数。
4.1 为什么评估函数是这局的胜负手
期望搜索在叶子节点上的表现直接受到评估函数的影响。可以这样理解:搜索深度是望远镜,评估函数是判断镜片是否清晰的磨工。望远镜倍数再高,镜片磨歪了,看到的依然是一片模糊。在爱因斯坦棋里,骰子的随机性让搜索树的平均分支因子比象棋低——通常一个骰子点数对应的合法走法只有 2~4 个——所以搜索深度 6 并不难达到。但评估函数如果只会数“谁离底线更近”,AI 会在中盘做出大量看似推进、实则送死的决定。
更麻烦的是,评估函数的错误会被期望搜索放大。因为期望节点会把多个分支的值加权求和,如果某个分支的评估值严重失真(比如把 4 步后被堵死的棋算成高分),它的分值会平均到其他分支上,导致整个局面判断都偏移。这跟 Minimax 完全不同:Minimax 的 min 节点会掩盖一部分错误估值(只取最小值),而期望节点是求和,错误信息会直接叠加。所以评估函数的准确度比搜索深度更值得花时间。
4.2 特征工程:位置分、存活分、到达分、行动自由度
评估函数的设计我一般从四个特征起步,每个特征都有明确的棋理依据:
第一个是位置分。棋盘 5×5 很小,每前进一步都意味着离胜利更近一步。位置分可以简单地按“距离底线的行数”加权,白方第 row 行的棋子得分为 row × 10,黑方为 (4 - row) × 10。这个线性分数简单但有效。
第二个是到达分。到达对方底线的棋子是赢棋的直接条件,应当给予极高的奖励。但要注意,砲对方的底线之后这局棋已经结束,搜不到这一步就该结束。所以到达分的意义更多是引导搜索优先推进离底线仅一步的棋子,而不是真的在被评估的局面中出现。
第三个是存活分。每枚棋子的价值不是均等的。靠近底线的棋子比刚出发的棋子更有价值,因为它距离胜利更近;但同时它也更脆弱,更容易被对方斜走切入。存活分可以按棋子当前行号做指数加权,让 AI 在“冒险深入”和“稳扎稳打”之间做权衡。
第四个是行动自由度。即当前玩家的所有棋子中,有多少枚还没有被吃且未到达底线。行动自由度越高,掷骰后“掷出无用点数”的概率越低。这个特征在实战中非常有效,因为有的时候牺牲一枚棋子反而让其他棋子获得更多行动机会——这种交换在评估函数里会反映为自由度的增加。
def evaluate(board, player): score = 0.0 for p in range(1, 7): pos = board.pieces[player][p] if pos is None: continue row = pos[0] if player == WHITE: score += row * 10 # 位置分 if row == 4: score += 1000 # 到达分 else: score += (4 - row) * 10 if row == 0: score += 1000 # 存活分:越接近底线权重越大,指数形式 score += 2 ** min(row, 4 - row) * 0.5 # 行动自由度:己方可行动棋子数越多越好 movable = [p for p in range(1, 7) if board.pieces[player][p] is not None] score += len(movable) * 3.0 # 对手的威胁:对手离我底线的距离越近,扣分越多 for p in range(1, 7): pos = board.pieces[opponent(player)][p] if pos is None: continue opp_row = pos[0] if player == WHITE: score -= (4 - opp_row) * 12 # 对手越靠下,对我威胁越大 else: score -= opp_row * 12 return score逻辑说明:这个评估函数把四个特征线性组合。位置分的权重 10 是基准值,到达分是 1000 以确保搜索只要有一步能赢就必选,存活分的衰减指数 0.5 让中盘的棋子也有一定的保底价值。行动自由度的权重 3.0 看起来不大,但在期望搜索里它会与骰子概率互相作用:每多一枚可行动棋子,期望值就会提升约 3 × (1/6) = 0.5,这个值在多轮搜索中会累积。对手威胁项用 12 的权重略高于位置分,是为了让 AI 不只顾自己冲线,当对手逼近底线时愿意回防。
参数说明:你看到的 10、1000、0.5、3.0、12 都是初始值,它们不是拍脑袋拍出来的,而是从“先保证不下出明显失误”这个目标出发的。1000 很大程度上确保了搜索不会在临近胜利时走错步;12 的威胁权重让防守行为在期望上不亏。后面如果要调参,建议一次只动一个数字,并用同一组开局连下 20 盘对比胜率,单独动多个参数的结果很难归因。
4.3 权重调参:手工标定做不到的时候就用简单自对弈
权重参数的调整是评估函数最费时间的一环,而且多少有点玄学。我的经验是:先用肉眼观察几盘完整的对局,找出 AI 最明显的决策失误,比如明明对手下一步能到底线它却不防守;针对失误去调相应特征的权重。这个循环重复三四轮之后,肉眼能发现的问题基本就没了,剩下的全靠自对弈统计。
自对弈的常见做法是让两个不同权重的 AI 互相对打,统计胜率。每局结果是一个样本,权重调整的方向由“新权重的胜率是否显著高于旧权重”决定。就是这个方向的判断往往是模糊的——因为骰子的随机性让单局胜负噪声很大,你必须下足够多的盘数才能看出差异。我会用 50 到 100 盘来评估一组权重,胜率差超过 5 个百分点才认为有区分度。别再少于 20 盘就去下结论,那和抛硬币没什么两样。
自对弈还有一个不容易察觉的好处:它可以暴露规则实现里的 bug。如果两边权重完全相同,胜负应该各半,但某些规则 bug 会让某一方占据固定优势——比如落子方向向量定义反了。所以在调权重之前,先跑 10 盘双 AI 同权重对局,检查胜率是否接近 50%。这一步能帮你省下大量排查隐蔽逻辑错误的时间。
5. 避坑:期望搜索实现中我踩过的五个坑
这一章写给那些已经把代码跑起来、但发现 AI 棋力鬼畜或者速度奇慢的人。以下五个坑都是我实际调试过程中遇到过的,每一条都按“现象 → 原因 → 解决”的顺序写,你可以直接对照自己的代码检查。
5.1 坑一:负无穷初始值让期望节点算出错误估值
现象:搜索进行到某个局面时,评估值突然变成接近-inf的负数,导致 AI 宁愿不动也不走任何棋。
原因:我在决策节点初始化best = -float("inf"),然后遍历合法动作更新最大值。如果某个动作的分支下子树递归返回的是-inf——通常是因为这个分支的某个叶子节点遭遇了无动作的递归链路——那么这个-inf会被 max 保留下来,继续传给上一层的 chance 节点。chance 节点把-inf乘以 1/6 加起来,整个期望值就变成了负无穷。
解决:在决策节点里,如果moves为空,直接返回评估值而不是递归;在 chance 节点里,对每一个子节点递归前先判空。另外可在expectimax入口统一加一个“合法动作列表为空则立即求值返回”的短路判断。这个判断要放在所有终止条件之后、递归之前,确保空动作不会污染上层期望。
5.2 坑二:把对手走棋也错当成了期望节点
现象:AI 明明掷骰后有几个点数无法行动,但搜索结果显示它把这些“无效点数”也按 1/6 加权了,导致 AI 高估了某些局面的价值。
原因:我在第一次实现时,把“玩家掷骰”和“对手掷骰”统一建模成 chance 节点,但在对手回合里,对手的决策节点也被错标成了 chance。仔细看规则会发现:掷骰是机会节点,掷完之后的走棋是决策节点。对手的走棋必须按 min 处理,因为对手会选对它最有利的走法。如果对手的走棋也按期望处理,等于假设对手随机乱走,评估出来的局面价值会系统性偏高。
解决:在递归函数里用is_chance和current_player两个参数区分。is_chance只表示“当前是否处在掷骰阶段”,与玩家身份无关;一旦进入决策节点,就根据current_player == maximizing_player判断用 max 还是 min。这样对手的决策节点永远是 min,骰子节点永远是期望。
5.3 坑三:评估函数的“到达分”把棋子送进了死胡同
现象:AI 的棋子总是义无反顾地冲到最前线,然后被对手的两枚棋子夹住,既不能前进也不能横向逃逸,白白浪费优势。
原因:到达分权重 1000 太高,导致搜索极度偏好把棋子往底线推。在离底线只剩一格时,AI 会忽略所有防守和逃脱路线,因为它认为“下一步就能赢”,但对手的骰子恰好可以走出一步堵住去路——这一层随机性在搜索深度不够时根本看不到。
解决:把到达分的权重从 1000 降到 300,同时给“即将到达底线的棋子”增加一个周围空格评估。如果这枚棋子的三个前方方向都被堵住,它就不应该得高分。具体做法是在evaluate里扫描每个棋子周围三格的占用情况,被包围的棋子扣除 50 分。调完这些之后,AI 的推进策略明显更稳健,不再无脑冲线。
5.4 坑四:搜索深度一上去速度就崩,缓存命中率上不来
现象:把搜索深度从 4 调到 6,单步耗时从 0.1 秒涨到 3 秒,且局面缓存命中率不到 15%。
原因:缓存键用的是棋盘状态的字符串序列化,每次搜索都做一次str(board.state),速度慢且占内存。更关键的是,爱因斯坦棋的棋盘具有对称性:同一局面对镜像位置来说,评估值应当相同。比如白方在 (2, 1) 的棋子和白方在 (2, 3) 的棋子,在列方向上是镜像关系。字符串序列化无法映射这些等价局面,所以缓存命中率上不去。
解决:把缓存键改成规范化后的棋盘状态。做法是:计算棋盘状态的镜像值,取两个值的较小者作为缓存键。具体实现可以用一个元组(tuple(whites), tuple(blacks)),然后取原状态和“列镜像状态”的字典序最小值存进去。这个改动后,缓存命中率从 15% 提到了 50% 左右,搜索深度也就能撑到 6 了。注意镜像只在列方向做,行方向不对称(因为底线方向不同,行镜像不合法)。
5.5 坑五:自对弈调权重时把“输赢”当成了唯一标签
现象:调整权重之后,AI 对局胜率变高了,但具体走出的棋反而更“抽搐”——有时明明能安心推进,却偏偏选择绕路。
原因:胜率是最终目标,但它太稀疏了。一盘棋几十步,只有最后一步才决定胜负,中间的每一步对胜率的贡献都被掩盖了。用胜率做反馈来调权重,相当于在黑匣子里瞎调:某组权重胜率高,但你不知道是因为中盘决策变好了,还是因为最后几步运气好。
解决:自对弈时要多记录中间状态。我会在每步决策后记录“搜索评估值”和“最终胜负”,然后用这些数据检查权重是否有明显的反向案例——比如所有胜利的对局里,某个特征的平均值反而更低。更实用的做法是加一个“翻盘率”指标:如果 AI 在中盘评估为劣势,但最终赢了,说明它的某个特征权重可能过低或过高。把反馈信号拆细之后,权重的调整方向才会更明确。这一步就是手动调参转向半自动化调参的起点。
6. 进阶:把期望搜索从“能下”推到“能赢”的三个具体技巧
当你把基础版跑通、AI 已经能完整体验对局之后,接下来就是常规优化阶段了。我这边最有效的是三个技巧:延迟评估、对称缓存加宽搜索、以及用快照数据反向检查评估函数。每个技巧的改动量都不大,但合在一起能让棋力上一个台阶。
第一个技巧是延迟评估。搜索深度较深时,叶子节点的评估值很容易因为“只差一步就到底线”而被高估。延迟评估的做法是:在到达最大深度时,不要立即调用评估函数,而是强制往下多搜索几层,直到局面趋于稳定——比如某方的所有棋子都已经脱离开局位置,或者双方的距离底线差距已经明显拉大。这会增加一点点搜索时间,但因为骰子棋的分支因子不大,多搜两层通常可接受,换来的评估准确性提升非常值得。
第二个技巧是保存并复用可以继续搜索的“半截结果”。搜索到深度上限返回时,评估值其实是基于多层信息的。你可以把这个评估值存起来,当成浅搜索的叶子节点值用。具体实现是在缓存里保存的不只是状态和值,还包括搜索深度。下次搜索时,如果当前状态在缓存里且缓存深度小于当前搜索深度,直接使用缓存值作为下界。这个技巧也叫迭代加深的“转置表替换策略”,在随机博弈里同样有效,能让同样时间下的有效深度提升一档。
第三个技巧是快照数据反向检查。我已经习惯每次自对弈结束后,把每个局面的评估值、实际走法、最终胜负存成一份对局记录。跑完 50 盘之后,我会随机抽 10 个局面,手动判断这个评估值是否符合直觉。这个习惯救过我很多次——有一回我发现评估函数对“黑方已到 0 行”的局面返回了正分,才意识到黑方的到达判定方向写反了。没有快照回放,这种隐蔽 bug 很难被找到,因为对局看起来还能正常下完。
这三个技巧加在一起,我的期望搜索 AI 从“偶尔赢初学者”进步到“稳定赢得过我自己”。更难得的是,我逐渐体会到期望搜索的优雅之处:它不试图消除随机性,而是把随机性当作博弈结构的一部分来求解。好运或坏运只影响某一回合,而策略的好坏决定长期胜率。希望这个思路和这些实现细节能帮到你——下棋 AI 的路子一通,换到其他带随机性的决策问题也就一通百通了。
本文还有配套的精品资源,点击获取