简介:基于C++实现亚马逊棋的课程设计资料包,面向计算机专业学生及对棋类游戏编程感兴趣的开发者。资源围绕亚马逊棋规则,覆盖棋盘数据结构、棋子与箭类设计、合法移动判断、界面交互和基础搜索算法,适合作为C++课程设计或计算概论大作业的完整参考方案。压缩包共91个文件、约40.07MB,包含C++源码与头文件、实验报告和程序框图文档、多张运行界面截图,以及可执行程序、DLL/PDB和Makefile等构建与调试产物,便于直接查看工程结构和复现演示。目前已有447人学习下载。通过该资料可快速理解亚马逊棋项目的模块划分与实现思路,对照源码、文档和截图完成自己的版本,也能从中积累面向对象设计、状态管理和命令行/GUI交互的实践方法。
1. 亚马逊棋是什么:10×10 棋盘、四个皇后和一支箭,C++ 落地难在哪
对于下过国际象棋的程序员,「亚马逊棋」这个名字会唤起某种亲切感——10×10 的棋盘,每方只有四枚皇后,每回合先移动自己的一个皇后,再从这个皇后最终所在的位置向某个方向射出一支箭,箭落点变成永久障碍。双方交替进行,直到某一方所有皇后都无法移动为止,无法移动的一方判负。规则听起来比国象简练得多,可一旦想在 C++ 里把它做成一个人机对弈程序,你会发现真正的难点根本不在那颗 alpha-beta 剪枝上,而是「走法生成的正确性」和「搜索时的状态恢复」这两件事在反复折腾你。这篇文章面向有 C++ 基础、想从零把亚马逊棋完整落地的从业者,我会以控制台人机对弈为主线,把棋盘编码、走法生成、搜索评估、交互层和调试中的常见坑依次讲透,最后给出一套可以直接采用的对局验证流程。
2. 棋盘编码与走法生成:100 格的一维数组、移动加射箭的两次扩散
2.1 棋盘编码:一维数组加方向表,比二维数组更省心
先说棋盘。亚马逊棋固定 10×10,总共 100 个格子。常见做法是直接用char board[100],空格子记 0,己方皇后记 1,对方皇后记 2,箭记 3;从这个一维索引换算行列时用idx / 10和idx % 10。为什么不直接用二维数组?因为搜索代码里会频繁做「沿某个方向走一步」的操作,一维数组配合方向偏移表可以避免两层下标循环,而且在拷贝棋盘时一次memcpy就能完成。
方向表直接写死 8 个偏移量,在 10 列宽的棋盘上为:
const int DIRS[8] = { -11, -10, -9, -1, 1, 9, 10, 11 };这里 -11 对应「上一行左一列」,-10 是单纯上一行,-9 是上一行右一列,-1 是左一格,1 是右一格,9 是下一行左一列,10 是下一行,11 是下一行右一列。因为一维数组没有行列边界信息,每次使用前必须判断目标格是否发生「跨行」。一个标准的落点合法性判断是这样:
inline bool in_board(int idx) { int r = idx / 10, c = idx % 10; return r >= 0 && r < 10 && c >= 0 && c < 10; }注意实际检查时不能只检查idx是否在 0..99,因为idx = 90(第 9 行第 0 列)往左走会变成 89,这在数组里完全合法却跨了行。我一般直接先把idx + DIRS[d]转成坐标再判断行列,一次搞定。不处理这一步,棋局中期会出现皇后「绕到棋盘另一边」的灵异走法。
2.2 走法生成:先移动皇后,再射箭,两次辐射式扩展
亚马逊棋一个完整回合包含两个阶段,走法生成也必须分两层做。第一步,选一个己方皇后,沿 8 个方向逐个推进,遇到边界、箭、皇后就停,每个可停的格子就是候选落点;第二步,从候选落点再沿 8 个方向推进,把「最后一步之前」的所有空格都作为候选箭落点。等于每一个「皇后落点」都要再扩散出一整组箭目标,所以一个回合走法数量远大于国象。
struct Move { int from, to, arrow; }; std::vector<Move> gen_moves(const Board& b, int side) { std::vector<Move> moves; for (int from = 0; from < 100; ++from) { if (b[from] != side) continue; // 只选己方皇后 for (int d = 0; d < 8; ++d) { int to = from + DIRS[d]; while (in_board(to) && b[to] == EMPTY) { // 皇后移动不能跳过障碍 // 到 to 后,再以 to 为起点扩散箭 for (int sd = 0; sd < 8; ++sd) { int arrow = to + DIRS[sd]; while (in_board(arrow) && b[arrow] == EMPTY) { moves.push_back({ from, to, arrow }); arrow += DIRS[sd]; // 继续沿同方向延伸 } } to += DIRS[d]; } } } return moves; }逻辑说明:外层while负责皇后移动,遇到非空格立刻停,所以皇后不能穿过箭和其他皇后;内层while负责箭的飞行,同样只允许落在空格上,遇到任何非空格就停止扩散。两个循环用的是同一套方向表,区别只在于起点不同、扩展目标不同。这段代码最值得注意的地方是箭目标不包含皇后停留的to格——因为to格此时已经站着皇后,会被b[arrow] == EMPTY拦掉,箭自然不能落在自己脚下。
参数与边界:把棋盘大小从 10 换成 8 或 12,只需要改方向表和in_board里的列宽 10;如果把棋盘改成矩形,方向表的 -11/-9/9/11 也要跟着变。所以换规则时第一件事是重写方向表,而不是重写走法逻辑。这个函数在最坏局面下会生成数千个 move,搜索之前最好先按某种启发式排序,排序策略放到第 3 章讲。
2.3 落子与悔棋:搜索速度的第一道门槛
人类下棋落子之后可能悔棋,搜索算法本身也需要在试探一个 move 之后把棋盘还原。亚马逊棋没有吃子规则,皇后不会吃掉对方棋子,所以撤销非常简单:apply时把from清空、to写成皇后、arrow写成箭;undo时反过来把from恢复成皇后、to和arrow都清空即可。不需要像国际象棋那样记录被吃掉的棋子。
void apply(Board& b, const Move& m, int side) { b[m.from] = EMPTY; b[m.to] = side; b[m.arrow] = ARROW; } void undo(Board& b, const Move& m, int side) { b[m.from] = side; b[m.to] = EMPTY; b[m.arrow] = EMPTY; }一个很容易忽略的细节是apply的顺序:必须先把from清空再写to和arrow。如果from和to在同一个方向且相邻,先写to不会影响from,但若先写arrow再清from,而from恰好等于arrow——这种情况不会发生,因为箭落点必须是空位,from原本是皇后。真正要小心的是「从起点直线走若干步到to」时,to路径中间的格子全部是空的,apply顺序其实无所谓;但写成「先清from」能让心智模型统一,避免以后给棋盘加吃子规则时改出 bug。
这一层的性能影响很容易被低估。搜索到第 6 层时,apply/undo要被调用几十万次,如果每次重新计算side牌或把 move 复制一份,开销会直接反映在搜索深度上。我习惯把Move定义成三个int的小结构体,vector预留容量,循环里传引用而不是按值返回大对象。
3. 搜索与评估:Alpha-Beta 剪枝、mobility 权重和置换表怎么配合
3.1 Alpha-Beta 剪枝的最小实现:负极大值写法
亚马逊棋没有吃子,每回合走子权固定交换,非常适合用 negamax 写法。核心思路是:每一层都从「当前要走棋的一方」的视角返回分数,父层取负号。alpha是当前方已知最佳,beta是对方能接受的上限,一旦当前节点的分差让beta <= alpha,就可以剪枝。
int negamax(Board& b, int depth, int alpha, int beta, int side) { auto moves = gen_moves(b, side); if (depth == 0 || moves.empty()) { return evaluate(b, side); } int best = -INF; for (const Move& m : moves) { apply(b, m, side); int score = -negamax(b, depth - 1, -beta, -alpha, flip(side)); undo(b, m, side); if (score > best) best = score; if (score > alpha) alpha = score; if (alpha >= beta) break; // 剪枝 } return best; }参数说明:depth从 0 开始逐层递减,depth == 0或没有合法走法时返回评估值;alpha初始化为-INF,beta为INF,根节点调用时传入期望的搜索深度,比如negamax(b, 6, -INF, INF, cur_side)。moves.empty()的返回值非常关键——亚马逊棋判负条件就是「无合法移动」,这里让空 move 返回评估值而不是返回一个极端负分,是因为评估值由上层取负号,效果等价但更安全。
这个实现能跑,但剪枝效率完全取决于走法顺序。不做排序时,开局深度 5 的搜索可能要跑十几秒;先把候选按「落点离棋盘中心越近越优先」粗排一遍,同样深度能快 5~10 倍。排序本身也要花钱,所以常见做法是先算一个便宜的key = |to_r - 4.5| + |to_c - 4.5|,曼哈顿距离小的优先。不需要std::sort全家桶,一个插入排序就够,比较次数少、无额外分配,实测比每次比较都重算距离的std::sort快不少。
3.2 评估函数:mobility 和中心倾向,权重怎么定
评估函数决定 AI 的风格和下限。亚马逊棋评估的公认核心是 mobility——当前方可行动作数。一个简单的版本是直接统计gen_moves数量,但在搜索内部这样做的代价太大,每评估一次都要生成数千个走法。更常见的折中是统计「每个皇后在 8 个方向能走到的空格总数」,不区分箭落点,这样单次评估可以控制在几百次判断内。
int mobility_quick(const Board& b, int side) { int total = 0; for (int from = 0; from < 100; ++from) { if (b[from] != side) continue; for (int d = 0; d < 8; ++d) { int cur = from + DIRS[d]; while (in_board(cur) && b[cur] == EMPTY) { ++total; cur += DIRS[d]; } } } return total; } int evaluate(const Board& b, int side) { int my_mob = mobility_quick(b, side); int opp_mob = mobility_quick(b, flip(side)); int score = (my_mob - opp_mob) * 100; // 皇后位置倾向棋盘中心,用两倍曼哈顿距离避免浮点 for (int idx = 0; idx < 100; ++idx) { if (b[idx] == side) { int r = idx / 10, c = idx % 10; score -= (abs(2 * r - 9) + abs(2 * c - 9)) * 6; } } return score; }逻辑说明:mobility_quick返回的是所有皇后「下一步能移动到的格数」的总和,不含射箭分支,因此它比完整gen_moves数量小很多,但走势一致。乘 100 是为了给 mobility 一个清晰的权重单位,避免后续加特征时浮点权重互相干扰;中心惩罚项用两倍曼哈顿距离,每远离中心一格扣 6 分,这个数值一般控制在 mobility 权重的 5%~10% 之间。
参数怎么调:mobility 权重乘以 100 之后,如果 AI 变得太保守,总把自己皇后缩在角落不动,说明中心惩罚太少,把 6 提到 10 或 12;如果 AI 只顾往中心挤、不顾步数,把中心惩罚降到 3。比赛级 AI 还会加「皇后能控制的区域格数差」这一项,但单机对弈里这一项要额外算 8 次扩展,评估速度掉一半,收益只在深搜索时明显,建议先把前两项调好再加。
3.3 置换表与迭代加深:从深度 4 到 6 的实用手段
alpha-beta 剪枝对「重复局面」无能为力,亚马逊棋棋盘大、走法多,置换表几乎必备。用 Zobrist 哈希时,把每个格子的棋子类型(空、己方皇后、己方箭、对方皇后、对方箭)各分配一个 64 位随机数,apply/undo时对变化的三个格子做 XOR 即可维护哈希值。置换表条目保存深度、分数、alpha/beta 标记和最佳着法:
struct TTEntry { uint64_t key; int depth; int score; uint8_t flag; // 0: exact, 1: lower bound, 2: upper bound Move best; };查表时对比key与 64 位完整值,深度满足条件才用——深度不够的条目不能当作最终答案,只能当作候选走法排序。迭代加深从depth = 1开始逐层加 1,每层用上一层的best move做走法排序,时间不够就停在当前层。控时可以用简单的节点计数或chrono粗略计时,不需要精确到毫秒。
参数建议:开局深度 6 配合置换表在 3 秒内通常可行,中局因为走法数涨得快,降回 4~5;置换表 1<<20 个条目、约 64MB 够单机用。如果换到比赛或更强引擎,再考虑 PVS 和 MTD(f),那是另一个量级的话题了。
4. 人机对弈交互层:坐标解析、合法性校验与控制台主循环
4.1 输入解析与合法性校验:把「d4 g4 a4」变成三个格子
没有界面的控制台版本,最容易让新手翻车的地方是输入格式。我一般用代数坐标,列a~j,行1~10,比如d4 g4 a4表示把皇后从 d4 移到 g4,再射箭到 a4。解析函数只做拆字符串,不负责规则判断:
bool parse_coord(const std::string& s, int& out) { if (s.size() != 2) return false; char col = s[0], row = s[1]; if (col < 'a' || col > 'j') return false; if (row < '1' || row > '0' + 10) return false; // '0'+10 是 ASCII 里 '9' 后面一位 int r = row - '1'; // '1' 对应第 0 行 int c = col - 'a'; out = r * 10 + c; return true; }注意row < '1' || row > '0' + 10这个写法有点绕,实际等价于row < '1' || row > ':',因为字符'9'的 ASCII 是 57,'0' + 10也是 57,所以这个判断拦不住'9'之后的'9'+1这些字符。更干净的写法是row >= '1' && row <= '9' || row == '0'或者直接检查row - '1'在 0..9 之间。我建议用后一种:int r = row - '1'; if (r < 0 || r > 9) return false;,不易写错。
验证一个完整走法需要三步:from必须是己方皇后;from到to的路径全空;从to到arrow的路径全空(终点arrow也必须是空位)。三段检查可以共用一个「路径是否全空」的函数:
bool path_clear(const Board& b, int from, int to) { int fr = from / 10, fc = from % 10; int tr = to / 10, tc = to % 10; int dr = tr - fr, dc = tc - fc; if (!(dr == 0 || dc == 0 || abs(dr) == abs(dc))) return false; // 不是直线 int step_r = (dr > 0) - (dr < 0); int step_c = (dc > 0) - (dc < 0); int r = fr + step_r, c = fc + step_c; while (r != tr || c != tc) { if (b[r * 10 + c] != EMPTY) return false; r += step_r; c += step_c; } return true; }这里把移动和射箭统一处理:如果from和to在同一行、同一列或同一对角线,就沿方向一步步走,路上任何非空格都让路径非法。终点本身是否为空由调用方另外检查,因为移动的终点要求是空位,射箭的终点也要求是空位,但套用同一个函数时语义略有差别,分开写比较清楚。
4.2 控制台主循环与 UI 方案:先跑通再谈图形
主循环的逻辑非常简单:生成当前方所有合法走法,如果为空就判负;人走就解析输入并校验,程序走就调搜索选一步,然后交换走棋方。
while (true) { auto moves = gen_moves(board, cur); if (moves.empty()) { std::cout << (flip(cur) == HUMAN ? "你赢了" : "程序赢了") << "\n"; break; } if (cur == HUMAN) { std::string cmd; std::getline(std::cin, cmd); Move m; if (parse_and_validate(board, cmd, m, cur)) { apply(board, m, cur); cur = flip(cur); } else { std::cout << "非法走法,请重试\n"; } } else { Move best = search_best(board, cur, search_depth); std::cout << "程序走: " << move_to_str(best) << "\n"; apply(board, best, cur); cur = flip(cur); } }parse_and_validate内部就是 4.1 里那三个检查的组合。程序走的时候search_best用根节点的negamax加走法排序,返回本轮最优 move。打印棋盘我用字符画,Q表示己方皇后,q表示对方皇后,*表示箭,空格表示空位,每行前面加a b c d e f g h i j列标,每行末尾加行号。这个棋盘打印函数建议单独写成print_board,调试搜索时也靠它在关键局面下肉眼确认状态对不对。
UI 方案没有唯一答案,取决于你要拿它做什么:
| 方案 | 依赖 | 开发量 | 适合场景 |
|---|---|---|---|
| 控制台字符画 | 无 | 低 | 调试、验证规则、快速出成果 |
| SDL 简单图形 | SDL2 | 中 | 想看棋子移动动画,不想要窗口框架 |
| Qt Widgets | Qt | 高 | 成品级人机对战,可扩展菜单、悔棋、存档 |
我自己的习惯是先控制台跑通全部规则,再做图形。原因很简单:图形界面一上,输入输出路径全变了,规则 bug 会被界面 bug 混在一起,排查成本翻倍;控制台阶段把走法生成和搜索验证扎实,换 UI 只是替换最外层那几行。
5. 亚马逊棋实现的避坑与调试:5 条血泪经验,从穿模到搜索变慢
5.1 箭穿皇后:箭从自己棋子身体里飞出去
现象:AI 走完一步,棋盘上箭头出现在自己皇后正后方的格子里,相当于箭从皇后身上穿过去才落地。
原因:射箭扩展时把「皇后占据的to格」当成了普通空格忽略,或者箭循环只检查了障碍没有检查棋子。我见过最隐蔽的版本是:内层循环条件写成了b[arrow] != ARROW,于是皇后和其他棋子全部变成可穿越目标。
解决:统一规则——箭的飞行路径上,任何非空格都是障碍,包括己方皇后、对方皇后、所有箭。内层while条件写成b[arrow] == EMPTY就天然正确。验证方法也简单:开局第一个回合,人为构造一个己方皇后贴着另一个皇后的局面,走一步射箭,如果箭落点在被贴皇后背后,就是穿模了。
5.2 皇后跨行:一维数组的灵异瞬移
现象:棋局中段,皇后从第 9 行第 0 列一步走到第 8 行第 9 列,看起来像是瞬移到了棋盘另一端,但程序认为这是合法走法。
原因:一维数组方向偏移量 -1 会让idx = 90变成 89,而 89 在 0..99 数组范围内,只检查0 <= idx && idx < 100拦不住这种跨行。这是用一维数组表示二维棋盘最经典的坑。
解决:行进合法性统一用行列检查,或者先算r = idx / 10、c = idx % 10,目标r/c越界直接判定非法。不要在循环里只依赖in_board(idx),那个函数要同时验证行列而不是只验证数组下标。我喜欢把in_board写成「先拆行列再判断」的形式,从根上杜绝这个坑。
5.3 评估方向写反:AI 在优势局面主动送死
现象:AI 明明领先很多,却连续走出送死棋,甚至主动把自己的皇后挪到角落,让对方轻松封锁。
原因:evaluate返回的是「固定某一方(比如白方)」的分数,而不是「当前搜索层要走棋方」的分数;negamax 每层取负号后,AI 实际上在最大化对手的优势,方向完全反了。
解决:让evaluate(b, side)永远返回「side方视角」的分数,调用处传当前层走棋方,而不是传固定白方。验证方法很简单:用一个明显对某一方有利的固定局面,分别计算evaluate(b, side)和evaluate(b, flip(side)),两个分数应该互为相反数;如果不是,说明评估函数内部扣分方向写反了。
5.4 undo 被提前 return 跳过:棋盘越搜越乱
现象:搜索层数一加深,棋盘状态就乱掉,甚至出现同一个皇后同时在两个格子的局面,随后所有走法生成都开始报错。
原因:在apply之后,因为depth == 0或moves.empty()直接return,跳过了后面的undo,棋盘状态被污染到上一层。负极大值循环里只要有一条提前返回路径忘了undo,整个搜索树的状态管理就崩了,而且这个 bug 通常只在特定深度触发,非常难复现。
解决:要么把return放到undo之后,要么把「生成子节点 + 递归」写成独立函数,所有返回路径集中到一处undo。我自己的习惯是把apply和undo包成带作用域的 RAII 小对象,构造函数里apply,析构函数里undo,递归函数体里随便return都不会泄漏状态。代价是多一个析构调用,但调试体验好得多。
5.5 走法排序反而变慢:std::sort 的隐形开销
现象:只加了走法排序,搜索深度没提升,反而更慢了,深度 5 从十几秒变成二十几秒。
原因:排序比较器里反复计算曼哈顿距离,每次比较都调 lambda,排序开销吃掉了剪枝收益。alpha-beta 的剪枝效率确实依赖好的走法顺序,但代价不能超过收益。
解决:先算好每个 move 的 key 再排序,或干脆用插入排序只排前 N 个候选。alpha-beta 只需要前几个好走法来快速抬高alpha,后面的顺序影响很小,所以「全量精确排序」是浪费。实测中我固定只排前 8 个,后面的保持生成顺序,排序耗时降到原来的十分之一,剪枝率几乎不变。
6. 从能下到能赢:自对弈批量验证与评估调参方法
评估函数和搜索深度都到位后,下一步不是和人对战,而是让程序自己和自己下。自对弈能快速暴露规则 bug 和权重问题,也能给你一个可复现的调参基准。我常用的自对弈循环很简单:双方都用同一个搜索函数,固定深度,跑 N 局,统计胜负和平均步数。
int black_wins = 0; for (int game = 0; game < N; ++game) { Board b = initial_board(); int side = BLACK; while (true) { auto moves = gen_moves(b, side); if (moves.empty()) break; // 当前方无法移动,判负 Move m = pick_best(b, side, depth); apply(b, m, side); side = flip(side); } if (side == WHITE) ++black_wins; // 黑方刚走完,白方无棋可走,黑胜 }调参方法论是控制变量:固定深度,只动一个权重,跑 20~30 局,看胜率和平均步数。平均步数能反映很多问题——如果你把中心权重调得过高,会出现皇后争先恐后往中心挤、把自己的移动空间堵死的局面,这时胜率可能没下降,但平均步数明显变短,因为双方都过早进入无棋可走的状态。我自己就翻车过一次:把中心权重从 6 调到 15,AI 开局前五步全在抢中心,结果把箭射在自己皇后群中间,活活把自己堵死。后来改成只调 30% 以内,逐步观察,再没出过这种幺蛾子。
验证规则正确性还有一个笨但有效的办法:把每局走法序列存成文本,用控制台的复盘模式手动回放关键几步。搜索代码里走法生成和undo这两块,光靠单元测试覆盖不到所有时序,自对弈日志回放能最快帮你定位「哪一步之后棋盘状态开始不对」。当自对弈 30 局无一异常、胜负比例稳定、单步搜索时间在可接受范围时,这个 C++ 亚马逊棋程序才算真正落地。希望这些尝试和踩坑能帮到你。
本文还有配套的精品资源,点击获取