多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

五子棋人机AI实战:解压避坑与Alpha-Beta剪枝

五子棋人机AI实战:解压避坑与Alpha-Beta剪枝 简介一份基于VC开发的五子棋人机对战源码面向对人工智能与博弈算法感兴趣的编程学习者和课程设计学生。项目实现了用户与计算机的对弈重点演示Minimax搜索及Alpha-Beta剪枝优化并结合启发式函数评估棋局帮助理解经典AI决策思路同时通过MFC完成棋盘绘制与交互适合作为图形界面编程和算法落地的参考。压缩包共29个文件、约367KB涵盖bmp棋盘/棋子位图、h头文件、cpp实现源文件、ico图标、txt说明、可执行程序及工程文件源代码与编译产物一并提供便于直接运行与二次修改。目前已有133人学习下载。资源内附可执行的MyChess.exe和项目文档搭配源码可边运行边调试从棋型评估到剪枝搜索均有对应代码适合入门者在实操中掌握人机对战系统的完整构建流程。1. 拿到的 wuziqi.rar 打不开也跑不动问题通常不在 AI网上流传的“五子棋人机”压缩包十有八九是 Python 加 tkinter 写死的一个课程设计压缩包本身不大但解压密码和运行环境比 AI 算法更容易让人翻车。这类 rar 包被二次打包上传时经常被加上“伪加密”标识文件名能看见、双击就要密码就算解压出来主程序也可能因为 Python 版本、缺失的依赖库或者 GBK 编码问题在启动瞬间闪退。这篇笔记把这类包里最常见的结构、棋盘逻辑、人机搜索算法和踩坑点一次讲完让拿到压缩包的人能在一个小时内跑起来并把它改成自己能讲清楚代码来龙去脉的版本。适合正在交课程设计、想学博弈树搜索、或者单纯想给老程序加难度选项的开发者。2. 把 wuziqi.rar 变成可运行程序密码、入口与三个环境检查2.1 先确认是不是“伪加密”文件拿到手先不要急着双击。打开终端用file命令看一眼文件真实类型。file wuziqi.rar # 输出示例wuziqi.rar: RAR archive data, v1d, os: Win32如果输出里出现 “RAR archive data”那它确实是 RAR 格式如果输出显示 “Zip archive data”说明这个包只是改了后缀名直接用unzip解就行。这个检查能省去后面一大半“解压失败”的困扰。“伪加密”是这类课程的压缩包最容易出现的坑。表现是WinRAR 能看到压缩包里的文件名和目录但一解压就要求输入密码而提供者在帖子里明明写着“密码为空”。原因不是真的加密而是压缩包头的加密标志位被写过RAR 解压器误判成需要口令。处理办法有两个一是用支持“忽略加密标志”的解压参数硬解二是在 Python 脚本里把 RAR 头当成普通解压任务直接跑让底层 unrar 工具自己判断。提示遇到“看得见文件、打不开内容”的包先按伪加密处理不要盲目去跑暴力破解。暴力恢复密码在 RAR5 上是典型的“玄学”时间长且成功率低。2.2 用 rarfile 库在 Python 里安全解压我一般不会用图形界面工具解压这类包因为来源不明的 rar 文件可能带着自解压脚本或奇怪的目录结构在终端里解压至少能看清释放了哪些文件。常见做法是写一段 Python 脚本调用 rarfile 库# -*- coding: utf-8 -*- import rarfile import os rar_path wuziqi.rar extract_dir ./wuziqi_src # 使用系统已安装的 unrar 工具路径可按实际情况改 rarfile.UNRAR_TOOL /usr/bin/unrar os.makedirs(extract_dir, exist_okTrue) rf rarfile.RarFile(rar_path) # 列出包内全部文件先看结构再决定解压策略 for info in rf.infolist(): print(info.filename, info.file_size) # 伪加密的 rar 包密码参数传空字符串让底层 unrar 自行判断 rf.extractall(pathextract_dir, pwd)这段脚本先把压缩包内的文件名和大小打出来确认入口文件位置再执行解压。rarfile.UNRAR_TOOL指定底层解压程序路径在 Windows 上通常要改成C:\Program Files\WinRAR\UnRAR.exe这种形式extractall的pwd参数传空字符串是为了兼容伪加密包对空密码的校验要求。解压成功后进入目录看一下有没有main.py或wuziqi.py之类的 Python 文件这是这类项目最常见的入口。2.3 跑通主程序前的三个环境检查解压只是第一步真正让新手卡住的是运行环境。第一个检查点是 Python 版本。很多课程设计包是用 Python 2 写的print后面没有括号放到 Python 3 下直接语法报错反过来有些包用了 f-string必须 Python 3.6 以上才能跑。命令行执行python --version确认版本后再看代码里的关键字。第二个检查点是 tkinter 是否可用。tkinter 在 Python 安装包中默认存在但有些精简版 Linux 发行版不会自动带上。在终端敲python -c import tkinter如果报 ModuleNotFoundError用系统包管理器补装Debian/Ubuntu 上是apt install python3-tk。第三个检查点是源码编码。国内课程设计包里的中文注释大量使用 GBK 编码Python 3 默认按 UTF-8 读文件会直接抛SyntaxError: Non-UTF-8 code。这个最省事的解决方式是打开源文件另存为 UTF-8而不是在文件头顶加# coding: gbk——因为一旦混入特殊字符两种编码来回切会埋下更多隐患。3. 读懂五子棋人机程序的三个代码块棋盘、落子与胜负判定3.1 tkinter Canvas 棋盘是从哪一行代码开始的这类包几乎清一色用 tkinter 的 Canvas 画棋盘。理解这个结构之后不管是改棋盘大小还是加音效都知道去哪个函数里动手。import tkinter as tk BOARD_SIZE 15 # 15 路棋盘 CELL_SIZE 40 # 每个交叉点占 40 像素 MARGIN 30 # 棋盘四周留白 root tk.Tk() root.title(五子棋人机) canvas tk.Canvas(root, widthBOARD_SIZE * CELL_SIZE MARGIN * 2, heightBOARD_SIZE * CELL_SIZE MARGIN * 2, bg#D2B48C) canvas.pack() # 画网格线纵线和横线各 15 条 for i in range(BOARD_SIZE): start MARGIN i * CELL_SIZE end MARGIN i * CELL_SIZE canvas.create_line(MARGIN, start, MARGIN (BOARD_SIZE - 1) * CELL_SIZE, start) canvas.create_line(start, MARGIN, start, MARGIN (BOARD_SIZE - 1) * CELL_SIZE)BOARD_SIZE、CELL_SIZE、MARGIN三个常量决定了整张棋盘的大小改 CELL_SIZE 可以等比缩放窗口。create_line负责画网格线先画横线再画纵线落点坐标从 MARGIN 开始保证棋盘四周留白对称。tkinter 的坐标原点是左上角所以第 i 列的纵线 x 坐标是MARGIN i * CELL_SIZE这个坐标换算后面判断落子位置时还要再用到。3.2 鼠标点击怎么和落子动作串起来人机对战的交互核心是玩家点击棋盘程序把像素坐标换算成格子坐标再把合法落子写入状态数组最后让 AI 回一手。def click_handler(event): # 像素坐标换算成格子索引 col round((event.x - MARGIN) / CELL_SIZE) row round((event.y - MARGIN) / CELL_SIZE) # 边界保护点击了棋盘外直接忽略 if not (0 row BOARD_SIZE and 0 col BOARD_SIZE): return # 重复落子保护该位置已有棋子就返回 if board[row][col] ! 0: return # 玩家执黑先行 board[row][col] 1 draw_piece(row, col, black) # 这一步是切换人机身份的常见写法先判胜负再让 AI 落子 if check_win(row, col, 1): messagebox.showinfo(游戏结束, 你赢了) return ai_move() canvas.bind(Button-1, click_handler)round函数在这里承担像素到格子的换算法则鼠标点在距离格心半个格子以内都算这一格。board[row][col]是二维整数数组0 代表空1 代表黑棋2 代表白棋。draw_piece在画布上画一个圆形棋子check_win只检查新落子位置是否形成五连。AI 落子后同样会走一遍check_win这样整个回合就形成了“玩家落子 → 判胜 → AI 落子 → 判胜”的闭环。不把 AI 落子放在单独的线程里是因为 tkinter 的事件循环本身是单线程的AI 计算期间界面会短暂卡住但只要搜索深度控制在 6 层以内人眼基本感知不到。3.3 胜负判定只查四个方向五子棋的连珠判定不需要遍历全盘只要以刚落下的棋子为起点朝四个线性方向分别数黑白。这里说的四个方向是水平、垂直、主对角线、副对角线而不是八个方向因为每个方向的倒序方向在同一次检测里已经覆盖了。def check_win(row, col, player): directions [(0, 1), (1, 0), (1, 1), (1, -1)] for dr, dc in directions: count 1 # 正方向延伸 for step in range(1, 5): nr, nc row dr * step, col dc * step if 0 nr BOARD_SIZE and 0 nc BOARD_SIZE and board[nr][nc] player: count 1 else: break # 反方向延伸 for step in range(1, 5): nr, nc row - dr * step, col - dc * step if 0 nr BOARD_SIZE and 0 nc BOARD_SIZE and board[nr][nc] player: count 1 else: break if count 5: return True return False四个方向的定义中(1, -1) 是从左上到右下的副对角线。每个方向从当前点同时往两个方向延伸连子数累计到 5 就判定胜利。这样设计的好处是只关心刚下的这一手越靠后落子越高效不用在每一步都扫全盘。另一个常见细节是range(1, 5)的边界五连判定只需要向每个方向数最多 4 个间隔超过 5 个棋子的长连在标准无禁手五子棋规则里也是赢所以这里不做截断。4. 人机 AI 的核心从评分表到带剪枝的搜索4.1 最常出现的解压后 AI一张评分表打天下先跑起来再说——这是大多数 wuziqi.rar 的代码现状。解压之后看到的 AI 通常不是搜索树而是一张预设好的棋型评分表。程序遍历所有空位对每个空位分别计算“如果黑棋落在这里”和“如果白棋落在这里”的得分取最大值作为该位置的评分然后选全局最高分的格子落子。SCORE { FIVE: 100000, # 五连 LIVE4: 10000, # 活四 RUSH4: 5000, # 冲四 LIVE3: 1000, # 活三 SLEEP3: 200, # 眠三 LIVE2: 100, # 活二 SLEEP2: 20, # 眠二 } def evaluate_point(row, col, player): total 0 for dr, dc in directions: pattern collect_line(row, col, dr, dc, player) total SCORE.get(pattern, 0) return total def ai_move_greedy(): best_score -1 best_pos None for r in range(BOARD_SIZE): for c in range(BOARD_SIZE): if board[r][c] ! 0: continue # 攻防合一进攻看自己是几分防守看对手是几分 attack evaluate_point(r, c, 2) defense evaluate_point(r, c, 1) score attack * 1.1 defense if score best_score: best_score score best_pos (r, c) if best_pos: board[best_pos[0]][best_pos[1]] 2 draw_piece(best_pos[0], best_pos[1], white)这个贪心 AI 的脾气很典型它的棋力取决于collect_line对棋型的识别粒度。如果只数了当前方向的连子数量没区分“活三”和“眠三”AI 就会经常犯只看眼前、不补后手的毛病。attack * 1.1这个系数是给进攻加了一点权重但这个权重调太高会出现 AI 一味进攻、对手做两个冲四就直接崩盘的局面。我现在看到这类代码第一件事就是检查它的collect_line到底返回了几种棋型很多包实际上只回了三种长度、是否被堵头、是否够五连。4.2 把 AI 升级成 MinMax 搜索至少能看懂两步棋评分表 AI 的问题是只看一步。升级为 MinMax 搜索后AI 会模拟“我下一手、对手下一手”的若干回合再用同一个评估函数给终局局面打分。这是我把这类课程设计改造成能看的棋力的最小改动。def minmax(board, depth, maximizing, ai_player, human_player): # 到达搜索深度用评估函数打分 if depth 0: return evaluate_board(board, ai_player, human_player) best float(-inf) if maximizing else float(inf) candidates generate_candidates(board, n10) # 只考虑最近落子周边的空位 for r, c in candidates: player ai_player if maximizing else human_player board[r][c] player if maximizing: best max(best, minmax(board, depth - 1, False, ai_player, human_player)) else: best min(best, minmax(board, depth - 1, True, ai_player, human_player)) board[r][c] 0 return best def ai_move_search(depth4): candidates generate_candidates(board, n15) best_move candidates[0] best_val float(-inf) for r, c in candidates: board[r][c] 2 # AI 执白 val minmax(board, depth - 1, False, 2, 1) board[r][c] 0 if val best_val: best_val val best_move (r, c) return best_move这里的generate_candidates是性能关键。它只返回已有棋子周边两格范围内的空位而不是遍历全部 225 个交叉点这一步直接把搜索分支数量从 225 降到几十个量级。evaluate_board对整个棋盘打分常见做法是对 AI 的所有棋型求和再减去对手棋型求和。MinMax 交替极大极小的逻辑可以简单理解为AI 在这一层取最高分下一层假设对手会取最低分再下一层又回到 AI 取最高分。深度设置为 4 时AI 能看到“自己下两步、对手下两步”已经比纯评分表模型有明显进步。4.3 深度、候选数与剪枝三个必调参数纯 MinMax 到深度 4 还能勉强跑到深度 6 就明显卡顿。这里需要加 Alpha-Beta 剪枝把不可能影响最终决策的分支直接砍掉也就是三个必调参数搜索深度、候选格数量和剪枝开关。def alphabeta(board, depth, alpha, beta, maximizing, ai_player, human_player): if depth 0: return evaluate_board(board, ai_player, human_player) candidates generate_candidates(board, n8) # 候选格数量调小加速明显 if maximizing: value float(-inf) for r, c in candidates: board[r][c] ai_player value max(value, alphabeta(board, depth - 1, alpha, beta, False, ai_player, human_player)) board[r][c] 0 alpha max(alpha, value) if alpha beta: # beta 剪枝 break return value else: value float(inf) for r, c in candidates: board[r][c] human_player value min(value, alphabeta(board, depth - 1, alpha, beta, True, ai_player, human_player)) board[r][c] 0 beta min(beta, value) if alpha beta: # alpha 剪枝 break return value深度参数直接决定棋力上限。深度 2 的 AI 只会“你堵我一下我换方向”深度 4 就懂得预先做出双三深度 6 在 15 路棋盘上已经能让普通玩家感到压迫。但深度不是越大越好每加一层耗时会按候选数近似指数增长在 tkinter 事件循环里超过 8 层会导致窗口长时间无响应看起来像程序死了。候选数参数n8是质量和速度之间常用的平衡点只评估距离现有棋子最近的 8 个空位。剪枝的alpha和beta初始值分别设为负无穷和正无穷搜索过程中会不断收窄这个区间区间为空的那一侧分支直接跳过。4.4 先手优势与开局响应为什么 AI 第一步固定落天元五子棋先手优势巨大几乎所有的 rar 包都会在 AI 执白时做一个开局硬编码玩家第一步如果落在天元AI 通常应在一侧星位玩家如果没落天元AI 直接把第一步下在天元。这段代码虽然简单但对棋力提升立竿见影。FIRST_MOVE (7, 7) # 15 路棋盘天元 def ai_first_move(): if board[7][7] 0: return (7, 7) # 对手先占了天元AI 占左上星位 return (3, 3)天元是棋盘中心往四个方向的展开距离最均衡任何棋型从中间做起都比从边角做起更有辐射力。对手占了天元时选星位属于“抢占第二中心”的常见应对逻辑。很多包在这里还加了一步随机在天元和星位之间按概率选择目的是让同样的开局不要每次都一模一样。我建议保留这个随机因为纯固定开局会让对局模式很快被摸透失去练习棋感的乐趣。5. 避坑伪加密、闪退、编码问题与 AI“装菜”的五个典型现场5.1 伪加密包解压要求密码现象用 WinRAR 打开 wuziqi.rar文件名全部可见但解压时弹出密码框输入空密码也报错。原因RAR 头的加密标志位被第三方工具误写文件内容没有真正加密。解决用命令行 unrar 直接解压很多情况下能跳过密码校验再不行就用 Python rarfile 的pwd参数尝试。按经验这一步能解决九成“没密码打不开”的求助帖。5.2 双击 py 文件窗口一闪而过现象解压后双击main.py黑色命令窗口闪一下就没了。原因脚本运行时抛异常Python 解释器执行完直接退出错误信息没来得及显示。解决不要在资源管理器里双击改成在命令行里执行python main.py让错误堆栈留在终端里。常见的具体错误包括缺少 tkinter、代码里用了 Python 2 语法、文件编码不对等看到报错后按前文的三个环境检查逐项排查。5.3 GBK 中文注释乱码与 SyntaxError现象代码一打开就是满屏 “SyntaxError: Non-UTF-8 code”或者运行时报错指向中文注释行。原因源码在 Windows 简体中文环境里默认用 GBK 编码保存Python 3 按 UTF-8 读取失败。解决用 VS Code 或 Notepad 打开文件右下角编码切换成 GBK再另存为 UTF-8不要试图在文件头加# coding: gbk因为这只对 Python 2 有效Python 3 里旧编码混入会引发更隐蔽的字符串乱码。5.4 AI 只会堵不会攻现象AI 一直被动贴着眼对手的棋子走棋盘上几乎没有自己主动做棋的痕迹。原因评估函数里“防守分”权重压过了“进攻分”或者更糟代码只识别了对手落点附近的棋型压根没有计算自己潜在落子的进攻价值。解决给 AI 自身棋型评分加权重系数。我用的是进攻分乘以 1.2 再和防守分相加这个系数在 1.1 到 1.5 之间调整调到 AI 明显开始主动做冲三、活三但又不会完全无视对手的必胜形位置。5.5 同样的代码在不同电脑上棋力忽高忽低现象在自己机器上 AI 下得很好换一台电脑就觉得 AI 变笨了。原因新增的搜索深度或候选数参数用了硬编码的常量机器配置不同导致实际响应时间差异程序可能在“假死”后被用户强行关闭。解决把深度参数做成运行时配置启动时判断time.time()计算单步耗时如果超过两秒就自动降一层深度这种方式比手动调参更适合 tkinter 这类单线程事件循环。人机料法环测放到五子棋上“机”的算力差异才是体验波动最大的变量。6. 把它改成能打的版本难度档位、开局库与悔棋最后一个落地技巧是把 AI 强度做成三个档位而不是让用户面对一个写死的搜索深度。我一般把档位映射到搜索层数和候选数简单档深度 2、候选 6中等档深度 4、候选 8困难档深度 6、候选 12。切换函数在启动弹窗里选择本质上只是给alphabeta传不同的参数比维护多套 AI 代码干净得多。开局库也不复杂维护一个dict键是玩家前 5 手棋的坐标元组值是 AI 的应对坐标查不到就走搜索。这个技巧对先手优势的压制特别明显AI 在前五步永远不落入被动。悔棋功能利用历史栈做状态回退每次落子把(row, col, player)push 进栈悔棋时 pop 两步并把对应棋盘位置清 0重画整个画布即可。给这个方向投入时间之前先想清楚你要什么。如果只是交课程设计评分表 AI 加深度 2 的搜索足够应付演示如果真想写一个自己能讲明白“为什么这步会这么下”的 AI建议专心调校评估函数而不是盲目加大搜索深度——评估函数的质量决定了搜索的上限深度只是把好评估放大成好棋它没法把烂评估变好。我做这类改造的习惯是先保存一份“未改动的原包”备份这类压缩包的伪加密和乱码实在太多留着原包等于留着后悔药。希望帮到你。本文还有配套的精品资源点击获取
返回列表