每日算法 — 使用java实现数独:约束传播与候选数链式排除

数独(Sudoku)是源自18世纪瑞士的经典逻辑填数游戏。玩家在9×9的格子中填入1-9的数字,满足每行、每列、每个3×3宫格均不重复。表面上看是简单的填数,背后却涉及约束满足问题(CSP)约束传播(Constraint Propagation)回溯搜索(Backtracking)三大算法支柱。本文将用Java完整实现一个带智能提示的数独求解器,从候选数管理到链式约束排除逐层剖析。

一、数独的约束满足问题建模

1.1 为什么用CSP视角

标准数独有81个变量(格子),每个变量的取值域为{1,2,…,9}。约束条件包括:
行约束:同一行9个格子取值互异
列约束:同一列9个格子取值互异
宫约束:同一3×3宫格内9个格子取值互异

这种”变量-取值域-约束”的三元组结构,天然适合用约束满足问题框架求解。与暴力回溯相比,约束传播能在搜索前大量剪枝,将搜索空间压缩数个数量级。

1.2 核心数据结构:候选数集合

每个格子维护一个候选数集合(Bitmask实现),表示当前仍可行的数字:

/**
 * 数独格子:维护候选数集合的位掩码表示
 * 第i位为1表示数字(i+1)仍可行,例如bitmask=0b101表示候选数{1,3}
 */
class Cell {
    // 格子值,0表示未填
    int value;
    // 候选数位掩码,9位分别对应数字1-9
    int candidates;
    // 已确定数值时candidates=0
    boolean fixed;

    Cell() {
        this.value = 0;
        this.candidates = 0b111111111; // 初始时1-9均可
        this.fixed = false;
    }

    /**
     * 设置确定值,清空候选数
     */
    void setValue(int v) {
        this.value = v;
        this.candidates = 0;
        this.fixed = true;
    }

    /**
     * 从候选数中移除指定数字
     * @return 如果候选数发生变化返回true
     */
    boolean eliminate(int num) {
        int mask = 1 << (num - 1);
        if ((candidates & mask) != 0) {
            candidates &= ~mask;
            return true;
        }
        return false;
    }

    /**
     * 获取当前候选数数量
     */
    int candidateCount() {
        return Integer.bitCount(candidates);
    }

    /**
     * 获取唯一的候选数(当candidateCount==1时有效)
     */
    int getOnlyCandidate() {
        return Integer.numberOfTrailingZeros(candidates) + 1;
    }

    /**
     * 检查候选数中是否包含某数字
     */
    boolean hasCandidate(int num) {
        return (candidates & (1 << (num - 1))) != 0;
    }

    @Override
    public String toString() {
        return fixed ? String.valueOf(value) : ".";
    }
}

1.3 数独棋盘整体结构

/**
 * 数独棋盘:9×9格子 + 约束传播引擎
 */
class SudokuBoard {
    static final int SIZE = 9;
    static final int BOX_SIZE = 3;
    private final Cell[][] cells;
    // 用于快速定位同行/同列/同宫的格子索引
    private final List<int[]>[] rowCells;   // 每行包含的格子坐标
    private final List<int[]>[] colCells;   // 每列包含的格子坐标
    private final List<int[]>[] boxCells;   // 每宫包含的格子坐标

    @SuppressWarnings("unchecked")
    SudokuBoard() {
        cells = new Cell[SIZE][SIZE];
        rowCells = new ArrayList[SIZE];
        colCells = new ArrayList[SIZE];
        boxCells = new ArrayList[SIZE];
        for (int i = 0; i < SIZE; i++) {
            rowCells[i] = new ArrayList<>();
            colCells[i] = new ArrayList<>();
            boxCells[i] = new ArrayList<>();
        }
        for (int r = 0; r < SIZE; r++) {
            for (int c = 0; c < SIZE; c++) {
                cells[r][c] = new Cell();
                rowCells[r].add(new int[]{r, c});
                colCells[c].add(new int[]{r, c});
                int boxIdx = (r / BOX_SIZE) * BOX_SIZE + (c / BOX_SIZE);
                boxCells[boxIdx].add(new int[]{r, c});
            }
        }
    }

