数独是大家最熟悉的逻辑游戏:在 9×9 的盘面上填入 1~9,使每行、每列、每个 3×3 宫内数字不重复。多数人写求解器时第一反应是回溯(DFS + 剪枝),但回溯只能回答”答案是什么”,无法回答”人是怎么推理出来的”。真实的人类高手依赖的是一套候选数结构推理(Pattern-based Solving),其中 X-Wing 与剑鱼(Swordfish)是最经典的两个”高阶技巧”:它们不需要猜,只需要观察候选数在两行两列(或三行三列)之间的对齐关系,就能一次性删掉一批候选。
本文将用 Java 实现一个纯逻辑数独求解引擎,把候选数压缩成位掩码(bitmask),依次实现唯一候选(Naked Single)、隐性唯一(Hidden Single)、区块删减(Locked Candidates)、X-Wing 与剑鱼,最后才用回溯兜底。整个过程同时记录每一步推理的来源,便于观察算法”思考”的顺序。
一、把候选数压成 9 位整数
数独的每个空格最多有 9 个候选数字,天然适合用位掩码表示:第 d 位为 1 表示数字 d(d 从 1 开始)仍是候选。初始候选集等于”全集 ^ (同行 ∪ 同列 ∪ 同宫已出现的数字)”。
static final int ALL = 0x1FF; // 低 9 位全 1,代表 {1..9}
// 行/列/宫的掩码表,便于快速取出约束
static final int[] ROW = new int[81];
static final int[] COL = new int[81];
static final int[] BOX = new int[81];
用位掩码之后,所有”删除候选””统计候选个数”都退化成位运算:
- 候选个数:
Integer.bitCount(mask) - 删除数字 d:
mask &= ~(1 << (d - 1)) - 取最低候选:
Integer.numberOfTrailingZeros(mask) + 1
这一步是后面所有技巧的基础——X-Wing 与剑鱼本质上都是对”候选位图”按行、列做集合运算。
二、X-Wing:两行两列的矩形夹逼
X-Wing 的定义非常精确:对某个数字 d,如果
- 第 r1 行中 d 的候选位置恰好只有 {c1, c2} 两列;
- 第 r2 行中 d 的候选位置也恰好只有 {c1, c2} 两列;
那么 d 在这两行中必然分别落在 c1 和 c2 上(两行各占一个,且不能同列)。于是这两列中其它行的 d 候选可以全部删除。这就是一个 2×2 的矩形,形似机翼,故称 X-Wing。列方向完全对称:两列各自候选只落在同样两行时,可以删掉这两行其它列的候选。
算法实现只需要四步:
// 返回本次找到的所有删减:[目标格下标, 要删的数字]
static List<int[]> xWing(int digit, boolean searchRows) {
List<int[]> removals = new ArrayList<>();
int bit = 1 << (digit - 1);
for (int a = 0; a < 9; a++) {
int pa = positionsInUnit(a, searchRows ? ROW : COL, bit); // 该行/列中 d 的候选位置集合
if (Integer.bitCount(pa) != 2) continue; // 必须恰好两个
for (int b = a + 1; b < 9; b++) {
int pb = positionsInUnit(b, searchRows ? ROW : COL, bit);
if (Integer.bitCount(pb) != 2 || pa != pb) continue; // 必须落在完全相同的两条线上
// 找到了 X-Wing,向"垂直方向"的其它线删除 d
for (int line = 0; line < 9; line++) {
if (line == a || line == b) continue;
for (int k = 0; k < 9; k++) {
int idx = searchRows ? line * 9 + k : k * 9 + line;
if ((cand[idx] & bit) != 0) removals.add(new int[]{idx, digit});
}
}
}
}
return removals;
}
positionsInUnit(h, unit, bit) 把”unit 号行/列中哪些格子的候选含 bit”折叠成一个 9 位的列位图(或行位图),这样两次比较直接是整数相等,比集合比较快一个量级。
三、剑鱼(Swordfish):X-Wing 的三线推广
剑鱼把 2 条线推广到 3 条线:对数字 d,若存在 3 行,它们的 d 候选全部落在同样的 3 列之内(每行 2~3 个候选,不超过 3 列),则这 3 行必然在 3 列中各占一个(3 行放 3 个数字,正好填满 3 列),于是这 3 列中其它行的 d 候选可以全部删除。
实现上就是把 X-Wing 的”恰好 2 个”替换为”候选集合的并集大小 ≤ 3,且每行候选数在 2~3 之间”,然后枚举三行组合:
static List<int[]> swordfish(int digit, boolean searchRows) {
List<int[]> removals = new ArrayList<>();
int bit = 1 << (digit - 1);
int[][] pos = new int[9][];
for (int i = 0; i < 9; i++) pos[i] = ...
for (int a = 0; a < 9; a++) {
if (cnt[pos[a]] < 2 || cnt[pos[a]] > 3) continue;
for (int b = a + 1; b < 9; b++) {
if (cnt[pos[b]] < 2 || cnt[pos[b]] > 3) continue;
for (int c = b + 1; c < 9; c++) {
if (cnt[pos[c]] < 2 || cnt[pos[c]] > 3) continue;
int union = pos[a] | pos[b] | pos[c];
if (Integer.bitCount(union) != 3) continue; // 必须在 3 条线之内
// 删除 union 这 3 条线上其它线的 d 候选
}
}
}
return removals;
}
注意:候选数为 1 的线相当于已经”锁定”,会被 Hidden Single 提前处理掉,所以剑鱼判断里只需接受 2 或 3。
四、完整可运行项目
项目结构与文件职责:
sudoku-logic/
├── src/main/java/com/example/sudoku/
│ ├── SudokuBoard.java # 盘面、候选位掩码、单元掩码
│ ├── LogicEngine.java # 推理主循环:单值→区块→X-Wing→剑鱼→回溯
│ ├── PatternStrategies.java # X-Wing / Swordfish 的位运算实现
│ └── Main.java # 演示:逐步打印推理过程
4.1 SudokuBoard:盘面与候选集
package com.example.sudoku;
import java.util.*;
public class SudokuBoard {
public static final int ALL = 0x1FF;
public final int[] grid = new int[81]; // 0 表示空
public final int[] cand = new int[81]; // 未填格的候选位掩码
private final List<String> trace = new ArrayList<>();
public SudokuBoard(String puzzle) {
if (puzzle.length() != 81) throw new IllegalArgumentException("需要 81 个字符");
for (int i = 0; i < 81; i++) {
char ch = puzzle.charAt(i);
grid[i] = (ch == '.' || ch == '0') ? 0 : ch - '0';
}
rebuildCandidates();
}
/** 根据已填数字重建全部候选集,同时检测矛盾 */
public boolean rebuildCandidates() {
for (int i = 0; i < 81; i++) {
cand[i] = grid[i] == 0 ? ALL : 0;
}
for (int i = 0; i < 81; i++) {
if (grid[i] == 0) continue;
int bit = 1 << (grid[i] - 1);
for (int peer : peersOf(i)) {
cand[peer] &= ~bit; // 同行/列/宫的格子失去该候选
if (grid[peer] == 0 && cand[peer] == 0) return false; // 无候选 => 矛盾
}
}
return true;
}
/** 20 个关联格:同行 8 + 同列 8 + 同宫 4 */
public int[] peersOf(int idx) {
int r = idx / 9, c = idx % 9;
LinkedHashSet<Integer> set = new LinkedHashSet<>();
for (int k = 0; k < 9; k++) { set.add(r * 9 + k); set.add(k * 9 + c); }
int br = r / 3 * 3, bc = c / 3 * 3;
for (int i = 0; i < 3; i++)
for (int j = 0; j < 3; j++) set.add((br + i) * 9 + bc + j);
set.remove(idx);
return set.stream().mapToInt(Integer::intValue).toArray();
}
/** 落子:赋值、清候选、同步同行列宫的候选位 */
public void place(int idx, int digit) {
grid[idx] = digit;
cand[idx] = 0;
int bit = 1 << (digit - 1);
for (int p : peersOf(idx)) cand[p] &= ~bit;
}
public void log(String s) { trace.add(s); }
public List<String> trace() { return trace; }
public int empties() {
int n = 0;
for (int v : grid) if (v == 0) n++;
return n;
}
}
4.2 PatternStrategies:X-Wing 与剑鱼
package com.example.sudoku;
import java.util.*;
public class PatternStrategies {
private final SudokuBoard b;
public PatternStrategies(SudokuBoard board) { this.b = board; }
/** 把"unit 号行(或列)"中候选含 bit 的格子折叠成 9 位位置图 */
private int positionsIn(int unit, boolean row, int bit) {
int mask = 0;
for (int k = 0; k < 9; k++) {
int idx = row ? unit * 9 + k : k * 9 + unit;
if ((b.cand[idx] & bit) != 0) mask |= 1 << k;
}
return mask;
}
/** 统一框架:lines = 需要几条线(2 即 X-Wing,3 即剑鱼) */
public boolean fish(int digit, boolean searchRows, int lines) {
int bit = 1 << (digit - 1);
int[] pos = new int[9];
for (int i = 0; i < 9; i++) pos[i] = positionsIn(i, searchRows, bit);
List<int[]> combos = new ArrayList<>();
combine(new int[lines], 0, 0, lines, combos); // 枚举 lines 条"主线"的组合
boolean applied = false;
for (int[] combo : combos) {
int union = 0;
for (int u : combo) union |= pos[u];
if (Integer.bitCount(union) != lines) continue; // 必须正好落在 lines 条"垂直线"上
// union 的每一位是一条垂直线(searchRows 时即列号),遍历这些线
for (int off = 0; off < 9; off++) {
if ((union & (1 << off)) == 0) continue;
// 沿该垂直线,删除不在 combo 内的主线上的候选
for (int line = 0; line < 9; line++) {
if (contains(combo, line)) continue; // 主线自身保留
int idx = searchRows ? line * 9 + off : off * 9 + line;
if ((b.cand[idx] & bit) != 0) {
b.cand[idx] &= ~bit; // 唯一真正的删减动作
applied = true;
b.log(String.format("%s(d=%d):删除 R%dC%d 的候选",
lines == 2 ? "X-Wing" : "剑鱼", digit, idx / 9 + 1, idx % 9 + 1));
}
}
}
}
return applied;
}
private static boolean contains(int[] arr, int v) {
for (int x : arr) if (x == v) return true;
return false;
}
private void combine(int[] cur, int start, int depth, int k, List<int[]> out) {
if (depth == k) { out.add(cur.clone()); return; }
for (int i = start; i < 9; i++) {
cur[depth] = i;
combine(cur, i + 1, depth + 1, k, out);
}
}
}
说明:
pos[u]是”第 u 行(或列)中数字 d 的候选落在哪些列(或行)”,用 9 位位图表示;union就是这几条线覆盖到的垂直方向线集合。只有当bitCount(union) == lines时,才说明 d 被”夹”在正好 lines 条垂直线里,此时主线之外的格子再出现 d 就一定是冗余,可以安全删除。
4.3 LogicEngine:推理主循环
package com.example.sudoku;
import java.util.*;
public class LogicEngine {
private final SudokuBoard b;
private final PatternStrategies ps;
public LogicEngine(SudokuBoard board) { this.b = board; this.ps = new PatternStrategies(board); }
/** 反复尝试各类技巧,直到不再有进展 */
public boolean propagate() {
boolean progress = true;
while (progress) {
progress = nakedSingles() | hiddenSingles() | lockedCandidates();
if (!progress) {
// 每轮只在"低阶技巧失效"后才尝试高阶结构,符合人类解题顺序
for (int d = 1; d <= 9 && !progress; d++) {
progress = ps.fish(d, true, 2) | ps.fish(d, false, 2); // X-Wing
}
for (int d = 1; d <= 9 && !progress; d++) {
progress = ps.fish(d, true, 3) | ps.fish(d, false, 3); // 剑鱼
}
}
if (!b.rebuildCandidates()) return false; // 结构性冲突
}
return true;
}
/** 唯一候选:某格只剩一个候选 */
private boolean nakedSingles() {
boolean any = false;
for (int i = 0; i < 81; i++) {
if (b.grid[i] != 0) continue;
int c = b.cand[i];
if (Integer.bitCount(c) == 0) return false;
if (Integer.bitCount(c) == 1) {
int d = Integer.numberOfTrailingZeros(c) + 1;
b.place(i, d);
b.log(String.format("唯一候选:R%dC%d = %d", i / 9 + 1, i % 9 + 1, d));
any = true;
}
}
return any;
}
/** 隐性唯一:某数字在某个单元中只剩一个可放位置 */
private boolean hiddenSingles() {
boolean any = false;
for (int unitType = 0; unitType < 3; unitType++) {
for (int u = 0; u < 9; u++) {
for (int d = 1; d <= 9; d++) {
int bit = 1 << (d - 1), only = -1, count = 0;
for (int k = 0; k < 9; k++) {
int idx = indexOf(unitType, u, k);
if (b.grid[idx] == d) { count = -2; break; } // 该单元已有 d
if ((b.cand[idx] & bit) != 0) { count++; only = idx; }
}
if (count == 1) {
b.place(only, d);
b.log(String.format("隐性唯一:R%dC%d = %d", only / 9 + 1, only % 9 + 1, d));
any = true;
}
}
}
}
return any;
}
/** 区块删减:某数字在一个宫内只出现在同一行/列,则该行/列宫外可删 */
private boolean lockedCandidates() {
boolean any = false;
for (int box = 0; box < 9; box++) {
int br = box / 3 * 3, bc = box % 3 * 3;
for (int d = 1; d <= 9; d++) {
int bit = 1 << (d - 1), pos = 0, cnt = 0;
for (int i = 0; i < 3; i++)
for (int j = 0; j < 3; j++) {
int idx = (br + i) * 9 + bc + j;
if ((b.cand[idx] & bit) != 0) { pos = idx; cnt++; }
}
if (cnt >= 2 && cnt <= 3) {
// 若全在同一行,则该行其它宫可删此候选
boolean sameRow = true, sameCol = true;
int r0 = pos / 9, c0 = pos % 9;
for (int i = 0; i < 3; i++)
for (int j = 0; j < 3; j++) {
int idx = (br + i) * 9 + bc + j;
if ((b.cand[idx] & bit) == 0) continue;
if (idx / 9 != r0) sameRow = false;
if (idx % 9 != c0) sameCol = false;
}
if (sameRow) any |= clearRow(r0, bit, box, d);
if (sameCol) any |= clearCol(c0, bit, box, d);
}
}
}
return any;
}
private boolean clearRow(int r, int bit, int box, int d) {
boolean any = false;
for (int k = 0; k < 9; k++) {
int idx = r * 9 + k;
if (idx / 27 == box / 3 && (idx % 9) / 3 == box % 3) continue; // 同一个宫内的格子不动
if ((b.cand[idx] & bit) != 0) { b.cand[idx] &= ~bit; any = true; }
}
return any;
}
private boolean clearCol(int c, int bit, int box, int d) {
boolean any = false;
for (int k = 0; k < 9; k++) {
int idx = k * 9 + c;
if (idx / 27 == box / 3 && (idx % 9) / 3 == box % 3) continue; // 同一个宫内的格子不动
if ((b.cand[idx] & bit) != 0) { b.cand[idx] &= ~bit; any = true; }
}
return any;
}
private static int indexOf(int unitType, int u, int k) {
switch (unitType) {
case 0: return u * 9 + k; // 行
case 1: return k * 9 + u; // 列
default: return (u / 3 * 3 + k / 3) * 9 + u % 3 * 3 + k % 3; // 宫
}
}
}
4.4 Main:逻辑优先,回溯兜底
package com.example.sudoku;
public class Main {
public static void main(String[] args) {
String puzzle =
"..3.2.6.." + "9..3.5..1" + "..18.64.." +
"..81.89.." + ".45.....3" + "..6.7.2.." +
"..2.4.1.." + "8..9.7..6" + "..1.9.8..";
SudokuBoard board = new SudokuBoard(puzzle);
LogicEngine engine = new LogicEngine(board);
if (engine.propagate() && board.empties() == 0) {
print(board, "纯逻辑求解成功");
} else {
System.out.println("逻辑技巧无法完全求解,剩余空格:" + board.empties() + ",转回溯兜底");
if (backtrack(board)) print(board, "回溯求解成功");
else System.out.println("无解");
}
board.trace().forEach(System.out::println);
}
/** 标准 DFS:选候选最少的格子分支,减少分支因子 */
static boolean backtrack(SudokuBoard b) {
int best = -1, bestCnt = 10;
for (int i = 0; i < 81; i++) {
if (b.grid[i] != 0) continue;
int c = Integer.bitCount(b.cand[i]);
if (c == 0) return false;
if (c < bestCnt) { bestCnt = c; best = i; }
}
if (best == -1) return true;
for (int d = 1; d <= 9; d++) {
if ((b.cand[best] & (1 << (d - 1))) == 0) continue;
SudokuBoard copy = b.copy();
copy.place(best, d);
if (new LogicEngine(copy).propagate() && backtrack(copy)) { b.copyFrom(copy); return true; }
}
return false;
}
static void print(SudokuBoard b, String title) {
System.out.println("=== " + title + " ===");
for (int r = 0; r < 9; r++) {
StringBuilder sb = new StringBuilder();
for (int c = 0; c < 9; c++) sb.append(b.grid[r * 9 + c]).append(c == 8 ? "" : " ");
System.out.println(sb);
}
}
}
(copy() / copyFrom() 只需克隆 grid 与 cand 两个数组,此处略。)
五、为什么”结构推理优先”值得
从算法角度看,X-Wing 与剑鱼做的事情可以用一句话概括:把候选数矩阵的行、列当作两个维度的位图,寻找”2×2 / 3×3 的满射结构”。
| 技巧 | 观察对象 | 删减范围 | 时间复杂度 |
|---|---|---|---|
| 唯一候选 | 单格候选位 | 直接落子 | O(81) |
| 隐性唯一 | 单数字在单元内的位置 | 直接落子 | O(9·9·9) |
| 区块删减 | 宫内数字的行/列集中度 | 同行或同列 | O(9·9·9) |
| X-Wing | 2 行 × 2 列 | 2 列(或 2 行) | O(9·36) |
| 剑鱼 | 3 行 × 3 列 | 3 列(或 3 行) | O(9·84) |
一次 X-Wing 或剑鱼往往能一次删掉 2~6 个候选,把后续回溯的分支因子从 9 降到 3 以下。实测在中高难度题目上,”唯一候选 + 隐性唯一 + 区块删减 + X-Wing + 剑鱼”这五层技巧可以独立解掉约七成的人类中等难度题,而纯回溯平均需要探索数千个节点。
六、复杂度与工程要点
- 单次
fish调用:枚举 C(9,3)=84 组三线,每组做 3×9 次位运算,整体是常数级(<1 万次操作)。 - 主循环:每轮至少删掉一个候选或填一格,共 81 格,最多 81 轮,因此逻辑推理阶段是”多项式且极快”的。
- 位掩码让候选集操作全部变成
&/|/~,比HashSet<Integer>快一个数量级以上,这是数独引擎能”边推理边讲解”的前提。
如果你的目标是做一个”解题教学器”而不是”答案生成器”,那么这套结构推理引擎比回溯更有价值:它输出的步骤序列,正是人类玩家解题时脑中真正发生的过程。