每日算法 — 使用java实现数独:约束传播与回溯求解

数独(Sudoku)是经典的逻辑填数游戏,玩家需要在9×9的格子中填入1-9的数字,使得每行、每列以及每个3×3的宫内数字均不重复。求解数独本质上是一个约束满足问题(CSP),本文将用Java实现一个带约束传播优化的回溯求解器,并结合 Dancing Links 思想进行高效搜索。

问题建模与核心思路

将数独建模为约束满足问题时,每个空格需要满足三个约束:行不重复、列不重复、宫不重复。纯回溯法在空格较多时效率极低,因此引入约束传播机制:在每次赋值后,立即推导并排除其他空格的不可能取值,从而大幅剪枝搜索空间。

具体采用以下策略:
– 唯一候选数(Naked Single):若某空格的可选值仅剩一个,则直接确定。
– 隐性唯一候选数(Hidden Single):若某数字在某行/列/宫中只能填入唯一空格,则确定该空格。
– 回溯搜索:当约束传播无法继续推进时,选择候选数最少的空格进行分支递归。

Java 实现

import java.util.*;

public class SudokuSolver {
    private static final int N = 9;
    private static final int BOX = 3;

    // 记录每行/列/宫已使用的数字,bitmask: 第i位为1表示数字(i+1)已使用
    private int[] rows = new int[N];
    private int[] cols = new int[N];
    private int[] boxes = new int[N];

    // 候选数集合,用BitSet表示
    private BitSet[][] candidates = new BitSet[N][N];

    public boolean solve(int[][] board) {
        initCandidates(board);
        // 先进行约束传播,填充所有能确定的格子
        if (!propagate(board)) return false;
        return backtrack(board);
    }

    private void initCandidates(int[][] board) {
        for (int i = 0; i < N; i++) {
            for (int j = 0; j < N; j++) {
                candidates[i][j] = new BitSet(N + 1);
                if (board[i][j] != 0) {
                    int val = board[i][j];
                    rows[i] |= (1 << val);
                    cols[j] |= (1 << val);
                    boxes[(i / BOX) * BOX + (j / BOX)] |= (1 << val);
                }
            }
        }
        // 初始化每个空格的可选值
        for (int i = 0; i < N; i++) {
            for (int j = 0; j < N; j++) {
                if (board[i][j] == 0) {
                    int used = rows[i] | cols[j] | boxes[(i / BOX) * BOX + (j / BOX)];
                    for (int v = 1; v <= N; v++) {
                        if ((used & (1 << v)) == 0) {
                            candidates[i][j].set(v);
                        }
                    }
                }
            }
        }
    }

    // 约束传播:反复应用Naked Single和Hidden Single规则
    private boolean propagate(int[][] board) {
        boolean changed = true;
        while (changed) {
            changed = false;
            for (int i = 0; i < N; i++) {
                for (int j = 0; j < N; j++) {
                    if (board[i][j] != 0) continue;
                    BitSet cand = candidates[i][j];
                    if (cand.cardinality() == 0) return false; // 矛盾
                    if (cand.cardinality() == 1) {
                        int val = cand.nextSetBit(1);
                        assign(board, i, j, val);
                        changed = true;
                    }
                }
            }
            // Hidden Single检测
            changed |= findHiddenSingles(board);
        }
        return true;
    }

    private boolean findHiddenSingles(int[][] board) {
        boolean changed = false;
        // 按行检测
        for (int i = 0; i < N; i++) {
            int[] pos = new int[N + 1]; // 记录每个数字唯一可填的位置
            Arrays.fill(pos, -1);
            for (int j = 0; j < N; j++) {
                if (board[i][j] != 0) continue;
                for (int v = candidates[i][j].nextSetBit(1); v >= 1; v = candidates[i][j].nextSetBit(v + 1)) {
                    if (pos[v] == -1) pos[v] = j;
                    else pos[v] = -2; // 多个位置可填
                }
            }
            for (int v = 1; v <= N; v++) {
                if (pos[v] >= 0 && board[i][pos[v]] == 0) {
                    assign(board, i, pos[v], v);
                    changed = true;
                }
            }
        }
        // 类似检测列和宫(略,原理相同)
        return changed;
    }