    /**
     * 从字符串加载数独(81个字符,'.'或'0'表示空格)
     */
    void loadFromString(String puzzle) {
        for (int i = 0; i < 81; i++) {
            char ch = puzzle.charAt(i);
            int r = i / SIZE, c = i % SIZE;
            if (ch >= '1' && ch <= '9') {
                cells[r][c].setValue(ch - '0');
            }
        }
    }

    Cell getCell(int r, int c) {
        return cells[r][c];
    }

    /**
     * 获取指定格子所属宫格的索引
     */
    static int boxIndex(int r, int c) {
        return (r / BOX_SIZE) * BOX_SIZE + (c / BOX_SIZE);
    }

    boolean isSolved() {
        for (int r = 0; r < SIZE; r++) {
            for (int c = 0; c < SIZE; c++) {
                if (cells[r][c].value == 0) return false;
            }
        }
        return true;
    }

    /**
     * 打印当前棋盘状态
     * 已填数字显示数字,未填显示候选数数量提示
     */
    void printBoard() {
        for (int r = 0; r < SIZE; r++) {
            if (r > 0 && r % BOX_SIZE == 0) {
                System.out.println("------+-------+------");
            }
            for (int c = 0; c < SIZE; c++) {
                if (c > 0 && c % BOX_SIZE == 0) System.out.print("| ");
                Cell cell = cells[r][c];
                System.out.print(cell.fixed ? cell.value : ".");
                System.out.print(" ");
            }
            System.out.println();
        }
    }

    /**
     * 深拷贝棋盘(用于回溯时保存状态)
     */
    SudokuBoard deepCopy() {
        SudokuBoard copy = new SudokuBoard();
        for (int r = 0; r < SIZE; r++) {
            for (int c = 0; c < SIZE; c++) {
                Cell src = this.cells[r][c];
                Cell dst = copy.cells[r][c];
                dst.value = src.value;
                dst.candidates = src.candidates;
                dst.fixed = src.fixed;
            }
        }
        return copy;
    }
}

二、核心算法一:约束传播引擎

2.1 初始约束传播

当棋盘上已有一些预设数字时,首先需要将这些信息传播到相关格子的候选数中:

/**
 * 约束传播引擎:负责维护候选数集合的一致性
 */
class ConstraintPropagator {
    private final SudokuBoard board;
    // 传播队列:存储刚被确定的格子坐标,触发周边候选数更新
    private final ArrayDeque<int[]> propagationQueue;

    ConstraintPropagator(SudokuBoard board) {
        this.board = board;
        this.propagationQueue = new ArrayDeque<>();
    }

    /**
     * 执行完整的初始约束传播
     * 步骤:
     * 1. 将所有预设数字加入传播队列
     * 2. 循环处理队列:对刚确定的数字,从同行/同列/同宫格子中消除该候选数
     * 3. 若某格子候选数减为1,自动确定其值并加入队列继续传播
     * @return 如果传播过程中发现矛盾(某格子候选数为空)返回false
     */
    boolean propagateAll() {
        // 初始化:将所有已填数字加入传播队列
        for (int r = 0; r < SudokuBoard.SIZE; r++) {
            for (int c = 0; c < SudokuBoard.SIZE; c++) {
                Cell cell = board.getCell(r, c);
                if (cell.fixed) {
                    propagationQueue.add(new int[]{r, c});
                }
            }
        }
        return processQueue();
    }

    /**
     * 处理传播队列,直到队列为空
     */
    private boolean processQueue() {
        while (!propagationQueue.isEmpty()) {
            int[] pos = propagationQueue.poll();
            int r = pos[0], c = pos[1];
            Cell cell = board.getCell(r, c);
            int val = cell.value;

            // 从同行消除
            for (int[] rc : board.rowCells[r]) {
                if (rc[0] == r && rc[1] == c) continue;
                if (!eliminateCandidate(rc[0], rc[1], val)) return false;
            }
            // 从同列消除
            for (int[] rc : board.colCells[c]) {
                if (rc[0] == r && rc[1] == c) continue;
                if (!eliminateCandidate(rc[0], rc[1], val)) return false;
            }
            // 从同宫消除
            int boxIdx = SudokuBoard.boxIndex(r, c);
            for (int[] rc : board.boxCells[boxIdx]) {
                if (rc[0] == r && rc[1] == c) continue;
                if (!eliminateCandidate(rc[0], rc[1], val)) return false;
            }
        }
        return true;
    }

