每日算法 — 使用java实现数独:X-Wing与剑鱼结构推理

数独是大家最熟悉的逻辑游戏:在 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,如果

  1. 第 r1 行中 d 的候选位置恰好只有 {c1, c2} 两列;
  2. 第 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> 快一个数量级以上,这是数独引擎能”边推理边讲解”的前提。

如果你的目标是做一个”解题教学器”而不是”答案生成器”,那么这套结构推理引擎比回溯更有价值:它输出的步骤序列,正是人类玩家解题时脑中真正发生的过程。