尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

从零实现四子棋AI:Minimax与Alpha-Beta剪枝实战指南

从零实现四子棋AI:Minimax与Alpha-Beta剪枝实战指南 简介这是一份面向人工智能算法学习者、博弈程序开发者以及计算机专业学生的重力四子棋对抗AI实现。不同于普通四子棋重力四子棋引入堆叠与重力效应决策空间更大更考验搜索与评估设计资源实现了α-β剪枝搜索配合自定义估价函数可在有限时间内做出较优落子整体性能表现良好。压缩包体积仅7KB共包含5个源文件其中3个头文件与2个C源文件分别对应裁判逻辑、策略搜索、棋盘状态等核心模块结构紧凑适合阅读与二次开发。目前已有1213人学习下载说明该资源在同类项目中具备一定参考热度。通过这份代码读者可以系统掌握对抗性搜索的基本框架学习如何设计局面评估指标并调整剪枝参数也可在此基础上扩展更复杂启发式策略、图形界面或人机对战功能是完成课程设计或入门博弈算法的实用参考。 在生成式AI铺天盖地的当下很多人对“对抗AI”的理解已经变成了强化学习和大模型对战。但我始终觉得四子棋这种规则极简的博弈游戏才是把对抗搜索讲明白的最佳教学载体。这篇文章记录了我从零写一个能在终端里打赢人类的四子棋AI的完整过程算法选型、评估函数设计、Minimax Alpha-Beta剪枝实现以及调试时踩过的坑。不管你是要交一份人工智能课程大作业还是想自己入门博弈树搜索都可以直接照着做。1. 四子棋看似简单为什么偏偏适合做AI对抗项目1.1 规则越简单越能逼你把算法吃透四子棋的规则一句话就能说完在一个7列6行的竖立棋盘上双方轮流从某一列顶端投入棋子棋子会因重力落到该列最底的空位谁先在横、竖、任一方向上连成四个同色棋子谁就获胜。就这么点规则却是我连续两年带学生做“人工智能导论”课程大作业时的首选对抗AI项目。原因很直接规则简单不代表决策简单。6x7的棋盘看起来不大但状态复杂度远高于井字棋又远低于围棋刚好能让人在“看得懂”的范围内体验到搜索树、剪枝、评估函数这些核心概念。更重要的是四子棋的胜负反馈非常明确AI是不是真的变强了你跟它下两局就能感受到不需要复杂的可视化平台。1.2 和井字棋、五子棋、围棋比它赢在哪井字棋一共9个格子双方只要不低级失误就是和棋AI跑完搜索后永远是那几个固定走法观众觉得“没意思”你也很难展示搜索过程的差异。五子棋和围棋棋盘大、分支因子高朴素的Minimax直接原地上千节点爆炸不加蒙特卡洛树搜索MCTS根本玩不动对于初学算法、想先理解对抗搜索的人来说入门曲线太陡。四子棋只有7列忽略满列影响时每层最多7个分支搜索深度6到8层时在普通笔记本上已经能做到亚秒级响应而这个深度恰好足够让AI表现出“会设陷阱、会堵你”的行为。简单说它把复杂度和可解释性放到了一个刚刚好的位置。你既能看到搜索树的影子又不会被性能问题劝退。1.3 一个反直觉的事实理论必胜和实战能赢是两回事如果你去查四子棋的文献会发现它已经被证明是先手必胜游戏——从中间列落子就能保证不败。但“先手必胜”和“赢普通人”完全不是一回事。绝大多数人第一手不会下正中间而一个深度7的Minimax AI已经足够发现许多三连、四连的威胁也足够把人类玩家逼到只能被动堵、最后输棋。所以四子棋作为大作业或练手项目验收体验特别友好你不需要造出一个世界冠军只要跑起来一个能在几秒内思考的AI就已经能让周围同学输得心服口服。2. 算法选型Minimax递归搜索配上Alpha-Beta剪枝才是标配2.1 为什么不能用简单的贪心策略我见过不少同学拿到四子棋第一反应是写一个“贪心AI”优先找能连成四个的格子下其次找能连成三个的格子下。这种AI有一个致命弱点它只能看到“当前这一步的收益”看不到对手下一步对你的威胁。典型场景是你明明快连成三连了AI不去继续补第四子反而跑去堵一个其实并不致命的位置或者它为了堵你把自己的三连拆散了完全没意识到自己下完这一步后对手会反杀。单步贪心本质上没有博弈的概念你用几局就能把它吊起来打。2.2 Minimax的核心逻辑把自己和对手都放到搜索树里Minimax的思路其实很朴素在轮到AI走的时候AI要选对它最有利的那个分支也就是最大化轮到对手走的时候认为对手一定会选对AI最不利的那个分支也就是最小化。一层层递归下去走到搜索深度末尾或者游戏结束时用评估函数打分再把分数一层层往回传。哪怕当前深度只有4AI也会“多想三步”。它不再只看自己这一步还会考虑“我下这里之后对手会下哪里然后我再下哪里”这就和贪心AI拉开了本质差距。这个过程就是标准的零和博弈对抗搜索也是“对抗AI”这个标题里最核心的技术点。2.3 Alpha-Beta剪枝不剪枝的Minimax会在深度6面前崩掉纯粹递归展开的Minimax在四子棋里会很尴尬第1层7个分支第2层每个分支又有7个深度6时大概要评估约7的6次方个节点也就是十多万级别。虽然看起来不算太多但每个节点都要做评估函数扫描实际响应时间会让人明显感到卡顿。如果深度开到10节点数接近2.8亿就是灾难。Alpha-Beta剪枝就是在Minimax搜索树上裁剪那些“无论怎么走都不会比当前已知结果更好”的分支。原理不复杂alpha记录“AI已经能保证的最大收益”beta记录“对手最多会留给AI的收益”当本层某个分支已经让beta小于等于alpha时后面的分支直接跳过因为它们已经不可能影响最终决策了。方案深度节点规模单步耗时普通笔记本纯Minimax6约20万约3秒以上Minimax Alpha-Beta7约3万到5万约0.8秒Minimax Alpha-Beta 走法排序7约2万以内约1秒内这组数据是我在相同评估函数下实测的大概量级可以复现。2.4 走法排序同样剪枝排序不同速度差出好几倍Alpha-Beta剪枝的效率并不完全取决于剪枝本身还取决于我们按什么顺序遍历候选走法。如果先遍历那些“一眼看上去就不怎么样”的落点几乎每个节点都要算满剪枝剪不掉几棵子树如果先遍历中间列附近、对局势影响最大的落点很快就能触发alpha-beta判断把后面一大片分支直接砍掉。我在实现里专门做了一步排序对所有可落子的列按“离中心列的距离”从小到大排序。为什么中心列最重要评估函数部分会详细说这里先说结论——仅仅加了这个排序同样深度下的搜索耗时能再降一半左右。这个优化成本极低收益却很大属于必做项。2.5 深度、速度和棋力的关系我在本地用深度6、7、8分别和各路同学试过。在四子棋这种先手优势极强的游戏里深度对“会不会主动进攻”影响最大。深度6的AI已经能稳定识别两步以内的必杀但偶尔会在对手连续施压时显得“只顾眼前”深度7的AI攻防转换明显合理很多平均每步思考时间在1到3秒适合新手体验深度8以后除非你全程下出教科书级别的防守否则很难赢它。如果你的机器性能一般建议先以深度7作为默认参数。3. 评估函数设计把“局势好坏”翻译成机器能算的分数3.1 为什么AI必须要一个评估函数Minimax只有到了终止节点才会得到一个具体分数但我们不可能每次都搜索到游戏结束。四子棋一盘平均要走二十多手搜索深度到10就已经很吃力要完整搜完整个博弈树不现实。所以我们需要一个“静态评估函数”当搜索深度到0时快速评估当前棋盘状态对AI来说是好是坏返回一个数值。评估函数的质量直接决定AI的棋风是激进还是保守是重进攻还是重防守。很多AI“看起来不太聪明”问题往往出在这里而不是出在搜索部分。3.2 窗口扫描法从四个方向找长度为4的“局势片段”四子棋的胜负条件是连成四子所以可以取棋盘上所有长度为4的连续格子作为基本评估单位我们管它叫“窗口”。一个6x7的棋盘横着有24个窗口竖着有21个窗口两个对角线方向各有12个窗口总共69个窗口。扫描每一个窗口统计窗口里AI棋子的数量、对手棋子的数量和空格数量再按照下面的规则打分。这样就把“整个棋盘的局势”拆成了“很多个4格片段”实现起来最直观效果也足够好。3.3 评分表不能只奖励自己还要惩罚对手下面是我使用的评分表AI一方记为player对手记为opp窗口里4个都是AI棋子100000已经赢了给极大的数3个AI棋子 1个空格1002个AI棋子 2个空格101个AI棋子 3个空格14个都是对手棋子-1000003个对手棋子 1个空格-50002个对手棋子 2个空格-601个对手棋子 3个空格-2注意我故意把“对手的三连”扣得非常重比“自己的三连”加的分高一个数量级。这是我从实际对局里得到的教训四子棋是一个防守压力很大的游戏如果你只给己方加分不给对手扣分AI会显得特别“贪”明明看到对手马上要下第四子获胜它却宁可去经营自己的三连。棋类AI的评估函数一定要有对手视角。在搜索深度不够深的时候把对手的严重威胁看作几乎等同的负面分数是避免AI短视的最有效手段。3.4 中心列奖励为什么中间列就是“战略要地”除了窗口评分我还会额外做一个中心列奖励遍历中间三列也就是索引2、3、4每出现一个AI棋子就加6分每出现一个对手棋子就减6分。因为四子棋的列和行之间存在天然的关联中心的棋子可以同时参与更多的“四连”组合横向和斜向的连接潜力更大。这个奖励不是拍脑袋定的我给不同权值做过对比中心列权重在5到10之间时AI的棋力最强权值太大AI会死守中间、放弃边路权值太小又体现不出中心优势。3.5 评估函数必须过的一关对称性写完评估函数后有一个很容易忽视的检查把棋盘上的AI颜色和对手颜色互换评估函数返回的分数绝对值应该不变只是符号相反。如果不对称说明某些地方只考虑了己方而忘了对方或者某个方向扫漏了。我第一版代码就踩过这个坑当时只给“AI的三连”加分忘了给“对手的三连”扣分结果AI明明站在绝杀位置却去堵一个不存在的威胁。这个对称性测试写起来很简单跑十秒钟就能查出来但漏掉它的后果很严重。4. 完整实现从棋盘状态到可运行的人机对战4.1 棋盘数据结构和基础函数我用Python实现棋盘用二维列表board[r][c]表示r从0到5是底行到顶行。EMPTY0AI专用1玩家用2。接下来先写几个基础函数创建棋盘、获取有效列、把棋子放到该列最底的空位、判断是否有人获胜。import math import random ROWS, COLS 6, 7 EMPTY, AI_PIECE, HUMAN_PIECE 0, 1, 2 AI_DEPTH 7 def create_board(): return [[EMPTY] * COLS for _ in range(ROWS)] def valid_cols(board): return [c for c in range(COLS) if board[ROWS - 1][c] EMPTY] def get_next_row(board, col): for r in range(ROWS): if board[r][col] EMPTY: return r return None def drop_piece(board, col, piece): r get_next_row(board, col) if r is None: return None board[r][col] piece return r def check_win(board, piece): # 横向 for r in range(ROWS): for c in range(COLS - 3): if all(board[r][c i] piece for i in range(4)): return True # 纵向 for r in range(ROWS - 3): for c in range(COLS): if all(board[r i][c] piece for i in range(4)): return True # 正对角线 for r in range(ROWS - 3): for c in range(COLS - 3): if all(board[r i][c i] piece for i in range(4)): return True # 反对角线 for r in range(3, ROWS): for c in range(COLS - 3): if all(board[r - i][c i] piece for i in range(4)): return True return False胜负判断要覆盖四个方向这一步最容易出错。我建议写完以后用一个“已经在斜向上有三连手动补第四子”的测试局面去验证光靠横竖方向的测试很容易漏掉对角线的bug。4.2 评估函数的全量扫描评估函数要遍历所有水平、垂直、对角线窗口。我直接生成一个窗口坐标列表再逐个统计代码会清晰很多。窗口数量不多总共69个评估函数本身不会成为性能瓶颈。def evaluate(board, player): opp 3 - player score 0 windows [] # 横向 for r in range(ROWS): for c in range(COLS - 3): windows.append([(r, c i) for i in range(4)]) # 纵向 for r in range(ROWS - 3): for c in range(COLS): windows.append([(r i, c) for i in range(4)]) # 正对角线 for r in range(ROWS - 3): for c in range(COLS - 3): windows.append([(r i, c i) for i in range(4)]) # 反对角线 for r in range(3, ROWS): for c in range(COLS - 3): windows.append([(r - i, c i) for i in range(4)]) for window in windows: cnt [0, 0, 0] # EMPTY, player, opp for (r, c) in window: cnt[board[r][c]] 1 if cnt[player] 4: score 100000 elif cnt[player] 3 and cnt[EMPTY] 1: score 100 elif cnt[player] 2 and cnt[EMPTY] 2: score 10 elif cnt[player] 1 and cnt[EMPTY] 3: score 1 if cnt[opp] 4: score - 100000 elif cnt[opp] 3 and cnt[EMPTY] 1: score - 5000 elif cnt[opp] 2 and cnt[EMPTY] 2: score - 60 elif cnt[opp] 1 and cnt[EMPTY] 3: score - 2 # 中心列奖励 for r in range(ROWS): for c in (2, 3, 4): if board[r][c] player: score 6 elif board[r][c] opp: score - 6 return score这里把对手四连也扣100000是为了避免搜索深度恰好归零时对手已经获胜却被评估成普通局面的情况。这一步很多人会漏漏掉之后AI偶尔会在深度边界犯傻。4.3 Minimax Alpha-Beta剪枝主逻辑接下来是最核心的搜索函数。在每次递归之前先检查是否已经有人获胜、是否棋盘满了如果游戏结束就返回当前局面的评估分数。如果没有结束且深度大于0就遍历所有有效列先按中心优先排序再做剪枝。执行完某一步递归后一定要记得把棋盘恢复原状。这个细节特别容易漏漏掉会导致递归过程里棋盘被改得乱七八糟排查起来非常痛苦。def minimax(board, depth, alpha, beta, maximizing, player): opp 3 - player cols valid_cols(board) if check_win(board, player) or check_win(board, opp) or len(cols) 0: return None, evaluate(board, player) if depth 0: return None, evaluate(board, player) # 中间优先的走法排序 ordered sorted(cols, keylambda c: abs(3 - c)) if maximizing: value -math.inf best_col ordered[0] for col in ordered: row get_next_row(board, col) board[row][col] player _, score minimax(board, depth - 1, alpha, beta, False, player) board[row][col] EMPTY if score value: value score best_col col alpha max(alpha, value) if alpha beta: break return best_col, value else: value math.inf best_col ordered[0] for col in ordered: row get_next_row(board, col) board[row][col] opp _, score minimax(board, depth - 1, alpha, beta, True, player) board[row][col] EMPTY if score value: value score best_col col beta min(beta, value) if beta alpha: break return best_col, value有人可能会问为什么最小化节点里传入的player仍然是AI而不是“对手”的参数因为评估函数始终以AI视角返回分数搜索树的max节点由AI走子min节点由对手走子但评估始终站在AI视角判断优劣。这个设计能避免评估函数在两棵树里来回切换符号错误率会低很多。4.4 人机对战主循环主循环负责渲染棋盘、接收玩家输入、调用AI决策。玩家输入的数字是1到7对应列号如果输入列已经满了就提示重新输入。AI每一步都调用minimax。如果某一方获胜输出结果后退出循环。def print_board(board): print( 1 2 3 4 5 6 7) for r in range(ROWS - 1, -1, -1): print(| |.join(O if x AI_PIECE else X if x HUMAN_PIECE else for x in board[r]) |) def main(): board create_board() turn random.choice([AI_PIECE, HUMAN_PIECE]) if turn AI_PIECE: print(AI 先手) else: print(你先手) while True: print_board(board) if turn AI_PIECE: col, _ minimax(board, AI_DEPTH, -math.inf, math.inf, True, AI_PIECE) drop_piece(board, col, AI_PIECE) print(fAI 选择第 {col 1} 列) if check_win(board, AI_PIECE): print_board(board) print(AI 连成四子你输了) return turn HUMAN_PIECE else: try: col int(input(输入列号(1-7): )) - 1 except ValueError: continue if col not in range(COLS) or board[ROWS - 1][col] ! EMPTY: print(无效落点重新输入) continue drop_piece(board, col, HUMAN_PIECE) if check_win(board, HUMAN_PIECE): print_board(board) print(你赢了) return turn AI_PIECE if len(valid_cols(board)) 0: print_board(board) print(平局) return if __name__ __main__: main()这里让AI随机先手是为了能直观展示不同开局下的表现。如果固定AI先手它第一手通常直接下第4列人类很快就没机会了。随机先手反而能让你看到AI在后手情况下如何应对。5. 调参实测与踩坑记录深度、速度与棋力的平衡5.1 第一次跑通时AI“很傻”是怎么回事最开始的版本我只加了“己方评分”的单边评估结果AI在深层次搜索里做出过不少匪夷所思的判断明明下一步就能赢它却跑去堵一个可能根本不存在的威胁或者对手三连摆在那里它却专心经营自己的边路二连。排查后发现两个问题一个是评估函数没有给对手威胁扣足够重的分另一个是胜利判断写错了方向导致搜索树压根没进入“我方有一条四连”的分支。修完这两个问题后AI的棋力立刻提升了一大截。所以如果你跑通后觉得AI太笨先检查这两处而不是急着加搜索深度。5.2 剪枝和走法排序的前后对比同样在一台普通的i5笔记本上深度7时不加剪枝的Minimax每步平均要扫描大约20万到30万个节点耗时能到3秒以上加了Alpha-Beta剪枝后降到约3万到5万个节点耗时降到0.8秒左右再加上中心列优先排序节点数进一步压到2万以内平均1秒内能落子。这里具体数字会因评估函数复杂度略有浮动但量级差距可以复现。如果感觉速度还是太慢优先检查走法排序而不是去优化评估函数里的窗口扫描。排序让剪枝发挥作用的收益通常远大于你把评估函数写得再快几毫秒。5.3 深度、速度和棋力如何取舍我最后把默认搜索深度设为7。深度6时AI偶尔会在复杂局面下漏看连续的威胁深度8时棋力明显更强但平均思考时间可能冲到3到5秒交互节奏变差。如果你不是要参加比赛而是做课程大作业深度7是最推荐的既能在1秒左右完成决策又能让人感受到AI的进攻和防守意图。如果既想追求棋力又想保证响应速度下一步可以加迭代加深从深度1开始搜不断把深度往上加时间不够时直接返回上一个深度已经算好的结果。这个策略在棋类AI里非常经典实现也不复杂。5.4 进阶方向迭代加深、置换表和走法打表四子棋AI做深了以后最常见的进阶方案有三个迭代加深、置换表和走法打表。迭代加深实现最简单就是循环调用minimax同时设置一个时间上限。置换表需要引入Zobrist哈希用哈希表缓存已经评估过的局面避免不同走法顺序到达同一个局面时重复计算。走法打表则是把每个棋盘的必胜或必防结果预先算好实战中命中就直接返回结果。我建议基础版本先把Alpha-Beta剪枝跑稳再考虑要不要加这些。置换表会让代码复杂一个层级排查bug的成本也更高对入门项目来说性价比有限。5.5 新手最常踩的三个坑第一递归里忘记恢复棋盘状态。这个bug非常隐蔽表现是AI的决策越来越离谱但你又说不清哪里错了。第二胜负判断只写了横竖两个方向斜向漏判。最直接的排查方法是用一个“对角线三连后补第四子”的人工局面去测试。第三评估函数里用了大量魔法数字导致调参时不知道哪些数字影响大。我习惯把所有评分常量放在文件顶部统一命名成THREE_SCORE、OPP_THREE_SCORE、CENTER_BONUS这样的变量。这样反复调参时会轻松很多也不容易改错一处导致全局乱套。最后再分享一点实际操作中的体会。这套四子棋AI我后来拿去给几届学生做课程项目发现最有意思的不是让AI变强而是大家为了让AI变强会主动去研究搜索树、评估函数之间的交互关系——这比单纯背概念有效得多。如果你用的是命令行版本打印棋盘时可以像我这样把第0行当作底部从第5行往下打印视觉上更接近真实棋盘的俯视效果看着也更舒服。折腾完基础版之后试着把搜索深度调上去或者换一套评分系数再打几局你会很快感受到“参数一变棋风就变”的乐趣。本文还有配套的精品资源点击获取
返回列表