每日算法 — 使用java实现数独:SAT求解器转化与DPLL回溯搜索

数独(Sudoku)是一种经典的逻辑填数游戏,玩家需要在9×9的格子中填入数字1-9,使得每行、每列以及每个3×3宫格内的数字均不重复。本文将介绍如何将数独问题转化为布尔可满足性问题(SAT),并使用DPLL算法进行高效求解。

问题建模:从数独到SAT

SAT问题是判定一个布尔公式是否存在满足赋值的问题。将数独转化为SAT的核心思想是:为每个格子(i,j)和每个可能的数字k定义一个布尔变量x(i,j,k),当且仅当格子(i,j)填入数字k时,x(i,j,k)为真。

约束条件的CNF表示

数独规则可以编码为合取范式(CNF):

  1. 每个格子至少填一个数字:对于每个格子(i,j),至少有一个x(i,j,k)为真
  2. 每个格子至多填一个数字:对于每个格子(i,j),不能同时有两个不同的数字
  3. 每行数字不重复:每行中每个数字k只能出现一次
  4. 每列数字不重复:每列中每个数字k只能出现一次
  5. 每个宫格数字不重复:每个3×3宫格中每个数字k只能出现一次

DPLL算法核心思想

DPLL(Davis-Putnam-Logemann-Loveland)算法是求解SAT问题的经典回溯算法,核心策略包括:

  • 单元传播(Unit Propagation):如果子句中只剩一个未赋值文字,则该文字必须为真
  • 纯文字消除(Pure Literal Elimination):如果一个变量在所有子句中只以正或负形式出现,则可以直接赋值
  • 回溯搜索(Backtracking):对未赋值变量尝试真/假赋值,递归求解

Java实现

import java.util.*;

/**
 * SAT求解器:使用DPLL算法解决数独问题
 * 将数独转化为CNF布尔公式,通过单元传播和回溯搜索找到满足解
 */
public class SudokuDPLL {

    // 数独大小
    private static final int N = 9;
    private static final int BOX = 3;

    // CNF公式:子句列表,每个子句是文字列表(正数表示正文字,负数表示负文字)
    private List<List<Integer>> clauses;

    // 变量赋值状态:0=未赋值, 1=真, -1=假
    private int[] assignment;

    // 变量总数:N*N*N = 729个布尔变量
    private int varCount;

    public SudokuDPLL() {
        this.clauses = new ArrayList<>();
        this.varCount = N * N * N;
        this.assignment = new int[varCount + 1];
    }

    /**
     * 将数独坐标和数字编码为变量编号
     * 变量编号 = (row * N + col) * N + (num - 1) + 1
     */
    private int var(int row, int col, int num) {
        return (row * N + col) * N + (num - 1) + 1;
    }

    /**
     * 添加一个子句到CNF公式
     */
    private void addClause(List<Integer> clause) {
        clauses.add(new ArrayList<>(clause));
    }

