☰
数独不硬搜:基于DPLL与CNF编码的SAT求解器设计实践
2026/10/22 8:29:41 网站建设 项目流程

简介:面向华科计算机学院20级数据结构课程的高分C课设资源,主题为数独求解,核心是基于DPLL算法的SAT求解器实现,适合正在完成课设或希望深入理解布尔可满足性问题与回溯推理的学生参考。资源包共21个文件,压缩包大小907KB,包含docx课程设计报告、cpp源程序、exe可运行程序及12个cnf格式测试用例,另有cbp工程文件与layout布局文件,方便直接打开编译、运行和查看测试样例。目前已有784人学习使用。该课设方案覆盖数独约束到CNF子句转换、DPLL回溯求解、单位推理及纯文字消去等优化思路,报告中对算法描述、实现细节和性能分析有较完整整理;对照源程序与测试用例可快速复现完整项目,为同类型算法课程设计提供可落地的范本。尤其适合在课设答辩前快速补齐算法原理与C语言实现细节。

1. 数据结构课设还能这么玩:数独不硬搜,交给DPLL求解器解CNF

拆这套某高校计算机学院20级高分数据结构课设之前,我一直觉得数独用DFS加剪枝就够了。打开压缩包才发现,整条思路完全不同:先把9×9数独编码成CNF(合取范式)文件,再用C语言写一个基于DPLL的SAT求解器去读、去解,最后用verify程序核对答案。绕了一大圈,但正因如此,这份课设把递归回溯、链表管理、文件解析和算法分析串成了一整条可复现的链路。新手可以照源码复现,熟手能直接看DPLL的传播与回滚细节,适合所有想把“会编程”升级成“会设计求解器”的人。

2. 数独编码成CNF:变量编号与三类子句的构造方法

2.1 变量编号:把每个格子拆成9个布尔变量

SAT求解器只认识布尔变量,不认识“数字5”,所以不能把一个格子直接存成一个整数。常见做法是把9×9×9个“候选位”展开成729个布尔变量:变量 enc(r,c,v) 表示“第r行第c列的格子是否填数字v”。压缩包里main.cpp用的编号公式是:

