简介:这份资源为C语言实现的Renju(连珠)规则五子棋AI,面向棋类AI开发者与算法学习者,完整示范了博弈树搜索与棋型评估的落地流程。包体十分精简,共3个C源文件、仅11KB,分别提供Linux运行版、Linux竞赛版与Windows版,便于对照不同平台下的代码组织、宏开关及条件编译写法。已有466人学习,适合快速研读、移植,也适合作为课程设计或比赛方案底本。AI集成了Minimax搜索、启发式评估、Alpha-Beta剪枝、预评估、迭代加深、VCT/VCF、PVS、杀手启发式搜索以及“非完全动态”的多线程并行等关键技术,兼顾禁手与连珠规则,可运行于常规对弈和比赛环境。阅读源码既能理解多种搜索加速手段如何组合、如何评估候选点,也能获得实战排错与重构思路,是学习五子棋AI、Renju规则的实用参考资料。
1. 先说清楚:Renju规则五子棋AI难在哪,以及为什么非C语言不可
普通五子棋AI,把合法空位都让黑棋下,黑棋可以靠冲四和活三的组合一路压着白棋。但换成Renju规则(连珠)后,事情立刻变了:黑棋被加了三道禁手——双三、双四和长连,哪一条踩中直接判负。这意味着你拿普通五子棋的搜索逻辑跑,AI会在自己局面大优的时候突然走出禁手,然后莫名其妙输掉。C语言在这里的优势不是语法漂亮,而是速度和可控性:同样的评估函数,用C实现可以在相同深度下比解释型实现快一个量级;棋盘和递归栈都在你手里,内存与边界行为完全可预期。这篇文章是一条完整落地路径:从棋盘数据结构和禁手判断,到极小化极大搜索与评估函数,再到处处是坑的工程优化,目标是让你在本地跑通一个完整按Renju规则下棋的AI,并且知道每一步为什么要这么做。适合自己动手做对弈程序的开发者,也适合拿来做课程设计或比赛项目的同学对标参考。
2. 用C语言实现Renju合法落子与禁手判断:数据结构与三个判定函数
先说一个反直觉的点:Renju和Gomoku的差别,不在于棋盘大小,而在于“哪些落子是合法的”。普通五子棋里黑棋随便下,但Renju规则下黑棋的合法落子集合被画了一个圈,AI的搜索过程只有在这个圈里跑才有意义。整棵博弈树的地基,是“禁手判断正确”。
2.1 棋盘表示与方向遍历:用坐标和数组把规则翻译给机器
我常用的表示方法很简单:一个15x15的二维数组。0表示空,1表示黑棋,2表示白棋。不要一开始就上位棋盘、zobrist哈希这些花活,先把规则跑通再说。
#define N 15 static int board[N][N]; /* 0=空,1=黑,2=白 */ static const int dirs[4][2] = { {0, 1}, /* 水平 */ {1, 0}, /* 垂直 */ {1, 1}, /* 主对角线 */ {1, -1} /* 副对角线 */ }; static int in_bounds(int x, int y) { return x >= 0 && x < N && y >= 0 && y < N; } static void put_stone(int x, int y, int player) { board[y][x] = player; } static void remove_stone(int x, int y) { board[y][x] = 0; }方向数组是后面所有判定函数都要反复用的东西,四个方向分别对应五子棋的横、竖、两条斜线。判断连线时,只需要沿每个方向往两端数格子,就能拿到连续棋子的长度。in_bounds一开始就要写好,因为后面每一个循环都要拿它挡边界。C语言里数组越界是未定义行为,写错一次可能只在特定棋形下才崩溃,极难排查。
一句话说清为什么用二维数组而不是更高级的结构:五子棋的搜索过程需要频繁落子和悔子,二维数组的读写是 O(1),这个特性在递归搜索里的收益非常直接。位棋盘(bitboard)在跳棋和国际象棋里很有用,但五子棋棋型判断是围绕“某一条线”扫描的,二维数组配合方向遍历反而是最容易写对、也最容易验证的方案。
2.2 禁手判断实现:主动防守黑棋的“伪最优解”
禁手的核心是三条:长连(超过5颗连续)、双四(同时形成两个成五机会)、双三(同时形成两个活三)。这里有两个被初学者反复踩的边界,先写出来:
- 如果黑棋落子后直接形成五连,那这一手是取胜,不判禁手;哪怕是同一手也造成六连以上,仍按五连优先判胜。
- “双四”里的四,既包括两端堵不住的活四,也包括只有一端开口的冲四;“双三”里的三,必须达到活三标准,眠三不算。
我先说为什么不能用朴素的“连续计数”来判断禁手。像OXXXXO这种两端都被白棋堵死的四颗黑子,连续计数会认为它是“长四”,但实际上它既不是活四也不是冲四,更不构成双四。如果用简单的长度加两端状态去套,会把大量无效棋型误判成禁手,导致AI该赢的棋不敢赢。所以更可靠的做法是枚举“五元组窗口”。
/* 统计在 (x,y) 放置黑子后,包含该点的四方向五连窗口数 */ static int count_win_windows(int x, int y) { int d, off, cnt = 0; for (d = 0; d < 4; d++) { int dx = dirs[d][0], dy = dirs[d][1]; /* 含 (x,y) 的长度为5的窗口,起点相对坐标偏移 -4 到 0 */ for (off = -4; off <= 0; off++) { int sx = x + off * dx; int sy = y + off * dy; int ex = sx + 4 * dx; int ey = sy + 4 * dy; int i, ok = 1; if (!in_bounds(sx, sy) || !in_bounds(ex, ey)) continue; for (i = 0; i < 5; i++) { int px = sx + i * dx; int py = sy + i * dy; if (board[py][px] != 1) { ok = 0; break; } } if (ok) cnt++; } } return cnt; }count_win_windows的思路是:把 (x,y) 当作已经落下的黑子,沿四个方向,把所有包含这个点的长度为 5 的窗口全部枚举一遍,统计五格全是黑棋的窗口数量。这个数字的含义很直接——如果大于等于 2,说明这手棋同时形成了两个以上成五的可能,也就是双四;如果等于 1,说明这手棋直接成五,是合法获胜步。
长连判定相对简单,沿四个方向统计包含 (x,y) 的连续同色棋子最大长度:
static int count_overline(int x, int y) { int d, max_len = 0; for (d = 0; d < 4; d++) { int len = 1, i; for (i = 1; i < N; i++) { int nx = x + i * dirs[d][0], ny = y + i * dirs[d][1]; if (!in_bounds(nx, ny) || board[ny][nx] != 1) break; len++; } for (i = 1; i < N; i++) { int nx = x - i * dirs[d][0], ny = y - i * dirs[d][1]; if (!in_bounds(nx, ny) || board[ny][nx] != 1) break; len++; } if (len > max_len) max_len = len; } return max_len; }双三的判定不能直接用五元组窗口,因为三还没到成五那一步。我单独写一个方向级计数函数,对每个方向扫描含 (x,y) 的连续黑子段,只要“连续三颗且两端都是空位”就算一个活三:
static int count_three_types(int x, int y) { int d, open_threes = 0; for (d = 0; d < 4; d++) { int dx = dirs[d][0], dy = dirs[d][1]; int len = 1, open_end = 0, i; /* 朝一个方向延伸 */ for (i = 1; i < N; i++) { int nx = x + i * dx, ny = y + i * dy; if (!in_bounds(nx, ny) || board[ny][nx] == 2) break; if (board[ny][nx] == 0) { open_end++; break; } len++; } /* 朝另一个方向延伸 */ for (i = 1; i < N; i++) { int nx = x - i * dx, ny = y - i * dy; if (!in_bounds(nx, ny) || board[ny][nx] == 2) break; if (board[ny][nx] == 0) { open_end++; break; } len++; } if (len == 3 && open_end == 2) open_threes++; } return open_threes; }注意,这是简化版。真正的连珠裁判还会考虑跳三(如_X_X_X_)这类形态,需要枚举更多窗口才能补全。对工程落地来说,先处理线性连续棋型已经能打赢相当多对局,而且这个版本足够你理解禁手判断的骨架。
完整的禁手判断函数把三类结果合起来,模拟落子、判断、悔子:
static int is_forbidden(int x, int y) { int result; if (board[y][x] != 0) return 1; put_stone(x, y, 1); /* 模拟黑棋落子 */ if (count_win_windows(x, y) >= 1) { result = 0; /* 直接成五,不判禁手 */ } else if (count_overline(x, y) >= 6) { result = 1; /* 长连禁手 */ } else if (count_win_windows(x, y) >= 2) { result = 1; /* 双四禁手 */ } else if (count_three_types(x, y) >= 2) { result = 1; /* 双三禁手 */ } else { result = 0; } remove_stone(x, y); return result; }逻辑顺序是有讲究的:先看成五,再看长连,再看双四,最后看双三。实战里最容易错的是长连和五连同时出现的局面,一定要把五连优先级放最前面。我建议你把is_forbidden单独做成模块,在接入搜索之前,先手工摆好棋谱跑单测:双三一组、双四一组、长连一组、五连优先一组,各准备两个用例。禁手判定的正确性直接决定AI会不会在关键时刻下出“自杀棋”,这一步省不得。
3. 让AI会思考:极小化极大搜索与α-β剪枝的落地写法
进入AI核心之前,先明确一个边界:Renju五子棋AI的棋力主要来自三件事——搜索深度、评估函数准确性、候选点生成质量。极小化极大是骨架,所有优化都是在给它减枝和提速。
3.1 为什么五子棋AI最终都回到博弈树搜索
五子棋是零和博弈:黑棋想赢,白棋想阻止黑棋赢。每一步落子后双方轮流行动,直到出现五连、禁手或平局。这种状态空间非常适合用极小化极大搜索建模:黑棋节点取子节点中的最大值,白棋节点取子节点中的最小值。由于黑棋有禁手,每个黑棋落子点还要额外做合法性检查,这让Renju AI比普通五子棋AI多了一层天然剪枝——一部分看似凶悍的进攻点被直接排除。
搜索深度怎么定?在我的实测里,15x15棋盘上,评估函数没做复杂优化时,4层搜索是入门水平,能挡住新手的大部分直接冲四;6层深度配合好的评估函数能看出活三和冲四的组合杀招,但单步耗时上升到秒级。大多数课程设计和比赛项目,5层是安全和棋力的平衡点。这个数字取决于候选点收敛到什么程度——如果每层平均生成 20 个候选点,5层就是 20^5 = 320 万个叶子节点,在C里配合剪枝通常能在 1 秒左右跑完,再深就要靠后面的优化。
3.2 落子候选点生成:先缩小战场,再谈搜索效率
全盘搜索是新手最容易犯的错误。15x15只有225个格点,但乘以深度后呈指数爆炸。我一般用一个朴素但有效的启发式:只生成距离已有棋子不超过2格的空位作为候选点。五子棋的任何战斗都围绕现有棋子展开,远端空位在一两手尺度内不可能影响当前局面。
typedef struct { int x, y; } Point; static int has_nearby_stone(int x, int y) { int dx, dy; for (dy = -2; dy <= 2; dy++) { for (dx = -2; dx <= 2; dx++) { int nx = x + dx, ny = y + dy; if (in_bounds(nx, ny) && board[ny][nx] != 0) return 1; } } return 0; } static int gen_candidates(Point *out, int max_count) { int x, y, cnt = 0; for (y = 0; y < N && cnt < max_count; y++) { for (x = 0; x < N; x++) { if (board[y][x] == 0 && has_nearby_stone(x, y)) { out[cnt].x = x; out[cnt].y = y; cnt++; } } } return cnt; }max_count我用 32,因为 15x15棋盘上任意棋局中距离棋子2格以内的空位通常不会超过这个数。这个参数不是越大越好——它直接决定每层搜索宽度,宽度从 32 减半到 16,总耗时能降到四分之一。如果某局面候选点超过 32,说明双方棋子已形成缠斗,这时评估函数往往比搜索深度更管用,先截断是对的。
候选点的次序也很关键。搜索中先评估的点如果能让 α/β 区间收窄,剪枝效率会大幅提升。我通常在生成候选点后,用“该点到最近棋子的曼哈顿距离”做一次升序排列,距离越小越优先,这个策略能减少约一半的节点扩展量:
static int manhattan_to_nearest(int x, int y) { int dx, dy, best = N * 2; for (dy = 0; dy < N; dy++) { for (dx = 0; dx < N; dx++) { if (board[dy][dx] != 0) { int dist = abs(x - dx) + abs(y - dy); if (dist < best) best = dist; } } } return best; } static int point_cmp(const void *a, const void *b) { const Point *pa = (const Point *)a; const Point *pb = (const Point *)b; int da = manhattan_to_nearest(pa->x, pa->y); int db = manhattan_to_nearest(pb->x, pb->y); return da - db; }manhattan_to_nearest的实现就是二重循环找最近棋子,开销很小但收益很大,因为搜索树的上层节点里,靠近棋子的点更容易产生有力进攻或防守,α-β剪枝会更早触发。
3.3 极小化极大 + α-β剪枝的完整C实现
核心搜索函数写成递归形式。为了可读性,胜负检测和评估函数先假设已存在:
#define INF 100000000 static int alpha_beta(int depth, int alpha, int beta, int player) { Point cands[32]; int i, cnt, best, val, winner; winner = check_board_winner(); if (winner == 1) return 1000000 + depth; if (winner == 2) return -1000000 - depth; if (depth == 0) return evaluate_board(); cnt = gen_candidates(cands, 32); qsort(cands, cnt, sizeof(Point), point_cmp); best = (player == 1) ? -INF : INF; for (i = 0; i < cnt; i++) { int x = cands[i].x, y = cands[i].y; if (player == 1 && is_forbidden(x, y)) continue; /* 黑棋不能落在禁手点 */ put_stone(x, y, player); val = alpha_beta(depth - 1, alpha, beta, 3 - player); remove_stone(x, y); if (player == 1) { if (val > best) best = val; if (best > alpha) alpha = best; } else { if (val < best) best = val; if (best < beta) beta = best; } if (alpha >= beta) break; /* 剪枝 */ } return best; }这段代码有四个关键点需要说明:
depth表示当前还能搜几层,递归边界处调用评估函数。进入下一层时3 - player把 1 变 2、2 变 1,完成黑白交替。winner检查发生在递归入口,而不是落子之后,这样能让已经分出胜负的节点从根上截断,不再往下扩展。返回时加上depth的权重,是为了让同样取胜的局面里,步数少的优先,AI会更主动追求速胜。alpha和beta是剪枝区间。黑棋节点只更新alpha,白棋节点只更新beta,当区间为空时说明对方下一步已经不可能被当前节点接受,直接剪掉,这是整个α-β剪枝的精髓。- 黑棋禁手点直接
continue跳过。规则允许黑棋主动下禁手然后等对手抓禁,但AI没必要这么走,跳过既保证合法性又缩小搜索宽度。
调用入口一般包成这个函数:
Point ai_move(int player) { Point cands[32]; int i, cnt, best_val = -INF; Point best_move = {-1, -1}; if (board[7][7] == 0) return (Point){7, 7}; /* 开局占天元 */ cnt = gen_candidates(cands, 32); qsort(cands, cnt, sizeof(Point), point_cmp); for (i = 0; i < cnt; i++) { int x = cands[i].x, y = cands[i].y; int val; if (player == 1 && is_forbidden(x, y)) continue; put_stone(x, y, player); val = alpha_beta(DEPTH - 1, -INF, INF, 3 - player); remove_stone(x, y); if (val > best_val) { best_val = val; best_move = (Point){x, y}; } } return best_move; }第一层不剪枝,因为需要遍历所有候选点找出唯一最优解,不能因为某个分支提前超过 β 就放弃整个搜索。DEPTH我定义成宏,先设 5,实测单步时间可接受后再加到 6。
工程上还有一个细节:check_board_winner没必要每层全盘扫描,只需要检查上一层落子位置周围的四个方向。这个优化在搜索深度提高后很明显,10层的递归里能省掉大量重复扫描。
4. 把棋型特征量化为分数:评估函数设计与棋型分数表
搜索只负责回答“这一步怎么走更优”,但“优”本身由评估函数定义。很多人把评估函数想得很玄,其实它就是一个暴力打分器:把当前棋盘按横、竖、两条斜线切成一条条一维序列,然后对每条序列里的线段形态查表打分,最后汇总。棋力差异,就藏在查表分值的设计里。
4.1 从零设计一张棋型分数表:活三值多少、眠三值多少
先列一张我常用的分数表,它不是标准答案,但经过大量对局验证,方向是对的:
| 棋型 | 含义 | 分数 |
|---|---|---|
| 五连 | 五颗同色连续 | 10000000 |
| 活四 | 两端都开放的四个 | 1000000 |
| 冲四 | 一端开放的四个 | 50000 |
| 活三 | 两端开放的三个 | 5000 |
| 眠三 | 一端开放的三个 | 500 |
| 活二 | 两端开放的二个 | 100 |
| 眠二 | 一端开放的二个 | 10 |
为什么活四给 1000000 而不是 100000?因为在五子棋里,活四等价于必杀:对手无论堵哪一端,另一端都能成五。活四和活三之间必须有一个数量级差距,不能让AI为了一个活三放弃已有的必杀形势。冲四和活三的关系也要拉开:冲四是一次性威胁,活三具有持续性,实战中活三的威胁通常高于普通冲四,但冲四能逼迫对手,让活三略低于冲四但差距不大是合理的。
这里有一个Renju规则特有的坑:黑棋的分数不能照搬白棋。黑棋有禁手,很多在普通五子棋里看起来很强的双三、双四形态,对黑棋反而是死路。我处理的方法是:评估时对黑棋棋型单独处理,如果确认是禁手相关棋型,黑棋这部分分数直接打七折。这个细节让AI执黑时更谨慎,不会为了追求华丽棋型把局面走死。
4.2 用滑动窗口扫描棋盘:把四方向特征压缩成数值
实现评估函数的第一步,是把棋盘拆成一维线段。15x15 的棋盘有 15 行 + 15 列 + 29 条主对角线 + 29 条副对角线,一共 88 条线。对每一条线做一次从左到右的扫描,找到连续的相同棋子段,记录长度和两端状态:
static const int kScore[5][3] = { /* open_ends=0, open_ends=1, open_ends=2 */ {0, 0, 0}, /* len=1 */ {0, 10, 100}, /* len=2 */ {50, 500, 5000}, /* len=3 */ {5000, 50000, 1000000}, /* len=4 */ {10000000, 10000000, 10000000} /* len=5 */ }; static int evaluate_line(int *line, int length) { int i = 0, score = 0; while (i < length) { int player, len, start; int left_block, right_block, open_ends; if (line[i] == 0) { i++; continue; } player = line[i]; len = 0; start = i; while (i < length && line[i] == player) { len++; i++; } left_block = (start > 0 && line[start - 1] != 0); right_block = (i < length && line[i] != 0); open_ends = (left_block ? 0 : 1) + (right_block ? 0 : 1); if (len >= 5) { score += (player == 1) ? 10000000 : -10000000; } else { int v = kScore[len - 1][open_ends]; score += (player == 1) ? v : -v; } } return score; }扫描过程中,left_block和right_block的判断是评估函数最容易出错的地方:只有相邻位置被对方棋子或棋盘边界堵住,才算“封堵”;如果是空位,就是开口。有些实现会把“空一格再堵住”也算封堵,那就过度拟合了——跳二的棋型在五子棋里依然有很强的棋力,不能直接等同眠二。
整体评估就是把 88 条线依次跑一遍,把两个玩家的分数相减。一般让评估函数返回black_score - white_score,正数代表黑方优势。注意,不能把黑方的禁手惩罚塞进evaluate_line,因为禁手是“黑棋落子瞬间”的规则,必须在搜索的落子合法性里处理,评估函数只处理静态棋型。
围绕这个查询表,有两个需要特别当心的陷阱。
第一是int溢出。上面分数表里最大值 10000000,搜索中如果局面阶段性地积累了几千万分,加上depth权值也不会爆掉 int 的 21 亿上限。但如果你把五连的分数调成 10^9,再把冲四调成 10^7,累计起来就可能溢出。分数表跨度过大时,优先级会变得不稳定。我见过有人把五连设成 100000000,结果AI为了一个潜在的活三,宁可放弃已经到手的必胜冲四——因为中间节点的分数量级太高,让搜索误判了威胁程度。
第二个陷阱是斜线加权。按 88 条线独立扫描会让一个段在交叉处被重复计算。比如棋盘正中的一个棋子,会同时出现在行、列、两条对角线四条线里,中心影响力被计了四次。这不是bug,反而是一种有效的中心加权。但分数表如果设计得太大,这种重复会使中心优势过度膨胀。我的折中办法:在查表时给斜线方向的棋型分数乘一个 0.8 系数,因为对角线上的棋型通常比横竖线更难形成连线。这个系数不是银弹,但它让开局AI更愿意占天元和星位,而不是一味往角上跑。
5. 避坑指南:Renju规则五子棋AI最容易翻车的6个常见问题
把搜索和评估写出来只是第一步,真正让项目“活着”的,是把这些隐蔽问题提前打掉。下面这些是我实际跑程序时踩过的坑,按现象、原因、解决来写。
5.1 搜索层数一深,棋盘状态“幽灵化”
现象:设置深度 6 后,AI落到某一步时棋盘上突然多出几颗不存在的棋子,或者落子撤销后残留棋子,局势判断一团乱。
原因:递归搜索中put_stone和remove_stone必须严格配对,任何一条提前return的分支都会破坏棋盘状态。搜索深度提高后,这种配对被破坏的概率也在增加。
解决:搜索函数里只允许在remove_stone之后 return,或者把棋盘状态作为参数传到下一层递归,虽然每层复制 225 字节开销不小,但在调试期能极大减少心智负担。我自己的习惯是在alpha_beta入口先加断言:assert(board[y][x] == player);,确保悔子前后状态一致。否则段错误往往发生在十几层之后,靠 printf 逐层打印极难排查。
5.2 AI主动下出黑棋禁手:双三判定标准太宽松
现象:AI执黑时,明明可以通过冲四制胜,程序却选择了一个双三落点,之后被白棋抓禁判负。
原因:count_three_types把len==3 && open_ends==2的线性三段都当成活三,但实战中很多形态是“形似活三、实则被压制”。例如_XXX_X_里,三个黑子的两端看似开放,实际上中间有个断点让白棋能从容应对,这应该算眠三而非活三。
解决:严格定义活三——连续三颗黑子,两端必须都是空位,且这两个空位落子后都能直接形成冲四。按照这个定义去补全count_three_types,而不是只看长度和两端是否为空。刷棋谱时把_XXX_X_、_X_XX_这种跳跃形态单独立一组测试用例,它们最善于骗过朴素的计数逻辑。
5.3 对手执黑时,白棋不知道“抓禁”
现象:对手黑棋下出禁手后,白棋完全无动于衷,继续按普通五子棋思路防守。
原因:评估函数里没有把“对方已经处于禁手状态”当作评分项。规则里,黑棋一旦落成禁手,白棋下一步可以直接指出并对局结束。
解决:在evaluate_board里加一道检测——每当轮到白方落子,检查黑棋上一手是否落在禁手点。如果是,白方直接返回一个极大正数作为评估值。更简单的方式是:搜索入口对黑棋候选点做了禁手过滤,本就不会生成禁手点,但白棋的应对策略里要显式识别“对手已经被禁”的局面,否则会出现对手被禁后AI还在棋盘上胡乱下的错觉。
5.4 搜索一步要20秒:候选点没收敛
现象:深度 5 的AI在残局阶段单步耗时急剧上升,超过 20 秒。
原因:残局阶段棋子密集,has_nearby_stone对几乎所有空位都返回 1,生成的候选点数量接近满盘空位数,max_count上限托不住。
解决:残局时收紧候选点范围,把has_nearby_stone的半径从 2 改成 1,只认相邻格子。同时把gen_candidates的max_count从 32 降到 16。这样虽然会漏掉个别“隔空进攻”的手筋,但残局长考反而更容易超时,保持快速反应比追求完美更实际。
5.5 递归深度到7,程序直接段错误
现象:把DEPTH调到 7,第一手还没算完程序就崩了,GDB显示栈溢出。
原因:每次递归都会在栈上分配Point cands[32]和多个局部变量,深度 7 时每层约 1KB 栈空间,在默认 8MB 栈的Linux上勉强能活;如果评估函数里的临时变量再膨胀,会顶破栈上限。
解决:三个方向。一是把cands从局部数组改成函数内静态数组或堆分配,避免每次递归都压栈;二是用迭代加深代替一次性深层搜索,到时间限制就返回当前最优;三是如果确实要跑深度 8 以上搜索,用pthread_attr_setstacksize增大线程栈,而不是只改递归深度。实际项目中我推荐第二种,迭代加深不只栈安全,还天然支持时间控制,这一点第 6 章会展开。
5.6 评估分数怎么调都不对:黑白视角没有归一化
现象:AI在某些局面下完全“瞎走”,既不看自己的进攻,也不挡对方的活三。
原因:evaluate_line返回的是绝对值,黑白双方用同一个查分表,但双方棋型价值不对称——黑棋的双三会走向禁手,白棋的双三是纯粹威胁。把两个分数直接相减会让AI错估局面。
解决:在evaluate_board里显式拆分黑子分数和白子分数,黑棋分数乘以一个小于 1 的系数再相减,例如return black_score * 0.92 - white_score;。这个系数代表“黑棋因为禁手而折损的进攻效率”,具体取值可以在自对弈中观察AI的落子风格调整。不用太纠结精度,关键是让AI明白黑棋和白棋的棋型不对等。
6. 从能跑到能赢:迭代加深与候选点排序的立竿见影优化
把前面几章代码串起来,你已经有一个能走棋但不一定强的AI了。这一章给两个能马上落地的优化,一个验证棋力的方法。
6.1 迭代加深:用时间预算换搜索深度
固定深度搜索有个尴尬:残局候选点少,深度 5 只花 0.2 秒;开局候选点多,可能需要 3 秒。迭代加深的做法是从深度 1 开始逐层往上搜,每层记录当前最优步,直到超过时间预算:
Point ai_move_with_time(int player, int max_ms) { int depth; Point best = {-1, -1}; for (depth = 1; depth <= MAX_DEPTH; depth++) { int before = clock(); best = ai_move_fixed_depth(player, depth); int elapsed = clock() - before; if (elapsed >= max_ms * CLOCKS_PER_SEC / 1000) break; } return best; }迭代加深的额外收益很多人没意识到:前一层的搜索结果可以作为当前层的候选点排序,让 α-β 剪枝在高层就命中关键分支。对五子棋这种分支因子不算太大的游戏,迭代加深几乎不浪费时间——深度 N 层的总耗时本来由最深层主导。
6.2 候选点排序:让剪枝在第一波就命中最优
排序键的选择强烈影响剪枝率。我用三类优先级组合:是否直接形成五连、是否防守对方成五、是否靠近已有棋子,再加上中心倾向:
static int move_priority(int x, int y, int player) { int score = 0; int opp = 3 - player; if (would_form_five(x, y, player)) score += 100000; if (would_form_five(x, y, opp)) score += 50000; if (has_nearby_stone(x, y)) score += 100; score -= manhattan_to_nearest(x, y) * 2; return score; }给分设计让“己方成五”最高,“挡对方成五”次之,然后是贴近战场的点。这个排序决定了搜索树的第一分支有多聪明,剪枝率能从 40% 提升到 70% 以上。would_form_five可以直接复用count_win_windows的思路。
6.3 一个成本很低的验证方法:自对弈随机测试
验证AI棋力不用做界面。写一个主循环让两个AI互相对弈,每次只打印每步耗时和最终胜负。跑 20 局,如果超过 3 局出现黑棋禁手失误,先回头修is_forbidden;如果超过 5 局在明显优势局面拖到百手以上,多半是冲四和活三权重失衡。这个测试脚本大概 100 行,是你调整所有参数的最低成本实验场。
我第一次做Renju AI时,把活三和冲四权重调得过分接近,结果AI鬼迷心窍一样乱冲四,把对手防守棋越养越肥。后来把活四调到冲四的20倍,冲四调到活三的10倍,所有诡异行为瞬间消失。这种调参玄学在没有自对弈基线时很容易走偏,但有了自动对弈胜负率,你每次改参数都能立刻看到效果。希望这个思路能帮你在自己的实现里少走几步弯路。
本文还有配套的精品资源,点击获取