    /**
     * 构建数独的CNF约束
     * 生成所有必要的子句确保数独规则被满足
     */
    public void buildCNF(int[][] puzzle) {
        clauses.clear();

        // 1. 每个格子至少填一个数字
        for (int r = 0; r < N; r++) {
            for (int c = 0; c < N; c++) {
                List<Integer> clause = new ArrayList<>();
                for (int n = 1; n <= N; n++) {
                    clause.add(var(r, c, n));
                }
                addClause(clause);
            }
        }

        // 2. 每个格子至多填一个数字(两两互斥)
        for (int r = 0; r < N; r++) {
            for (int c = 0; c < N; c++) {
                for (int n1 = 1; n1 <= N; n1++) {
                    for (int n2 = n1 + 1; n2 <= N; n2++) {
                        List<Integer> clause = new ArrayList<>();
                        clause.add(-var(r, c, n1));
                        clause.add(-var(r, c, n2));
                        addClause(clause);
                    }
                }
            }
        }

        // 3. 每行数字不重复
        for (int r = 0; r < N; r++) {
            for (int n = 1; n <= N; n++) {
                for (int c1 = 0; c1 < N; c1++) {
                    for (int c2 = c1 + 1; c2 < N; c2++) {
                        List<Integer> clause = new ArrayList<>();
                        clause.add(-var(r, c1, n));
                        clause.add(-var(r, c2, n));
                        addClause(clause);
                    }
                }
            }
        }

        // 4. 每列数字不重复
        for (int c = 0; c < N; c++) {
            for (int n = 1; n <= N; n++) {
                for (int r1 = 0; r1 < N; r1++) {
                    for (int r2 = r1 + 1; r2 < N; r2++) {
                        List<Integer> clause = new ArrayList<>();
                        clause.add(-var(r1, c, n));
                        clause.add(-var(r2, c, n));
                        addClause(clause);
                    }
                }
            }
        }

        // 5. 每个宫格数字不重复
        for (int br = 0; br < BOX; br++) {
            for (int bc = 0; bc < BOX; bc++) {
                for (int n = 1; n <= N; n++) {
                    for (int i = 0; i < N; i++) {
                        int r1 = br * BOX + i / BOX;
                        int c1 = bc * BOX + i % BOX;
                        for (int j = i + 1; j < N; j++) {
                            int r2 = br * BOX + j / BOX;
                            int c2 = bc * BOX + j % BOX;
                            List<Integer> clause = new ArrayList<>();
                            clause.add(-var(r1, c1, n));
                            clause.add(-var(r2, c2, n));
                            addClause(clause);
                        }
                    }
                }
            }
        }

        // 6. 已填数字的单元子句(强制约束)
        for (int r = 0; r < N; r++) {
            for (int c = 0; c < N; c++) {
                if (puzzle[r][c] != 0) {
                    List<Integer> clause = new ArrayList<>();
                    clause.add(var(r, c, puzzle[r][c]));
                    addClause(clause);
                }
            }
        }
    }

    /**
     * DPLL核心求解算法
     * 结合单元传播和回溯搜索求解SAT问题
     * @return 是否存在满足赋值
     */
    public boolean solve() {
        Arrays.fill(assignment, 0);
        return dpll(new ArrayList<>(clauses));
    }

    /**
     * DPLL递归求解
     * @param currentClauses 当前子句集合
     * @return 是否存在满足赋值
     */
    private boolean dpll(List<List<Integer>> currentClauses) {
        // 应用单元传播直到无法继续
        boolean changed;
        do {
            changed = false;
            Iterator<List<Integer>> it = currentClauses.iterator();
            while (it.hasNext()) {
                List<Integer> clause = it.next();

                // 简化子句:移除已满足的文字和已赋值为假的文字
                List<Integer> simplified = simplifyClause(clause);

                if (simplified == null) {
                    // 子句已被满足
                    it.remove();
                    changed = true;
                } else if (simplified.isEmpty()) {
                    // 空子句,冲突
                    return false;
                } else if (simplified.size() == 1) {
                    // 单元子句,强制赋值
                    int lit = simplified.get(0);
                    int var = Math.abs(lit);
                    int val = lit > 0 ? 1 : -1;

                    if (assignment[var] == -val) {
                        return false; // 冲突
                    }
                    if (assignment[var] == 0) {
                        assignment[var] = val;
                        changed = true;
                    }
                    it.remove();
                }
            }
        } while (changed);

        // 检查是否所有子句都被满足
        if (currentClauses.isEmpty()) {
            return true;
        }

        // 选择未赋值变量进行分支(使用MOMS启发式:选择出现在最短子句中最频繁的变量)
        int branchVar = selectVariable(currentClauses);
        if (branchVar == 0) {
            return true;
        }

        // 保存当前赋值状态以便回溯
        int[] savedAssignment = assignment.clone();

        // 尝试赋值为真
        assignment[branchVar] = 1;
        List<List<Integer>> clausesCopy = copyClauses(currentClauses);
        if (dpll(clausesCopy)) {
            return true;
        }

        // 回溯,尝试赋值为假
        assignment = savedAssignment;
        assignment[branchVar] = -1;
        clausesCopy = copyClauses(currentClauses);
        return dpll(clausesCopy);
    }

