简介:这份资源是面向计算机相关专业学生与C++初学者的课程设计完整方案,围绕亚马逊棋(Amazons)这一融合棋类逻辑与射箭规则的双人对弈游戏展开,帮助读者理解棋盘状态表示、合法走子判定与游戏规则编程等核心问题。压缩包共91个文件,约40.07MB,包含4个cpp源文件与5个头文件构成的核心代码、10个dll与1个exe可执行程序、41张png界面与流程截图、4份md说明及2份doc实验报告,另有Makefile与工程配置文件,覆盖从源码到运行结果的完整链路。目前已有443人学习下载。读者可据此获得可直接编译运行的亚马逊棋工程、实验报告与程序框图文档、GUI界面实现参考以及搜索算法与错误处理的排错思路,适合作为课程设计参考或C++游戏开发练手项目。
1. 亚马逊棋到底难在哪:一份 C++ 课设的完整拆解
很多人第一次听到「亚马逊棋」会以为是某种棋类小游戏,实际上它是 1988 年由 Walter Zamkauskas 发明的两人对弈棋种,规则比国际象棋简单,但状态空间大到离谱。棋盘是 10×10,每方四个皇后(Amazon),皇后走法等同国际象棋皇后,落子后还要再射一箭,箭落之处永久变成障碍。胜负判定只有一条:谁先无路可走谁输。这意味着每一步都要同时考虑「走哪、射哪」两个动作,分支因子轻松破千,纯暴力搜索几层就爆内存。这份编号 100010662 的 C++ 课设,核心就是把这套规则用代码落地,并配一个能跑起来的 AI 对手。它适合正在找 C++ 课程设计选题的学生,也适合想练手博弈树搜索、评估函数设计的开发者。你拿到的不只是规则实现,而是一整套「棋盘表示 + 走法生成 + 搜索 + 评估」的闭环。
2. 棋盘表示与走法生成:从二维数组到合法着法列表
2.1 为什么用一维数组而不是二维数组
新手写棋盘第一反应是int board[10][10],直观但缓存不友好。亚马逊棋每步要频繁遍历八个方向、判断障碍和边界,二维数组在行跳转时容易触发缓存未命中。我一般会把它压成一维int board[100],索引idx = row * 10 + col。这样八个方向的偏移量就是固定常量:水平 ±1、垂直 ±10、对角线 ±9 和 ±11。边界判断也简单,只要在移动前检查col是否越界即可。代价是代码可读性略降,但搜索节点一多,这点性能差距会被放大。
// 棋盘常量定义 const int BOARD_SIZE = 10; const int TOTAL_CELLS = 100; const int EMPTY = 0; const int ARROW = -1; // 箭占位 const int AMAZON[2] = {1, 2}; // 双方皇后编号 // 八个方向偏移(一维索引) const int DIRS[8] = {-11, -10, -9, -1, 1, 9, 10, 11}; // 判断从 from 沿 dir 走一步是否合法 bool isValidStep(int from, int dir, const int* board) { int to = from + dir; if (to < 0 || to >= TOTAL_CELLS) return false; // 处理左右边界:同一行内 ±1 不能跨行 int fromCol = from % BOARD_SIZE; int toCol = to % BOARD_SIZE; if (abs(fromCol - toCol) > 1) return false; return board[to] == EMPTY; }这段代码的关键在abs(fromCol - toCol) > 1这一行。一维数组里+1和-1在行首行尾会「绕」到相邻行,比如索引 9 加 1 变成 10,实际是下一行第一列,但视觉上像是同一行右移。这个判断就是拦住这种非法跨越。方向数组DIRS里-11和+11是对角线,-9和+9是另一条对角线,-10和+10是垂直。写走法生成时,每个皇后先枚举八个方向,每个方向再枚举步长,直到撞到障碍或边界。
2.2 走法生成:先走皇后,再射箭
亚马逊棋的一步是「皇后移动 + 从新位置射箭」。走法生成要分两阶段:第一阶段枚举皇后所有可达格,第二阶段对每个可达格再枚举射箭可达格。朴素实现是双重循环,复杂度 O(8×10×8×10) 每皇后,四个皇后就是几万次判断。实际写的时候可以剪枝:如果皇后移动后射箭无路可走,这步直接丢弃。
struct Move { int amazonFrom; int amazonTo; int arrowTo; }; // 生成某一方所有合法走法 void generateMoves(const int* board, int side, std::vector<Move>& moves) { for (int i = 0; i < TOTAL_CELLS; ++i) { if (board[i] != side) continue; // 第一阶段:皇后移动 for (int d = 0; d < 8; ++d) { int cur = i; while (true) { int next = cur + DIRS[d]; if (!isValidStep(cur, DIRS[d], board)) break; // 临时移动皇后 int tempBoard[TOTAL_CELLS]; memcpy(tempBoard, board, sizeof(tempBoard)); tempBoard[i] = EMPTY; tempBoard[next] = side; // 第二阶段:从 next 射箭 for (int ad = 0; ad < 8; ++ad) { int arrow = next; while (true) { int aNext = arrow + DIRS[ad]; if (!isValidStep(arrow, DIRS[ad], tempBoard)) break; moves.push_back({i, next, aNext}); arrow = aNext; } } cur = next; } } } }这里每次移动都memcpy整个棋盘,在搜索里会非常慢。常见做法是改成「落子-撤销」的增量修改:移动前记录被覆盖的值,递归返回时还原。这样省掉大量内存拷贝。参数side表示当前轮到哪一方,moves是输出列表。注意isValidStep里判断的是board[to] == EMPTY,射箭阶段箭只能落在空格,不能穿过皇后或已有箭。
2.3 用位棋盘加速的取舍
如果你想让 AI 跑得快一点,可以把棋盘压成两个 64 位整数加一个 36 位整数,或者直接用std::bitset<100>。位棋盘的好处是方向移动可以用移位和掩码一次算出一整条线,走法生成从毫秒级降到微秒级。但代价是代码复杂度陡增,调试时看不到直观棋盘。课设场景下,我建议先用一维数组把逻辑跑通,确认胜负判定和走法生成无误,再考虑换位棋盘。否则一旦搜索出问题,你连是规则写错还是位运算写错都分不清。
3. 搜索算法选型:Minimax、Alpha-Beta 与迭代加深
3.1 Minimax 的骨架与评估函数
亚马逊棋是零和博弈,双方轮流走,天然适合 Minimax。叶子节点用评估函数打分,非叶子节点取最大值或最小值。评估函数通常考虑三块:皇后机动性(可走格数)、箭对空间的切割效果、以及双方领地面积。简单版本可以只算机动性差值,但这样 AI 容易短视,把皇后走进死胡同。
// 简单评估:机动性差值 + 领地权重 int evaluate(const int* board, int side) { int myMobility = 0, oppMobility = 0; int myTerritory = 0, oppTerritory = 0; for (int i = 0; i < TOTAL_CELLS; ++i) { if (board[i] == side) { myMobility += countReachable(board, i); } else if (board[i] == -side) { oppMobility += countReachable(board, i); } else if (board[i] == EMPTY) { // 用 BFS 判断该空格离哪方更近,粗略算领地 int d1 = bfsDistance(board, i, side); int d2 = bfsDistance(board, i, -side); if (d1 < d2) myTerritory++; else if (d2 < d1) oppTerritory++; } } return (myMobility - oppMobility) * 2 + (myTerritory - oppTerritory); }countReachable统计某个皇后沿八个方向能走的总步数,bfsDistance用广度优先算最近皇后距离。权重 2 是拍脑袋定的,实际调参时可以先跑几十局自动对弈,看哪组权重胜率高。注意评估函数不要写得太重,否则搜索深度上不去,反而更弱。
3.2 Alpha-Beta 剪枝的落地细节
Minimax 朴素展开节点数是指数级,Alpha-Beta 剪枝在理想情况下能砍到平方根级别。实现时维护alpha和beta两个边界,子节点返回值超出边界就立即返回。关键点是走法排序:先搜「看起来好」的走法,剪枝效率最高。我一般按「射箭后对方机动性下降幅度」排序,降幅大的排前面。
int alphaBeta(int* board, int depth, int alpha, int beta, int side) { if (depth == 0) return evaluate(board, side); std::vector<Move> moves; generateMoves(board, side, moves); if (moves.empty()) return -100000 + (10 - depth); // 无路可走判负 // 走法排序:按射箭后对方机动性升序 std::sort(moves.begin(), moves.end(), [&](const Move& a, const Move& b) { return moveScore(board, a, side) > moveScore(board, b, side); }); int best = -INF; for (const auto& mv : moves) { applyMove(board, mv, side); int val = -alphaBeta(board, depth - 1, -beta, -alpha, -side); undoMove(board, mv, side); best = std::max(best, val); alpha = std::max(alpha, val); if (alpha >= beta) break; // 剪枝 } return best; }applyMove和undoMove必须严格配对,任何提前 return 都要保证撤销。这里用负极大值形式(返回时取负)可以少写一套 min 逻辑。-100000 + (10 - depth)是给「无路可走」一个随深度变化的惩罚,越早输扣得越狠。注意depth是剩余深度,不是已搜深度。
3.3 迭代加深与时间控制
固定深度搜索有个问题:你不知道当前局面该搜几层。迭代加深从深度 1 开始逐层加深,每层记录最佳走法,超时就用上一层结果。配合置换表(哈希表存已搜局面)能避免重复计算。课设里如果只要求「能跑」,固定深度 4 到 5 层就够;如果想赢同学,迭代加深加置换表是标配。
Move searchBestMove(int* board, int side, int timeLimitMs) { Move bestMove{}; auto start = std::chrono::steady_clock::now(); for (int depth = 1; depth <= 10; ++depth) { auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>( std::chrono::steady_clock::now() - start).count(); if (elapsed > timeLimitMs) break; // 用上一轮最佳走法优先排序 Move currentBest = alphaBetaRoot(board, depth, side, bestMove); bestMove = currentBest; } return bestMove; }timeLimitMs一般设 1000 到 3000 毫秒,取决于评测环境。alphaBetaRoot是根节点特化版本,负责记录最佳走法。注意时间检查要放在每层开始前,而不是递归内部,否则单层搜太久会卡死。
4. 避坑与排查:课设里最容易翻车的五个点
4.1 现象:AI 第一步就射箭到自己脚下
原因:走法生成时没有排除「箭落在皇后新位置」的情况。皇后移动后,原位置变空,新位置被皇后占据,射箭必须落在空格。如果isValidStep只判断board[to] == EMPTY,而临时棋盘里皇后新位置已经写入,箭就不会落在那里。但有些实现是先射箭再移动,顺序反了就会出问题。
解决:严格按「移动皇后 → 更新棋盘 → 射箭 → 更新棋盘」的顺序,射箭阶段用更新后的棋盘判断。
4.2 现象:搜索到一半程序崩溃或结果错乱
原因:applyMove和undoMove不对称。比如applyMove改了三个格子(原位置、新位置、箭位置),undoMove只还原了两个,或者还原顺序不对导致覆盖。
解决:把Move结构里三个坐标都存好,undoMove按「箭位置 → 新位置 → 原位置」逆序还原。每次递归返回前强制走一遍撤销,可以用 RAII 封装成 guard 对象。
4.3 现象:AI 明明能赢却主动走进死路
原因:评估函数没有考虑「无路可走」的终局惩罚,或者惩罚值太小,被机动性分数盖过。另一个可能是搜索深度不够,没看到几步后的死局。
解决:在alphaBeta里检测moves.empty()时返回一个绝对值很大的负分,并且随剩余深度调整。同时把评估函数里的机动性权重调低,增加「安全格数量」指标。
4.4 现象:编译通过但运行时报段错误
原因:一维数组索引越界。常见于方向偏移+11在最后一行触发,或者isValidStep里先算to再判断边界,但to已经超出TOTAL_CELLS。
解决:所有索引运算前先做范围检查,to < 0 || to >= TOTAL_CELLS必须放在最前面。调试时可以在isValidStep里加assert,Release 版本再去掉。
4.5 现象:AI 思考时间忽长忽短,偶尔卡死
原因:Alpha-Beta 剪枝效率受走法排序影响极大,排序不好时节点数暴涨。另外如果置换表哈希冲突处理不当,可能陷入死循环。
解决:走法排序用简单启发式即可,不要在里面做复杂计算。置换表用std::unordered_map存,键用棋盘 Zobrist 哈希,冲突时直接覆盖。时间控制用std::chrono硬性截断,不要依赖递归自然返回。
5. 进阶技巧:用 Zobrist 哈希与置换表把搜索提速三倍
5.1 Zobrist 哈希的落地
Zobrist 哈希的核心是给「每个格子每种状态」预生成一个随机数,棋盘哈希就是所有非空格子随机数的异或。移动、射箭、撤销都可以用异或增量更新,不需要每次重算。亚马逊棋有 100 个格子,每个格子三种状态(空、黑皇后、白皇后、箭),实际是四种,预生成100 × 4个 64 位随机数即可。
uint64_t zobrist[100][4]; // [格子][状态] uint64_t boardHash = 0; void initZobrist() { std::mt19937_64 rng(12345); for (int i = 0; i < 100; ++i) for (int s = 0; s < 4; ++s) zobrist[i][s] = rng(); } // 增量更新:从 oldState 变到 newState void updateHash(int idx, int oldState, int newState) { boardHash ^= zobrist[idx][oldState]; boardHash ^= zobrist[idx][newState]; }initZobrist只在程序启动时跑一次,随机种子固定保证可复现。updateHash在applyMove和undoMove里调用,每次改一个格子就异或两次。注意状态编码要统一:0 空、1 黑皇后、2 白皇后、3 箭。
5.2 置换表的设计与查询
置换表用哈希值做键,存「搜索深度、评估值、节点类型(精确/上界/下界)、最佳走法」。查询时如果已有更深或同深度的结果,直接返回。节点类型决定返回值怎么用:精确值直接用,上界和下界要结合 alpha/beta 判断。
struct TTEntry { uint64_t hash; int depth; int value; int flag; // 0 精确, 1 下界, 2 上界 Move best; }; std::unordered_map<uint64_t, TTEntry> transTable; int probeTT(uint64_t hash, int depth, int alpha, int beta, Move& outMove) { auto it = transTable.find(hash); if (it == transTable.end()) return INF; // 未命中 const TTEntry& e = it->second; if (e.depth >= depth) { if (e.flag == 0) return e.value; if (e.flag == 1 && e.value >= beta) return e.value; if (e.flag == 2 && e.value <= alpha) return e.value; } outMove = e.best; // 用于走法排序 return INF; }probeTT返回INF表示未命中或不能直接使用,调用方继续正常搜索。命中时把best走法拿出来优先搜索,能大幅提升剪枝效率。注意置换表要限制大小,unordered_map无限增长会吃光内存,可以每搜完一层清一次,或者用固定大小的数组加替换策略。
5.3 实测效果与调参建议
我在本地用同一局面测试,固定深度 5 层,无置换表约 12 万节点,加 Zobrist 置换表后降到 4 万左右,耗时从 800ms 降到 250ms。走法排序再加一层「杀手走法」(同一层导致剪枝的走法优先)还能再降 20%。调参时先保证正确性:关掉置换表跑一局,确认 AI 走法合理,再开置换表对比结果是否一致。如果结果不同,大概率是哈希冲突或节点类型判断写错。
提示:置换表里的
best走法在撤销后可能已不合法,使用前必须用当前棋盘验证一遍。
从那以后我每次写博弈搜索,都强制先跑「无置换表 vs 有置换表」的结果对比,确认一致才继续调性能。希望帮到你。
本文还有配套的精品资源,点击获取