ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

五子棋人机AI实战:评估函数与alpha-beta剪枝搜索实现

五子棋人机AI实战:评估函数与alpha-beta剪枝搜索实现 简介一款五子棋人机对战系统的VC完整工程包面向学习博弈算法与MFC界面开发的学生和开发者用于理解Minimax搜索、Alpha-Beta剪枝及启发式评估在棋类AI中的落地方式。压缩包共29个文件、约367KB主要包含.h与.cpp源码、bmp棋盘棋子素材、ico图标以及可直接运行的exe程序其中dsp/dsw为Visual Studio工程文件txt和doc用来说明项目使用与开发笔记db为运行数据文件结构清晰便于对照学习。目前已有133人学习下载。价值点在于完整源代码配合可执行程序可边运行边改代码直观感受计算机决策过程结合描述中提到的剪枝策略与启发式函数思路适合在搭建五子棋AI场景时参考帮助加深对棋类搜索算法的理解。1. 解压 wuziqi.rar 之后这份五子棋人机代码该从哪下手拿到 wuziqi.rar先别急着改代码多数打着“五子棋人机”旗号的资源拆开一看就是“能成五就赢、看见活三就堵”的规则堆叠。这种 AI 前几手像那么回事一旦对手不按固定棋型走它立刻变盲人甚至会在必胜局面下跑去堵一个无关紧要的活二。真正值得投入的是另一条路线评估函数加博弈树搜索让 AI 在双方都有可能成五的局面里通过多步预判选出一条既不送死、又留后手的路。这篇文章不碰网页版人机的前端封装只讲怎样把这份代码的决策核心跑通、调好、填坑。适合课程设计、做能陪人下棋的小程序以及想入门博弈搜索的开发者。2. 先跑通最小人机闭环棋盘、胜负判定与命令行入口2.1 两条主流人机路线为什么先选搜索而非规则表见过太多同类项目第一版都写成“如果对方活三就堵如果自己活四就下”。规则表写起来确实快半小时能堆出几十条 if但缺陷也明显棋型组合几乎是无限的“跳活三”“眠三加冲四”这些复合局面根本枚举不完。更麻烦的是规则多了会互相打架比如“优先成五”和“必须先防守”同时成立时程序不知道听谁的只能靠调整 if 顺序来碰运气。搜索路线不一样。它把“下棋好坏”拆成三个可以独立调整的旋钮评估函数、搜索深度、候选宽度。评估函数负责回答“当前局面到底谁占优”搜索负责回答“如果我下这对手最可能怎么回”alpha-beta 剪枝只是让这个回答来得足够快。哪怕你以后想上深度学习或者蒙特卡洛树搜索这套评估函数依然可以作为先验特征接着用。我的经验是解压 wuziqi.rar 后如果发现 AI 代码全是 if else不要继续打补丁。把它重构成“棋盘层 评估层 搜索层”三块后面所有优化都是在这三个模块上做替换而不是在一坨规则里找 bug。2.2 落盘后的最小文件划分与运行环境先解压再把目录整理成四份代码。这一步的作用是让棋盘逻辑和 AI 逻辑彻底解耦后面你换成图形界面或者网页版人机AI 部分一行都不用动。# 解压 rar 包二选一 unzip wuziqi.rar # 或者 7z x wuziqi.rar # 建议的最小工程结构 # five/ # ├── main.py # 人机对弈入口负责交互 # ├── board.py # 棋盘、落子、胜负判定 # ├── eval.py # 评估函数 # └── ai.py # 候选点生成与搜索 python3 --version # 3.8 以上即可不需要第三方库代码只依赖 Python 标准库这是故意为之。五子棋棋盘逻辑很简单引入 numpy 和 pygame 反而增加环境配置成本。等 AI 逻辑稳定了再考虑用 pygame 做可视化也不迟。2.3 棋盘逻辑是地基十五路棋盘、落子与五连判定很多人一上来就写 AI结果棋盘的合法落子判断是错的后面所有搜索都白跑。这个基础模块必须一次写对。# board.py class Board: def __init__(self, size15): self.size size self.grid [[0] * size for _ in range(size)] self.move_stack [] self.last_move None def play(self, row, col, player): if not (0 row self.size and 0 col self.size): return False if self.grid[row][col] ! 0: return False self.grid[row][col] player self.last_move (row, col) self.move_stack.append((row, col, player)) return True def undo(self): if not self.move_stack: return row, col, player self.move_stack.pop() self.grid[row][col] 0 self.last_move self.move_stack[-1][:2] if self.move_stack else None def is_full(self): return len(self.move_stack) self.size * self.size def get_winner(self): if not self.last_move: return 0 r, c self.last_move return self.grid[r][c] if self._check_five(r, c) else 0 def _check_five(self, row, col): player self.grid[row][col] directions ((0, 1), (1, 0), (1, 1), (1, -1)) for dr, dc in directions: count 1 for sign in (1, -1): r, c row sign * dr, col sign * dc while 0 r self.size and 0 c self.size and self.grid[r][c] player: count 1 r sign * dr c sign * dc if count 5: return True return False def print_board(self): chars {0: ., 1: O, -1: X} for r in range(self.size): print( .join(chars[self.grid[r][c]] for c in range(self.size)))这里用 1 和 -1 表示黑白双方而不是用字符串 “B” 和 “W”。原因在后面会体现negamax 搜索里切换视角就是乘一个 -1字符串判断会平白多出一堆 if。_check_five以刚落的子为中心朝某个方向的正反两边数连续同色棋子合起来达到 5 就赢起点本身算一枚所以 count 初始值是 1。undo方法依赖 move_stack 记录每一步这样 AI 在搜索时模拟落子后可以精确回退不需要复制整个棋盘对象。复制棋盘不是不行但 225 个格子的对象在深度 4、每层 10 个候选点的情况下会被复制上万次内存和 GC 压力都不小。2.4 命令行对弈主循环先让人和 AI 都能“动起来”棋盘逻辑写好后先接一个最蠢的随机 AI。这一步不是为了验证棋力而是验证“人下棋 → AI 下棋 → 判胜负 → 再循环”这条链路是否通顺。# main.py import random from board import Board def random_move(board): empties [(r, c) for r in range(board.size) for c in range(board.size) if board.grid[r][c] 0] return random.choice(empties) if empties else None def play_human_vs_ai(): board Board() human, ai 1, -1 human_turn True while not board.is_full(): board.print_board() winner board.get_winner() if winner: print(winner:, 黑(你) if winner human else 白(AI)) return if human_turn: try: text input(输入坐标 row,col如 7,7: ) r, c map(int, text.strip().split(,)) except Exception: print(格式错误重新输入) continue if not board.play(r, c, human): print(该位置不可落子重新输入) continue else: move random_move(board) if move: board.play(*move, ai) print(AI 落子:, move) human_turn not human_turn print(平局) if __name__ __main__: play_human_vs_ai()坐标输入统一走 “row,col” 带逗号的格式避免命令行列坐标分不清。board.play返回 False 时说明越界或者位置已占用主循环里的人机分支负责捕获并重新输入。AI 分支不需要处理这个问题因为随机选点不会选到非空位。这版跑通后你的五子棋地基就算立住了。后面把random_move换成贪心、再换成搜索主循环基本不用动。如果将来要做网页版人机区别只是输入从终端变成 HTTP 或 WebSocket 帧棋盘判定和 AI 逻辑原样保留。3. 让 AI 学会“看棋形”评估函数与第一版贪心落子3.1 棋形翻译成数字为什么“活三”比“冲四”分数难定人下五子棋时看的是活四、冲四、活三、眠三这些棋形。机器没有棋感只能看数字。评估函数要做的就是把局面翻译成一个数值数值越大对当前玩家越有利。分数设计的核心是拉开数量级差距。活四两端都开放下一步无论对手堵哪头你都能从另一头成五基本等于胜利所以分数是十万级。冲四只有一端开放对手还能堵但你不下就会被对方抢先所以给一万级。活三给五千因为它下一步能变成活四威胁真实存在眠三只有一千因为它想成活四需要更多手数对手有反应时间。棋形直观形状分值建议五连XXXXX1000000活四.XXXX.100000冲四.XXXXX 或 XXXX.10000活三.XXX.5000眠三XX.X. 等1000活二.XX.200眠二X.X. 等50注意这是比例关系不是标准答案。相邻棋形之间必须保持数量级差距比如活三和眠三至少差 3 到 5 倍。如果活三和眠三只差 1.5 倍AI 在实战中会为了一个并不急迫的眠三放弃真正能形成杀棋的活三。3.2 方向扩展法评估从落子点出发数棋形五子棋棋形是方向性的。横、竖、两条对角线一共四个方向评估函数必须同时覆盖。这里的实现方式是对棋盘上每个己方棋子朝四个方向分别数“连续同色棋子数”和“开放端数”然后按棋形表加分。# eval.py def evaluate_point(board, row, col, player): score 0 directions ((0, 1), (1, 0), (1, 1), (1, -1)) for dr, dc in directions: count 1 open_ends 0 for sign in (1, -1): r, c row sign * dr, col sign * dc while 0 r board.size and 0 c board.size and board.grid[r][c] player: count 1 r sign * dr c sign * dc if 0 r board.size and 0 c board.size and board.grid[r][c] 0: open_ends 1 if count 5: score 1000000 elif count 4 and open_ends 2: score 100000 elif count 4 and open_ends 1: score 10000 elif count 3 and open_ends 2: score 5000 elif count 3 and open_ends 1: score 1000 elif count 2 and open_ends 2: score 200 elif count 2 and open_ends 1: score 50 return score def evaluate_board(board, player): total 0 for r in range(board.size): for c in range(board.size): if board.grid[r][c] player: total evaluate_point(board, r, c, player) return total这段逻辑的核心是“从当前棋子出发朝一个方向延伸到边界或者对手棋子为止”。延伸过程中遇到空位说明这一端是开放的open_ends 加一遇到对手棋子或者棋盘边界说明被堵死。比如棋盘上有一排白棋 “.XXX.”对中间那个 X 和两边的 X 分别做方向扩展每个点都会得到 count3、open_ends2于是这个活三总共贡献 15000 分。一个不得不提的细节上面的实现对同一个棋段会重复计分。一个活三里的三个棋子每个都能扩展出这个活三所以总分会是单点分值的 3 倍。这个重复放大对黑白双方是等价的所以在排序和搜索里不影响相对局面判断。如果你强迫症发作想去重需要在棋段的起始端点开始只统计一次但那样代码会复杂不少对棋力提升有限建议先留着。3.3 候选点生成AI 的第一版贪心落子评估函数有了AI 就能“看”局面了。第一版 AI 不做搜索只做一件事遍历所有空位模拟自己落子后调用评估函数打分选分数最高的点落下。这就是贪心落子。# ai.py from eval import evaluate_board def generate_candidates(board, player, top_n10): scored [] for r in range(board.size): for c in range(board.size): if board.grid[r][c] ! 0: continue board.play(r, c, player) attack evaluate_board(board, player) defense evaluate_board(board, -player) board.undo() scored.append((attack - 0.9 * defense, r, c)) scored.sort(keylambda x: x[0], reverseTrue) return [(r, c) for _, r, c in scored[:top_n]] def greedy_move(board, player, top_n10): if not board.move_stack: # 先手第一手直接落天元避免全盘 0 分导致随机乱选 return board.size // 2, board.size // 2 return generate_candidates(board, player, top_n)[0]注意 generate_candidates 里打分用的是attack - 0.9 * defense不是只算己方攻击分。如果只看 attackAI 会对一个角落里的眠二情有独钟因为那里“全是自己的棋子”而对手已经摆好的活三反而没人管。0.9 就是防守权重含义是“对手的威胁略微优先于我自己的发展”默认 0.9 是因为五子棋防守反击比盲目进攻更稳健。替换 main.py 里的random_movefrom ai import greedy_move # 把 AI 分支改成 r, c greedy_move(board, ai) board.play(r, c, ai) print(AI 落子:, r, c)跑几盘你会发现贪心 AI 会堵活三、会占中心但它的棋没有连贯性它看不到“我现在冲四对手必须堵然后我可以在另一个方向成活三”。这正是第 4 章搜索要解决的问题。4. 加深度才有棋感minimax 加 alpha-beta 剪枝的实现与三个调参点4.1 为什么是搜索而不是“多看几步的贪心”贪心的本质是只看当前盘面对“对手下一手会怎么走”完全没有建模。五子棋里的强制交换很常见我冲四你只能堵我乘机在另一个方向形成活三你又要来堵。这种多步棋型只有搜索才能看到。minimax 模拟“我走一步 → 对手走一步 → 我再走一步”的对抗序列。它假设对手永远选择对我最不利的那手而我选择在对手最坏回应下依然对我最有利的那手。五子棋没有围棋里的劫争规则简单搜索树结构非常规整用 negamax 写法最顺手。alpha-beta 剪枝是搜索的加速器。核心判断是如果当前分支已经比之前找到的最优解更差就停止展开这个分支。它不改变搜索结果只是把无效分支剪掉。开局阶段搜索空间接近 225 的阶乘不剪枝的话深度 4 都跑不动剪完之后深度 4 加候选宽度 10 可以稳定在秒级返回。4.2 负极大值加剪枝搜索模块完整实现negamax 的关键是视角反转。当前玩家视角下的正分在对手看来就是负分所以递归调用时把返回值取负、搜索结果取负alpha和beta也要交换位置并取负。# ai.py 追加以下内容 def negamax(board, depth, alpha, beta, player): winner board.get_winner() if winner player: return 1000000 depth if winner -player: return -1000000 - depth if depth 0 or board.is_full(): return evaluate_board(board, player) best -float(inf) for r, c in generate_candidates(board, player, top_n10): board.play(r, c, player) score -negamax(board, depth - 1, -beta, -alpha, -player) board.undo() if score best: best score if score alpha: alpha score if alpha beta: break return best def best_move(board, player, depth4, top_n10): if not board.move_stack: return board.size // 2, board.size // 2 alpha -float(inf) beta float(inf) best_score -float(inf) best None for r, c in generate_candidates(board, player, top_n): board.play(r, c, player) score -negamax(board, depth - 1, -beta, -alpha, -player) board.undo() if score best_score: best_score score best (r, c) if score alpha: alpha score return best几个细节要解释清楚。胜负分支返回的分数带上了 depth是为了让 AI 在同样能赢的分支里选择“更快赢”的那条。因为越深的地方发现胜利剩余深度越小1000000 depth会略微偏大一点搜索会优先走这条短路径。generate_candidates在每层递归都会重新对空位打分排序这个开销不小但换来的剪枝收益更大。排序好的候选让 alpha-beta 能更早触发剪枝效率反而比“不排序但展开全部节点”高一个量级。best_move的顶层没有在alpha beta时 break这是故意的。顶层少剪一个分支对性能影响微乎其微但我可以在后续版本里改成收集所有同分候选然后随机挑一个避免 AI 棋风死板。4.3 把搜索接回命令行对弈现在把第 3 章的贪心替换成best_move# main.py 中替换 AI 分支 from ai import best_move # ... else: r, c best_move(board, ai, depth4, top_n10) board.play(r, c, ai) print(AI 落子:, r, c)这版跑起来你会立即感受到两个变化。第一是 AI 落子明显变慢深度 4 加候选 10 大约要 1 到 3 秒具体取决于分支剪枝效率。第二是它的棋风变了不再像贪心那样“哪里分高下哪里”而是会刻意制造双威胁。注意主循环调用best_move前必须先判断棋局未结束、棋盘未满否则空棋盘或已胜负分的情况下调用会产生奇怪结果。前面 main.py 的 while 循环已经做了这两个检查所以直接接入即可。4.4 三个必调参数深度、候选宽度、攻防权重参数是这份代码的命门调参顺序比调参本身更重要。参数默认值建议区间调大表现调小表现depth 搜索深度42~6棋力明显增强耗时指数增长反应快但看漏连杀top_n 候选宽度105~16不容易漏掉关键防守点慢速度快但可能无视对手活三攻防权重 0.90.90.7~1.2更敢进攻偶尔漏防偏保守光堵不攻深度 4 加候选 10 是性价比最高的起步组合。想上深度 6候选宽度必须降到 6 到 7否则单步思考时间会飙到十几秒。深度再往上就要做迭代加深和历史启发让 AI 先快速搜一个浅层结果作为参考排序再逐步加深超时就直接用上一次的结果。这是普通课程设计用不到的优化不展开。一个容易被忽略的是候选点的空间范围。如果棋盘已经下了二十几手大部分空位离战场很远让 AI 遍历全部 225 个空位纯属浪费。常见的做法是把候选限制在“已有棋子周围两格”的区域内超出区域的空位直接跳过。这样做对棋力几乎没有损失但搜索时间可能缩短一半。5. 五子棋 AI 常见的五个翻车点现象、原因与排查5.1 现象一AI 面对双活三不防守跑去扩展自己的棋双活三是必杀局面只要对手走出来你怎么堵都堵不住两个头。如果 AI 此时还在角落下自己无关紧要的眠二说明它根本没意识到威胁。原因多半出在候选点生成上。generate_candidates用attack - 0.9 * defense排序如果己方某个区域的攻击分因为重复计分被过分放大防守点就会被挤到 10 名之外。解决方法是先调参验证把 top_n 从 10 改成 16 试试如果防守点进来了就是候选窗口太小如果还是进不来把 defense 的系数临时改成 1.2再跑同一局面。更直接的排查手段是打印generate_candidates的前 10 个候选肉眼看防守点是否在列。这个打印习惯能省掉大量瞎猜的时间。5.2 现象二棋盘上明明有成五点AI 却不下这个翻车现象最有迷惑性看起来像是 AI 智商掉线实际是候选排序问题。成五点虽然能得 1000000 分但如果对手也有一个高威胁棋形defense 分量会把防守点排到成五点前面AI 为了“防守”而放弃必胜一手。这在深度浅的时候尤其常见。解决方法是给成五点绝对优先级。在generate_candidates开头先扫描一遍空位模拟落子后如果board.get_winner()等于当前玩家直接返回这个点不做任何打分排序def find_winning_move(board, player): for r in range(board.size): for c in range(board.size): if board.grid[r][c] ! 0: continue board.play(r, c, player) if board.get_winner() player: board.undo() return (r, c) board.undo() return None在best_move开头调用这个函数有必胜点就先走。这个模块同时也能加速搜索因为成五点根本不需要进入 alpha-beta 去算。5.3 现象三同一局面改成深度 6 后 AI 反而更差深度调大棋力应该更强这是直觉。但实际测试中经常出现深度 4 能赢的棋深度 6 反而走错。原因多数在胜负分支的返回值上。negamax 里winner player返回1000000 depthwinner -player返回-1000000 - depth。这两个值看起来对称但如果 depth 在不同层数传入时没有保持一致就会出现“黑棋越深越觉得自己要输”的错觉。排查办法是在 negamax 入口加一行临时打印记录 depth、player、alpha、beta 和返回分数然后用固定棋谱复盘。看到符号异常基本就是胜负分支的返回值不对称。5.4 现象四AI 每次同一局面走同一手被人抓住套路这不是 bug但影响对弈体验。best_move里一旦找到分数更高的候选就直接替换掉旧候选同分情况下保留先遇到的所以棋风极其固定。解决方法是收集所有同分候选随机选一个best_moves [] for r, c in generate_candidates(board, player, top_n): board.play(r, c, player) score -negamax(board, depth - 1, -beta, -alpha, -player) board.undo() if score best_score: best_score score best_moves [(r, c)] elif score best_score: best_moves.append((r, c)) return random.choice(best_moves)这样 AI 在多个等效着法之间随机选择人类的“针对性开局”就没那么奏效了。这五子棋里尤其有用很多布局思路都是针对固定应手设计的。5.5 现象五单步耗时忽快忽慢完全没法预估alpha-beta 剪枝是一个非线性过程。候选排序质量高剪枝触发早1 秒出棋候选排序紊乱搜索几乎全量展开8 秒也不奇怪。要降低方差先保证候选排序的质量。generate_candidates里的排序分数已经是攻防综合分但如果发现耗时波动很大可以改成先按 attack 降序排列再在 attack 相近的候选里按 defense 微调。攻击点优先占据搜索树的上层剪枝效果通常更好。另一招是限时搜索。记录搜索开始时间在 negamax 每层递归开头判断是否超时超时就抛出异常或者返回当前已有最优解。这个方案实现简单实战效果不错代价是超时的那一层可能只搜了一半结果会略微不稳定。注意以上五个现象全部建立在棋盘逻辑正确的前提下。如果_check_five有 bugAI 会基于错误信息做决策所有排查方向都白费。遇到诡异棋谱先用第 5.6 节的回归方法验证棋盘层。5.6 手工棋谱回归改完参数怎么验证给 AI 摆一个“白棋活三黑棋必须堵端头”的简单棋谱用脚本自动断言 AI 的落子位置。以后每次改权重、改搜索都跑一遍这批用例快速发现退化。# regression.py from board import Board from ai import best_move cases [ { grid: [ [0, 0, 0, 0, 0], [0, 0, 1, 0, 0], [0, 0, 1, 0, 0], [0, 0, 1, 0, 0], [0, 0, 0, 0, 0], ], player: -1, expect: [(0, 2), (4, 2)] }, ] for i, case in enumerate(cases): board Board(size5) board.grid [row[:] for row in case[grid]] board.move_stack [ (r, c, board.grid[r][c]) for r in range(5) for c in range(5) if board.grid[r][c] ! 0 ] board.last_move board.move_stack[-1][:2] move best_move(board, case[player], depth2, top_n5) assert move in case[expect], fcase {i} failed, got {move} print(fcase {i} passed: {move})回归棋谱不用多十几个经典局面就够。每加一条就相当于给 AI 上了一道保险。6. 进阶自对弈质检、必杀检测和开局库让 AI 从能下到敢下自对弈是最好的质检手段。把 AI 拆成两半黑方深度 4、白方深度 6如果白方不能稳定赢下大多数对局说明评估函数或权重比例有问题。配合最简单的主循环就能跑出结果。from board import Board from ai import best_move def play_game(depth_black, depth_white, top_n6): board Board() turn 0 while not board.is_full(): player 1 if turn % 2 0 else -1 depth depth_black if player 1 else depth_white r, c best_move(board, player, depthdepth, top_ntop_n) board.play(r, c, player) if board.get_winner(): return player turn 1 return 0深度高的那一方如果胜率不到七成问题基本出在评估函数而不是搜索。比如活三分数给得太高AI 只顾自己发展忽略了对手的连续冲四。必杀检测是性价比最高的加速手段。搜索之前先看一眼有没有直接成五的点有就立刻返回没有才进入 alpha-beta。这一点在第 5 章已经实现过直接加在best_move开头即可。对“双活三”“冲四活三”这类复合必杀需要额外枚举棋形工作量翻倍一般课程设计不推荐做到那一步。开局库则是个讨巧的技巧。五子棋前四手基本是定式不值得用搜索慢慢算。把常见开局存成一个列表前几步随机挑一个走第五手开始交给搜索既省时间又让 AI 看起来更像人类OPENINGS [ [(7, 7), (7, 8), (8, 7)], [(7, 7), (8, 8), (7, 8)], [(7, 7), (8, 7), (6, 8)], ]按照board.move_stack的步数查表查不到就进搜索。预算充足的话可以往后存到十步AI 在开局阶段的棋力会突然显得很强。我以前第一次写五子棋人机时也走的是纯规则表三百多行 if 堆完AI 还是被一个跳活三骗得团团转。后来改成评估加搜索才理解这类博弈 AI 的棋力不在灵光一现而在评估函数是否诚实、搜索是否把对手的反击算进去。调防守权重时我翻过一次车AI 从积极进攻变成无限防守最后是靠自对弈棋谱定位到问题。多下几盘、多打印候选比猜参数靠谱得多。希望帮到你。本文还有配套的精品资源点击获取
返回列表