    /**
     * 从指定格子消除候选数num
     * 如果消除后候选数唯一,自动赋值并加入传播队列
     * @return false表示出现矛盾(候选数为空)
     */
    private boolean eliminateCandidate(int r, int c, int num) {
        Cell cell = board.getCell(r, c);
        if (cell.fixed) return true; // 已确定,无需处理

        boolean changed = cell.eliminate(num);
        if (!changed) return true;

        // 矛盾检测:候选数为空
        if (cell.candidates == 0) {
            return false; // 约束冲突
        }

        // 唯余法(Naked Single):候选数只剩一个,自动确定
        if (cell.candidateCount() == 1) {
            int onlyVal = cell.getOnlyCandidate();
            cell.setValue(onlyVal);
            propagationQueue.add(new int[]{r, c});
        }

        return true;
    }
}

2.2 隐单法(Hidden Single)

除了唯余法(某格子只剩一个候选数),还有更强的隐单法:在某行/列/宫中,某个数字只能出现在一个格子的候选数中,则该格子必为该数字:

/**
 * 隐单法检测:在某约束单元中,某数字仅出现在一个格子的候选数中
 */
class HiddenSingleFinder {
    private final SudokuBoard board;

    HiddenSingleFinder(SudokuBoard board) {
        this.board = board;
    }

    /**
     * 在整个棋盘扫描隐单,直到没有新的隐单被发现
     * @return 如果找到并设置了隐单返回true(表示需要继续传播)
     */
    boolean findAndApplyHiddenSingles() {
        boolean progress = false;
        boolean changed;
        do {
            changed = false;
            // 扫描所有行
            for (int i = 0; i < SudokuBoard.SIZE; i++) {
                if (scanUnit(board.rowCells[i])) changed = true;
            }
            // 扫描所有列
            for (int i = 0; i < SudokuBoard.SIZE; i++) {
                if (scanUnit(board.colCells[i])) changed = true;
            }
            // 扫描所有宫
            for (int i = 0; i < SudokuBoard.SIZE; i++) {
                if (scanUnit(board.boxCells[i])) changed = true;
            }
            if (changed) progress = true;
        } while (changed);
        return progress;
    }

    /**
     * 扫描一个约束单元(行/列/宫),查找隐单
     * @return 如果找到并应用了隐单返回true
     */
    private boolean scanUnit(List<int[]> unitCells) {
        boolean changed = false;
        // 对每个数字1-9,统计在哪些未填格子的候选数中出现
        for (int num = 1; num <= 9; num++) {
            int count = 0;
            int[] targetPos = null;
            for (int[] rc : unitCells) {
                Cell cell = board.getCell(rc[0], rc[1]);
                if (cell.fixed && cell.value == num) {
                    count = -1; // 该数字已存在,跳过
                    break;
                }
                if (!cell.fixed && cell.hasCandidate(num)) {
                    count++;
                    targetPos = rc;
                }
            }
            // 隐单发现:某数字恰好只在一个未填格子的候选数中
            if (count == 1 && targetPos != null) {
                Cell target = board.getCell(targetPos[0], targetPos[1]);
                if (!target.fixed) {
                    target.setValue(num);
                    changed = true;
                }
            }
        }
        return changed;
    }
}

三、核心算法二:约束传播驱动的回溯求解

3.1 求解器主框架

将约束传播与回溯结合:每次赋值前先进行完整的约束传播,大幅剪枝搜索空间:

/**
 * 数独求解器:约束传播 + 隐单法 + 回溯搜索
 */
class SudokuSolver {
    private int searchCount; // 统计搜索节点数,用于评估剪枝效果