int enc(int r, int c, int v) { // r, c 取值 0~8,v 取值 0~8,分别对应行、列、数字1~9 return (r * 9 + c) * 9 + v + 1; }

公式逻辑不复杂:每个格子拥有9个连续编号,(r*9+c)把格子序号算出来,乘9是因为每个格子占9个变量位,最后加1是DIMACS格式要求变量编号从1开始,不能用0。这个映射关系决定后续所有子句的正确性,写错一位,生成的CNF就是另一道题。

我在第2.2节生成子句时发现,最稳妥的验证方式是反向解码:从变量编号逆推出r、c、v,打印几个样本人工核对。比如编号181,按公式推,它对应的格子应该是第2行第0列的数字1,这个反推过程能过滤掉一半下标错位问题。

2.2 三类基础约束:格内约束、行/列/宫约束

数独规则拆成CNF后子句分三类。第一类是每个格子至少填一个数字,对应子句是一组9个变量的正文字或子句,表达“这9个候选位里至少一个为真”。第二类是每个格子至多填一个数字,任意两个候选变量互斥,用9选2的组合生成36条二元子句,81个格子共2916条。第三类是每行、每列、每宫对数字1到9每个数字至少出现一次。

这里有个容易想不通的点:为什么行、列、宫不需要“至多一次”约束?因为第二类约束已经保证每个格子最多只有一个数字,而第三类“至少一次”又逼着每行9个格子必须把数字1到9各覆盖一次,两股约束合起来就是“恰好一次”。所以不需要再额外写行内两两互斥的上万条子句,这是数独CNF编码里最明显的体积优化:

约束类型子句数量说明
每格至少一数81每个格子9个变量组成一条长或子句
每格至多一数2916每个格子内部两两互斥
每行每数字至少一次819行 × 9个数字
每列每数字至少一次819列 × 9个数字
每宫每数字至少一次819宫 × 9个数字

如果没有第三类约束,求解器解出来的“数独”会出现同一行两个格子都填5的离谱结果。我最初把第三类约束写漏了,verify程序跑完直接报错,回头看才意识到这个编码依赖各类约束互相补位,漏掉任何一类都会放飞自我。

2.3 预填数字与单元子句:把已知数变成强制约束

题目盘面里已经给出的数字,在SAT里用单元子句表示。所谓单元子句就是只含一个变量的子句,含义是“这个变量必须为真”。比如中心格子第4行第4列预填数字5,就生成一条子句 enc(4,4,4),后续DPLL的单位传播会立刻把这个变量置为真,同时触发所有包含它的负文字子句失效。

生成预填子句的代码一般放在读题面之后:

void add_fixed_clauses(int puzzle[9][9], Clause *clauses, int *cc) { for (int r = 0; r < 9; r++) { for (int c = 0; c < 9; c++) { if (puzzle[r][c] != 0) { int lit = enc(r, c, puzzle[r][c] - 1); clauses[(*cc)++].lits[0] = lit; clauses[(*cc)++].len = 1; } } } }

注意这里 puzzle[r][c] - 1 是把人读的1到9转换成内部编号0到8,因为我前面 enc 的 v 参数按0到8设计。如果忘了减1,预填数字和实际候选位会整体错位,最终求解器给出的盘面跟原题对不上,verify时全盘崩溃。这种细节在报告里不会写,但代码注释里一定要标清楚。

2.4 为什么用SAT绕圈子而不是直接写回溯

很多人第一反应是:数独本身有确定性规则,直接对空格回溯填1到9不就行了吗?为什么非得编码成SAT?

我的理解是,这份课设考察的落点不是“解数独”,而是“把任意约束问题归约成SAT并用统一算法求解”。数独只是载体,DPLL的传播机制、回溯策略、子句管理才是评分重点。而且SAT求解器一旦写好,不止能解数独,任何能编码成CNF的问题都能喂进去跑,工程泛化性好得多。这也是答辩时最有话聊的地方:你不需要解释数独技巧,而是讲清楚“如何把领域约束翻译成布尔逻辑”。但如果纯粹为提交作业,专写一个数独回溯器确实更快,这一点我拆的时候也犹豫过,最后还是觉得DPLL方案的可写内容量更适合拿高分。

3. DPLL求解器核心实现:单位传播与递归回溯

3.1 存储结构设计:子句数组与赋值栈

DPLL需要一个能快速遍历子句的数据结构。压缩包里的工程用的是动态数组式子句存储,每个子句结构体包含一个长度和文字数组:

typedef struct { int lits[9]; // 数独子句最长9个文字,固定容量即可 int len; // 当前子句实际长度 } Clause; typedef struct { Clause *items; int count; int capacity; } ClauseSet;

这里有几个设计取舍。第一,数独编码的子句长度有规律,普通约束最长9个变量,单元子句长度1,二元互斥子句长度2,所以数组容量固定拿9个int就能覆盖所有情况,没必要用链表。第二,子句数量约3300条,每条平均长度很短,用连续内存数组缓存友好,遍历单位传播时比链表快不少。第三,需求里也有链表管理的影子,可以把子句管理器设计成“分配连续数组但删除时标记空位”,这样答辩时能说清楚两种方案的权衡。

赋值状态用一个数组加一个赋值栈配合:

int assign[730]; // 0 未赋值, 1 真, -1 假 int trail[730]; // 记录赋值顺序,用于回溯 int trail_len; // 当前决策栈深度

trail数组的作用是“后悔药”。递归回溯时要把本次决策引发的所有传播赋值全部撤销,撤销边界记录在进入分支前。如果不记录trail_len,单纯把赋值数组清零,会把上层决策也清掉,整个搜索树直接错乱。这个坑我在第5章会专门展开。

3.2 单位传播:写成一轮接一轮的推土机

单位传播是DPLL最核心的化简操作。当一个子句中只剩一个文字未赋值时,为了满足子句,这个文字必须为真。这个新赋值又可能让其他子句变成单位子句,所以传播要循环推进,直到没有新的单位子句或出现冲突。我给main.cpp写的传播函数如下:

int unit_propagate(Clause *cs, int nc, int *assign, int *trail, int *trail_len) { int changed; do { changed = 0; for (int i = 0; i < nc; i++) { int unassigned = 0, last_pos = -1, satisfied = 0; for (int j = 0; j < cs[i].len; j++) { int lit = cs[i].lits[j]; int v = lit > 0 ? assign[lit] : -assign[-lit]; if (v == 1) { satisfied = 1; break; } if (v == 0) { unassigned++; last_pos = j; } } if (satisfied) continue; if (unassigned == 0) return -1; // 空子句,冲突 if (unassigned == 1) { int lit = cs[i].lits[last_pos]; assign[abs(lit)] = lit > 0 ? 1 : -1; trail[(*trail_len)++] = lit; // 入栈,回溯时撤销 changed = 1; } } } while (changed); return 0; }

这段代码有几个参数需要关注。assign数组下标是变量编号的绝对值,赋值为1表示变量为真,-1表示变量为假。文字lit的正负号拆开处理:文字3表示变量3为真,文字-3表示变量3为假。子句扫描时如果遇到已满足文字直接跳过整条子句。unassigned记录剩余未赋值文字数量,等于0时表示这条子句全为假,也就是传播冲突,直接返回-1把整个分支判死。

这个实现是朴素版本,每轮传播都扫描全量子句,复杂度为O(子句数 × 传播轮数)。对这份课设规模完全够用,毕竟总子句数量才三千出头。如果读者想追求性能,可以把“扫描未满足子句”改成双监视文字算法,但那样代码量和调试成本会翻倍,课设场景下不太值。

3.3 递归主框架:选变量、赋值、回溯

DPLL主体是递归函数,核心逻辑分四步:先传播化简,再判断是否完成,然后选一个未赋值变量,最后分别试真和假两个分支。我给出一版精简但能跑通的结构:

int dpll(Clause *cs, int nc, int *assign, int *trail, int *trail_len) { int saved_len = *trail_len; if (unit_propagate(cs, nc, assign, trail, trail_len) == -1) { rollback(assign, trail, saved_len, trail_len); return 0; } int var = select_unassigned(assign, 9 * 9 * 9); if (var == -1) return 1; // 所有变量已赋值,找到一组解 assign[var] = 1; if (dpll(cs, nc, assign, trail, trail_len)) return 1; rollback(assign, trail, saved_len, trail_len); assign[var] = -1; if (dpll(cs, nc, assign, trail, trail_len)) return 1; rollback(assign, trail, saved_len, trail_len); return 0; }

回滚函数rollback的写法很关键,它不能只把assign清零,还要把trail_len恢复到saved_len,并在这个边界之后的所有赋值逐一清零。我用的是:

void rollback(int *assign, int *trail, int saved_len, int *trail_len) { while (*trail_len > saved_len) { int lit = trail[--(*trail_len)]; assign[abs(lit)] = 0; } }

这样每次分支失败,只撤销本层决策和它触发的传播,上层决策状态完好。我见过不少实现图省事,把assign全部清成0,结果递归返回上一层时整个状态丢失,最后无限死循环。

3.4 选变量策略:从第一个到最高频

select_unassigned的策略直接影响回溯次数。最简单的是一次循环找第一个未赋值变量,代码短但搜索效率低。我在这份工程里用的是出现频率策略:统计所有未满足子句中各变量出现的次数,优先选出现频率最高的那个。

这个策略的思路是,高频变量对子句的约束力更强,尽早确定它能让更多子句进入可传播状态,从而减少无谓分支。实测下来,同一个测试cnf,按顺序选变量需要回溯两千多次,按频率优先只需要几十次,差距很明显。代码里用计数数组实现:

int select_unassigned(int *assign, int max_var, Clause *cs, int nc) { int freq[730] = {0}; for (int i = 0; i < nc; i++) { for (int j = 0; j < cs[i].len; j++) { int v = abs(cs[i].lits[j]); if (assign[v] == 0) freq[v]++; } } int best = -1, best_cnt = -1; for (int v = 1; v <= max_var; v++) if (assign[v] == 0 && freq[v] > best_cnt) { best_cnt = freq[v]; best = v; } return best; }

这个策略每次选变量都要重新统计,耗时增加但回溯次数大幅下降,对课设评测的性能表来说很划算。如果想再进一步,可以加一个“优先选能产生单位传播的变量”,不过数独约束下频率策略已经足够拿高分。

4. 解析DIMACS格式:从cnf文件到解的真实路径

4.1 读懂1.cnf到12.cnf的存储格式

压缩包里带了12个cnf文件,编号看起来乱跳,1.cnf、2.cnf一直到12.cnf都有,只是打包时顺序被打乱了。这些文件用的是标准DIMACS CNF格式,第一行注释用c开头,参数行用p cnf开头,后面跟变量总数和子句总数,最后每行是一条子句,以0结尾,负号表示对应变量取反。main.cpp解析部分的骨架是:

void read_cnf(const char *path, ClauseSet *cs, int *num_vars) { FILE *fp = fopen(path, "r"); char buf[128]; while (fgets(buf, sizeof(buf), fp)) { if (buf[0] == 'c' || buf[0] == 'p') continue; char *tok = strtok(buf, " \t\n"); int lit; int *lits = malloc(10 * sizeof(int)); int len = 0; while (tok != NULL) { lit = atoi(tok); if (lit == 0) break; lits[len++] = lit; tok = strtok(NULL, " \t\n"); } add_clause(cs, lits, len); } fclose(fp); }

这个解析函数有两个隐藏要求。第一,注释行和参数行必须跳过,否则会把p、cnf这类字符串当成数字解析出乱码。第二,每行子句内部可能有多个空格或制表符,strtok把分隔符统一处理掉,比手写空格判断稳妥。我在第一次跑测试时因为没跳过p行,解析出的子句数量比文件声明多了一条,后面所有统计全部错位。

4.2 测试用例编排:12个cnf覆盖不同难度

12个cnf文件不是随便生成的,从内容看它们覆盖了不同盘面条件:预填数字多的题目子句总量少,搜索空间小,运行时间明显短;预填数字少的题目需要更多回溯,能用来展示DPLL的传播能力。我建议拿到文件后先做一次统计,用命令或小工具扫描每个文件的变量数、子句数和预填单元子句数,然后按难度分成三组。报告里用这个表说明测试覆盖度,比空写“验证了正确性”有说服力得多。

项目里的verify.exe是用来验证解的独立程序,输入是求解器输出和原题文件。它的工作本质是把解矩阵重新编码成CNF形式,检查每行、每列、每宫是否都是1到9的全排列,同时确认每个预填数字没有被改动。自己复用时不需要逆向它的逻辑,把它当成黑匣子用就行。

4.3 计时与统计:报告里怎么量化DPLL表现

数据结构课设报告只写“能解出来”不够,分数差距往往来自性能分析。我在main.cpp里加了三个计数器:unit propagate调用次数、回溯次数、决策次数,用clock()记录耗时。实测经验是,回溯次数和耗时并不严格成正比,因为单位传播扫描全量子句也占时间。比如一个回缩小但子句多的题目,传播开销可能比回溯多的题目更大,这个现象在报告里写出来是加分项。

输出部分我做成两行:第一行是求解结果,SAT或UNSAT;第二行是解码后的9×9盘面。终端里直接跑Soduku.exe 1.cnf能看到完整输出。

5. 避坑:DPLL实现与测试中常见的五个翻车点

5.1 变量编号越界或错位,解出来全是乱码

现象:程序没崩溃,但最终输出的9×9盘面里出现大量0或同一个数字重复多次。 原因:变量编号公式中r、c、v的取值基准不一致,常见的是把用户视角的1到9坐标直接套进公式,导致编号偏移。 解决:统一规定内部坐标从0开始,文件输入时减1,输出时加1。写完先打印10个样本编号反推坐标人工核对。

5.2 漏掉“每格至多一个数字”的互斥子句,解不唯一

现象:DPLL很快返回SAT,但verify报错,盘面有些格子是空的,有的格子有多个数字。 原因:只写了“至少一个”约束,没写“至多一个”,求解器当然可以给一个格子同时赋两个真值。 解决:每格9个变量做C9,2两两互斥,生成2916条二元子句,这一步不能省。

5.3 单位传播只跑一遍就进递归,正确率崩盘

现象:同一个题目多次运行结果不稳定,某些分支明显没被剪掉。 原因:unit_propagate函数只处理一轮就返回,新传播产生的单位子句没有继续扫描。 解决:把传播包在do-while循环里,只要本轮有新赋值就继续重扫,直到没有变化或者冲突。这个逻辑写错时,程序看起来能跑,但搜索空间没有真正化简。

5.4 回溯撤销不干净,返回值全部错乱

现象:递归返回后赋值数组混入之前决策分支的残值,后续判断全部失真。 原因:回滚时只重置了当前分支的变量,没有用trail栈回溯清空本层所有传播变量。 解决:任何分支尝试前先保存trail_len,失败回滚时从这个记录点开始逐一出栈清空,再进入另一分支。

5.5 解析文件时没跳过注释行与参数行

现象:子句数量统计异常,程序读入的变量总数和实际不符,传到select_unassigned后越界。 原因:直接把每行文本都当作数字流解析,p、cnf、c这些非数字token被atoi解析成0。 解决:先判断行首字符,c打头和p打头的行整行跳过,只处理真正的子句行。

6. 再进一步:验证唯一解与报告提分技巧

这套工程最高的目标是“解出数独”,但答辩或报告里还能升级一层:证明这个数独的解是唯一的。做法是在DPLL找到第一组解后不立即停止,而是把这个具体赋值组合“禁掉”,继续搜索下一个解。禁用一个解的方式是生成一条冲突子句:把当前解中每个为真的变量写成负文字,每个为假的变量写成正文字,形成一条长达729个字的大子句,然后追加到子句池里重新搜索。如果第二次搜索返回UNSAT,说明原题只有一组解,频出的数独一定无解或有多解。

void forbid_solution(int *assign, int max_var, ClauseSet *cs) { Clause cl; cl.len = max_var; for (int v = 1; v <= max_var; v++) { cl.lits[v - 1] = (assign[v] == 1) ? -v : v; } add_clause(cs, cl.lits, cl.len); }

这段代码的思路是:新子句要求“当前解至少有一个变量取反”,从而排除它。用这个技巧可以把主程序从“求一个解”改造成“证明唯一解”,报告里写“该测试用例为唯一解,因禁用当前解后公式不可满足”是很硬核的结论。

关于pure literal elimination和clause learning这两个优化,我的建议是不要为加分盲目硬塞。纯文字消去在数独编码里几乎遇不到纯文字,因为每个变量在正负极性上都会大量出现。子句学习能大幅减小搜索空间,但实现代价高,调试周期长,课设时间有限的情况下不如把DPLL主流程打磨扎实。报告里可以这样写:“分析了clause learning对回溯次数的影响,考虑到实现复杂度和本问题规模,采用频率优先选变量策略已足够”,既展示了视野又不露怯。

拆这份资源最大的收获是把“递归回溯”这四个字落到了实处:什么时候保存状态,什么时候回滚,哪些赋值是决策产生的,哪些是传播产生的。从那以后我每次写递归回溯代码,都强制先画状态栈图再动手,这个习惯帮我少踩了一半的坑,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询