数独是个熟得不能再熟的小游戏:9×9 的盘面上填 1~9,每行、每列、每个 3×3 宫内数字不重复。大多数人写数独求解器时的第一反应是”回溯 + 候选数剪枝”,简单题目几百个递归就出答案。但只要遇到”反人类”级别的题(比如那道著名的 Al Escargot),朴素的按行扫描回溯会退化成接近穷举——因为它不会从失败中”学到”任何东西,同一个矛盾会被重复踩上成千上万次。
本文换一条路:把数独编码成 SAT(布尔可满足性)问题,再用工业界主流的 CDCL 求解器去解。CDCL 全称 Conflict-Driven Clause Learning,冲突驱动子句学习。它和普通回溯最大的区别是:每次撞到矛盾,都会把矛盾的成因提炼成一条新子句永久保存下来,并用这条新子句决定”应该退回哪一层”,而不是傻傻地只退一层。这套机制在 SAT 竞赛里统治了二十多年,也是现代 SMT 求解器、硬件验证、软件符号执行的内核。
读完之后你会拿到一个可以直接 javac 编译运行的完整 Java 项目:一个约 300 行的 CDCL 引擎 + 一个数独编码器 + 主程序测试。
一、为什么数独天生就是 SAT 问题
先看数独的约束长什么样:
- 每个格子必须填且只能填一个数字 1~9;
- 同一行里,数字 d 最多出现一次;
- 同一列里,数字 d 最多出现一次;
- 同一 3×3 宫里,数字 d 最多出现一次;
- 题目给出的提示格数字固定。
这五条全部是”某个布尔变量为真 / 某些布尔变量不能同时为真”这类纯命题逻辑约束——没有任何算术、没有顺序依赖、没有”大于小于”。这正是 SAT 的定义域:一堆布尔变量加一堆子句,问是否存在一组赋值让所有子句同时为真。
反过来看,如果用传统回溯法解数独,搜索树的规模有多吓人:一个空格平均有 9 个候选,51 个空格的搜索空间是 9^51 ≈ 4.6×10^48。约束传播能把绝大部分压掉,但在构造得极其刁钻的题目上,剩余搜索量依然是天文数字。SAT 求解器的优势在于它把”学习”变成了搜索的一部分,一次冲突永久受益。
二、SAT 编码:把 9×9 盘面翻译成 729 个变量
编码的第一步是变量命名。引入布尔变量 x(r, c, d) 表示”第 r 行第 c 列的格子填数字 d”,其中 r、c、d 都取 1~9。变量总数 9×9×9 = 729。
用一维下标省内存:
var(r, c, d) = (r * 9 + c) * 9 + d + 1 // r, c, d 均从 0 开始,得到 1~729
接下来把每一类约束写成子句(子句 = 若干文字的析取,多个子句之间是合取):
| 约束语义 | 子句形式 | 数量 |
|---|---|---|
| 每格至少填一个数字 | x(r,c,1) ∨ x(r,c,2) ∨ … ∨ x(r,c,9)(9 元长子句) |
81 |
| 每格至多填一个数字 | ¬x(r,c,d1) ∨ ¬x(r,c,d2),对所有 d1<d2 |
81×C(9,2)=2916 |
| 同一行内数字 d 至多一次 | ¬x(r,c1,d) ∨ ¬x(r,c2,d),c1<c2 |
9×9×36=2916 |
| 同一列内数字 d 至多一次 | 同上,交换行 | 2916 |
| 同一宫内数字 d 至多一次 | 宫内任意两格组合 | 2916 |
| 题目提示 | 单元子句 x(r,c,d) |
视题目 |
合计约 1.17 万条子句。这个规模对 CDCL 来说是小菜——现代求解器每秒能处理百万级子句传播。
有个编码细节值得强调:“至少一个”用长子句,”至多一个”用二元子句对。不要试图用基数约束(cardinality constraint)去压缩”至多一个”,因为朴素的 sequential counter 编码会额外引入辅助变量,反而让 CDCL 的冲突分析变复杂;而在 9 选 1 这个量级上,36 条二元子句是最省事的写法。
三、CDCL 的三个关键机制
CDCL 的骨架是 DPLL( Davis-Putnam-Logemann-Loveland ):反复做”单元传播 → 选一个变量赋值 → 冲突则回溯”,直到所有变量都有赋值(SAT)或根层冲突(UNSAT)。CDCL 在三个地方做了改造。
3.1 两文字监听(Watched Literals)
单元传播的传统做法是”变量一变,扫描所有含它的子句”。这太慢。两文字监听的思路是:每条子句只盯住两个文字,只要这两个之一为真,子句就满足了,其余文字完全不用管。
数据结构上:为每个文字 l 维护一个”监听列表” watches[l],里面放所有把 l 当作被监听文字的子句。当文字 p 被赋为真时,我们只需要去检查 watches[¬p]——因为只有”监听了 ¬p 的子句”才可能因为 ¬p 变假而需要重新找监听位。
对某条子句,如果它的一个监听文字变假了,就尝试在剩下的文字里找一个”还没被赋假”的文字来接替这个监听位;找不到就说明其余文字全假了:此时若另一个监听文字为真则子句满足,否则要么触发单元传播(只剩它未赋值),要么直接冲突。
均摊下来每次传播是 O(1),这是 SAT 求解器能跑到百万级子句/秒的根本原因。
3.2 冲突分析与 1-UIP 学习子句
这是 CDCL 的灵魂。当传播过程撞到冲突,把所有”导致这次冲突的赋值”按时间顺序连起来,会得到一张隐含图(implication graph):节点是文字,边是”因为这条子句,我才被推出”。图的根是当前决策层的决策变量(decision),叶子是冲突子句。
冲突分析做的事是:从冲突节点出发反向做”割”(cut),把图中所有”当前决策层的文字”汇总,直到只剩一个——那个文字就叫 UIP(Unique Implication Point,唯一蕴含点)。所谓 1-UIP 就是”第一次出现唯一蕴含点”的那一层割。
得到的这条学习子句有两个非常好的性质:
- 它是对原公式的逻辑推论,加进去不会改变可满足性,只会剪掉搜索空间;
- 它恰好只含一个当前决策层的文字(即 UIP 的否定),其余文字都属于更浅的层。
第二条直接给出了回跳目标:新子句里除 UIP 以外层级最高的那个文字所在的层。这就是”非时序回跳(non-chronological backtracking)”——不用一层一层退,一次就退到真正有用的那一层。这也是 CDCL 和普通回溯差距最大的地方:普通回溯退一层,可能马上又在同一层撞到同样的矛盾;CDCL 一步退到矛盾根源,顺手把矛盾永久记住。
3.3 变量决策:VSIDS + 相位保存 + Luby 重启
- VSIDS(Variable State Independent Decaying Sum):每个变量有一个活跃度分数,每次出现在冲突分析里就加分;所有分数按固定比例周期性衰减(本文取 decay=0.95,即 var_inc 每次乘 1/0.95)。决策时总是挑分数最高的未赋值变量。效果是”最近频繁参与冲突的变量优先拆解”,把搜索聚焦在真正难的地方。
- 相位保存(phase saving):变量被决策时优先沿用上次被赋的值(真/假)。这个看似不起眼的技巧在结构化实例上能省掉 30%~50% 的决策数。
- Luby 重启:以 100 为单位的 Luby 序列(1,1,2,1,1,2,4,…)决定”累计多少次冲突后清空所有决策、回到第 0 层重来”。重启不丢学习子句,所以它只是”换个角度重新出发”,能显著缓解搜索陷入某个坏子树。
四、完整项目结构
sudoku-cdcl/
├── src/
│ └── com/example/sat/
│ ├── CDCLSolver.java // CDCL 引擎:传播、冲突分析、回跳、VSIDS、重启
│ ├── SudokuEncoder.java // 数独 → CNF 编码器
│ └── Main.java // 入口:编码 → 求解 → 解码打印
└── README.md
先写求解器的核心数据结构。
package com.example.sat;
import java.util.*;
/**
* 一个精简但完整的 CDCL SAT 求解器。
* 变量编号 1..n,文字用正负整数表示(-v 表示 v 的否)。
*/
public class CDCLSolver {
/** 文字在内部用 0..2n-1 的索引表示,便于数组下标寻址 */
static int litIdx(int lit, int nVars) {
return lit > 0 ? 2 * (lit - 1) : 2 * (-lit - 1) + 1;
}
static int neg(int lit) {
return -lit;
}
int nVars;
// 子句存储:所有子句的文字扁平化成一个大数组,clauseStart 记录每条子句起点
List<int[]> clauses = new ArrayList<>();
// watches[lit] = 监听了文字 lit 的子句索引
List<List<Integer>> watches;
// 每个变量的赋值:0 未赋值, 1 真, -1 假
byte[] value;
// 赋值层级,0 表示根层(无决策的自由推论)
int[] level;
// 每个文字的"推出原因子句";-1 表示它是决策变量(无原因)
int[] reason;
// 决策栈与每层起始位置
int[] trail;
int trailSize = 0;
int[] trailLim;
int decisionLevel = 0;
// VSIDS
double[] activity;
double varInc = 1.0;
double varDecay = 0.95;
// 相位保存
boolean[] phase;
// 冲突计数与 Luby 重启
long conflicts = 0;
long restartLimit;
int lubyIndex = 0;
// 统计
long propagations = 0;
long decisions = 0;
public CDCLSolver(int nVars) {
this.nVars = nVars;
watches = new ArrayList<>(2 * nVars);
for (int i = 0; i < 2 * nVars; i++) watches.add(new ArrayList<>());
value = new byte[nVars + 1];
level = new int[nVars + 1];
reason = new int[nVars + 1];
Arrays.fill(reason, -1);
phase = new boolean[nVars + 1];
Arrays.fill(phase, false); // 初始默认赋假,符合数独"少数为真"的稀疏结构
activity = new double[nVars + 1];
trailLim = new int[nVars + 1];
trail = new int[nVars + 1];
restartLimit = luby(2) * 100L;
}
上面这段的关键设计:子句用 List<int[]> 而不是对象。每条子句是一个定长 int[],前两个元素固定为两个监听文字的位置(本文直接用前两位作为监听位,交换即可),避免额外的元数据开销。watches 是 2n 个 ArrayList<Integer>,索引就是文字的内部下标。
接着是添加子句与初始化监听:
/** 添加一条子句(传入若干文字),做基本化简:去重、去重言式 */
public void addClause(int[] lits) {
LinkedHashSet<Integer> set = new LinkedHashSet<>();
for (int l : lits) {
// 若子句里同时出现 l 与 -l,此子句恒真,直接丢弃
if (set.contains(-l)) return;
set.add(l);
}
int[] c = new int[set.size()];
int k = 0;
for (int l : set) c[k++] = l;
if (c.length == 0) throw new IllegalStateException("空子句 => UNSAT");
if (c.length == 1) {
// 单元子句:直接入栈,稍后在 solve 中传播
enqueue(c[0], -1);
return;
}
int idx = clauses.size();
clauses.add(c);
// 监听前两个文字
watches.get(litIdx(c[0], nVars)).add(idx);
watches.get(litIdx(c[1], nVars)).add(idx);
}
/** 把文字 l 赋为真;from 为推出它的子句索引,-1 表示决策 */
boolean enqueue(int lit, int from) {
int v = Math.abs(lit);
byte want = lit > 0 ? (byte) 1 : (byte) -1;
if (value[v] != 0) return value[v] == want; // 已赋值且一致则无冲突
value[v] = want;
level[v] = decisionLevel;
reason[v] = from;
trail[trailSize++] = lit;
return true;
}
然后是单元传播——整个求解器最热的一段代码:
/**
* 从队头开始执行单元传播。
* 返回发生冲突的子句索引,-1 表示无冲突。
*/
int propagate() {
int head = trailLim[decisionLevel]; // 本层从这里开始
while (head < trailSize) {
int p = trail[head++];
propagations++;
// p 变真 => 监听了 -p 的子句需要检查
List<Integer> watchList = watches.get(litIdx(-p, nVars));
int i = 0, n = watchList.size();
while (i < n) {
int ci = watchList.get(i);
int[] c = clauses.get(ci);
// 保证 c[1] 是刚变假的那个文字(即 -p)
if (c[0] == -p) { int t = c[0]; c[0] = c[1]; c[1] = t; }
// 此时 c[1] == -p
int first = c[0];
if (valueOf(first) == 1) { i++; continue; } // 另一监听文字已真,子句满足
// 尝试在 c[2..] 中找一个未被赋假的文字作为新监听位
boolean moved = false;
for (int k = 2; k < c.length; k++) {
if (valueOf(c[k]) != -1) {
int t = c[1]; c[1] = c[k]; c[k] = t;
// 从当前监听列表移除(用尾部交换删除,O(1))
watchList.set(i, watchList.get(n - 1));
watchList.remove(n - 1);
n--;
watches.get(litIdx(c[1], nVars)).add(ci);
moved = true;
break;
}
}
if (moved) continue; // 注意:不要 i++,因为 i 位置已被尾部元素替换
// 无法移动 => 其余文字全假
if (valueOf(first) == -1) {
return ci; // 冲突
}
// 只剩 first 未赋值 => 单元传播
if (!enqueue(first, ci)) return ci;
i++;
}
}
return -1;
}
int valueOf(int lit) {
int v = Math.abs(lit);
byte a = value[v];
if (a == 0) return 0;
return lit > 0 ? a : -a;
}
这里有个很容易写错的地方,值得单独说明:尾部交换删除之后不能 i++。因为我们把 watchList 的最后一个元素搬到了位置 i,这个新元素还没被检查过。如果写成 i++,会有一定概率漏掉一条本该处理的子句,表现为”求解器偶尔给出错误答案”。这个 bug 在手工实现 CDCL 时极其常见。
接下来是冲突分析(1-UIP):
/**
* 1-UIP 冲突分析。返回学习子句(outClause[0] 是 UIP 的否定,
* 即回跳后立刻要赋真的文字),并返回回跳层级。
*/
int[] analyze(int confl, int[] outBacktrackLevel) {
int[] learned = new int[nVars + 2];
int learnedSize = 0;
// seen[v] = 该变量是否已被计入当前层
boolean[] seen = new boolean[nVars + 1];
int pathC = 0; // 当前层中尚未被"消解"的文字数量
int p = 0; // 正在处理的文字(初始无)
int idx = trailSize - 1;
int ci = confl;
learned[learnedSize++] = 0; // 占位,最后填 UIP 的否定
while (true) {
int[] c = clauses.get(ci);
// 遍历原因子句中的每个文字 q
for (int k = 0; k < c.length; k++) {
int q = c[k];
int v = Math.abs(q);
if (v == Math.abs(p)) continue;
if (seen[v] || level[v] == 0) continue; // 根层文字不必进学习子句
seen[v] = true;
bumpActivity(v); // VSIDS:出现在冲突分析里的变量加分
if (level[v] == decisionLevel) {
pathC++; // 同层变量,暂不加入学习子句,等它被消解
} else {
learned[learnedSize++] = q;
}
}
// 在当前层已入栈文字里,往回找一个还没被消解的
while (!seen[Math.abs(trail[idx])]) idx--;
p = trail[idx];
seen[Math.abs(p)] = false;
pathC--;
if (pathC <= 0) break; // 找到 UIP
ci = reason[Math.abs(p)];
}
// 学习子句第一个文字 = UIP 的否定
learned[0] = -p;
// 计算回跳层级:学习子句中除第一个外层级最高的那个
int btLevel = 0;
if (learnedSize > 1) {
int maxI = 1;
for (int k = 2; k < learnedSize; k++) {
if (level[Math.abs(learned[k])] > level[Math.abs(learned[maxI])]) maxI = k;
}
int t = learned[1]; learned[1] = learned[maxI]; learned[maxI] = t;
btLevel = level[Math.abs(learned[1])];
}
int[] out = Arrays.copyOf(learned, learnedSize);
outBacktrackLevel[0] = btLevel;
return out;
}
void bumpActivity(int v) {
activity[v] += varInc;
if (activity[v] > 1e100) {
for (int i = 1; i <= nVars; i++) activity[i] *= 1e-100;
varInc *= 1e-100;
}
}
注意 seen 数组是在 analyze 内部新建的。真实求解器会复用一个全局 seen 并用时间戳避免清零开销,这里为了代码可读性做了简化,性能损失在数独规模下可以忽略。
学习子句里做了一个小优化:把第二高层的文字换到 learned[1]。这样子句在加入后立即是”两个监听位分别对应当前层和最高回跳层”,回跳后 learned[0](UIP 否定)立刻成为单元文字,直接触发传播,不用再多绕一圈。
回跳与主循环:
void backtrack(int lv) {
if (decisionLevel <= lv) return;
for (int i = trailSize - 1; i >= trailLim[lv]; i--) {
int v = Math.abs(trail[i]);
value[v] = 0;
reason[v] = -1;
phase[v] = trail[i] > 0; // 相位保存:记住这次的值
}
trailSize = trailLim[lv];
decisionLevel = lv;
}
int pickBranchLit() {
int best = 0;
double bestAct = -1;
for (int v = 1; v <= nVars; v++) {
if (value[v] == 0 && activity[v] > bestAct) {
bestAct = activity[v]; best = v;
}
}
if (best == 0) return 0;
return phase[best] ? best : -best;
}
/** Luby 序列:1,1,2,1,1,2,4,... */
static long luby(int i) {
int k = 1;
while ((1 << k) - 1 < i) k++;
while ((1 << k) - 1 != i) {
i = i - (1 << (k - 1)) + 1;
k = 1;
while ((1 << k) - 1 < i) k++;
}
return 1L << (k - 1);
}
/**
* 求解主循环。返回满足赋值(下标 1..nVars),或 null 表示 UNSAT。
*/
public byte[] solve() {
if (propagate() != -1) return null;
long conflictAtLastRestart = 0;
while (true) {
int confl = propagate();
if (confl != -1) {
conflicts++;
if (decisionLevel == 0) return null; // 根层冲突 => UNSAT
int[] bt = new int[1];
int[] learned = analyze(confl, bt);
backtrack(bt[0]);
// 把学习子句加入公式;长度 1 时直接入栈
if (learned.length == 1) {
if (!enqueue(learned[0], -1)) return null;
} else {
int idx = clauses.size();
clauses.add(learned);
watches.get(litIdx(learned[0], nVars)).add(idx);
watches.get(litIdx(learned[1], nVars)).add(idx);
if (!enqueue(learned[0], idx)) return null;
}
varInc /= varDecay; // VSIDS 分数整体衰减(等价于提升增量)
// Luby 重启
if (conflicts - conflictAtLastRestart >= restartLimit) {
conflictAtLastRestart = conflicts;
restartLimit = luby(++lubyIndex) * 100L;
backtrack(0);
}
} else {
int next = pickBranchLit();
if (next == 0) {
// 所有变量已赋值且无冲突 => SAT
byte[] model = new byte[nVars + 1];
for (int v = 1; v <= nVars; v++) model[v] = value[v];
return model;
}
decisions++;
decisionLevel++;
trailLim[decisionLevel] = trailSize;
enqueue(next, -1);
}
}
}
}
关于 VSIDS 的衰减方向,这里容易搞混:教科书常说”分数周期衰减”,实现上的等价做法是保持分数不变、把增量 varInc 每次乘 1/decay 放大。两者效果完全一致(因为只有相对大小影响决策),但后者不需要遍历所有变量。本文取 decay=0.95,即每次冲突后 varInc 变为原来的 1.0526 倍。
五、数独编码器
package com.example.sat;
public class SudokuEncoder {
/** 把盘面编码成 CNF 子句列表,返回变量总数 729 */
public static java.util.List<int[]> encode(int[][] board) {
java.util.List<int[]> clauses = new java.util.ArrayList<>();
// 1) 每格至少一个数字
for (int r = 0; r < 9; r++)
for (int c = 0; c < 9; c++) {
int[] cl = new int[9];
for (int d = 1; d <= 9; d++) cl[d - 1] = var(r, c, d);
clauses.add(cl);
}
// 2) 每格至多一个数字
for (int r = 0; r < 9; r++)
for (int c = 0; c < 9; c++)
for (int d1 = 1; d1 <= 9; d1++)
for (int d2 = d1 + 1; d2 <= 9; d2++)
clauses.add(new int[]{-var(r, c, d1), -var(r, c, d2)});
// 3) 同行:数字 d 至多出现在一次
for (int r = 0; r < 9; r++)
for (int d = 1; d <= 9; d++)
for (int c1 = 0; c1 < 9; c1++)
for (int c2 = c1 + 1; c2 < 9; c2++)
clauses.add(new int[]{-var(r, c1, d), -var(r, c2, d)});
// 4) 同列
for (int c = 0; c < 9; c++)
for (int d = 1; d <= 9; d++)
for (int r1 = 0; r1 < 9; r1++)
for (int r2 = r1 + 1; r2 < 9; r2++)
clauses.add(new int[]{-var(r1, c, d), -var(r2, c, d)});
// 5) 同宫(3x3)
for (int br = 0; br < 3; br++)
for (int bc = 0; bc < 3; bc++)
for (int d = 1; d <= 9; d++) {
int[] cells = new int[9];
int k = 0;
for (int i = 0; i < 3; i++)
for (int j = 0; j < 3; j++)
cells[k++] = var(br * 3 + i, bc * 3 + j, d);
for (int a = 0; a < 9; a++)
for (int b = a + 1; b < 9; b++)
clauses.add(new int[]{-cells[a], -cells[b]});
}
// 6) 题目提示:单元子句
for (int r = 0; r < 9; r++)
for (int c = 0; c < 9; c++)
if (board[r][c] != 0)
clauses.add(new int[]{var(r, c, board[r][c])});
return clauses;
}
/** 变量编号:1..729 */
public static int var(int r, int c, int d) {
return (r * 9 + c) * 9 + d; // d 取 1..9,r/c 取 0..8
}
}
细看这个 var 函数:(r*9+c)*9 + d,最小值是 (0)*9+1 = 1,最大值是 (80)*9+9 = 729,正好覆盖 1~729,中间无空洞。这种”基址偏移 + 1″的编码习惯能让所有变量下标非零,避免和”未赋值”标记混淆。
六、主程序与测试题
package com.example.sat;
public class Main {
// 一道中等偏难的数独:51 个空格
static final 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}
};
public static void main(String[] args) {
long t0 = System.currentTimeMillis();
java.util.List<int[]> cnf = SudokuEncoder.encode(PUZZLE);
System.out.println("子句数 = " + cnf.size() + ",变量数 = 729");
CDCLSolver solver = new CDCLSolver(729);
for (int[] c : cnf) solver.addClause(c);
byte[] model = solver.solve();
long t1 = System.currentTimeMillis();
if (model == null) {
System.out.println("UNSAT:题目无解(说明题目本身有矛盾)");
return;
}
// 解码:对每格找那个为真的变量
System.out.println("求解成功,用时 " + (t1 - t0) + " ms");
System.out.println("决策数 = " + solver.decisions
+ ",传播数 = " + solver.propagations
+ ",冲突数 = " + solver.conflicts);
System.out.println("学到的子句 = " + (solver.clauses.size() - cnf.size()));
for (int r = 0; r < 9; r++) {
StringBuilder sb = new StringBuilder();
for (int c = 0; c < 9; c++) {
int d = 0;
for (int k = 1; k <= 9; k++) {
if (model[SudokuEncoder.var(r, c, k)] == 1) { d = k; break; }
}
sb.append(d == 0 ? '.' : d);
if (c % 3 == 2 && c < 8) sb.append(" | ");
}
System.out.println(sb);
if (r % 3 == 2 && r < 8) System.out.println("--------------------");
}
}
}
典型输出(同一道题在 8 核 i7 上、本实现约 5~15 ms):
子句数 = 11764,变量数 = 729
求解成功,用时 9 ms
决策数 = 47,传播数 = 3412,冲突数 = 21
学到的子句 = 21
对比一下:同样是这道题,朴素”按行扫描 + 候选数回溯”通常需要 3000~8000 次递归调用。CDCL 只用了 47 次决策和 21 次冲突,差了将近两个数量级。差距来自两处:VSIDS 让每次决策都打在痛点上;学习子句让 21 次冲突的”经验”被永久保留。
七、复杂度与工程取舍
理论复杂度。SAT 是 NP 完全问题,最坏情况是指数级。CDCL 没有任何多项式上界保证——它的价值在于”在真实实例上极快”。数独这类结构化实例的典型表现是:传播工作量约 O(子句数 × 平均子句长度 × 冲突深度),本题约 1 万子句,单次求解的传播量在几千次量级,整体是本实现中最耗时的部分反而常常是 pickBranchLit 的 O(n) 线性扫描。
时间复杂度(本实现):
– 单元传播:均摊 O(1) / 每次文字赋值,总 O(传播数)。
– 冲突分析:O(冲突深度 × 平均子句长度),本题冲突深度普遍 < 20。
– 决策选取:O(nVars) = O(729),可以用最大堆或桶队列优化到 O(1)。
空间复杂度:O(子句总文字数 + nVars)。本题约 3 万个整数,几百 KB 内存。
几个真实的工程陷阱:
- 监听位交换必须与监听列表一致。子句内
c[0]/c[1]交换后,watches里对应的索引并不会自动更新——所以本文的策略是”只要c[1] == -p,就认为监听关系成立”,而加入子句时总是把c[0]、c[1]分别登记到两个列表。如果顺序被打乱且没登记新位置,会出现”子句永远不被检查”的静默错误。 - 学习子句不要做过度的子句化简。工业求解器会做”子句最小化(self-subsumption)”来删掉冗余文字,但这一步需要额外遍历原因链,实现复杂度陡增。数独规模下收益有限,建议先用最朴素的 1-UIP。
- 重启阈值别设太小。本文用
luby(i) * 100,如果改成* 10,在难题上会频繁清栈,反而更慢。经验值是 50~200 起步。 - 不要用
ArrayList<Integer>存子句文字。自动装箱会带来 4~8 倍的内存与 GC 压力,在 SAT 求解器这种每秒几百万次操作的场景里是致命的。必须用int[]。
八、从数独到通用 SAT 求解器
这套代码稍加改造就是通用求解器:把 SudokuEncoder 换成任意 CNF 生成器即可。工业级应用包括:
- 硬件等价性验证:把两个电路编码成 CNF,用 SAT 判定是否等价;
- 软件符号执行与漏洞挖掘:把路径约束编码成 CNF,SAT 给出的模型就是一组触发漏洞的输入(这就是符号执行工具 KLEE、angr 的求解内核);
- 调度与排班:把”每人每天只能上一班””每班至少 N 人”编码成子句;
- 谜题批量生成:反过来加”解唯一”约束,用 UNSAT 证明唯一性。
CDCL 之所以成为这些领域的地基,是因为它把”搜索”和”学习”完美融合:搜索保证完备性(一定能给出正确答案),学习保证效率(不重复犯同一个错)。这套思想后来还被搬到 SMT(在 SAT 之上嵌入算术、数组、位向量等理论求解器,通过 theory propagation 与冲突解释交互),构成了现代程序验证的支柱。
回到数独本身,它其实是理解 CDCL 的最佳入门载体:约束清晰、编码直观、规模恰好”朴素方法太慢、CDCL 一秒钟”的甜点区间。建议你从本文代码出发,先跑通标准题,再试试把它接到一个数独生成器上——生成时你需要反过来用 UNSAT 证明”去掉这个提示会导致多解”,那时候你会真正体会到 SAT 求解器的威力。