    SudokuBoard solve(SudokuBoard original) {
        searchCount = 0;
        SudokuBoard board = original.deepCopy();

        // 第一阶段:约束传播
        ConstraintPropagator propagator = new ConstraintPropagator(board);
        if (!propagator.propagateAll()) {
            return null; // 初始约束矛盾,无解
        }

        // 第二阶段:隐单法
        HiddenSingleFinder hsFinder = new HiddenSingleFinder(board);
        if (hsFinder.findAndApplyHiddenSingles()) {
            // 隐单产生新确定值,需要再次约束传播
            ConstraintPropagator cp2 = new ConstraintPropagator(board);
            if (!cp2.propagateAll()) return null;
        }

        // 第三阶段:回溯搜索(带MRV启发式)
        SudokuBoard result = backtrack(board);
        System.out.println("搜索节点数: " + searchCount);
        return result;
    }

    /**
     * 回溯搜索,采用MRV(Minimum Remaining Values)启发式:
     * 优先选择候选数最少的格子进行尝试,最大化剪枝效率
     */
    private SudokuBoard backtrack(SudokuBoard board) {
        if (board.isSolved()) return board;

        // MRV:找到候选数最少且大于0的未填格子
        int[] mrvPos = findMRV(board);
        if (mrvPos == null) return null; // 有未填格子但无候选数,死路

        int r = mrvPos[0], c = mrvPos[1];
        Cell cell = board.getCell(r, c);
        int candidates = cell.candidates;

        // 按候选数从小到大尝试(排序启发式)
        List<Integer> candList = new ArrayList<>();
        for (int num = 1; num <= 9; num++) {
            if ((candidates & (1 << (num - 1))) != 0) {
                candList.add(num);
            }
        }

        for (int num : candList) {
            searchCount++;
            SudokuBoard copy = board.deepCopy();
            Cell copyCell = copy.getCell(r, c);
            copyCell.setValue(num);

            // 赋值后立即进行约束传播
            ConstraintPropagator cp = new ConstraintPropagator(copy);
            if (!cp.propagateAll()) continue; // 传播出现矛盾,剪枝

            // 隐单法再次应用
            HiddenSingleFinder hs = new HiddenSingleFinder(copy);
            if (hs.findAndApplyHiddenSingles()) {
                ConstraintPropagator cp2 = new ConstraintPropagator(copy);
                if (!cp2.propagateAll()) continue;
            }

            SudokuBoard result = backtrack(copy);
            if (result != null) return result;
        }
        return null; // 所有候选数尝试失败
    }

    /**
     * MRV启发式:找到候选数最少(且大于0)的未填格子
     * @return [row, col] 或 null(如果没有未填格子或矛盾)
     */
    private int[] findMRV(SudokuBoard board) {
        int minCandidates = 10;
        int[] bestPos = null;
        for (int r = 0; r < SudokuBoard.SIZE; r++) {
            for (int c = 0; c < SudokuBoard.SIZE; c++) {
                Cell cell = board.getCell(r, c);
                if (!cell.fixed) {
                    int count = cell.candidateCount();
                    if (count == 0) return null; // 矛盾
                    if (count < minCandidates) {
                        minCandidates = count;
                        bestPos = new int[]{r, c};
                    }
                }
            }
        }
        return bestPos;
    }
}

3.2 完整可运行的主程序

import java.util.*;

/**
 * 数独求解器主程序
 * 演示约束传播 + 隐单法 + MRV回溯的完整求解流程
 */
public class SudokuSolverApp {
    public static void main(String[] args) {
        // 世界最难数独之一(由芬兰数学家Arto Inkala设计)
        String hardPuzzle =
            "800000000" +
            "003600000" +
            "070090200" +
            "050007000" +
            "000045700" +
            "000100030" +
            "001000068" +
            "008500010" +
            "090000400";

        // 中等难度数独
        String mediumPuzzle =
            "530070000" +
            "600195000" +
            "098000060" +
            "800060003" +
            "400803001" +
            "700020006" +
            "060000280" +
            "000419005" +
            "000080079";

        System.out.println("=== 数独约束传播求解器 ===\n");

        // 求解中等难度
        System.out.println("【中等难度数独】");
        solveAndDisplay(mediumPuzzle);

        System.out.println("\n【世界最难数独】");
        solveAndDisplay(hardPuzzle);
    }

