五子棋到了高手层面,胜负往往不是被”连成五子”终结的,而是被禁手终结的:黑棋一旦形成”双三”、”四四”或”长连”,当场判负。这条规则是为了平衡先手优势,但它给 AI 带来的麻烦远超想象——一个能算清 VCF 的引擎,如果不会判定禁手,会亲自走进死局。
本文专门讲禁手判定算法:如何用”棋型状态机 + 中心线扫描”在常数时间内判断一个落点是否构成三三、四四、长连禁手,以及如何把禁手规则嵌入到着法生成器中。
禁手的本质:从”棋型”到”活三的计数”
规则回顾(Renju 黑棋禁手):
| 禁手类型 | 定义 |
|---|---|
| 长连 | 同一直线上连续 ≥ 6 子 |
| 四四 | 同时形成两个及以上的”四”(含冲四与活四) |
| 三三 | 同时形成两个及以上的”活三” |
难点集中在三三。因为”活三”的定义是”再下一子能变成活四”,而”再下一子”本身可能又会被其它规则限制(假活三)。严格的判定必须做一层递归验证:候选活三的点,是否真的能在下一手变成活四。
很多开源实现就在这一步出错——把”跳三”与”眠三”混算,导致误判禁手。
用中心线扫描代替整盘棋型匹配
不需要扫描全盘。落点 (r,c) 的四个方向(横、竖、两条斜线)各自最多延伸 4 格就足以判定(因为五连最多需要 5 个连续子,向外 4 格即可)。对每个方向:
- 提取以落点为中心、半径 4 的 9 格窗口;
- 把窗口编码成一个以
0/1/2(空/黑/白)为字符的字符串; - 在字符串上匹配棋型模板。
窗口化之后,棋型识别变成字符串模式匹配问题,这比在二维数组里写一堆 if 清晰得多,也让”假活三”的递归验证变得容易实现——只需要在匹配到的空位上再模拟一次落子并重跑匹配。
棋型模板与状态编码
把中心点固定为窗口的下标 4。常见棋型模板(1=自家黑子,0=空,2=白子或边界):
五连 11111
活四 011110
冲四 11110 / 01111 / 11011 / 10111 / 11101
活三 01110 / 010110 / 011010 (两端有空且能延伸成活四)
跳活三 011010 / 010110
注意 01110 需要额外判断两端外侧是否至少有一侧为空(否则是”眠三”,即被堵死的三)。
完整实现
import java.util.*;
/**
* 五子棋禁手判定器(Renju 黑棋规则)。
* 棋盘:0 空,1 黑(先手,受禁手约束),2 白。
*/
public class RenjuForbidden {
static final int N = 15;
static final int[] DR = {0, 1, 1, 1}; // 横、竖、主对角、副对角
static final int[] DC = {1, 0, 1, -1};
private final int[][] board = new int[N][N];
private static final int W = 9; // 9 格窗口
private static final int MID = 4; // 中心下标
/** 落子前的禁手预判:返回 null 表示合法,否则返回禁手类型 */
public String checkForbidden(int r, int c) {
board[r][c] = 1; // 先虚拟落子
try {
if (hasLongConnect(r, c)) return "长连禁手(overline)";
int fourCount = 0, openThreeCount = 0;
for (int d = 0; d < 4; d++) {
String win = window(r, c, d);
if (countPattern(win, "four") > 0) fourCount++;
if (countPattern(win, "openThree") > 0) openThreeCount++;
}
if (fourCount >= 2) return "四四禁手(double-four)";
if (openThreeCount >= 2) return "三三禁手(double-three)";
return null;
} finally {
board[r][c] = 0; // 撤销虚拟落子
}
}
/** 长连:任一方向连续黑子 ≥ 6 */
private boolean hasLongConnect(int r, int c) {
for (int d = 0; d < 4; d++) {
int len = 1;
len += countDir(r, c, DR[d], DC[d]);
len += countDir(r, c, -DR[d], -DC[d]);
if (len >= 6) return true;
}
return false;
}
private int countDir(int r, int c, int dr, int dc) {
int n = 0;
int rr = r + dr, cc = c + dc;
while (inBoard(rr, cc) && board[rr][cc] == 1) {
n++; rr += dr; cc += dc;
}
return n;
}
/**
* 提取以 (r,c) 为中心、沿方向 d 的 9 格窗口。
* 越界与白子都编码为 '2'(阻塞),黑子 '1',空 '0'。
*/
private String window(int r, int c, int d) {
StringBuilder sb = new StringBuilder(W);
for (int k = -MID; k <= MID; k++) {
int rr = r + DR[d] * k, cc = c + DC[d] * k;
if (!inBoard(rr, cc)) sb.append('2');
else sb.append((char) ('0' + board[rr][cc]));
}
return sb.toString();
}
private boolean inBoard(int r, int c) {
return r >= 0 && r < N && c >= 0 && c < N;
}
/** 在窗口中统计某类棋型出现次数(棋型必须覆盖中心点) */
private int countPattern(String win, String type) {
int cnt = 0;
switch (type) {
case "four":
for (int s = 0; s + 5 <= W; s++) {
if (!coversCenter(s, s + 5)) continue;
if (isFour(win.substring(s, s + 5))) cnt++;
}
break;
case "openThree":
for (int s = 0; s + 5 <= W; s++) {
if (!coversCenter(s, s + 5)) continue;
if (isOpenThree(win.substring(s, s + 5), win, s, s + 5)) cnt++;
}
break;
}
return cnt;
}
private boolean coversCenter(int start, int end) {
return start <= MID && MID < end;
}
/** 5 格窗口内判定"四":4 个 '1' + 1 个 '0' */
private boolean isFour(String sub) {
int one = 0, zero = 0;
for (char ch : sub.toCharArray()) {
if (ch == '1') one++;
else if (ch == '0') zero++;
else return false; // 出现白子或边界,不是四
}
return one == 4 && zero == 1;
}
/**
* 活三判定:形如三连或跳三,且能通过再补一子变成活四。
* 用"两端外侧至少一侧为空"作为可延伸的充分条件。
*/
private boolean isOpenThree(String sub, String win, int start, int end) {
// 形态 1:连续三 01110
if (sub.equals("01110")) {
return openEnds(win, start, end);
}
// 形态 2:跳三 010110 / 011010 —— 3 个 1 被 1 个 0 分隔
if (sub.equals("01011") || sub.equals("11010") ||
sub.equals("01101") || sub.equals("10110")) {
return openEnds(win, start, end);
}
return false;
}
/** 窗口两端外侧(start-1 / end)至少一侧为空,才可能延伸成活四 */
private boolean openEnds(String win, int start, int end) {
boolean leftOpen = start - 1 >= 0 && win.charAt(start - 1) == '0';
boolean rightOpen = end < W && win.charAt(end) == '0';
return leftOpen || rightOpen;
}
/** 着法过滤:把禁手点从候选中剔除 */
public List<int[]> legalMoves() {
List<int[]> moves = new ArrayList<>();
for (int r = 0; r < N; r++) {
for (int c = 0; c < N; c++) {
if (board[r][c] != 0) continue;
if (checkForbidden(r, c) == null) moves.add(new int[]{r, c});
}
}
return moves;
}
public static void main(String[] args) {
RenjuForbidden f = new RenjuForbidden();
// 造一个三三局面:横竖各一个活三,交点在 (7,7)
f.board[7][3] = 1; f.board[7][4] = 1; f.board[7][5] = 1;
f.board[3][7] = 1; f.board[4][7] = 1; f.board[5][7] = 1;
System.out.println("(7,7) 判定 = " + f.checkForbidden(7, 7));
}
}
为什么”三三”必须做递归验证
上面对活三的判定用了”外侧至少一侧为空”的近似。这在多数局面下正确,但在严格的 Renju 规则里,”活三”必须保证补一子后得到的是活四(而不是被对方堵住的冲四,或自身触犯别的禁手)。
严格的实现要做三层:
- 第一层:找到所有能让当前三变成四的补点(在窗口内枚举空位);
- 第二层:对每个补点,重跑一次窗口匹配,确认其结果是”活四”(
011110且两端外还有空); - 第三层(可选):确认该补点本身是否也是禁手点。若是,则这个三不算活三——这是规则里最刁钻的部分,俗称”禁手中的禁手”。
层数越多,性能越差。实战引擎的折中方案是:缓存每个落点的”方向棋型指纹”(4 个方向各一个枚举值),补子时只更新被影响的那一个方向,从而把窗口扫描降为常数级的位运算。
与搜索算法的结合
禁手判定在搜索中的位置很关键:
- 着法生成阶段过滤:黑棋的所有候选都先过
legalMoves(),避免搜索树里出现非法分支; - 评估函数里体现:如果一方即将被迫走进禁手,应该在评估中体现为极大负值(这实际上是一种”逼禁”(forced forbidden)战术,白棋常用的取胜手段);
- 胜负判定:黑棋形成禁手型即判负,此时应立刻把该分支剪掉,而不是继续往下搜。
一个常见的工程细节:checkForbidden 通过”虚拟落子 + finally 撤销”实现,这让它可以在搜索中被任意嵌套调用而不会污染棋盘状态。对 15×15 棋盘做全盘扫描约 900 次窗口匹配,单次耗时在微秒级,可以承受放在深层搜索节点里。
小结
禁手判定的核心是把二维棋型识别降维成一维窗口的字符串模板匹配,再用中心点归属判断排除掉不该计数的棋型。长连与四四的判定是直接的计数问题,而三三需要一层递归才能严格成立。把这一层判定接入着法生成器,黑棋 AI 才具备”不自杀”的基本素质——这也是所有 Renju 引擎在调通 VCF 之后必须补上的第一课。