简介:这份资料包围绕亚马逊棋这一高复杂度两人策略棋类游戏展开,面向机器博弈方向的学习者、AI 算法开发者以及棋类程序爱好者,提供可直接运行或研读的多个版本实现,帮助理解游戏规则建模、搜索策略与智能决策的落地方式。压缩包共 203 个文件,约 687KB,以 c、h、in 等源码与构建配置为主,辅以 png 界面素材、makefile 编译脚本及 readme、changelog 等说明文档,并含少量可执行文件与桌面配置,目录结构便于按模块查阅。已有 618 人学习关注。读者可从中对比不同版本在状态表示、走法生成、评估函数与搜索剪枝上的差异,观察开发者如何逐步优化性能、处理复杂棋局,也可将其作为教学材料,用于课程设计或算法实验中的二次开发与调试参考。
1. 亚马逊棋:一个被低估的博弈搜索试验场
第一次接触亚马逊棋,是在给一个博弈搜索课程做陪练程序的时候。规则看起来不复杂:10×10 棋盘,四块棋子,每步先走子再放箭,箭落下的格子永久封死。真正动手写搜索才发现,它的分支因子比国际象棋大得多,一局棋的合法着法动辄上千,而终局往往要拖到七八十手。这意味着任何靠暴力枚举的思路都会当场翻车,必须认真设计评估函数、着法排序和剪枝策略。亚马逊棋附带资料这类需求,本质上就是想要一套能跑通、能改参数、能观察搜索行为的完整实现,而不是只给一段伪代码。这篇笔记按我实际搭一套可复现引擎的顺序来写:先讲清楚规则里哪些细节会直接影响搜索,再落到棋盘表示、着法生成、评估函数和搜索主循环,最后把调参和排错的血泪经验摊开。适合已经会写基础 minimax、想找一个分支因子够大又规则干净的棋种练手的开发者。
2. 亚马逊棋的规则边界与搜索建模:先把分支因子算清楚
2.1 一步棋其实是两段动作,别按单步着法建模
很多人第一次实现亚马逊棋,会把「走子」和「放箭」当成两个独立的 ply 来处理,结果搜索深度算错、评估函数在中间状态被调用,程序行为变得很玄学。正确的建模是:一个完整着法 = 棋子从起点移动到落点 + 从落点射出一支箭到目标格,两段动作在同一个 ply 内完成。棋子移动遵循国际象棋里的后(Queen)走法,沿八个方向直线移动任意格,不能越过棋子或箭;箭的飞行规则与棋子移动完全相同,从棋子新位置出发,同样八方向直线,不能越过任何障碍,落点必须是空格。箭落点之后该格永久不可通行,棋子也不能再站上去。
这个建模直接决定了着法生成函数的返回结构。我一般用一个三元组表示着法:(from_pos, to_pos, arrow_pos),其中from_pos是棋子原位,to_pos是棋子新位,arrow_pos是箭的落点。搜索树里每个节点存的是完整三元组,而不是分两步存。这样做的代价是单层着法数量大,但换来的是评估函数永远在「完整局面」上被调用,不会出现棋子走完还没放箭的半成品状态。下面这段是着法生成的核心骨架,用 Python 写,重点看它如何把两段动作合并进一次枚举。
# 棋盘用一维数组表示,0=空,1=黑棋,2=白棋,3=箭 # 方向向量:八个直线方向 DIRS = [(-1, 0), (1, 0), (0, -1), (0, 1), (-1, -1), (-1, 1), (1, -1), (1, 1)] def generate_moves(board, size, color): moves = [] for idx, cell in enumerate(board): if cell != color: continue r, c = divmod(idx, size) # 第一段:棋子沿八方向滑动 for dr, dc in DIRS: nr, nc = r + dr, c + dc while 0 <= nr < size and 0 <= nc < size: nidx = nr * size + nc if board[nidx] != 0: break # 遇到棋子或箭,停止该方向 # 第二段:从落点出发枚举箭的落点 for adr, adc in DIRS: ar, ac = nr + adr, nc + adc while 0 <= ar < size and 0 <= ac < size: aidx = ar * size + ac if board[aidx] != 0: break moves.append((idx, nidx, aidx)) ar += adr ac += adc nr += dr nc += dc return moves这段代码里有两个容易写错的地方。第一,箭的枚举起点是棋子落点(nr, nc),不是棋子原位,写错会导致箭从旧位置射出,规则直接失效。第二,内层箭循环里board没有临时把棋子挪过去,所以判断箭落点是否为空时,棋子原位仍然占着格子,这在大多数情况下不影响正确性,因为箭不会飞回棋子原位方向被自己挡住——但严格来说,如果棋子原位恰好在箭的飞行路径上,这里会误判。稳妥做法是在枚举箭之前,先在逻辑上把board[idx]置空、board[nidx]置为棋子,枚举完再还原。这个细节在低深度搜索里看不出来,深度一上去就会出现非法着法,属于典型的后悔药没处买。
2.2 分支因子到底多大,决定了你该选哪种搜索
10×10 棋盘、四子、平均每子约 15 个可达格、每个落点约 20 个箭落点,粗算单方着法在 800 到 1500 之间,开局阶段更高。这个量级意味着:纯 minimax 深度 3 就是十亿级节点,不剪枝根本跑不动。所以亚马逊棋的搜索实现里,alpha-beta 剪枝不是可选项,是及格线。更关键的是着法排序——剪枝效率高度依赖先搜到好着法,排序做得好,同样深度能少访问一个数量级的节点。
我一般把搜索方案分成三档来选。第一档是带迭代加深的 alpha-beta 加历史启发和杀手着法,适合教学和快速验证,单线程每秒能搜几万节点,深度 4 到 5 在合理时间内出结果。第二档是加入置换表(Zobrist 哈希)和空着裁剪,深度能推到 6 左右,但要小心置换表在「箭改变棋盘」这种不可逆操作下的命中正确性。第三档是蒙特卡洛树搜索(MCTS)配合随机模拟,适合不想手写评估函数的场景,但亚马逊棋的随机走子模拟质量很差,需要加启发式 rollout 才有意义。对大多数想复现的读者,我建议从第一档起步,把评估函数和排序调明白,再考虑上置换表。
| 方案 | 典型深度 | 单步耗时量级 | 适用场景 | 主要风险 |
|---|---|---|---|---|
| alpha-beta + 排序 | 4–5 | 百毫秒到秒级 | 教学、快速验证 | 排序差则剪枝失效 |
| alpha-beta + 置换表 | 6 | 秒级 | 对弈强度要求高 | 哈希冲突、表项覆盖 |
| MCTS + 启发式 rollout | 不定 | 秒级 | 免评估函数 | 模拟质量低、方差大 |
提示:先别急着上置换表。把着法排序和评估函数调好,深度 5 的 alpha-beta 往往已经能下出像样的棋,置换表带来的复杂度在早期调试阶段是负担。
3. 棋盘表示与着法生成:从一维数组到增量评估
3.1 一维数组够用,但增量维护才是性能关键
棋盘表示我试过三种:二维列表、一维数组、位棋盘。二维列表写起来直观,但每次访问要两次索引,在每秒几十万次评估的搜索里开销不可忽略。位棋盘在 10×10 这种非 2 的幂尺寸上很别扭,需要 100 位,用两个 64 位整数拼接,移位逻辑容易出错。最后落在一维数组上,长度 100,索引r * 10 + c,访问是一次索引加一次乘法,编译器层面很容易优化。这个选择不惊艳,但足够稳。
真正影响性能的不是表示本身,而是评估函数是否增量更新。如果每到一个叶子节点都从头扫 100 个格子算 mobility、算棋子位置分,评估开销会吃掉搜索预算的大头。我的做法是在 make/unmake 着法时同步维护几个累加量:双方棋子到棋盘中心的距离和、双方可移动格数、被封死的格子数。走子时减去旧贡献、加上新贡献,放箭时更新封死格计数。这样叶子节点评估基本是 O(1) 查表加少量算术,搜索速度能提升三到五倍。下面是一个简化的增量结构,重点看 make 和 unmake 的对称性。
class BoardState: def __init__(self, size=10): self.size = size self.cells = [0] * (size * size) self.mobility = {1: 0, 2: 0} # 双方可移动格数缓存 self.center_score = {1: 0, 2: 0} def make_move(self, move, color): frm, to, arrow = move self.cells[frm] = 0 self.cells[to] = color self.cells[arrow] = 3 # 增量更新:这里只示意中心分,实际还要更新 mobility self.center_score[color] += self._center_delta(frm, to) return (frm, to, arrow, self._center_delta(frm, to)) def unmake_move(self, move, color, undo_info): frm, to, arrow, delta = undo_info self.cells[arrow] = 0 self.cells[to] = 0 self.cells[frm] = color self.center_score[color] -= delta def _center_delta(self, frm, to): return self._dist_to_center(to) - self._dist_to_center(frm) def _dist_to_center(self, idx): r, c = divmod(idx, self.size) mid = (self.size - 1) / 2 return abs(r - mid) + abs(c - mid)make_move返回的undo_info必须包含所有被修改的累加量的旧值或差值,unmake_move严格逆序还原。这里最常见的翻车是「只还原了棋盘格子,忘了还原缓存」,搜索跑几十层后评估值开始漂移,棋力莫名其妙下降,排查起来非常痛苦。我的习惯是:任何在 make 里改动的状态,都必须在 undo_info 里有对应项,写完后用「make 再 unmake 后棋盘和所有缓存必须完全一致」的断言跑一遍随机着法序列,这个自检能挡掉八成增量更新的 bug。
3.2 着法排序的三个信号:历史表、杀手着法、吃子优先
alpha-beta 的效率对排序极度敏感。亚马逊棋没有传统意义上的吃子,所以「吃子优先」要换成「封死对手 mobility 优先」——也就是优先搜索那些箭落点能大幅压缩对方可移动格的着法。我一般用三个信号叠加打分:历史启发表记录每个(from, to)组合在剪枝中造成 beta 截断的次数,杀手着法记录同一层造成截断的最近两个着法,mobility 削减量作为静态分。排序时按分数降序,历史表分数做衰减,避免早期偶然截断的着法长期霸榜。
def score_move(move, history, killers, board, color): frm, to, arrow = move score = history.get((frm, to), 0) if move in killers: score += 10000 # 静态分:箭落点对对手 mobility 的削减估计 score += estimate_mobility_cut(board, arrow, color) * 10 return score def order_moves(moves, history, killers, board, color): return sorted(moves, key=lambda m: score_move(m, history, killers, board, color), reverse=True)history用字典或二维数组都行,键用(frm, to)而不是完整三元组,因为箭的位置组合太多,表会稀疏到没有统计意义。killers每层维护两个槽位,新截断着法挤掉旧的。estimate_mobility_cut不需要精确,用箭落点周围空格数做个近似即可,精确计算反而拖慢排序。这里有个参数值得调:mobility 削减量的权重系数,我一般从 10 开始,如果发现搜索偏向「乱放箭」而不是「走好棋」,说明这个权重过高,压到 3 到 5 之间通常更平衡。
注意:着法排序本身也消耗时间。如果单层着法上千,全排序的开销可能占到搜索时间的 20% 以上。常见做法是只对前 N 个候选做精细排序,其余保持生成顺序,N 取 32 到 64。
4. 评估函数与搜索主循环:让深度 5 下出像样的棋
4.1 评估函数的四个维度,别只算 mobility
评估函数是亚马逊棋引擎里最需要手工调的部分。我见过不少实现只算双方 mobility 之差,结果引擎下棋像无头苍蝇——因为 mobility 高不代表位置好,棋子挤在角落也可能 mobility 不低。我一般用四个维度加权:mobility 差、棋子到中心区域的距离和、棋子之间的连通性(互相能支援的格数)、以及封死格对双方的不对称影响。权重不是拍脑袋,而是用一组固定开局局面做小规模对弈,看哪个权重组合胜率高,逐步收敛。
| 维度 | 含义 | 初始权重 | 调整方向 |
|---|---|---|---|
| mobility 差 | 己方可移动格减对方 | 1.0 | 过高会导致乱放箭 |
| 中心距离和 | 棋子离棋盘中心的曼哈顿距离 | 0.5 | 过高会挤在中路被围 |
| 连通性 | 己方棋子互相可达的格数 | 0.3 | 鼓励协同封锁 |
| 封死格不对称 | 对方被封格减己方被封格 | 0.8 | 过高会过早封死自己 |
权重调参有个血泪经验:不要一次调四个。固定三个,只动一个,跑 50 局自对弈看胜率变化,再换下一个。四个一起调,你根本分不清是哪个维度在起作用,最后得到一组「碰巧能赢」的参数,换个开局就崩。另外,评估函数里所有维度都要做归一化,否则量纲差异会让某个维度实际主导一切,权重形同虚设。
4.2 迭代加深加时间控制,比固定深度实用
固定深度搜索在实际对弈里很尴尬:开局分支多,深度 5 可能超时;残局分支少,深度 5 又浪费。迭代加深从深度 1 开始逐层加深,每层记录最佳着法,时间到了就返回上一层的结果。配合时间预算(比如每步 2 秒),引擎能自适应局面复杂度。实现上要注意:每层搜索前把上一层的 PV(主变例)着法排到最前面,这本身就是最好的排序信号,能让下一层剪枝效率显著提升。
import time def search_root(board, color, time_limit=2.0): deadline = time.time() + time_limit best_move = None for depth in range(1, 20): try: move, score = alpha_beta_root(board, color, depth, deadline) best_move = move except TimeoutError: break # 本层未完成,沿用上一层结果 if abs(score) > 90000: # 已分出胜负 break return best_move def alpha_beta_root(board, color, depth, deadline): best_score = -float('inf') best_move = None moves = order_moves(generate_moves(board.cells, board.size, color), history, killers, board.cells, color) for move in moves: if time.time() > deadline: raise TimeoutError undo = board.make_move(move, color) score = -alpha_beta(board, 3 - color, depth - 1, -float('inf'), -best_score, deadline) board.unmake_move(move, color, undo) if score > best_score: best_score = score best_move = move return best_move, best_scorealpha_beta_root里用-best_score作为 beta 传入下一层,是 fail-soft alpha-beta 的写法,配合根节点逐着法更新,能比固定窗口多剪掉一些分支。TimeoutError用异常传递而不是返回值,是为了在任意深度中断,避免每层都写超时判断。这里有个坑:如果时间控制在根节点第一层就超时,best_move会是None,实际对弈时要兜底返回第一个合法着法,否则程序直接崩。我一般把时间预算设得比实际可用时间少 20%,留出着法生成和通信的余量。
4.3 置换表的正确用法与哈希陷阱
上置换表之前,先确认你的 Zobrist 哈希覆盖了所有影响局面的信息:棋子位置、箭位置、轮到谁走。少哈希任何一项,都会导致不同局面命中同一条表项,搜索返回错误分数,棋力断崖式下跌。亚马逊棋的箭只增不减,局面不可逆,所以置换表里存的深度和分数要按「距叶子剩余深度」来比较,而不是绝对层数。表项覆盖策略我一般用「同键且新深度不小于旧深度才替换」,简单但够用。
def probe_tt(tt, key, depth, alpha, beta): entry = tt.get(key) if entry is None: return None stored_depth, flag, score, move = entry if stored_depth < depth: return None if flag == 'exact': return score if flag == 'lower' and score >= beta: return score if flag == 'upper' and score <= alpha: return score return Noneflag分 exact、lower、upper 三种,分别对应「精确值」「下界」「上界」,写入时根据搜索是否发生截断来判定。move字段存最佳着法,下次探测命中时直接排到着法列表最前面,这是置换表除了省节点之外的第二个价值。哈希冲突在 64 位键下概率极低,但如果你用 32 位键,深度 6 以上就会开始出现莫名其妙的评估跳变,别省这个位数。
5. 亚马逊棋引擎避坑清单:五条踩过的坑
5.1 箭落点枚举漏了棋子原位,非法着法混进搜索
现象:引擎在深度 4 以上偶尔走出「箭落在棋子刚离开的格子」这种非法着法,低深度完全正常。原因:着法生成时没有临时把棋子从原位挪走,箭的飞行路径判断把原位当成障碍,某些方向被错误截断,同时另一些边界情况下又放行了非法落点。解决:在枚举箭之前,先在cells里把frm置 0、to置为棋子颜色,枚举完还原;或者维护一个「临时占用」集合,判断障碍时同时查棋盘和集合。改完后用随机局面跑一万次着法生成,逐条用独立规则校验器验证合法性。
5.2 增量评估的缓存没在 unmake 里还原,分数缓慢漂移
现象:自对弈跑到中后盘,引擎开始出现明显昏招,但单独测试评估函数又是对的。原因:make_move更新了 mobility 缓存,unmake_move只还原了棋盘格子,缓存越积越偏,评估值失真。解决:所有增量状态统一走undo_info还原,并加断言——每次 unmake 后重新全量计算一遍缓存,和增量值比对,不一致就抛异常。这个断言在调试期开着,性能损失可接受,上线前再关掉。
5.3 置换表键没包含行棋方,轮到谁走都命中同一条
现象:搜索分数忽高忽低,同一局面两次搜索结果不一致。原因:Zobrist 键只哈希了棋盘,没异或行棋方标识,黑方和白方面对同一棋盘布局命中同一条表项。解决:初始化时给行棋方单独生成一个随机键,每次切换行棋方就异或进去;或者键里直接拼上行棋方标志位。改完后跑固定局面重复搜索,结果必须完全可复现。
5.4 时间控制用固定深度,残局超时开局浪费
现象:开局每步想 5 秒还搜不完深度 4,残局 0.2 秒就搜完深度 6 干等着。原因:固定深度不感知局面分支因子变化。解决:换成迭代加深加时间预算,每层开始前检查剩余时间,预估本层能否完成,不能完成就提前退出沿用上层结果。预估可以用上一层的节点数乘以分支因子增长率做粗略判断,宁可保守一点。
5.5 着法排序权重过高,引擎沉迷放箭不走路
现象:引擎开局几步就把箭乱撒一通,棋子原地不动,中盘 mobility 崩盘。原因:mobility 削减量在排序分里权重过大,放箭的静态分普遍高于走子,排序把放箭全排前面,搜索在浅层就锁定了「封路」路线。解决:把排序分里的 mobility 权重从 10 降到 3 到 5,同时给「棋子向中心移动」的着法加一个小加成;评估函数里也检查封死格不对称项的权重,避免引擎过早自封。调完后观察前 10 步的箭落点分布,应该集中在对方半场而不是自己脚下。
6. 用自对弈和局面回放验证引擎强度
引擎写完不等于能用,验证环节比写代码更花时间。我一般分三步走。第一步是规则一致性自检:随机生成局面,对着法生成器输出的每一条着法用独立实现的慢速校验器逐条验证,确保没有非法着法漏网,这一步能挡掉大部分规则建模错误。第二步是自对弈回归:让新旧两版引擎各执黑白对弈 100 局,记录胜率、平均步数、每步搜索节点数和耗时,新版胜率低于 55% 就不合并。第三步是局面回放,挑几十个关键局面(开局、中盘封锁、残局收束),把引擎的搜索树前几层打印出来,人工看它优先搜了哪些着法、剪掉了哪些,判断排序和评估是否符合预期。
自对弈脚本的关键是固定随机种子和开局库,否则两版引擎的对局分布不可比。我一般准备 20 个固定开局(前两手着法序列),每个开局跑 5 局,黑白互换,这样 100 局里开局多样性够,又不至于引入太多噪声。统计时除了胜率,重点看「平均每步节点数」——如果新版胜率涨了但节点数暴涨三倍,说明是靠蛮力堆出来的,实际对弈时间预算下未必占优,这种「虚假进步」要警惕。
局面回放有个技巧:把搜索的 PV 打印成可读的坐标序列,配合一个简单的文本棋盘渲染,逐层看引擎的意图。如果发现它在某个局面反复搜索同一类无效着法,多半是评估函数某个维度给了错误信号,回到权重表针对性调整。这个循环我跑了大概两周,引擎从「能走合法棋」到「深度 5 稳定压制随机策略」,再到「和上一版自对弈胜率 60% 左右」,每一步都有可量化的依据,而不是凭感觉说「好像变强了」。
最后说个习惯:每次改评估函数或排序逻辑,先把改动前的引擎二进制和参数存档,改完必须能复现旧版的搜索结果。亚马逊棋这种分支因子大的棋种,任何一处改动的影响都会被搜索放大,没有存档对比,你根本不知道是改好了还是碰巧赢了。这套流程跑顺之后,换任何一款分支因子大的棋类,建模、增量评估、迭代加深、自对弈验证这四步都能直接搬过去。希望帮到你。
本文还有配套的精品资源,点击获取