简介:这是一份用Python实现的AI五子棋项目,面向Python开发者、AI算法初学者以及博弈编程爱好者,重点演示极大极小值搜索与Alpha-Beta剪枝如何从理论变为可运行的人机对战程序。压缩包内共12个文件,包含两个主要Python脚本、一个编译后的pyc文件、项目配置文件(xml/iml)、说明文档(doc/pdf)及gitignore等,整体仅150KB,结构紧凑。其中Python源码覆盖棋盘判定、AI落子、图形界面与主程序入口,配合外文论文和中文文档,可对照学习搜索树展开、局面评估、剪枝条件以及缓存优化等实现细节。已有3016人学习下载,资源轻量但完整,既能作为课程设计或毕业设计的参考,也适合该项目的爱好者下载后直接运行、改写和二次开发。通过实际代码理解Minimax与Alpha-Beta剪枝,比抽象阅读算法描述更直观,尤其适合希望夯实AI基础的学习者。
1. 从零复现一个会下五子棋的AI:极大极小值搜索不是黑匣子
期末要交课程设计,翻了几天资料,最后选中了这份带论文、带源码的Python AI五子棋项目。它不是神经网络那种黑匣子,而是用决策树里最经典的极大极小值搜索(Minimax)配合Alpha-Beta剪枝,让程序在15×15棋盘上和人正经对弈。整份代码结构清晰,适合正在做算法课设、想搞懂搜索策略实战、又不想碰强化学习那么重内容的Python开发者。压缩包里除了主程序还附了一篇英文参考论文和一份中文项目文档,写报告时能直接对着抄结构和公式。下面我把这份资源从算法原理拆到代码实现,再讲调参时最容易翻车的几个位置,你有多少基础都能跟得上。
2. 先立住算法:极大极小值搜索与Alpha-Beta剪枝的实现边界
五子棋是典型的双人零和博弈,AI赢人类就输,棋盘上任意局面都能用一个数值表示“AI赢的可能性”。极大极小值搜索正是基于这个前提:AI回合是MAX层,在所有合法落子里选评估值最大的;人类回合是MIN层,假设对手会选让AI最难受的落子,也就是评估值最小的。两层交替向下展开,直到搜到设定深度或分出胜负。这套逻辑天然适配五子棋,因为它的评估函数好定义——胜负由五连决定,中间局面则用活三、冲四、眠三这些局部棋形近似刻画。相比围棋那种全局性强、局部评估误差大的棋类,五子棋的局部棋形与最终胜负相关度高,所以不需要太深的搜索就能下出“像样”的棋。
一个容易被忽略的前提:Minimax假设对手每一步都回应最优,AI在算的是“最坏情况下我能保住多少”。它不赌对手失误,宁可把对手想得足够强,这就是为什么Minimax搜索出来的棋看起来特别稳,该防一定防,不贪侥幸。理解这一点,后面调评估函数时你才不会疑惑“AI怎么这么怂”。
2.1 MAX/MIN交替:决策树里的攻防角色怎么切换
Minimax的递归结构很固定:每一层先判断当前轮到谁,轮到AI就取子节点分数的最大值,轮到对手就取最小值。递归过程模拟的是双方轮流落子,搜索深度就是往后看了几步棋。
# 伪代码展示核心结构 def minimax(board, depth, is_maximizing, ai_player): if depth == 0 or game_over(board): return evaluate(board, ai_player) if is_maximizing: best = -float("inf") for move in get_moves(board): board[move] = ai_player best = max(best, minimax(board, depth - 1, False, ai_player)) board[move] = 0 return best else: best = float("inf") for move in get_moves(board): board[move] = 3 - ai_player # 对手落子 best = min(best, minimax(board, depth - 1, True, ai_player)) board[move] = 0 return best逻辑说明:max层把分数越推越高,min层把分数越压越低,递归自然形成攻防交替。每次落子后必须复位棋盘,这是回溯的本质,少了这一步搜索树会“污染”。参数说明:ai_player用1表示黑棋、2表示白棋,3 - ai_player就是对手编号;depth是剩余搜索层数,每深入一层减1,归零时调用评估函数。
2.2 Alpha-Beta剪枝:两个边界值如何把搜索量降一个数量级
Minimax的硬伤是复杂度随深度指数增长,分支因子B、深度D时最坏要搜B的D次方个节点。Alpha-Beta剪枝通过维护两个边界值提前放弃没希望的分支:Alpha是MAX层已经确定能拿到的最优下界,初始为负无穷;Beta是MIN层已经确定能接受的最优上界,初始为正无穷。搜索过程中只要beta不大于alpha,当前分支无论再展开多少层都不会影响父节点决策,直接剪掉。
这里的关键认知是:剪枝不改变搜索结果,只改变搜索量。它只是把“算完整棵树再比较”换成“边算边比较,没希望的分支提前终止”。理想情况下配合良好的走法排序,复杂度能从O(B^D)降到约O(B^(D/2)),相当于同样时间多搜一倍的深度。不少初学者以为剪枝是近似优化,其实不是,被剪掉的分支一定不影响根节点的最终选择。
用数字举例:根节点是MAX层,已搜完第一个分支拿到评估值3,Alpha更新为3。接着搜第二个分支,它是MIN层,第一个叶节点返回2。因为MIN会选数值最小的节点,所以这个分支里MIN最多只能拿出2,而MAX已经在别处拿到了至少3的保证,剩下的几十个节点就不用看了,直接剪枝,这就是alpha >= beta发生的瞬间。
2.3 递归效率的隐藏前提:终局检查必须放在入口
实际落地Alpha-Beta时,递归函数入口处要先检查棋盘是否有五连,有就直接返回大分值,不再向下展开。这一条看着简单,实际项目里我见过不少实现把它放到了depth == 0才判断,结果AI在“再走一步就五连”的局面上反应迟钝,问题就出在终局检查的位置太晚。
另一个隐藏细节是评估值的符号统一。整棵搜索树返回的分数都站在AI视角,正值对AI有利,负值对AI不利,符号不能在中途反转。MIN层如果写错方向,比如也取了最大值,AI会表现得像在帮对手走棋。这两个位置是我评估“一个人是不是真懂了Minimax”的快速判断标准,也是后面第4章会展开的两个高频翻车点。
3. 把项目跑起来:文件结构、评估函数与搜索主循环逐段拆解
3.1 压缩包文件清单:先分清主程序和依赖库
解压后第一件事是弄清楚每个文件干嘛的,按我实际跑通的顺序整理成了一张表:
| 文件/目录 | 类型 | 作用 |
|---|---|---|
| GOAI_RUN.py | 主程序 | 游戏循环、AI决策入口、人机交互 |
| graphics.py | 依赖库 | graphics教学绘图库,负责棋盘渲染与鼠标点击 |
| references/wagnervirag_2001.pdf | 参考资料 | 与搜索算法相关的英文论文 |
| 论文内容.doc | 文档 | 项目配套论文,含算法与实现说明 |
| pycache/graphics.cpython-36.pyc | 缓存 | 原环境Python 3.6编译产物,可整个删掉 |
| .idea/ | IDE配置 | PyCharm项目文件,对运行无影响 |
运行顺序很直接:先确认环境有tkinter,然后直接跑GOAI_RUN.py,graphics.py会被当作本地模块导入,不需要pip安装。但如果你当前Python版本和原来的3.6差得远,建议先删掉__pycache__整个目录,让解释器重新生成当前版本的缓存文件。老项目带着旧pyc在新环境里报ModuleNotFoundError的情况太常见了。
3.2 棋盘表示与候选点生成:为什么不该遍历全部361个点
棋盘用15×15的二维列表,0表示空、1表示黑棋、2表示白棋,board[r][c]直接索引,渲染时按坐标换算像素,这个选择最朴素也最好调试。真正影响搜索性能的是候选点生成——如果每层递归都把全盘空点当作分支,深度4时最坏要展开361的4次方量级路径,个人电脑直接卡死。
工程上的常见做法是只取“最后一次落子周围两格以内的空点”作为候选。五子棋的棋子有聚集性,离最近一子太远的点既威胁不到对方,也形成不了自己的攻势,在深层搜索里几乎不会成为最优解。
def generate_moves(board, last_move, radius=2): """生成候选落子:只扫描最近落子周围 radius 格内的空点""" if last_move is None: return [(7, 7)] # 空棋盘时先落天元 moves = set() r0, c0 = last_move for r in range(max(0, r0 - radius), min(15, r0 + radius + 1)): for c in range(max(0, c0 - radius), min(15, c0 + radius + 1)): if board[r][c] == 0: moves.add((r, c)) return list(moves)逻辑说明:last_move是上一手坐标,range边界用max和min裁剪,防止数组越界。返回list而不是set,方便后面排序和遍历。参数说明:radius取2时候选点最多约24个,足够覆盖绝大多数局部攻防;取3搜索量明显上升,适合开局阶段。我见过有人在开局强制全盘搜索、中后盘切回radius=2,效果不错,但要多维护一个阶段判断。
3.3 评估函数:棋形打分是整份代码的“棋感来源”
评估函数决定了AI的棋力上限,也是最容易改出“玄学”效果的地方。不要把它当黑匣子,拆开看就是四步:沿四个方向数同色连子长度、统计两端封堵情况、映射成棋形名称、按棋形累加分数。
SHAPE_SCORE = { "FIVE": 100000, # 五连,直接赢 "LIVE4": 50000, # 活四,对手挡不住 "RUSH4": 5000, # 冲四,逼对手应 "LIVE3": 1000, # 活三,下一步可能成四 "SLEEP3": 200, # 眠三,威胁减半 "LIVE2": 100, # 活二,潜在发展 "SLEEP2": 20, # 眠二 } def count_line(board, r, c, dr, dc, player): """从(r,c)出发沿(dr,dc)双向数同色连子,返回长度和封堵数""" length = 0 blocked = 0 rr, cc = r, c while 0 <= rr < 15 and 0 <= cc < 15 and board[rr][cc] == player: length += 1 rr += dr cc += dc if 0 <= rr < 15 and 0 <= cc < 15 and board[rr][cc] != 0: blocked += 1 rr, cc = r - dr, c - dc while 0 <= rr < 15 and 0 <= cc < 15 and board[rr][cc] == player: length += 1 rr -= dr cc -= dc if 0 <= rr < 15 and 0 <= cc < 15 and board[rr][cc] != 0: blocked += 1 return length, blocked def classify(length, blocked): """把连子长度和封堵数映射成棋形名""" if length >= 5: return "FIVE" if blocked == 0: if length == 4: return "LIVE4" if length == 3: return "LIVE3" if length == 2: return "LIVE2" if blocked == 1: if length == 4: return "RUSH4" if length == 3: return "SLEEP3" if length == 2: return "SLEEP2" return "NONE" def evaluate_board(board, ai_player): """整盘打分:AI总分减去对手总分的加权值""" directions = [(1, 0), (0, 1), (1, 1), (1, -1)] ai_score = 0 human_score = 0 for r in range(15): for c in range(15): player = board[r][c] if player == 0: continue for dr, dc in directions: # 只统计以当前点为起点的连子,避免同一条线重复计数 if 0 <= r - dr < 15 and 0 <= c - dc < 15 and board[r - dr][c - dc] == player: continue length, blocked = count_line(board, r, c, dr, dc, player) shape = classify(length, blocked) if player == ai_player: ai_score += SHAPE_SCORE[shape] else: human_score += SHAPE_SCORE[shape] return ai_score - int(human_score * 1.2)逻辑说明:evaluate_board扫描所有非空格子,沿横、竖、两条对角线统计连子。起点判断是关键,当前格子的前一个位置不是同色棋子时才把它当作连子起点,防止同一条五连线被重复计分。参数说明:human_score乘1.2是把对手威胁放大两成,让AI防守更敏感;想更激进就调低这个系数,想更稳健就调高。这是整份代码里性价比最高的调参位置。
3.4 搜索主循环:递归、剪枝与最佳落子回传
搜索主循环把前面的概念全部串起来。找到最佳走法的逻辑是:先枚举AI这一步的候选落子,每落一子就调用递归搜索评估后续局面,最后取分数最高的那个位置返回。递归内部通过is_maximizing在MAX层和MIN层之间切换,alpha和beta随递归向上传递。
def minimax(board, depth, alpha, beta, is_maximizing, last_move, ai_player): winner = check_winner(board) if winner == ai_player: return 100000 + depth if winner != 0: return -100000 - depth if depth == 0: return evaluate_board(board, ai_player) moves = generate_moves(board, last_move, radius=2) # 走法排序,让alpha-beta尽早遇到好分支 moves.sort( key=lambda m: score_position( board, m[0], m[1], ai_player if is_maximizing else (3 - ai_player) ), reverse=True, ) if is_maximizing: max_eval = -float("inf") for r, c in moves: board[r][c] = ai_player val = minimax(board, depth - 1, alpha, beta, False, (r, c), ai_player) board[r][c] = 0 max_eval = max(max_eval, val) alpha = max(alpha, val) if beta <= alpha: break # 剪枝 return max_eval else: opponent = 3 - ai_player min_eval = float("inf") for r, c in moves: board[r][c] = opponent val = minimax(board, depth - 1, alpha, beta, True, (r, c), ai_player) board[r][c] = 0 min_eval = min(min_eval, val) beta = min(beta, val) if beta <= alpha: break # 剪枝 return min_eval def find_best_move(board, ai_player, last_move, depth=4): best_move = None best_val = -float("inf") for r, c in generate_moves(board, last_move, radius=2): board[r][c] = ai_player val = minimax(board, depth - 1, -float("inf"), float("inf"), False, (r, c), ai_player) board[r][c] = 0 if val > best_val: best_val = val best_move = (r, c) return best_move逻辑说明:check_winner做四方向五连扫描,返回胜方编号;score_position是3.3里评估函数的单点版本,只统计某个候选点对四个方向棋形的贡献,用来排序已经足够。find_best_move枚举AI第一步落子,逐层调用minimax,最后返回评估值最高的落子位置。
参数说明:depth是总搜索深度,我调试时从2、3、4逐级试。depth=2时AI只会应冲四和保活三,相当于入门;depth=4能看到“先活三再反冲四”的两步交换,棋力明显上来;depth=6以上必须依赖候选点裁剪和走法排序,否则单步耗时在普通笔记本上会超过10秒。搜索结束后递归落子必须复位成0,漏掉这一步棋盘上会出现“幽灵棋子”,AI后半盘会突然乱下。
提示:调试时如果发现AI下棋时快时慢,优先查两处——递归里board是否正常回退,以及走法排序是否真的生效。这两处占了我此前调这个项目时踩坑的七成。
4. 调参避坑:深度、候选点与UI层的五个常见问题
4.1 落子慢得像卡死:候选点和深度先查这两处
现象:AI每一步要算十几秒,看起来像程序无响应。
原因:搜索深度设太高,同时候选点没有裁剪。depth=4配全盘候选点时,状态数直接爆炸,个人电脑基本不可用。某些实现还在递归里重复调用全盘扫描的get_moves,更是雪上加霜。
解决:把generate_moves限定为radius=2的局部候选,再把find_best_move的depth从2开始向上调。我给自己定的约束是:单步搜索超过2秒就降一档深度。同时在每层搜索入口打印当前候选点数量,如果始终超过100个,说明候选生成逻辑漏了裁剪。
4.2 AI只会攻不会守:评估函数漏了对手分数
现象:AI每次都往自己的冲四处落子,对手都活三了也不去堵,整盘棋像在自嗨。
原因:评估函数只统计了AI一方的棋形分数。五子棋是零和博弈,最好的进攻往往就是防守——你在对手活三上堵一下,自己的子同时可能构成反攻。只算单方分数,AI就对对手的威胁视而不见。
解决:把评估函数改成“AI总分 - 对手总分”。乘1.2的系数是攻防倾向的旋钮,想稳就调高,想凶就调低。至少保证对手的冲四、活三分值能抵消你的单方棋形得分,AI才会在进攻和防守之间真正做权衡。
4.3 剪枝没生效:走法排序是剪枝的前置条件
现象:加了alpha-beta后搜索节点数没有明显下降,每步还是很慢。
原因:剪枝效率高度依赖走法排序。如果第一层就把最差的走法排在最前,alpha更新极慢,剪枝基本不触发。很多教程只讲剪枝原理,不讲它有个隐含前提——好走法要先搜。
解决:在展开moves之前,按score_position做一次降序排序。粗糙的启发式排序就能让剪枝效率提升一个数量级。我的实测数据是:不排序时depth=4可能展开上万节点,排序后通常两三千就结束。排序逻辑就是3.4代码块里的moves.sort,别嫌它占那一行耗时。
4.4 graphics窗口打不开:先清理缓存再查tkinter
现象:运行GOAI_RUN.py时直接报错,提示graphics模块导入失败,或者窗口闪一下就消失。
原因:压缩包里的__pycache__/graphics.cpython-36.pyc是Python 3.6的编译产物,在Python 3.10及以上环境里加载旧字节码会失败。graphics.py本身依赖tkinter,如果装的是精简版Python或缺系统组件,导入就会中断。
解决:先删除整个__pycache__目录,让解释器重新生成当前版本的缓存文件。再确认tkinter可用,命令行执行python -c "import tkinter"无报错。Windows用官方安装包默认带tkinter,Linux服务端发行版可能要单独装python3-tk。
4.5 AI主动送棋:MIN层取反和终局检查位置
现象:AI单步看起来正常,但经常主动把棋送到对手的枪口上,比如给对手制造活三机会。
原因:两个典型错误。一是MIN层写错方向,对手回合也取了最大值,等于AI在替对手走棋;二是终局检查放在depth == 0处,搜索深度为奇数或偶数时,对“再走一步就赢”的局面的判断会不一致。
解决:给minimax加一行调试打印,在搜索入口输出当前回合和层数,肉眼确认MAX层对应AI先手、MIN层对应对手。终局检查必须放在递归函数入口处、深度判断之前。再用半小时写一个测试:摆一个已知的“两步杀”局面让AI走,如果AI抓不住,优先怀疑终局检查位置而不是评估函数。
5. 让AI再强一档:走法排序与迭代加深的落地技巧
先说一个反直觉的结论:同样的代码框架里,决定棋力上限的往往不是评估函数写得有多细腻,而是搜索深度和剪枝效率的配合。评估函数粗糙一点没关系,只要搜索多一层,AI就能多算一步交换,棋力立刻上一个台阶。所以最后这两个技巧都围绕“限定时间内搜得更深”展开。
第一个技巧是迭代加深。与其固定depth=4,不如从depth=2开始逐层加深,每层之间检查耗时,超时就把上一层的最佳走法返回。这么做还有一个副产品:上一层算出的最佳走法会作为下一层排序的首选,因为浅层和深层搜索的最优走法高度相关,第一分支就命中好走法时,alpha-beta收敛会非常快。代码就是包一层循环:
def find_best_move_iterative(board, ai_player, last_move, max_time=2.0): start = time.time() best = None for depth in range(2, 8): candidate = find_best_move(board, ai_player, last_move, depth=depth) best = candidate if time.time() - start > max_time: break return best逻辑说明:每完成一层就暂存一份结果,超时后直接返回上一深度算出的走法。它不追求单层最优,只保证在限定时间里把当前搜得最深的一步交还给交互逻辑,下棋过程不会出现让人干等的长停顿。
第二个技巧是给排序函数加攻防权重。许多实现里的排序只按当前玩家视角打分,导致AI在攻防转换时慢半拍。我的做法是对AI和对手的候选点分别打分,取加权差作为排序键,让搜索层优先看“同时威胁对手”的着法。排序不需要精确计算,它只影响剪枝效率,所以别在这一步消耗太多开销。
如果你打算拿这份资源交课程设计,我建议把评估函数单独拆成模块,论文里附两张搜索节点统计图,一张是不排序depth=4的数据,一张是排序后的。这组对比是Alpha-Beta剪枝价值最直观的证明,答辩时也最好讲。从那以后,我每次改完评估函数都要先跑一组AI先后手各20手的对照,记录每手耗时和剪枝命中率,再谈增强。希望这个习惯和你这次跑通的代码都能帮到你。
本文还有配套的精品资源,点击获取