简介:一份面向人工智能算法学习者的重力四子棋对抗AI核心实现,聚焦人机对弈与搜索决策。资源包共五个文件,其中三个头文件负责类声明与数据结构,两个C++源文件实现搜索与估价逻辑,压缩后大小仅七KB,代码十分简洁精炼。该实现采用α-β剪枝技术,配合精心设计的局面估价函数,可在有限深度内快速评估落子收益,具备不错的对战性能,适合作为博弈算法实践的参考范本。对于正在做课程设计、参加智能博弈竞赛或研究棋类AI的开发者而言,这份代码提供了可直接运行参考的完整方案,有助于理解剪枝优化、启发式评估以及落子策略的工程化写法。目前已有1219人学习下载,适合具备一定C++与算法基础、希望动手实践博弈树搜索的读者深入研读。
1. 四子棋对抗 AI:这台终端里的机器,靠什么陪你下完一盘棋
第一次写完这个四子棋对抗 AI 的时候,我犯了一个特别蠢的错误:评估函数把玩家得分当成了 AI 得分,结果 AI 眼争争看着对手凑成三连不堵,专门去填自己那边的空位。改完符号之后它才真正会“防守”。
这个项目就是这样一个纯终端运行的四子棋对抗 AI:6 行 7 列棋盘,棋子受重力落到当前列的最低空位,人和 AI 轮流落子,AI 用 Minimax 搜索加 Alpha-Beta 剪枝选择落点,默认提供三档难度。整份工程不依赖任何第三方库,拿到源码就能跑,适合想拆开看出博弈搜索到底是怎么下棋的开发者,也适合拿来当自己练习 AI 改造的底子。如果你以前只写过五子棋的规则判断,那可以从这个项目的搜索链路里体会到“为未来几步提前计分”这种事怎么做。
2. 架构与模块边界:Board / AI / Evaluate 三层怎么分工
2.1 技术选型与项目定位
这个项目我把它定位成“教学型可运行的棋类 AI 底座”,所以技术选型上刻意避开了重型框架。棋盘用二维列表存储,玩家输入用标准库 input(),终端渲染只做字符拼装。这样做的直接好处是:你不需要配置任何环境,解压之后 python main.py 就能开始下棋。
很多人在做棋类 AI 时会习惯性地引入 Pygame 或者图形界面库,但实际上对于四子棋这种规则粒度的游戏,图形界面只会让“搜索逻辑”被一堆渲染代码遮盖住。在这个项目里,渲染层被压缩到了 renderer.py 一个文件,职责只有一件事:把棋盘对象转成字符串打印到终端。AI 的搜索模块和界面模块完全隔离,改动界面不会影响到搜索逻辑。
配置参数集中在 config.py,棋盘行数、列数、连子数、搜索深度都在那里定义。项目内部约定 0 表示空位,1 表示玩家棋子,2 表示 AI 棋子,整个工程所有模块都遵循这一个约定,避免到处传魔法数字。
2.2 目录结构与数据流
connect4_ai/ ├── main.py # 程序入口:难度选择、对局主循环 ├── board.py # 棋盘初始化、重力落子、胜负判定 ├── ai.py # Minimax 搜索与 Alpha-Beta 剪枝 ├── evaluate.py # 棋盘评估函数 ├── renderer.py # 终端渲染,打印棋盘 └── config.py # 行列数、连子数、搜索深度等常量模块之间的数据流非常单向:main.py 拿到玩家输入列号后调用 board.py 的落子函数,把新棋盘传给 ai.py 去算 AI 的落点,AI 返回一个列号后再次调用 board.py 落子,最后用 renderer.py 打印棋盘,继续循环。
board.py 不感知 AI 的存在,它只提供棋盘操作能力。ai.py 也不关心棋子是打印在终端还是绘制在窗口里,它接收一个二维数组,返回一个列号。这种依赖倒置让单元测试很好写:我直接构造一个棋盘状态,调用 ai.choose_best_move(),检查返回的列号是否符合预期,完全不需要启动整个对局。
2.3 为什么选 Minimax + Alpha-Beta 这套组合
四子棋的博弈树规模比五子棋小很多,但依然不能穷举。以 7 列棋盘为例,如果搜索深度拉到 5 层,每一层都有最多 7 个分支,最坏情况要评估 16807 个叶子节点,这个量级刚好在 Python 的舒适范围内。
Minimax 本质上是在模拟“双方都作出最优选择”的过程:AI 走一步时选对自己最有利的落点,对手回一步时选对 AI 最不利的落点。每一步的收益都由评估函数给出,这样就避免了对未来局势的粗暴估计,让 AI 具备“前瞻”能力。
Alpha-Beta 剪枝的意义则是减少无效分支。如果在某一层已经发现某个分支的最差结果比上层已经确定的下界还差,那这个分支剩下的部分不用再看了,直接剪掉。合理使用剪枝之后,同样深度下搜索耗时能下降一半以上,这也是这个项目不依赖位置记忆表也能跑得动多层搜索的原因。
3. 核心算法拆解:Minimax 搜索与 Alpha-Beta 剪枝的联合链路
3.1 棋盘状态与可落子列判断
整个搜索过程都建立在棋盘状态之上,所以先把落子合法性处理好。四子棋的重力规则意味着,不能凭空把棋子放到任意格子,你只能选择一列,棋子会掉到该列最低的空位。因此,判断一列能不能落子,只需要看该列最上面一行是否为空。
def get_valid_moves(board, rows=6, cols=7): # 遍历每一列,如果顶部格子为空说明这一列还能落子 valid_moves = [] for col in range(cols): if board[0][col] == 0: valid_moves.append(col) return valid_moves这个实现非常朴素,时间复杂度 O(cols),每次搜索都会调用,因为落子之后棋盘变化了,可落子列集合也要重新计算。
示例中传入的 board 是一个 6 行 7 列的二维列表,board[0] 是棋盘最顶部。之所以查 board[0][col] 而不是 board[rows-1][col],是因为四子棋的重力机制决定了新棋子总是落到最低处,顶部一旦被占满,这一列就再也不能落子了。如果你在处理这类逻辑时误查了底部,就会出现“已经满的列还能落子”或者“空列永远显示不能落子”的问题。
3.2 Minimax 递归搜索的实现细节
Minimax 的核心是递归地把“当前局面”的价值计算交给下一层。AI 层取最大值,对手层取最小值。递归出口有两个:一是搜索深度耗尽,二是当前局面已经分出胜负。
from math import inf from evaluate import evaluate_board def minimax(board, depth, alpha, beta, maximizing, ai_piece, human_piece): winner = check_winner(board) if winner is not None or depth == 0: return evaluate_board(board, ai_piece, human_piece) valid_moves = get_valid_moves(board) if maximizing: value = -inf for col in valid_moves: new_board = drop_piece(board, col, ai_piece) value = max(value, minimax(new_board, depth - 1, alpha, beta, False, ai_piece, human_piece)) alpha = max(alpha, value) if alpha >= beta: break return value else: value = inf for col in valid_moves: new_board = drop_piece(board, col, human_piece) value = min(value, minimax(new_board, depth - 1, alpha, beta, True, ai_piece, human_piece)) beta = min(beta, value) if alpha >= beta: break return value这段代码是整个项目最关键的部分。注意落子的对称性:在 maximizing 层,落的是 AI 自己的棋子;在 minimizing 层,落的是对手的棋子。这样才能模拟出“你走一步,我走一步”的交替过程。
调用时 depth 表示从当前局面还能往下推演多少步。depth=4 代表 AI 考虑“自己一步、对手一步、自己一步、对手一步”之后的局面,所以在决定当前落点时,实际要考虑的是执行完这四步之后的评估结果。把 depth 设成奇数或者偶数会直接影响 AI 的防守倾向,这一点在后面避坑章节会专门展开。
3.3 Alpha-Beta 剪枝:让搜索层数翻倍的关键
剪枝逻辑已经写在上面代码的 alpha、beta 更新里了。简单来说,alpha 是 AI 目前能保证的最差收益下限,beta 是对手目前能接受的最好收益上限。当 alpha 不小于 beta 时,说明当前分支继续搜下去没有意义,直接 break。
if alpha >= beta: break这段代码在搜索层数较大的时候特别重要。没有剪枝时 depth=5 的最坏情况要评估 16807 个节点,加上剪枝后往往只需要评估三四千个节点,省掉的时间非常可观。
剪枝的前提是分支顺序尽量好。理想情况下,AI 应该先检查“能直接获胜”的列,再检查“能堵住对手获胜”的列,最后才看普通落点。这样剪掉的分支会更多。我把这个排序放在了 choose_best_move 里,用一个小规则实现:先按是否有连成四子的机会排序,再按列中心距离排序,效果比按自然列序搜索好了不少。
3.4 评估函数:AI 是在替未来计分
每次搜索到叶子节点,都需要一个数值描述“这个局面到底谁占优”。评估函数做的事情就是扫描棋盘上所有连续四格的窗口,统计双方棋子的分布情况。
def evaluate_board(board, ai_piece=2, human_piece=1): score = 0 for row in range(6): for col in range(7): for d_row, d_col in [(0, 1), (1, 0), (1, 1), (1, -1)]: win_positions = [] for step in range(4): r = row + step * d_row c = col + step * d_col if 0 <= r < 6 and 0 <= c < 7: win_positions.append(board[r][c]) if len(win_positions) == 4: score += evaluate_window(win_positions, ai_piece, human_piece) return score四个方向分别对应水平、垂直、主对角线、副对角线。window 里如果同时有 AI 棋子和人类棋子,说明这一组四格已经不可能构成四连,直接计 0 分。
def evaluate_window(window, ai_piece, human_piece): ai_count = window.count(ai_piece) human_count = window.count(human_piece) if ai_count > 0 and human_count == 0: return [0, 1, 10, 50, 10000][ai_count] if human_count > 0 and ai_count == 0: return -[0, 1, 10, 50, 10000][human_count] return 0注意 window 里有四个格子,ai_count 和 human_count 相加不一定是 4,可能有 0 空位。计分思路是:AI 独占窗口时按棋子数量给正分,人类独占时给等量负分。10000 分代表这个窗口已经形成四连,AI 必须优先达成,同时也要优先阻止对手达成。
这个评估函数虽然简单,但它的梯度设计很关键。一个 AI 子 1 分、两个 AI 子 10 分、三个 AI 子 50 分,这不是线性增长,而是让 AI 偏好“形成连续力量”而不是“到处乱铺”。如果你想让 AI 更激进,可以把 50 调成 80;如果想让它更稳,可以把三个子的分值调得低一点。
4. 重力机制与对局主流程:规则层从落子到胜负的完整实现
4.1 重力落子:棋子永远落向最低空位
四子棋和五子棋最大不同就是棋子不能放到任意位置,只能从上往下掉。这个重力机制用代码表达起来很直观:从一列的底部向上扫描,找到第一个空位,把棋子放进去。
def drop_piece(board, col, piece, rows=6): # 从底部往上找空位,模拟棋子受重力下落到最低处 for row in range(rows - 1, -1, -1): if board[row][col] == 0: board[row][col] = piece return True return False # 这一列已经满了这里直接修改了传入的棋盘,没有做拷贝。在正式下棋的主流程里这样写没有问题,因为棋盘就是要被更新的。但在 AI 搜索中不能直接用这个函数,因为搜索需要尝试多个列,如果每个分支都改同一个棋盘,状态就乱了,所以 ai.py 里用了 copy.deepcopy 或者手动浅拷贝一层。
你可以在 drop_piece 前加一个参数控制是否返回新棋盘,我建议的写法是搜索模块自己负责拷贝,board.py 保持最简单的原地修改语义。这样 board.py 的逻辑更清晰,也不会因为隐式拷贝浪费性能。
4.2 胜负判定:四连子的方向扫描
胜负判定和评估函数的扫描方向完全一致,但判定逻辑更严格:一旦发现同一玩家的棋子连续四个,立即返回赢家。
def check_winner(board, rows=6, cols=7, connect=4): for row in range(rows): for col in range(cols): for d_row, d_col in [(0, 1), (1, 0), (1, 1), (1, -1)]: last_row = row + (connect - 1) * d_row last_col = col + (connect - 1) * d_col if not (0 <= last_row < rows and 0 <= last_col < cols): continue first_piece = board[row][col] if first_piece == 0: continue win = True for step in range(1, connect): if board[row + step * d_row][col + step * d_col] != first_piece: win = False break if win: return first_piece return None这段代码返回 1 代表玩家获胜,2 代表 AI 获胜。主循环拿到这个返回值后,根据 winner 是 1 还是 2 输出胜负信息。注意这里没有返回平局的逻辑,因为四子棋在棋盘填满但没有四连时也存在平局,你可以在主循环里判断 valid_moves 为空且 winner 为 None 时输出平局。
4.3 对局主循环与难度档位
主流程不需要太多花样,一个 while 循环就能跑完一整盘棋。每轮先打印棋盘,再让玩家输入列号,然后检查玩家是否赢,最后让 AI 落子,再检查 AI 是否赢。
def play_game(depth=4): board = create_board() print_board(board) while True: # 玩家回合 col = int(input("你想落在哪一列 (0-6): ")) if not drop_piece(board, col, HUMAN_PIECE): continue if check_winner(board) == HUMAN_PIECE: print("你赢了") break # AI 回合 ai_col = choose_best_move(board, depth) drop_piece(board, ai_col, AI_PIECE) print(f"AI 落在第 {ai_col} 列") print_board(board) if check_winner(board) == AI_PIECE: print("AI 赢了") break if not get_valid_moves(board): print("平局") break深度的档位就放在 main.py 里做映射:深度 2 对应简单模式,深度 4 对应普通模式,深度 6 对应困难模式。实际测试中深度 6 已经能让 AI 在 5 秒内做出决策,再往上涨就需要进一步优化评估函数了。
5. 避坑记录:调四子棋 AI 时最常翻车的四个细节
5.1 AI 放着能赢的棋不走,先去堵对手的三连
现象:棋盘上有一列落下去 AI 就能四连胜,但 AI 偏偏选了旁边一列去挡人类的三连,最后被人类反杀。翻车原因出在评估函数和搜索深度的配合上。如果 depth 是偶数,搜索到叶子节点时最后一步是“人类走的”,评估函数会把人类形成的三连当作很强的负面信号,AI 便优先去堵而不是去赢。
原因:Minimax 在偶数深度下评估的是对手刚走完的局面,AI 自己的“即将赢棋”状态出现在 depth 为奇数的那一层,等于 AI 没有机会看到自己胜势那一层的价值。
解决:最简单的方法是把 choose_best_move 里的搜索层数固定为奇数,比如 depth=5 而不是 depth=4。另外,可以在评估函数里单独加一个“如果有任何一方已经等于三连且第四格为空,立刻给最高分”,这样无论奇偶深层都能正确应对。我在实际项目中同时做了这两件事,AI 才真正做到“既要赢也要防”。
5.2 搜索深度调高之后,落子变得越来越慢
现象:从 depth=3 调到 depth=6 后,每一步棋要等十几秒,完全没法正常对局。最初我以为只是节点指数爆炸,后来发现每次递归都在对棋盘做深拷贝,Python 里复制一个 6x7 的二维列表居然成了最昂贵的部分。
原因:minimax 函数里每次模拟落子都用 copy.deepcopy(board),deepcopy 对嵌套结构的遍历开销很大。原本不剪枝时节点就多,再叠加大规模复制,性能当然扛不住。
解决:我把 deepcopy 换成了手动浅拷贝。因为棋盘是一个二维列表,每行都是一个独立的内层列表,落子只会修改某一行的一个元素,所以只需要复制外层列表,再复制被修改的那一行即可。修改后的 drop_piece 变成了返回新棋盘,而正式对局用的还是原地修改版本。这个改动让同样深度的搜索快了大约三倍。
提示:如果你打算继续加深搜索,可以考虑在子节点比较时使用 Zobrist 哈希做一个局面缓存,把重复到达的局面直接返回缓存结果,这比盲目加剪枝节省的时间更明显。
5.3 同分局面下 AI 反复横跳,像两个人格在打架
现象:连续几步棋,AI 每次都在两个列之间摇摆不定,这一局和下一点落点完全相反,看起来毫无稳定性。实际查看日志后发现,这两个列的评估分数一样,choose_best_move 里用“严格大于”才更新最优列,就会保留第一次遇到的那一个。
原因:评估函数的粒度太粗,很多中间局面的得分完全相同,Minimax 返回的值区分不出哪个落点更优,最终结果取决于 for 循环先遍历到哪一列。
解决:我在 evaluate_window 里给中心两列额外加了一个很小的中心奖励分,比如每靠近中心一格加 0.5 分。这样 AI 在同分时倾向占据中央区域,因为中央落子能参与更多方向上的四连组合。如果你想保留多种可能,也可以在 choose_best_move 里把最高分对应的所有列收集起来,用随机选一个,让对局更有变化。
5.4 玩家输入非数字或者输入 8,程序直接崩溃
现象:拿着程序演示时手滑按了一个字母,控制台直接抛 ValueError 就退出了。虽然代码逻辑本身没问题,但用户容忍度很低,这种细节会让整个工程显得不完整。
原因:input() 返回的是字符串,直接 int() 转换遇到非数字就异常了。列号大于 6 时又会被 drop_piece 直接忽略,但如果忘了做边界检查,还会出现访问越界的问题。
解决:把输入解析单独封装成函数,用 try 捕获 ValueError,并且检查列号是否在合法范围内。不合法就重新提示输入,同时用一个 max_retry 控制次数,超过次数自动退出避免死循环。这样处理之后,对局过程不中断,演示也不丢人。
6. 二次开发:把默认策略替换成你自己的棋风和评估习惯
如果你想拿这个项目做二次开发,最推荐从评估函数下手,因为改动成本最小、效果最明显。默认的 [0, 1, 10, 50, 10000] 是偏进攻的值,想让 AI 更稳,可以把 50 改成 30,它就不会急着堆三连,而是优先堵你;想让 AI 冒险,可以把 10000 降到 5000,这样它会优先形成自己的优势局面,而不是回回去救火。
第二个建议是给搜索加上“必胜点判断”前置过滤。在 choose_best_move 里先检查:当前 AI 是否存在落下一步直接四连的列,存在就直接返回,不用进搜索。再检查对手是否存在下一步直接四连的列,存在就优先堵。这两个判断放一层 if 就能覆盖很多关键场景,整个 AI 的棋力提升非常明显,而且只有几行代码量。
第三个改造方向是把终端渲染换成图形界面。renderer.py 已经隔离好了,你只需要写一个新的 render 函数接收棋盘二维数组,把它画到 Pygame 画布上,再把 main.py 中 input 换成一个事件函数即可。这部分工作量完全取决于你想做到多精致,核心 AI 一粒代码都不用改。
我曾经在一次改造中给评估函数加了“连续三连加额外奖励”,结果 AI 变得极其激进,连对手有明显四连都不怎么管。从那以后我每次修改评估函数的返回前,都强制写一个固定棋局的测试脚本,用十几个已知局面验证 AI 的落点是否合理,再放回真实对局里试跑。希望帮到你。
本文还有配套的精品资源,点击获取