    /**
     * 简化子句:根据当前赋值状态
     * @return null表示子句已被满足,空列表表示空子句(冲突),否则返回简化后的子句
     */
    private List<Integer> simplifyClause(List<Integer> clause) {
        List<Integer> result = new ArrayList<>();
        for (int lit : clause) {
            int var = Math.abs(lit);
            int val = assignment[var];
            if (val == 0) {
                result.add(lit);
            } else if ((lit > 0 && val == 1) || (lit < 0 && val == -1)) {
                return null; // 子句已被满足
            }
            // 否则该文字为假,跳过
        }
        return result;
    }

    /**
     * 复制子句列表
     */
    private List<List<Integer>> copyClauses(List<List<Integer>> clauses) {
        List<List<Integer>> copy = new ArrayList<>();
        for (List<Integer> clause : clauses) {
            copy.add(new ArrayList<>(clause));
        }
        return copy;
    }

    /**
     * 选择分支变量:选择第一个未赋值变量
     */
    private int selectVariable(List<List<Integer>> clauses) {
        for (List<Integer> clause : clauses) {
            for (int lit : clause) {
                int var = Math.abs(lit);
                if (assignment[var] == 0) {
                    return var;
                }
            }
        }
        return 0;
    }

    /**
     * 将求解结果转换回数独矩阵
     */
    public int[][] getSolution() {
        int[][] solution = new int[N][N];
        for (int r = 0; r < N; r++) {
            for (int c = 0; c < N; c++) {
                for (int n = 1; n <= N; n++) {
                    if (assignment[var(r, c, n)] == 1) {
                        solution[r][c] = n;
                        break;
                    }
                }
            }
        }
        return solution;
    }

    /**
     * 打印数独矩阵
     */
    public void printGrid(int[][] grid) {
        for (int r = 0; r < N; r++) {
            if (r % BOX == 0 && r != 0) {
                System.out.println("------+-------+------");
            }
            for (int c = 0; c < N; c++) {
                if (c % BOX == 0 && c != 0) {
                    System.out.print("| ");
                }
                System.out.print(grid[r][c] == 0 ? ". " : grid[r][c] + " ");
            }
            System.out.println();
        }
    }

    public static void main(String[] args) {
        // 测试数独谜题(0表示空格)
        int[][] puzzle = {
            {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}
        };

        SudokuDPLL solver = new SudokuDPLL();
        System.out.println("原始数独:");
        solver.printGrid(puzzle);
        System.out.println();

        long start = System.currentTimeMillis();
        solver.buildCNF(puzzle);
        System.out.println("CNF子句数量:" + solver.clauses.size());

        if (solver.solve()) {
            long end = System.currentTimeMillis();
            int[][] solution = solver.getSolution();
            System.out.println("求解结果:");
            solver.printGrid(solution);
            System.out.println("\n求解耗时:" + (end - start) + "ms");
        } else {
            System.out.println("该数独无解!");
        }
    }
}

算法复杂度分析

DPLL算法的最坏时间复杂度为O(2^n),其中n是变量数量。对于数独问题,有729个变量,直接穷举不可行。但得益于单元传播和约束传播,实际求解效率远高于穷举。

对于标准9×9数独,DPLL求解器通常能在毫秒级别完成求解。CNF子句数量约为11745个,包括单元子句、二元子句等。

总结

本文将数独问题转化为SAT问题,使用DPLL算法进行求解。通过CNF编码将数独的约束条件转化为布尔公式,利用单元传播减少搜索空间,通过回溯搜索找到满足解。这种方法展示了如何将组合优化问题转化为逻辑问题,并利用高效的SAT求解技术进行求解。