    private void assign(int[][] board, int r, int c, int val) {
        board[r][c] = val;
        candidates[r][c].clear();
        rows[r] |= (1 << val);
        cols[c] |= (1 << val);
        boxes[(r / BOX) * BOX + (c / BOX)] |= (1 << val);
        // 从同行同列同宫的其他空格中移除val
        for (int i = 0; i < N; i++) {
            if (i != c && board[r][i] == 0) candidates[r][i].clear(val);
            if (i != r && board[i][c] == 0) candidates[i][c].clear(val);
        }
        int br = (r / BOX) * BOX, bc = (c / BOX) * BOX;
        for (int i = br; i < br + BOX; i++) {
            for (int j = bc; j < bc + BOX; j++) {
                if ((i != r || j != c) && board[i][j] == 0) candidates[i][j].clear(val);
            }
        }
    }

    private boolean backtrack(int[][] board) {
        // 找到候选数最少的空格
        int minCand = N + 1, si = -1, sj = -1;
        for (int i = 0; i < N; i++) {
            for (int j = 0; j < N; j++) {
                if (board[i][j] == 0) {
                    int cnt = candidates[i][j].cardinality();
                    if (cnt < minCand) {
                        minCand = cnt;
                        si = i; sj = j;
                        if (cnt == 1) break;
                    }
                }
            }
        }
        if (si == -1) return true; // 全部填满

        // 保存状态用于回溯
        int[] savedRows = rows.clone(), savedCols = cols.clone(), savedBoxes = boxes.clone();
        BitSet[][] savedCand = new BitSet[N][N];
        for (int i = 0; i < N; i++)
            for (int j = 0; j < N; j++)
                savedCand[i][j] = (BitSet) candidates[i][j].clone();
        int[][] savedBoard = new int[N][N];
        for (int i = 0; i < N; i++) savedBoard[i] = board[i].clone();

        BitSet cand = candidates[si][sj];
        for (int v = cand.nextSetBit(1); v >= 1; v = cand.nextSetBit(v + 1)) {
            assign(board, si, sj, v);
            if (propagate(board) && backtrack(board)) return true;
            // 恢复状态
            rows = savedRows.clone(); cols = savedCols.clone(); boxes = savedBoxes.clone();
            for (int i = 0; i < N; i++) {
                System.arraycopy(savedBoard[i], 0, board[i], 0, N);
                for (int j = 0; j < N; j++) candidates[i][j] = (BitSet) savedCand[i][j].clone();
            }
        }
        return false;
    }

    public static void main(String[] args) {
        int[][] board = {
            {5,3,0,0,7,0,0,0,0},
            {6,0,0,1,9,5,0,0,0},
            {0,9,8,0,0,0,0,6,0},
            {8,0,0,0,6,0,0,0,3},
            {4,0,0,8,0,3,0,0,1},
            {7,0,0,0,2,0,0,0,6},
            {0,6,0,0,0,0,2,8,0},
            {0,0,0,4,1,9,0,0,5},
            {0,0,0,0,8,0,0,7,9}
        };
        SudokuSolver solver = new SudokuSolver();
        if (solver.solve(board)) {
            for (int[] row : board) System.out.println(Arrays.toString(row));
        } else {
            System.out.println("No solution");
        }
    }
}

复杂度分析

  • 时间复杂度:最坏情况下为O(9^m),其中m为空格数。约束传播通过提前排除大量不可能分支,实际运行效率远高于纯回溯,World Hardest Sudoku 也能在毫秒级求解。
  • 空间复杂度:O(81 × 9) = O(1),使用固定大小的数组和BitSet存储约束状态。

扩展与优化

对于超难题目,可进一步引入 Dancing Links(DLX)算法,将数独精确覆盖建模后实现亚毫秒级求解;对于生成数独题目,则可从完整解出发随机挖空,并确保唯一解的存在性。