ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C++实现亚马逊棋AI:博弈树搜索与alpha-beta剪枝实战

C++实现亚马逊棋AI:博弈树搜索与alpha-beta剪枝实战 简介本资源是面向计算机专业本科生的C课程设计项目完整实现双人对弈策略棋类游戏——亚马逊棋Amazons聚焦于游戏逻辑建模、状态管理与交互式开发实践。压缩包共91个文件含41张界面与流程图PNG涵盖程序框图、UI布局及实验报告配图、9个核心源码文件4个.cpp 5个.h、4份Markdown文档含README、实验报告与程序说明、以及编译生成的exe可执行文件、调试符号pdb、动态链接库dll等整体大小为40.07MB结构清晰便于分模块学习与调试。已有442人下载学习适用于算法课设、C综合实训或游戏编程入门实践。读者可直接运行exe体验完整对战流程参考源码掌握棋盘状态表示、棋子/箭行为封装、合法移动判定、命令行交互设计等关键实现配套实验报告与程序框图文档进一步厘清开发思路与模块职责显著降低理解门槛。 亚马逊棋这个题目我在课程设计列表里第一眼看到就有点好奇。10x10的棋盘黑白各4枚棋子没有吃子环节却要下到“对手无棋可走”才算赢——当时直觉告诉我这游戏不简单。真正动手用C实现之后发现它确实值得认真写一写覆盖了二维数组建模、八个方向遍历、组合状态生成、博弈树搜索和alpha-beta剪枝知识量刚好卡在课设需要的那个位置不会太简单也不会复杂到失控。这篇文章就按我实际开发的过程来写从规则拆解、数据结构选型到走法生成、AI设计和最后的排坑记录一步步讲完。项目编号100010662的常见要求是控制台人机对弈开发环境我用的是Visual Studio 2022也顺手在VS Code里用g编译验证过C17标准没有兼容问题。如果你也在做这个题目或者想用C练手棋类博弈算法这篇可以直接当一份完整的技术参考来用。1. 规则先理清亚马逊棋为什么是“移动射箭”的组合1.1 棋子与初始布局亚马逊棋的棋盘是标准10x10方格黑白双方各4枚棋子。棋子走法和国际象棋的皇后完全一致——横、竖、对角线方向不限格数但不能跳过其他棋子和火焰也不能走进被占据的格子。初始布局不是随便摆的双方棋子呈对称分布黑方先手(0,3)、(0,6)、(3,0)、(6,0)白方后手(9,6)、(9,3)、(6,9)、(3,9)坐标从0开始第一个值是行第二个值是列。这样布局的用意是让双方棋子从开局就形成对角线交叉的视野中间地带完全开放前几步就充满博弈味道。我第一次在控制台把这个棋盘打印出来的时候就觉得这布局比五子棋或者黑白棋的固定开局有意思多了。1.2 一回合的两段操作每个回合不是简单动一个子而是连续完成两个动作选择己方一枚棋子沿皇后走法移动到一个空格从移动后的位置再沿皇后走法射出一支箭。箭射中后那个格子变成一个永久火焰障碍。火焰会一直留在棋盘上任何棋子以后都不能经过、不能落入。这个规则我一开始理解得有点轻以为火焰只是多一个不能走的格子而已但后来下多了才意识到火焰是这游戏真正的核心武器——它不只是障碍更是分割棋盘、封锁对手行动空间的工具。每回合两段操作都完成之后才轮到对手。如果有一方在轮到自己的时候无论如何也找不到一组合法的“移动射箭”这方就输了。1.3 最容易误解的两个规则点我在写代码和测试过程中发现规则里有两个点特别容易被新手想岔。第一个点移动后的位置必须还能射箭否则这次移动本身非法。也就是说你选了一个棋子移动到某个空位之后如果这个空位被火焰、棋子包围得严严实实连一支箭都射不出去那么这个移动就不合法。合法走法的基本单位不是“一次移动”而是“一次移动加一次射箭”的组合。第二个点箭只能射向空格不能落在任何棋子上。有朋友问过我“能不能故意把箭射向自己的棋子来封路”答案是不行——箭的飞行路径会被棋子挡住目标格上有棋子时它根本射不过去。所以本质上射箭目标的合法集合就是移动目标集合的同样一套逻辑。这两点如果搞混写出来的走法生成器会多出一堆非法分支后面AI搜索也会跟着出各种诡异结果。我建议第一步先把这两个规则钉死再动代码。2. 棋盘建模与核心数据结构10x10格子怎么放最省心2.1 二维数组加枚举状态一目了然棋盘规模只有10x10完全没有必要用位运算或者压缩一维数组去硬优化代码可读性才是第一位。我用的是最直接的方案int board[10][10]加一个枚举。enum Cell { EMPTY 0, BLACK 1, WHITE 2, FIRE 3 };判断某个格子能不能走就查它是不是 EMPTY。判断某个格子是不是障碍就看它是不是不等于 EMPTY。因为火焰和棋子都会挡路所以“障碍判定”统一写成 board[x][y] ! EMPTY 就对了。这个枚举看起来简单但它在后面所有模块里都是最基础的约定。我有一个小习惯凡是涉及棋盘的函数入口处都用 assert 校验一下坐标防止越界访问把棋盘数据搞坏调试的时候这种断言能帮你快速定位问题。2.2 棋子坐标表不能只更新棋盘不更新棋子对象棋盘数组之外我还维护了双方棋子的坐标表struct Point { int x, y; }; Point blackPieces[4]; Point whitePieces[4];初始化时和棋盘状态同步。每次真正落子后除了改棋盘数组还要同步更新对应棋子的坐标。这个细节很多人会漏棋子移动了board 改了但 pieces 数组里的坐标还是旧的后面生成走法、做AI评估、判断终局的时候用的全是过期数据查起来非常痛苦。我调试时候就因此卡过一次黑棋明明已经在棋盘中央可AI一直认为它在初始位置导致走法全错。后来把棋子在棋盘上和pieces里的位置一起打印出来两秒就发现了问题。火焰的位置不需要单独维护列表因为火焰只增不减棋盘值为3的格子就是火焰。如果画界面的时候想高亮显示火焰再另外用一个 vectorPoint 收集也行但核心逻辑不需要它。2.3 坐标系统与输入输出转换坐标换算要统一。我的内部坐标用 (row, col)row是从上往下的行号col是从左往右的列号。打印棋盘给用户看的时候列用A-J表示行用数字1-10表示。用户输入“D1 D3 A1”这样的三个坐标分别代表起点、移动终点、射箭目标。解析函数是这么写的Point parsePoint(const string token) { char colCh toupper(token[0]); int col colCh - A; int row token[1] - 1; return {row, col}; }用户输入可能五花八门小写字母、中英文逗号、连字符、多余空格甚至全角符号。我的做法是在解析前先把整行做一次清洗把所有分隔符统一替换成空格再按空白切分成三段切出来正好三段才继续解析。如果用户输错了就提示再输一次不要直接崩溃。这段代码虽然不起眼但它是整个程序的门面。输入解析做得够宽容人机对弈体验会好很多演示的时候也能少很多尴尬。3. 走法生成八个方向遍历的两个关键细节3.1 方向向量表皇后走法的统一实现皇后走法的本质就是从当前点往8个方向连续延伸直到碰到边界或者障碍。C里最自然的实现是方向向量表const int dx[8] {-1, -1, -1, 0, 0, 1, 1, 1}; const int dy[8] {-1, 0, 1, -1, 1, -1, 0, 1};然后写一个函数从起点出发收集所有可达空格void getReachable(const Point start, vectorPoint out) { for (int d 0; d 8; d) { int nx start.x dx[d]; int ny start.y dy[d]; while (inBoard(nx, ny) board[nx][ny] EMPTY) { out.push_back({nx, ny}); nx dx[d]; ny dy[d]; } } }这里有个顺序问题必须强调每次前进之前先判断 inBoard再访问棋盘数组。顺序一旦写反访问越界的一瞬间可能不会立即报错但棋盘上会出现随机数字AI判断跟着全乱。我第一次写斜向遍历时就是先访问再判界结果棋盘上莫名其妙多了几个“火焰”查了半天才发现是这个原因。3.2 移动阶段和射箭阶段共用同一套扫描既然移动和射箭都遵循“皇后走法不能穿越障碍”的规则区别只在于移动后棋子到了新位置射箭后目标格变成火焰那这两个阶段的“可达区域扫描”就可以共用同一个 getReachable 函数。移动阶段很好理解对每枚棋子调用 getReachable 就能得到所有合法移动目标。射箭阶段稍微绕一点必须先把棋盘临时更新成“棋子已经移动过去”之后的状态再从移动目标点调用 getReachable。因为箭是从移动后的新位置射出去的新位置周边的障碍布局会影响箭的射程不能拿旧位置去算。这一步的逻辑我写成这样// 对每一枚棋子 for (int i 0; i 4; i) { vectorPoint moveTargets; getReachable(pieces[i], moveTargets); for (const auto mt : moveTargets) { // 临时模拟移动 board[pieces[i].x][pieces[i].y] EMPTY; board[mt.x][mt.y] player; vectorPoint arrowTargets; getReachable(mt, arrowTargets); for (const auto at : arrowTargets) { // 记录完整走法 } // 回溯棋盘 board[pieces[i].x][pieces[i].y] player; board[mt.x][mt.y] EMPTY; } }你能看到我在这里没有更新 pieces 数组里的坐标因为这只是临时模拟回溯后坐标不变真正的落子才需要同步坐标表。3.3 箭不会落在棋子上前提是棋盘状态没被污染getReachable 的循环条件已经保证了目标格必须为 EMPTY所以箭落点不可能是棋子。但这里藏着一个隐患如果模拟移动时忘了把原位置改成 EMPTY或者回溯时没有把原位置恢复成棋子那 getReachable 从新位置往外扫的时候会把自己的原位置当成一个障碍得到的射箭目标就会缺一块。这类“状态污染”问题在AI搜索里特别容易反复出现因为搜索会频繁模拟走棋又回溯。我处理的办法是把模拟一整套操作封装成一个带状态快照的类每次 apply 之前保存整个棋盘和棋子表中的一份拷贝undo 时直接整体恢复。10x10的棋盘拷贝成本很低换来的是逻辑上的绝对安全非常划算。4. 所有合法走法怎么生成移动目标是“一半”完整走法才是“全部”4.1 为什么不能把移动和射箭分开判定我之前犯过一个典型错误先列出所有可以移动的位置再单独列出所有可以射箭的位置然后做笛卡尔积觉得这样就够了。但规则里有一条——移动后必须还能射箭——导致不是每个移动目标都能和任意射箭目标组合。如果在移动阶段没有检查“移动后能不能射箭”就会出现一批非法走法混进搜索列表让AI做出明显违反规则的决策。正确的做法是把合法走法的原子单位定义成三元组源点、移动目标、射箭目标。这三个信息缺一不可而且射箭目标必须在“棋子移动后”的棋盘上生成不能拿移动前的棋盘算。4.2 generateAllMoves核心函数的完整实现我实现的 generateAllMoves 是整棵博弈搜索树的基石它的结构如下struct Move { Point from; Point moveTo; Point arrowTo; }; vectorMove generateAllMoves(int player) { vectorMove result; Point* pieces (player BLACK) ? blackPieces : whitePieces; for (int i 0; i 4; i) { vectorPoint moveTargets; getReachable(pieces[i], moveTargets); for (const auto mt : moveTargets) { // 模拟移动 board[pieces[i].x][pieces[i].y] EMPTY; board[mt.x][mt.y] player; vectorPoint arrowTargets; getReachable(mt, arrowTargets); for (const auto at : arrowTargets) { result.push_back({pieces[i], mt, at}); } // 回溯 board[pieces[i].x][pieces[i].y] player; board[mt.x][mt.y] EMPTY; } } return result; }这个函数每一局都会被调用很多次尤其是在AI搜索中。它看起来简单但性能直接影响搜索深度。棋盘越空旷合法走法数量越多开局阶段一次可能生成五六百个完整走法中后期棋盘被火焰分割后走法数量会明显下降。4.3 终局判定合法走法列表为空就是输胜负判断其实是个很干净的逻辑轮到当前玩家如果 generateAllMoves 返回空列表当前玩家就输了。bool hasAnyMove(int player) { return !generateAllMoves(player).empty(); }每步落子之后切换当前玩家然后调用 hasAnyMove 判断对方有没有合法走法。没有就结束当前玩家获胜。这里有一个容易踩的坑判断胜负时必须先对当前玩家生成完整的移动射箭组合而不是只判断“有没有棋子可以移动”。一个棋子能移动但移动后无箭可射的格子很多只看移动会误判成还能走。这个逻辑顺序一旦写反整个对局会永远无法结束。5. 电脑对手的AI评估函数、搜索深度与剪枝实践5.1 先把随机走棋跑通再谈智能AI的第一版最省事的就是从合法走法列表里随机选一个。这个AI水平极差连基本的封堵意识都没有但它有一个很重要的价值帮你验证规则模块的稳定性。让两个随机AI自动对局跑几百盘不崩溃基本说明棋盘更新、走法生成、胜负判断这些底层逻辑是可靠的。随机AI跑通之后再去做真正的搜索AI。跳跃式开发容易出那种“明明走法生成错了AI却看似正常”的混乱局面到时候你根本不知道是该查AI还是查规则。5.2 评估函数不只数棋子更要数行动力亚马逊棋没有吃子机制棋子数目永远固定所以评估函数的核心在于“行动力”——当前玩家有多少种合法走法以及能控制多少空间。我试过几种评估指标最后留下三个行动力当前玩家合法走法总数减去对手的合法走法总数。这个指标最直接能反映出谁的手脚更灵活。可达格数统计所有棋子移动可达的空格总数不如完整行动力精确但计算速度快很多在搜索中可以用它做粗评估。空间分割用BFS对棋盘做连通块分析把被火焰和棋子隔开的区域识别出来统计每个区域的大小和棋子归属作为后期优势的判断依据。评估函数大致是这样的int evaluate(int viewer) { int score 0; int myMob generateAllMoves(viewer).size(); int oppMob generateAllMoves(1 - viewer).size(); score 10 * (myMob - oppMob); int myReach reachableCount(viewer); int oppReach reachableCount(1 - viewer); score 3 * (myReach - oppReach); score spaceScore(viewer) - spaceScore(1 - viewer); return score; }注意这里我用 viewer 而不是固定黑方视角。搜索树里上层是AI在走下层是对手在走同一局面的“优势方向”是不同的评估函数必须能动态切换视角否则会出现AI时而激进时而保守的奇怪现象。5.3 极小化极大与alpha-beta剪枝深度2已经能打我用的是典型的minimax加alpha-beta剪枝。深度设多少亚马逊棋的分支因子很大开局阶段一次走法生成可能产生几百个完整走法两层搜索就是几十万次节点评估在C里还能接受三层在开局阶段就会明显卡顿。所以我的默认深度是2开局到中盘响应都在一两秒内体验比较好。核心搜索代码const int INF 1e9; int search(int depth, int alpha, int beta, int player) { auto moves generateAllMoves(player); if (moves.empty()) { return (player AI_SIDE) ? -INF : INF; } if (depth 0) { return evaluate(player); } if (player AI_SIDE) { int best -INF; for (const auto m : moves) { applyMove(m); best max(best, search(depth - 1, alpha, beta, 1 - player)); undoMove(m); alpha max(alpha, best); if (beta alpha) break; } return best; } else { int best INF; for (const auto m : moves) { applyMove(m); best min(best, search(depth - 1, alpha, beta, 1 - player)); undoMove(m); beta min(beta, best); if (beta alpha) break; } return best; } }贪心地讲alpha-beta剪枝的效果在分支因子大的棋类里特别明显前提是走法排序比较好。我试过在进入搜索前把走法按“射箭后自己行动力减少得最少”这个启发序排一下剪枝效率有很大提升搜索时间差不多能降低一半。5.4 迭代加深和限时保护为了让程序更实用我加了迭代加深先从深度1开始搜搜完保存结果如果时间还有富余再搜深度2。博弈树搜索一旦展开单层搜索时间可能超预期所以需要设定一个时间上限。我的实现方式是每次搜索前记录起始时间搜索过程中若超过设定上限就直接返回当前已经搜完的最佳走法。实际效果是默认深度2在绝大多数情况下响应很快偶尔遇到棋盘上火焰特别少、走法特别多的极端局面迭代加深会保护程序不卡死。演示的时候AI基本保持一两秒内落子观感比较舒服。6. 实测中的几个坑和修复方案6.1 斜向遍历的越界这个坑在前面已经提过但值得单独记录。我第一次写的 getReachable 是先访问数组再判断边界结果在斜向移动时出现了数组越界。内存越界不一定立刻崩溃但会让棋盘数据被随机值污染。调试时我发现某个格子的值变成了奇怪的负数顺着数据流找回去才发现问题出在 while 循环里的判断顺序。修复很简单把边界判断提到数组访问之前。6.2 棋盘状态污染AI搜索过程中如果 applyMove 和 undoMove 写得不严格棋盘和棋子表就会慢慢“漂移”。我最初只改棋盘数组忘了同步 pieces 坐标表导致搜索进行到深层时AI拿到的棋子位置是错的。这个bug的表现是AI偶尔会选择一个看起来位置的棋子但棋盘上那个位置根本没有棋子。查了很久才发现模拟走棋时坐标表没跟着更新。修复方案前面说了用状态快照。每次模拟前保存一个完整副本回溯时整体恢复。虽然多了一点拷贝开销但10x10棋盘完全无所谓换来的是正确的逻辑。6.3 用户输入“宽容度”不够控制台界面最容易让体验崩坏的地方是输入解析。用户可能输入“D1D3A1”带中文逗号也可能输入“d1 d3 a1”小写还可能输入“D1-D3-A1”带连字符。我一开始只接受以空格分隔的大写字母坐标本文还有配套的精品资源点击获取
返回列表