    static void solveAndDisplay(String puzzle) {
        SudokuBoard board = new SudokuBoard();
        board.loadFromString(puzzle);

        System.out.println("原始题目:");
        board.printBoard();

        long start = System.currentTimeMillis();
        SudokuSolver solver = new SudokuSolver();
        SudokuBoard solution = solver.solve(board);
        long elapsed = System.currentTimeMillis() - start;

        if (solution != null) {
            System.out.println("\n求解结果:");
            solution.printBoard();
            System.out.println("耗时: " + elapsed + "ms");
        } else {
            System.out.println("无解!");
        }
    }
}

四、关键算法详解

4.1 位掩码的候选数管理

本文使用9位整数表示候选数集合,相比HashSet<Integer>boolean[]有显著优势:
空间:每个格子仅需1个int(4字节),而非9个boolean或对象引用
速度:消除候选数是单次位运算&= ~mask,O(1)时间
计数Integer.bitCount()由JVM intrinsic优化,底层用CPU指令POPCNT

初始候选数:  0b111111111 = 511  (数字1-9均可)
消除数字3后: 0b111110111 = 503  (第3位置0)
只剩数字5:   0b000010000 = 16   (可直接确定)

4.2 MRV启发式的剪枝威力

最小剩余值(Minimum Remaining Values)启发式优先选择候选数最少的变量,其直觉是:约束最多的变量最容易导致失败,越早尝试它越早发现死路

以世界最难数独为例:
– 纯回溯(无约束传播):搜索节点数可达数百万
– 约束传播 + MRV:搜索节点数通常降至数百甚至数十
– 加入隐单法后,许多数独可零回溯直接求解

4.3 约束传播与回溯的关系

两者形成互补的求解策略:
约束传播是”推理”:通过逻辑推导直接确定某些格子的值,无需猜测
回溯搜索是”猜测”:当推理无法继续时,对某个格子进行假设赋值

高效求解器的核心在于让约束传播尽可能多做工作,减少回溯次数。本文实现的求解器在每次赋值后都触发完整传播,确保搜索树极度瘦削。

五、算法复杂度分析

模块 时间复杂度 空间复杂度 说明
候选数消除(单次) O(1) O(1) 位运算操作
约束传播(一轮) O(81×9) O(81) 每个格子最多消除9次
隐单法扫描 O(27×9×9) O(1) 27个单元×9数字×9格子
MRV选择 O(81) O(1) 遍历所有格子
回溯搜索最坏情况 O(9^81) O(81×深度) 理论上限,实际远小于此
实际中等难度 <1ms O(81) 约束传播后几乎零回溯

对于合法数独题目(唯一解),约束传播通常能直接解决60%-90%的格子,剩余格子的搜索空间已被极度压缩。

六、扩展方向

  1. 高级约束技巧:实现唯一矩形(Unique Rectangle)、X-Wing、Swordfish等高级模式,进一步提升无回溯求解率
  2. 多解检测:修改回溯逻辑,收集所有解而非找到第一个就返回,检测题目是否有唯一解
  3. 数独生成为NP问题:通过随机挖空+唯一解验证生成不同难度数独,挖空策略可用随机+回溯验证
  4. 并行求解:对不同分支赋值进行多线程并行回溯,利用现代CPU多核优势
  5. 可视化求解过程:用Swing/JavaFX展示约束传播过程中候选数的逐步消减动画
  6. 难度评估算法:基于求解过程中约束传播轮数、回溯节点数、高级技巧使用次数综合评分

七、总结

数独是理解约束满足问题的绝佳入门项目。本文实现的约束传播引擎通过位掩码候选数管理队列驱动的链式消除隐单法推理MRV回溯四大技术组合,将数独求解从指数级暴力搜索优化到毫秒级响应。掌握这些基础后,你可以进一步学习SMT求解器、CSP求解框架(如Choco、JaCoP),向更复杂的调度、配置、规划问题迈进。