
简介基于C语言实现的Renju规则五子棋AI程序包面向对棋类博弈算法感兴趣的进阶开发者可作为对战平台AI模块、课程设计或算法实验的完整参考。代码涵盖Minimax搜索、启发式评估、Alpha-Beta剪枝、预评估、迭代加深、VCT/VCF胜势搜索、PVS与杀手启发式等经典和进阶优化并引入动态多线程思路缓解搜索耗时同时实现禁手判断等Renju规则约束兼顾竞技性与完整性可在此基础上调试参数、替换启发函数、扩展自定义策略。资源共3个文件全部为C源文件压缩包仅11KB轻量易用三份代码分别面向Linux普通版、Linux竞赛版与Windows环境便于对照移植和编译调试。已有466人学习下载适合希望深入理解五子棋AI搜索策略、规则处理与多平台工程化适配的开发者直接参考算法框架进行二次开发。1. 从命令行跑通Renju规则五子棋AIC语言选手的开局选择有些老玩家对“五子棋AI”轻蔑五子棋不是已经研究到必胜了吗规则限制下还真不是。Renju规则连珠给黑棋加了双三、双四和长连禁手AI首先要学会“不能赢”而这个用C语言实现的课题远没有网上说的那么“水”。它可以作为课程设计可以给MFC界面当搜索内核也能在纯命令行里做人对机和机机对弈。适合谁有C语言基础、想在递归与回追溯双层结构上练手、或者需要把完整可编译的源码交出去的人。这篇文章不做安装教程也不从蒙特卡洛算法讲起而是按我做过的路子从棋盘表示讲到禁手判定再到alpha-beta剪枝和常见翻车点最后给出能直接验证的对抗方法。2. Renju规则建模棋盘数据结构和禁手判定怎么写才不踩坑2.1 Renju和普通五子棋的差异对AI意味着什么普通五子棋只要连成五颗就赢黑棋先手优势极其明显。Renju规则的初衷就是给黑棋套上枷锁黑棋不允许下出“双三”“双四”和“长连”。白棋没有任何禁手连成五颗以上也算赢。这个差异不是规则文本差别而是直接改写了AI的搜索逻辑。最直观的影响有四点。第一黑棋的走法生成必须过滤禁手点否则AI会反复走到被判负的位置。第二局面评估函数里同一个候选点对黑棋和白棋的价值不同黑棋可能因为形成双三而变成坏棋白棋却可以用相同形状得分。第三搜索算法里要区分“黑棋的胜利”和“白棋的胜利”黑棋五连是赢黑棋六连却是禁手必须在胜负判断里单独处理。第四白棋有“诱导黑棋禁手”这种间接赢法如果评估函数不识别禁手形状AI就永远不会下出这种逼迫性棋步。所以做这个项目的第一个决策不是写代码而是选定规则版本。常见做法是只做黑棋禁手即双三禁手、双四禁手、长连禁手白棋一概放行。如果你的答辩老师要求按国际连珠规则处理更细的“双活三”“嵌五”等定义那么在数据结构上预留一套模式配置即可核心代码不用推翻重写。2.2 棋盘与棋局数据结构C语言里怎么存一个15路棋盘我第一版用了最直接的二维数组15x15刚好能在一页代码里看清。项目跑通后再优化成一维数组也不迟因为搜索瓶颈不在数组形态而在评估函数的循环写法。#define BOARD_SIZE 15 #define EMPTY 0 #define BLACK 1 #define WHITE 2 typedef struct { int board[BOARD_SIZE][BOARD_SIZE]; // 0空 1黑 2白 int turn; // 当前该落子的颜色 int move_history[BOARD_SIZE * BOARD_SIZE][2]; // 复盘用 int move_count; // 已经下的手数 int last_move[2]; // 最近的落子便于胜负判定 } RenjuGame; void init_game(RenjuGame *g) { memset(g-board, EMPTY, sizeof(g-board)); g-turn BLACK; // Renju黑先 g-move_count 0; g-last_move[0] -1; g-last_move[1] -1; }逻辑说明turn字段很关键AI搜索时统一从g-turn取当前方而不是在递归里靠正负号推断。move_history不是给搜索引擎用的是给人机对弈复盘用的也方便做“悔棋”功能。last_move存最近一手坐标这样检测连五和禁手时不需要扫描全盘。我一般建议开局初始化时把turn显式置为BLACK不要依赖全局变量默认值否则后续接GUI项目时容易忘掉重置状态。如果之后要扩展到AI vs AI自对弈只需要在每局结束后恢复这两项即可。2.3 禁手判定双三、双四、长连怎么用C语言查出来禁手检测是Renju AI和普通五子棋AI最大的分水岭。普通AI可以直接judge_win(g, row, col)Renju AI还要问一个问题如果当前是黑棋这一手落下后是不是在禁手点上。先定义单方向模型。以落子点为中心沿水平、垂直、两条对角线扩展统计连续同色子的数量以及两端是空、边界还是异色子。这个信息浓缩成结构体typedef struct { int count; // 连续同色子数量含落子点 int open_ends; // 两端开放数量0/1/2 } LineInfo; LineInfo get_line_info(RenjuGame *g, int row, int col, int dr, int dc, int color) { LineInfo info {0}; int r, c; // 向正方向延伸 for (r row dr, c col dc; r 0 r BOARD_SIZE c 0 c BOARD_SIZE; r dr, c dc) { if (g-board[r][c] color) { info.count; } else { if (g-board[r][c] EMPTY) info.open_ends; break; } } // 向反方向延伸 for (r row - dr, c col - dc; r 0 r BOARD_SIZE c 0 c BOARD_SIZE; r - dr, c - dc) { if (g-board[r][c] color) { info.count; } else { if (g-board[r][c] EMPTY) info.open_ends; break; } } info.count; // 加上落子点本身 return info; }逻辑说明info.count从1开始延伸时遇到同色子就累加遇到异色或边界就停止。open_ends统计两端是不是空位如果一端被堵就少一个开放端。这是后面区分活三、眠三、冲四的基础。有了四个方向的LineInfo就能做禁手判别。常见做法是复制棋盘把候选点临时落上黑子再做统计这样不会破坏原局面int is_forbidden_move(RenjuGame *g, int row, int col) { if (g-turn ! BLACK) return 0; RenjuGame copy *g; // 落子前先复制安全第一 copy.board[row][col] BLACK; int dirs[4][2] {{1,0},{0,1},{1,1},{1,-1}}; int live_three 0; int four_count 0; int overline 0; for (int i 0; i 4; i) { LineInfo info get_line_info(copy, row, col, dirs[i][0], dirs[i][1], BLACK); if (info.count 6) overline 1; // 长连禁手 // 双四禁手活四或冲四都算一个“四” if (info.count 4 info.open_ends 1) four_count; // 双三禁手只统计真正的活三open_ends 2 if (info.count 3 info.open_ends 2) live_three; } return (live_three 2 || four_count 2 || overline) ? 1 : 0; }逻辑说明这里的关键是“方向不合并各自计数”。如果水平方向形成活三垂直方向也形成活三live_three累加为2触发三三禁手。四四禁手类似活四和冲四都算“四”。长连只要任意一个方向连续超过5颗黑子即可。参数说明copy *g这行不是偷懒而是利用结构体赋值做快照。15x15的棋盘每次复制不超过几百字节在几个搜索深度下开销完全可接受。禁手检测本身不需要关心对方棋子只要关心黑棋连成的形状所以get_line_info里遇到白子或边界就停。这个版本的简化之处在于把“跳活三”当成普通眠三处理对多数教学项目和自用AI够用。如果你想严格对齐某种连珠规则再扩展LineInfo的玩法即可。3. 走法生成与棋型评分把五子棋AI的进攻和防守变成可计算的值3.1 候选走法生成别遍历整个15x15棋盘初学者最容易写出的版本是每层搜索都扫描棋盘上所有空点。15x15的空点在开局是225个配合搜索深度5就是天文数字。实际走法生成只需要看已有棋子周围的一小块区域通常以相邻两格为半径。int generate_candidate_moves(RenjuGame *g, int moves[][2], int scores[]) { int used[BOARD_SIZE][BOARD_SIZE] {{0}}; int count 0; for (int i 0; i g-move_count; i) { int mr g-move_history[i][0]; int mc g-move_history[i][1]; for (int dr -2; dr 2; dr) { for (int dc -2; dc 2; dc) { int nr mr dr; int nc mc dc; if (nr 0 || nr BOARD_SIZE || nc 0 || nc BOARD_SIZE) continue; if (used[nr][nc]) continue; if (g-board[nr][nc] ! EMPTY) continue; moves[count][0] nr; moves[count][1] nc; scores[count] 0; used[nr][nc] 1; count; } } } // 开局棋盘为空时直接下天元 if (count 0) { moves[0][0] BOARD_SIZE / 2; moves[0][1] BOARD_SIZE / 2; scores[0] 0; count 1; } return count; }逻辑说明used数组用来去重。棋盘上已有多个棋子时同一个空点会从相邻的棋子出发被扫描到多次不用used会导致候选点重复评估函数多算一遍排序也会乱。dr和dc的范围取-2到2是因为棋型判断需要看到两格以外的空位走法候选的视野不能比棋型判断更窄。参数说明搜索深度加深后候选点数量会膨胀。常见做法是先用评估函数给每个候选点头一个初始分再做排序。生成函数把scores数组一并填好后续排序直接用就不用再算一遍。3.2 棋型评分表活三、冲四、活二怎么量化成数码评估是五子棋AI的“黑匣子”也是各家AI强度差异的主要来源。评分表的原则是好棋型分数要高而且要拉开数量级让搜索一眼认出“活三比活二重要得多”。我最常用的一组权重棋型分数备注连五1000000直接取胜必须最大活四100000下一手必胜冲四50000对方必须挡活三10000对方不挡就变活四眠三1000有机会变冲四活二1000开局布局的主力眠二100基本忽略这些数字不是拍脑袋而是保证搜索里“先成活三”会优先于“只成眠二”同时活四的分数又足够让AI为它放弃一堆眠三。注意不要给连五设成INT_MAX否则后面做迭代加深时加减深度会把整数撑爆这是第5章会展开的坑。单点评分的核心逻辑是假设我把当前颜色的子落在某个空位然后按四个方向统计每个方向形成的棋型查表加分。写成函数就是int evaluate_point(RenjuGame *g, int row, int col, int color) { if (g-board[row][col] ! EMPTY) return 0; RenjuGame copy *g; copy.board[row][col] color; int dirs[4][2] {{1,0},{0,1},{1,1},{1,-1}}; int score 0; for (int i 0; i 4; i) { LineInfo info get_line_info(copy, row, col, dirs[i][0], dirs[i][1], color); if (info.count 5) score 1000000; else if (info.count 4 info.open_ends 2) score 100000; else if (info.count 4 info.open_ends 1) score 50000; else if (info.count 3 info.open_ends 2) score 10000; else if (info.count 3 info.open_ends 1) score 1000; else if (info.count 2 info.open_ends 2) score 1000; else if (info.count 2 info.open_ends 1) score 100; } return score; }逻辑说明evaluate_point做的是“落子后快照评估”。先复制棋盘再临时把子放上去然后向四个方向查棋型。因为同一颗子同时参与多个方向的棋型所以四个方向分数直接相加。这不会产生过度膨胀因为棋型本身是事实存在的比如一步棋确实可以同时形成水平活三和垂直活三。这个函数同时是走法排序和局面评估的基础。对候选点做降序排序后alpha-beta剪枝效率能提升几倍到几十倍因为它让好的尝试更早出现在搜索路径上。3.3 评估函数的攻防平衡防守权重怎么调才不翻车单点评估只能估算“落在这里能形成多好的形状”但AI不能只进攻不防守。完整局面评估要考虑双方棋型的差值并乘一个防守权重。int evaluate_board_for_player(RenjuGame *g, int player) { int my_score 0; int opp_score 0; for (int r 0; r BOARD_SIZE; r) { for (int c 0; c BOARD_SIZE; c) { if (g-board[r][c] player) { // 已有棋子的位置评估其周围棋型 my_score evaluate_existing_stone(g, r, c, player); } else if (g-board[r][c] 3 - player) { opp_score evaluate_existing_stone(g, r, c, 3 - player); } } } return my_score - (int)(DEFENSE_WEIGHT * opp_score); }参数说明DEFENSE_WEIGHT是经验值常见范围是0.9到1.2。大于1会让AI过于保守总是先挡对方棋型自己进攻节奏全丢小于0.85又让AI眼瞎看不见对手的活三。我做过20盘自对弈对比1.0是稳定起步点之后调到0.95会更积极一些。在命令行版本里我把这个参数做成预定义宏方便反复测试。这个评估函数不是“精确解”也永远不可能是。因为五子棋棋型之间会相互转化静态评估只是当前快照的打分真正的博弈价值要靠minimax搜索去挖掘。4. Minimax搜索与Alpha-Beta剪枝C语言实现AI核心的步骤4.1 负极大值写法与递归终止条件Renju AI的搜索树和普通五子棋没有本质区别都是双方轮流落子。我用负极大值写法因为代码短只需要维护一个从对方视角取负数的递归函数。int negaMax(RenjuGame *g, int depth, int alpha, int beta, int player) { if (depth 0) { return evaluate_board_for_player(g, player); } int moves[MAX_MOVES][2]; int scores[MAX_MOVES]; int n generate_candidate_moves(g, moves, scores); for (int i 0; i n; i) { int r moves[i][0]; int c moves[i][1]; // Renju核心黑棋禁手直接废弃不用进入搜索 if (player BLACK is_forbidden_move(g, r, c)) continue; apply_move(g, r, c, player); int val -negaMax(g, depth - 1, -beta, -alpha, 3 - player); undo_move(g); if (val alpha) alpha val; if (alpha beta) break; // beta剪枝 } return alpha; }逻辑说明player BLACK时的is_forbidden_move必须在落子前判断。这个判断决定整棵搜索树的合法性漏掉它等于把黑棋AI变成“自杀AI”。apply_move和undo_move成对出现一旦某个递归分支提前退出棋盘状态没恢复后续搜索全乱了。所以我更建议在递归函数内部用“复制再落子”的方式把状态管理交给C语言栈int negaMax(RenjuGame *g, int depth, int alpha, int beta, int player) { if (depth 0) return evaluate_board_for_player(g, player); RenjuGame next *g; int moves[MAX_MOVES][2], scores[MAX_MOVES]; int n generate_candidate_moves(g, moves, scores); for (int i 0; i n; i) { int r moves[i][0], c moves[i][1]; if (player BLACK is_forbidden_move(g, r, c)) continue; next *g; next.board[r][c] player; next.turn 3 - player; next.move_history[g-move_count][0] r; next.move_history[g-move_count][1] c; next.move_count g-move_count 1; next.last_move[0] r; next.last_move[1] c; int val -negaMax(next, depth - 1, -beta, -alpha, 3 - player); if (val alpha) alpha val; if (alpha beta) break; } return alpha; }这种写法牺牲了一点性能但换来“绝不会破坏主棋盘”。15x15的结构体复制一次也就几十个字节搜索节点数在几千到几万这个量级时完全可以接受。真正耗时的是棋型扫描循环而不是这次结构体赋值。4.2 走法排序为什么顺序决定AI快慢alpha-beta剪枝的效果高度依赖走法顺序。如果每一层的第一个走法恰好就是最优解那么整层几乎不需要额外搜索如果最差走法排第一剪枝几乎不生效退化回完整minimax。所以每层搜索前要做排序。我不建议为这个场景调用重量级qsort用插入排序就够了。候选走法数量通常只有二十到四十个插入排序的简易性和可读性更好void sort_moves(int moves[][2], int scores[], int n) { for (int i 1; i n; i) { int r moves[i][0], c moves[i][1], s scores[i]; int j i - 1; while (j 0 scores[j] s) { moves[j 1][0] moves[j][0]; moves[j 1][1] moves[j][1]; scores[j 1] scores[j]; j--; } moves[j 1][0] r; moves[j 1][1] c; scores[j 1] s; } }逻辑说明scores[j] s是从大到小排序让分数高的候选点排前面。排序必须在禁手过滤之后做但要保留两步信息先调用generate_candidate_moves再对每个候选点调用evaluate_point计分最后排序。evaluate_point是走法排序最直接的评分函数它不需要考虑对手只要算出“我落在这里能形成多强的局面”即可。参数说明搜索深度超过6之后排序质量对耗时的影响会从“翻倍”变成“指数级”。我在深度4时看不出区别深度6时好的排序能省掉九成节点。这是做Renju AI时最值得花时间调优的地方。4.3 迭代加深与时间控制让AI在限定时间内走出最强手对弈AI不能无限搜索必须设置时间预算。常见做法是迭代加深从深度1开始逐层增加深度每层搜索都把上一层的最优走法作为第一候选并检查时间是否超限。typedef struct { int row; int col; int score; int depth_reached; } AIResult; AIResult ai_search(RenjuGame *g, int max_depth, double time_limit) { AIResult best {BOARD_SIZE / 2, BOARD_SIZE / 2, 0, 0}; clock_t start clock(); for (int depth 1; depth max_depth; depth) { int alpha -INF, beta INF; int moves[MAX_MOVES][2], scores[MAX_MOVES]; int n generate_candidate_moves(g, moves, scores); for (int i 0; i n; i) { int r moves[i][0], c moves[i][1]; if (g-turn BLACK is_forbidden_move(g, r, c)) continue; // 把上一层最优走法放到第一个位置这里简化处理实际应做插排 RenjuGame next *g; next.board[r][c] g-turn; next.turn 3 - g-turn; next.move_count g-move_count 1; next.last_move[0] r; next.last_move[1] c; int val -negaMax(next, depth - 1, -beta, -alpha, 3 - g-turn); if (val alpha) { alpha val; best.row r; best.col c; best.score val; best.depth_reached depth; } if (time_limit 0) { double elapsed (double)(clock() - start) / CLOCKS_PER_SEC; if (elapsed time_limit) return best; } } // 这层完整搜完把当前最优记录到best供下一层排序用 } return best; }逻辑说明ai_search返回值里保存了深度信息。实操中不要用max_depth定死深度参数而是设一个时间限制比如每步1秒。开局时搜索深度普遍可以达到6到8中盘棋型复杂时可能只能搜到4到5这都正常。INF定义为安全大数我习惯用1000000而不是INT_MAX原因见下章。5. Renju AI常见问题排查禁手漏判、INT_MIN溢出与搜索深度误区5.1 禁手漏判的三种典型翻车现场现象一AI执黑时反复走到禁手点上赛后直接被判负。原因很简单generate_candidate_moves只判断空点和边界没有对黑棋做禁手过滤。搜索到最后一层时AI还觉得自己形成了双三实际这手棋根本不能出。解决在搜索入口处统一加上if (player BLACK is_forbidden_move(g, r, c)) continue;。放在negaMax和根搜索两处因为禁手判断依赖当前turn递归到黑棋节点时同样要过滤。我见过只过滤根节点、不过滤递归节点的写法照样翻车。现象二AI认为黑棋的双三禁手到处都是导致黑棋完全不敢进攻只会防守。原因is_forbidden_move里把“假活三”也统计进去了。比如一颗黑子斜线方向上形成_X_X_两头是空的但中间有断点这不能算连续活三。get_line_info只统计连续同色子遇到空格就停所以像_X_X_这种跳三根本不会被open_ends 2捕捉到。问题通常出在“四四禁手”的统计上如果把活四和冲四都算那么一个方向形成活四另一个方向形成眠三并不满足双四条件AI却会因为four_count累加方式不对而在只有一格开放端的棋型上误判。解决在is_forbidden_move里根据你自己的规则版本选择统计口径。我常用的是双三只数count 3 open_ends 2的方向双四只数count 4 open_ends 1的方向。跳活三是否计算用宏开关控制默认不计算。现象三黑棋明明形成六连胜负检测却认为还没结束。原因长连检测只沿落子点的一个方向延伸漏掉了其他方向。比如落子在长连中间时需要同时检查水平、垂直和两条对角线的连续黑子数。解决在is_forbidden_move里用四方向循环get_line_info返回info.count判断info.count 6即触发长连禁手。胜负检测也要区分黑白白棋连六算赢黑棋连六算禁手而不是赢。5.2 搜索层的常见bug与性能坑现象四AI深度加大后输出结果完全错乱偶尔出现负数比负数更大的诡异局面。原因使用INT_MAX或INT_MIN作为无穷值。负极大值里会做-alpha、-beta、INF - depth这类运算INT_MIN取负会溢出比较逻辑随之崩盘。解决统一用#define INF 1000000并且所有必胜分数控制在INF / 2以内。比如连五评分是1000000那么INF至少要比它大一个数量级否则搜索中加减深度会互相污染。我用1000000作为连五分200000000作为理论无穷实际操作里不会有棋型分数超过千万所以不会溢出。现象五搜索到中盘明显变慢深度5以后每步棋要等十几秒。原因候选点太多剪枝效果差加之一上来就直接搜深度6没有走法排序也没有迭代加深兜底。解决先跑深度1到2用得到的走法顺序引导深度3再逐层加深。每层搜索前对候选点做evaluate_point降序排序alpha-beta 剪枝才能真正发力。还有一个易被忽视的参数时间检查不要在每层循环后才做应该在候选点循环内部检查否则一层搜到一半被打断返回的best可能是上一层的结果这比没有时间限制更危险。现象六人机对弈时AI思考结束后棋盘被改乱了落子位置和预期不一样。原因搜索里apply_move和undo_move没有配对出现continue提前跳出分支时棋盘状态没恢复。解决改用结构体复制写法在每个递归节点创建RenjuGame next *g;所有贪心修改都做在next上传引用继续递归。这样无论分支在哪结束主棋盘始终安全。性能损失在15x15棋盘上完全可以忽略换来的是对弈流程的确定性。6. 对弈验证与复盘把Renju AI从能跑变成能赢最后一个值得花时间做的东西不是搜索而是验证环境。没有验证你根本不知道AI是“做对了一切”还是“恰好没输”。我用纯命令行组织三套测试棋型回归测试、禁手测试、自对弈对抗测试。棋型回归测试用来锁死评分的边界条件。提前摆好几组已知局面比如黑棋活三、白棋冲四、黑棋双三禁手然后调用evaluate_point和is_forbidden_move断言输出必须是预设值。这个测试文件能挡住后续改规则时的“意外破坏”。禁手测试更直接。构造三个固定棋盘一个落子后形成双活三一个形成双四一个形成长连。黑棋调用is_forbidden_move必须返回1白棋在相同位置调用is_forbidden_move必须返回0因为白棋没有禁手。我最早就是因为只测了黑棋没测白棋结果白棋也把禁手躲着走棋力大打折扣。自对弈测试最花时间但最值得。让AI执黑对AI执白连续下20到40局统计白棋胜率。Renju规则下黑棋有先手优势但被禁手压制理想情况黑白胜率应该接近五五开。如果黑棋胜率长期低于三成多半是DEFENSE_WEIGHT调太高AI只顾防守如果黑棋胜率超过八成那白棋AI的搜索深度可能比黑棋低了或者黑棋的禁手检查漏了。我在一次调试里就发现白棋胜率畸高后来定位到原因黑棋在搜索中把“六连”视作胜利导致AI拼命往长连上冲白白送局。命令行调试阶段我习惯在棋盘打印函数里额外标出候选点和最近落子。加上gdb回溯搜索调用确实能查问题但更高效的是打印出每个候选点的评分一眼就能看出AI为什么选了某个位置。VSCode 里配好gcc后一个gcc main.c -o renju_ai -Wall就够了不用引入复杂构建工具。至于很多人问的“五子棋MFC代码”界面我反而建议先把控制台版本跑稳再接GUI。Renju AI真正的技术含量在禁手判定和搜索剪枝界面只是外包装。这个项目最后留给我的教训是不要第一时间怀疑评估函数。我在“AI为什么总输”的问题上折腾了一周重写过三版棋型评分最后发现是黑棋禁手过滤漏了一个方向。规则先于搜索校验先于调参这条顺序希望帮到你。本文还有配套的精品资源点击获取