简介:基于C语言实现的Renju规则五子棋AI程序包,面向对棋类博弈算法感兴趣的进阶开发者,可作为对战平台AI模块、课程设计或算法实验的完整参考。代码涵盖Minimax搜索、启发式评估、Alpha-Beta剪枝、预评估、迭代加深、VCT/VCF胜势搜索、PVS与杀手启发式等经典和进阶优化,并引入动态多线程思路缓解搜索耗时;同时实现禁手判断等Renju规则约束,兼顾竞技性与完整性,可在此基础上调试参数、替换启发函数、扩展自定义策略。资源共3个文件,全部为C源文件,压缩包仅11KB,轻量易用;三份代码分别面向Linux普通版、Linux竞赛版与Windows环境,便于对照移植和编译调试。已有466人学习下载,适合希望深入理解五子棋AI搜索策略、规则处理与多平台工程化适配的开发者,直接参考算法框架进行二次开发。
1. 从命令行跑通Renju规则五子棋AI:C语言选手的开局选择
有些老玩家对“五子棋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(©, 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(©, 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为什么总输”的问题上折腾了一周,重写过三版棋型评分,最后发现是黑棋禁手过滤漏了一个方向。规则先于搜索,校验先于调参,这条顺序希望帮到你。
本文还有配套的精品资源,点击获取