简介:这是一份面向计算机相关专业学生与C++初学者的课程设计资源,围绕亚马逊棋(Amazons)这一双人对弈策略游戏展开,帮助读者理解棋盘状态表示、走子与射箭规则、合法移动判定及胜负条件等核心逻辑。压缩包共91个文件,约40.07MB,包含4个cpp与5个h源码文件、4个md说明文档、2个doc实验报告,以及png截图、dll、obj、pdb等编译与运行产物,另附exe可执行程序、Makefile与LICENSE,目录结构完整,便于直接编译运行与二次开发。资源已有443人学习下载,配套实验报告与程序框图文档可辅助梳理设计思路。读者可从中获得完整的游戏逻辑实现、类与数据结构设计范例、命令行或GUI交互方案,以及搜索算法与错误处理等排错参考,适合作为课程大作业或C++综合练习的实战样本。
1. 亚马逊棋到底难在哪:一个 10x10 棋盘上的搜索爆炸
第一次接触亚马逊棋(Game of the Amazons)是在做 C++ 课程项目选题目的时候,当时觉得规则简单——10x10 棋盘、四个皇后、走一步再射一箭——能有多难?结果写完第一个能跑通规则的版本,让两个随机 AI 对弈,一局跑了将近 40 秒,而且棋力约等于零。问题出在分支因子上:国际象棋开局大约 20 种走法,亚马逊棋开局每个皇后有几十种移动方式,移动完还要从落点再射一箭,单步合法着法轻松突破 2000。这意味着同样搜 6 层,亚马逊棋的节点数比国际象棋高出好几个数量级。
这个标题「基于 C++ 实现亚马逊棋」背后真正要解决的核心矛盾就是:规则实现不难,难的是在可接受时间内做出一个不犯傻的 AI。C++ 在这里不是随便选的——你需要位棋盘、需要每秒几百万次的着法生成、需要精细的内存布局,用 Python 写同样的搜索深度,时间会差出两个数量级。这篇文章面向的是想用 C++ 从零搭一个能跑、能打、能继续优化的亚马逊棋引擎的人,不管你是课程项目、还是想练搜索算法和博弈树,下面的路径都能直接复现。我会把规则建模、着法生成、搜索框架、评估函数、性能调优这条链路拆开讲,重点放在那些真正卡住人的地方。
2. 棋盘表示与着法生成:为什么位棋盘是绕不过去的选择
2.1 从二维数组到 128 位位棋盘
最直觉的做法是用int board[10][10],0 表示空、1 表示障碍、2 表示皇后。写起来快,但每次生成着法都要双重循环扫描全盘,一个节点光着法生成就可能上万次内存访问。搜索树一深,这就是灾难。
常见做法是用位棋盘(bitboard)。10x10 是 100 格,超过 64 位,所以用两个uint64_t拼成 128 位,或者干脆用unsigned __int128(GCC/Clang 支持)。每个格子对应一个 bit,皇后占位、障碍占位各用一套位棋盘。这样「某个方向能走多远」可以用位运算一次性算出来,而不是逐格判断。
#include <cstdint> #include <array> // 用两个 64 位拼 100 格:low 存 0..63,high 存 64..99 struct Bitboard { uint64_t low = 0; uint64_t high = 0; void set(int idx) { if (idx < 64) low |= (1ULL << idx); else high |= (1ULL << (idx - 64)); } bool test(int idx) const { if (idx < 64) return low & (1ULL << idx); return high & (1ULL << (idx - 64)); } void clear(int idx) { if (idx < 64) low &= ~(1ULL << idx); else high &= ~(1ULL << (idx - 64)); } }; // 棋盘状态:皇后、障碍、轮到谁走 struct Position { Bitboard queens[4]; // 四个皇后各自占位 Bitboard blocked; // 障碍(射出的箭) int sideToMove = 0; // 0 或 1 };这段代码的关键在于把「棋盘」拆成几个独立的位平面。queens[i]记录第 i 个皇后的位置,blocked记录所有障碍。判断某格是否可走,就是queens全体和blocked的并集里该位是否为 0。参数上,idx是 0 到 99 的线性下标,映射规则是idx = row * 10 + col,这个映射必须在整个引擎里保持一致,否则调试时会非常痛苦。
2.2 八个方向的射线着法生成
亚马逊棋的皇后走法等同国际象棋的皇后:横、竖、斜八个方向,不能越过障碍或其他皇后。射箭的落点规则和皇后走法完全一样,只是射出的箭变成新障碍。所以核心是一个「沿方向延伸直到被挡住」的函数。
// 预计算每个格子沿八个方向的邻居,-1 表示越界 static const int DIR[8][2] = { {0,1},{0,-1},{1,0},{-1,0}, {1,1},{1,-1},{-1,1},{-1,-1} }; // 生成某个皇后从 from 出发的所有可达格 void genQueenMoves(const Position& pos, int from, std::vector<int>& out) { int r = from / 10, c = from % 10; Bitboard occ = pos.blocked; for (int i = 0; i < 4; ++i) occ.low |= pos.queens[i].low, occ.high |= pos.queens[i].high; for (auto& d : DIR) { int nr = r + d[0], nc = c + d[1]; while (nr >= 0 && nr < 10 && nc >= 0 && nc < 10) { int idx = nr * 10 + nc; if (occ.test(idx)) break; // 被挡住,停止延伸 out.push_back(idx); nr += d[0]; nc += d[1]; } } }逻辑说明:先把所有皇后和障碍合并成一个occ占用位棋盘,然后对八个方向逐格推进,遇到占用就 break。这里没有用位运算一次性算射线,是因为 10x10 的棋盘用循环已经够快,而且可读性好,方便后面调试。参数from是皇后当前下标,out收集所有可达格。注意occ的合并每调用一次都要重算,如果性能吃紧,可以在Position里维护一个occupied缓存,每次落子/射箭后增量更新。
2.3 一步棋的三段式结构
亚马逊棋的一步完整着法包含三段:选一个皇后、移动到某格、从新位置射箭到另一格。着法生成要嵌套两层——先枚举皇后的移动,再对每个落点枚举射箭。这是分支因子爆炸的根源。
struct Move { int queen; // 哪个皇后 int from; // 起点 int to; // 移动落点 int arrow; // 射箭落点 }; void genMoves(const Position& pos, std::vector<Move>& moves) { moves.clear(); for (int q = 0; q < 4; ++q) { // 找到该皇后的位置(也可在 Position 里直接存) int from = -1; for (int i = 0; i < 100; ++i) if (pos.queens[q].test(i)) { from = i; break; } if (from < 0) continue; std::vector<int> qMoves; genQueenMoves(pos, from, qMoves); for (int to : qMoves) { // 临时把皇后挪到 to,再生成射箭落点 Position tmp = pos; tmp.queens[q].clear(from); tmp.queens[q].set(to); std::vector<int> arrows; genQueenMoves(tmp, to, arrows); for (int a : arrows) moves.push_back({q, from, to, a}); } } }这里每次移动都拷贝了一份Position,在搜索里这是性能杀手。实际工程中会用 make/unmake 的方式:在Position上直接改,记录撤销信息,回溯时还原。上面这样写是为了先把逻辑讲清楚,第 4 章会讲怎么把它改成零拷贝。参数queen是皇后编号 0 到 3,from/to/arrow都是 0 到 99 的下标。生成出来的moves数量在开局阶段通常在一两千,中局会更多,这就是为什么搜索深度必须靠剪枝和着法排序来压。
3. 搜索框架:Alpha-Beta 在超高分支因子下怎么活下来
3.1 迭代加深 + Alpha-Beta 的基本骨架
亚马逊棋不可能一上来就搜固定深度,因为分支因子太大,深度 4 和深度 6 的耗时可能差几十倍。标准做法是迭代加深(iterative deepening):从深度 1 开始逐层加深,用上一层的结果给这一层做着法排序,同时配合时间控制,超时就返回上一层的最优解。
#include <chrono> struct SearchResult { Move best; int score; }; class Engine { std::chrono::steady_clock::time_point start; int timeLimitMs = 3000; Move bestMoveThisIter; bool timeUp() { auto now = std::chrono::steady_clock::now(); return std::chrono::duration_cast<std::chrono::milliseconds>( now - start).count() > timeLimitMs; } public: SearchResult search(Position& pos) { start = std::chrono::steady_clock::now(); SearchResult res; for (int depth = 1; depth <= 64; ++depth) { int score = alphaBeta(pos, depth, -100000, 100000, 0); if (timeUp()) break; // 超时丢弃本层结果 res.best = bestMoveThisIter; res.score = score; } return res; } int alphaBeta(Position& pos, int depth, int alpha, int beta, int ply); };逻辑说明:search负责迭代加深和时间控制,alphaBeta是递归主体。timeUp()用steady_clock而不是system_clock,避免系统时间调整导致计时错乱。参数timeLimitMs是总思考时间,实际比赛里通常给每步 3 到 10 秒。注意超时判断要放在递归入口和着法循环里,否则一个深节点可能卡很久才返回。bestMoveThisIter在每层搜索开始时重置,只有完整搜完一层才更新最终结果,这样超时不会返回半截的坏着法。
3.2 着法排序:让 Alpha-Beta 真正剪掉东西
Alpha-Beta 的剪枝效率极度依赖着法顺序。理想情况下先搜最好的着法,能把搜索树从 b^d 压到 b^(d/2)。亚马逊棋里最有效的排序手段有三个:上一轮迭代的最优着法优先(PV move)、吃子/限制对手 mobility 的着法优先、历史启发(history heuristic)。
// 历史启发表:记录某个 (from,to) 造成剪枝的次数 static int history[100][100]; void orderMoves(std::vector<Move>& moves, const Move& pvMove) { std::sort(moves.begin(), moves.end(), [&](const Move& a, const Move& b) { if (a.from == pvMove.from && a.to == pvMove.to && a.arrow == pvMove.arrow) return true; if (b.from == pvMove.from && b.to == pvMove.to && b.arrow == pvMove.arrow) return false; return history[a.from][a.to] > history[b.from][b.to]; }); }逻辑说明:pvMove是上一层迭代找到的最优着法,排在最前面能立刻收紧 alpha/beta 窗口。history表在每次发生 beta 截断时给对应(from,to)加分,分数越高说明这个走法越容易引发剪枝,下次优先搜。参数上history用int就够,但要注意定期衰减(比如每层除以 2),否则老数据会压制新信息。亚马逊棋里还有一个特殊排序依据:射箭落点越靠近棋盘中心、越能分割对手皇后活动区域,往往越好,这个可以做成一个小的位置分加进排序键。
3.3 空着裁剪与 mobility 评估的配合
在亚马逊棋里,空着裁剪(null move pruning)要谨慎用。因为一步棋包含移动加射箭,跳过一方等于让对手连走两步,误差比国际象棋大。常见做法是只在深度足够(比如剩余深度 ≥ 3)且当前方明显不处于被威胁状态时才尝试。
int alphaBeta(Position& pos, int depth, int alpha, int beta, int ply) { if (depth == 0) return evaluate(pos); if (timeUp()) return alpha; // 空着裁剪:让对手连走,若仍不能超过 beta 则剪掉 if (depth >= 3 && !inDanger(pos)) { Position tmp = pos; tmp.sideToMove ^= 1; int score = -alphaBeta(tmp, depth - 1 - 2, -beta, -beta + 1, ply + 1); if (score >= beta) return beta; } // ... 正常着法循环 }逻辑说明:inDanger判断当前方是否有皇后被严重限制(mobility 很低),如果有就不做空着裁剪,避免误判。depth - 1 - 2里的额外减 2 是补偿让对手多走一步的代价。参数-beta, -beta+1是零窗口搜索,只验证「是否超过 beta」,速度快但结果只用于剪枝判断。这个技巧在亚马逊棋里能省 20% 到 40% 的节点,但用不好会导致漏杀,建议先用 mobility 评估把局面判断做稳再开。
4. 性能优化与工程细节:从能跑到跑得快
4.1 make/unmake 消除拷贝开销
第 2 章里每次生成着法都拷贝Position,在搜索里这是不可接受的。正确做法是在Position上直接落子,记录被改动的位,回溯时还原。
struct Undo { int queen; int from; int to; int arrow; }; void makeMove(Position& pos, const Move& m, Undo& u) { u = {m.queen, m.from, m.to, m.arrow}; pos.queens[m.queen].clear(m.from); pos.queens[m.queen].set(m.to); pos.blocked.set(m.arrow); // 箭变成障碍 pos.sideToMove ^= 1; } void unmakeMove(Position& pos, const Undo& u) { pos.blocked.clear(u.arrow); pos.queens[u.queen].clear(u.to); pos.queens[u.queen].set(u.from); pos.sideToMove ^= 1; }逻辑说明:makeMove只改三个位棋盘上的少数几个 bit,unmakeMove严格逆序还原。参数Undo结构体很小,可以放在栈上,递归返回时自动失效。这样一次落子/撤销是常数时间,比整盘拷贝快两个数量级。注意sideToMove的翻转必须在 make 和 unmake 里对称,漏掉会导致搜索层数错乱,这是新手最常翻车的地方之一。
4.2 用 spdlog 做可开关的调试日志
搜索算法调试最痛苦的是「不知道它为什么选了这个着法」。用spdlog加一层可编译期关闭的日志,能在不拖慢发布版的前提下看清搜索过程。
#include <spdlog/spdlog.h> #ifdef ENABLE_SEARCH_LOG #define SLOG(...) spdlog::debug(__VA_ARGS__) #else #define SLOG(...) ((void)0) #endif // 在 alphaBeta 里 SLOG("ply={} depth={} alpha={} beta={} move={}->{} arrow={}", ply, depth, alpha, beta, m.from, m.to, m.arrow);逻辑说明:SLOG宏在没定义ENABLE_SEARCH_LOG时展开成空语句,编译器直接优化掉,零开销。开启时用spdlog::debug输出到控制台或文件。参数格式用{}占位,这是 spdlog 的 fmt 风格,比printf安全。建议只在深度较浅或特定局面下开日志,否则一个中局搜索能刷出几十万行。
4.3 评估函数:mobility 是亚马逊棋的灵魂
亚马逊棋没有吃子,胜负靠的是把对手皇后的活动空间压缩到零。所以评估函数的核心不是子力,而是双方皇后的机动性(mobility),以及棋盘的空间分割。
int evaluate(const Position& pos) { int myMob = 0, oppMob = 0; for (int q = 0; q < 4; ++q) { int idx = findQueen(pos, q); std::vector<int> tmp; genQueenMoves(pos, idx, tmp); if (pos.sideToMove == 0) myMob += tmp.size(); else oppMob += tmp.size(); } // 机动性差 + 少量位置分 int score = (myMob - oppMob) * 10; score += centerControl(pos) * 2; return pos.sideToMove == 0 ? score : -score; }逻辑说明:对每个皇后数可达格数量,累加得到总 mobility。myMob - oppMob乘以权重 10 是经验值,实际调参时可以从 5 试到 20。centerControl统计皇后和障碍对中心区域的控制,权重较低。注意评估函数必须返回「当前走子方视角」的分数,否则 negamax 框架会算反。这个评估很粗糙,但配合深度 4 到 6 的搜索已经能打败随机 AI 和大多数初学者写的贪心 AI。
5. 避坑与排查:那些让我重写三遍的坑
5.1 现象:搜索到深度 5 突然变慢十倍
原因:着法生成里对每个落点都拷贝了Position,深度一深,拷贝次数指数增长,内存带宽被打满。解决:改成第 4 章的 make/unmake,或者至少在生成射箭落点时复用同一个临时Position,不要每次新建。
5.2 现象:AI 偶尔走出自杀着法,把皇后堵死
原因:评估函数只看当前 mobility,没有考虑「走完这步后自己的 mobility 会不会骤降」。一步棋移动加射箭,可能把自己的皇后关进死角。解决:在评估里加入「走子后己方最小 mobility」的惩罚项,或者在着法排序时对会大幅降低己方 mobility 的着法降权。
5.3 现象:迭代加深返回的 bestMove 是上一层的旧结果
原因:bestMoveThisIter在每层开始时没有重置,超时 break 后返回了上一层残留的值。解决:每层搜索前显式清空,并且只在完整搜完一层后才把结果写入最终返回值。这个 bug 很隐蔽,因为大部分时候结果看起来「差不多对」。
5.4 现象:程序跑久了内存持续上涨
原因:genMoves里的std::vector每次都在堆上分配,搜索几百万节点后分配器碎片化。解决:把moves和临时qMoves、arrows做成Engine的成员,用clear()复用容量,或者用固定大小的栈数组加计数器。亚马逊棋单节点着法数有上限,预分配足够大的数组完全可行。
5.5 现象:同样的局面两次搜索结果不一样
原因:着法排序里用了std::sort但比较函数不是严格弱序(比如 PV move 判断里漏了 arrow 字段),导致顺序不稳定;或者用了未初始化的history表。解决:比较函数保证对所有字段做全序比较,history表在构造函数里清零。搜索的确定性对调试极其重要,不确定的搜索结果会让你怀疑人生。
6. 进阶技巧:用置换表把重复局面砍掉
搜索到中局后,不同着法顺序可能到达同一个局面,尤其是射箭位置不同但皇后布局相同的情况。置换表(transposition table)用 Zobrist 哈希把局面映射到缓存项,命中时直接返回之前算过的分数,能省下大量重复搜索。
#include <random> #include <unordered_map> // Zobrist 键:每个 (格子, 皇后编号) 和 (格子, 障碍) 一个随机数 static uint64_t zobQueen[4][100]; static uint64_t zobBlocked[100]; static uint64_t zobSide; void initZobrist() { std::mt19937_64 rng(12345); for (int q = 0; q < 4; ++q) for (int i = 0; i < 100; ++i) zobQueen[q][i] = rng(); for (int i = 0; i < 100; ++i) zobBlocked[i] = rng(); zobSide = rng(); } struct TTEntry { uint64_t key; int depth; int score; int flag; // 0=精确 1=下界 2=上界 }; std::unordered_map<uint64_t, TTEntry> tt; int probeTT(uint64_t key, int depth, int alpha, int beta) { auto it = tt.find(key); if (it == tt.end() || it->second.depth < depth) return -1000000; if (it->second.flag == 0) return it->second.score; if (it->second.flag == 1 && it->second.score >= beta) return it->second.score; if (it->second.flag == 2 && it->second.score <= alpha) return it->second.score; return -1000000; // 命中但不可用 }逻辑说明:Zobrist 哈希在每次 make/unmake 时增量更新——落子就 XOR 掉旧位置的键、XOR 上新位置的键,不需要重算整盘。TTEntry里的flag区分精确值、下界和上界,对应 Alpha-Beta 里不同的截断情况。参数depth用于替换策略:只有新搜索深度不小于缓存深度时才信任缓存。unordered_map在性能敏感场景下可以换成固定大小的数组加索引,减少哈希开销。
用置换表时有几个经验值:表大小设成 2 的幂,用key & mask做索引比unordered_map快得多;替换策略优先保留深度大的项;每步搜索之间可以保留表内容,但要注意分数依赖的 ply 需要做 mate distance 修正。我自己的引擎加上置换表后,同样 3 秒能多搜 1 到 2 层,中局棋力提升非常明显。
最后说个习惯:每次改完搜索或评估,我都会固定跑一组开局局面,记录搜索深度、节点数和耗时,对比改动前后的数字。没有这组基准,优化就是玄学。亚马逊棋这个题目看着简单,真正写下去会发现每个环节都有可以深挖的地方,从位棋盘到置换表,每一步都能让你的引擎强一点。希望帮到你。
本文还有配套的精品资源,点击获取