简介:这份资源是华中科技大学2018年数据结构课程设计的完整实现,围绕DPLL算法求解SAT问题展开,并延伸出数独解答器应用,适合正在学习算法设计、离散数学或准备课程设计的高校学生与自学者参考。压缩包共30个文件,约342KB,包含5个cpp源文件与1个头文件构成核心代码框架,13个cnf算例文件覆盖可满足与不可满足场景,另有8张png运行截图、2个txt说明及README文档,便于对照理解。程序分为Start、CnfParser、DPLLSolver与Sudoku四大模块:CnfParser负责解析cnf文件中的变元与子句;DPLLSolver实现DPLL算法框架并输出变元真假值;Sudoku模块通过挖洞法生成终盘与初盘,再转化为CNF公式交由DPLL求解。已有39人学习,读者可借此掌握SAT问题建模、CNF解析、递归回溯与数独转SAT的完整思路,并获得可直接编译运行的课程设计参考方案。
1. 从一份 2018 课设压缩包说起:DPLL 到底能跑多快
如果你手头正好有一批.cnf文件,想验证自己写的 SAT 求解器能不能在几秒内给出可满足赋值,或者你正在做数独求解器,想找一个不依赖第三方库、纯 C++ 手写的 DPLL 参考实现,那这份 2018 年的数据结构课程设计压缩包值得拆开看看。它把两个看起来不搭界的东西塞进了同一个工程:一个是标准 SAT 问题的 DPLL 求解框架,另一个是数独生成与求解,而数独最终也被转成 CNF 丢给同一个 DPLL 内核去跑。压缩包里带了src源码、若干.cnf算例、README 和一批运行截图,算例从 20 变元的小规模到 100 变元、90000 子句的7cnf20_90000_90000_7.shuffled-20.cnf都有,覆盖了可满足与不可满足两类。适合谁?适合正在学数据结构、想拿一个完整可编译工程练手的人,也适合想对照 DPLL 单子句传播、分支选择、回溯这几步到底怎么落地成 C++ 的人。下面按“它是什么 → 怎么编译跑起来 → 内核怎么读 → 坑在哪 → 怎么验证”的顺序拆。
2. 工程结构与编译链路:五个 cpp 怎么串起来
2.1 文件清单与模块职责
拿到压缩包先别急着编译,先把src目录里的文件过一遍。这个工程没有用 CMake,也没有第三方依赖,就是最朴素的多个.cpp加一个Global.h,用一条 g++ 命令就能编出来。模块划分和摘要里描述的一致,但落到文件上要看清谁依赖谁。
| 文件 | 职责 | 关键依赖 |
|---|---|---|
main.cpp | 入口,打印菜单,读用户输入 1 或 2 分流 | Global.h |
Start.cpp | 提示词与选择逻辑,进入数独或 SAT 分支 | Sudoku.cpp、CnfParser.cpp |
CnfParser.cpp | 读.cnf,提取变元数、子句数,子句存入数据结构,丢弃行尾 0 | Global.h |
DPLLSolver.cpp | DPLL 算法框架,计算变元真假值并记录结果 | Global.h |
Sudoku.cpp | 挖洞法生成终盘与初盘,转 CNF,调用SudokuDPLL | CnfParser.cpp、DPLLSolver.cpp |
Global.h | 全局数据结构、常量、函数声明 | 无 |
这里有个容易忽略的点:Sudoku.cpp并不是独立求解数独,它把数独初盘编码成 CNF 写到文件,再让CnfParser读回来,最后走DPLLSolver的同一套逻辑。也就是说,数独和 SAT 共用了一个求解内核,区别只在编码方式。理解这一点,后面调参和排错才不会走偏。
2.2 编译命令与参数
工程没有 Makefile,常见做法是自己写一条 g++ 命令把所有 cpp 一起编。注意Global.h只声明不实现,不要单独编译它。
# 在 src 目录下执行,-O2 打开优化,DPLL 递归深度大时差别明显 g++ -O2 -std=c++11 -o sat_solver \ main.cpp Start.cpp CnfParser.cpp DPLLSolver.cpp Sudoku.cpp # 运行,按提示输入 1 进数独,输入 2 进 SAT ./sat_solver参数说明:-O2不是可选项,DPLL 在 100 变元、上万子句的算例上递归和回溯次数很高,不开优化跑7cnf20_90000_90000_7.shuffled-20.cnf会明显慢;-std=c++11是因为源码里可能用到nullptr或范围 for,具体以你本地编译器报错为准,报'nullptr' was not declared就加上。如果编译报重复定义,检查是不是把Global.h里的变量定义写成了非extern,多文件工程里全局变量要在头文件用extern声明、在一个 cpp 里定义。
2.3 输入输出路径的处理
CnfParser读文件时用的是相对路径还是绝对路径,直接决定你在哪个目录下运行。压缩包里.cnf文件和src是分开的,常见翻车是编译完在src里运行,程序却去找同目录下的problem1-20.cnf,结果打开失败。稳妥做法是把算例路径写清楚,或者运行时把.cnf拷到可执行文件同目录。
# 假设算例在上级目录的 problems 文件夹 cp ../problems/problem1-20.cnf . ./sat_solver # 菜单选 2,再输入文件名 problem1-20.cnf如果程序对文件名是硬编码的,就去Start.cpp里找ifstream或fopen那一行,把路径改成你实际的相对路径。这一步没有玄学,就是路径对不上,但每年都有人卡在这。
3. DPLL 内核拆解:单子句传播、分支与回溯怎么落地
3.1 CNF 解析与数据结构选择
CnfParser的核心任务是把 DIMACS 格式的.cnf读进来。DIMACS 的约定是:以c开头是注释,p cnf V C给出变元数 V 和子句数 C,之后每行是一组以空格分隔的整数,正数代表该变元取真,负数取假,行尾以 0 结束。解析时要把这个 0 丢掉,否则子句里会多出一个不存在的变元。
// CnfParser.cpp 关键逻辑示意 // 读取 p cnf 行,拿到变元数 numVars 和子句数 numClauses // 每个子句用一个 vector<int> 存,整体用 vector<vector<int>> 存 std::vector<std::vector<int>> clauses; std::string line; while (std::getline(in, line)) { if (line.empty() || line[0] == 'c') continue; // 跳过注释和空行 if (line[0] == 'p') { std::istringstream iss(line); std::string p, cnf; iss >> p >> cnf >> numVars >> numClauses; continue; } std::istringstream iss(line); std::vector<int> clause; int lit; while (iss >> lit && lit != 0) { // 遇到 0 停止,丢弃行尾 0 clause.push_back(lit); } if (!clause.empty()) clauses.push_back(clause); }逻辑说明:lit != 0这个条件同时完成了“读入”和“丢弃行尾 0”两件事,比读完再pop_back更稳,因为有些算例一行可能不止一个子句。参数上,numVars决定后面赋值数组开多大,numClauses用来校验读进来的子句数对不对。解析完建议先打印一遍子句内容,和原文件人工比对,摘要里也提到这一步,别省,读错一个符号后面全错。
3.2 单子句传播与纯文字规则
DPLL 相比暴力枚举,快就快在两件事:单元传播(unit propagation)和纯文字(pure literal)消解。单元传播指某个子句只剩一个未赋值文字时,这个文字必须为真,否则该子句无法满足;纯文字指某个变元在所有子句里只以同一种极性出现,那就可以直接给它赋成让这些子句为真的值。
// DPLLSolver.cpp 单元传播示意 // assignment[i] 取值:0 未赋值,1 真,-1 假 bool unitPropagate(std::vector<std::vector<int>>& clauses, std::vector<int>& assignment) { bool changed = true; while (changed) { changed = false; for (auto& clause : clauses) { int unassigned = 0, lastLit = 0; bool satisfied = false; for (int lit : clause) { int var = std::abs(lit); if (assignment[var] == 0) { unassigned++; lastLit = lit; } else if ((lit > 0 && assignment[var] == 1) || (lit < 0 && assignment[var] == -1)) { satisfied = true; break; } } if (satisfied) continue; if (unassigned == 0) return false; // 子句全假,冲突 if (unassigned == 1) { // 单元子句 assignment[std::abs(lastLit)] = (lastLit > 0) ? 1 : -1; changed = true; } } } return true; }逻辑说明:外层while(changed)保证新赋值能继续触发传播,直到不动点。unassigned == 0且不满足就是冲突,直接返回 false 触发回溯;unassigned == 1就强制赋值。参数上,assignment用 1 基下标(0 号位不用)和 DIMACS 变元编号对齐,能省掉一堆-1偏移。纯文字规则可以放在单元传播之后做一轮扫描,对随机 3-SAT 算例收益一般,但对结构化算例(比如数独编码出来的)能砍掉不少分支。
3.3 分支策略与回溯
传播到不动点后如果还有未赋值变元,就要选一个分支。最朴素的是选第一个未赋值变元,先试真再试假。这个工程作为课设,大概率就是这种顺序分支,够用但不算快。
// 递归 DPLL 主框架示意 bool dpll(std::vector<std::vector<int>> clauses, std::vector<int>& assignment) { if (!unitPropagate(clauses, assignment)) return false; // 冲突回溯 int var = 0; for (int i = 1; i < (int)assignment.size(); i++) { if (assignment[i] == 0) { var = i; break; } } if (var == 0) return true; // 全部赋值且无冲突,可满足 for (int val : {1, -1}) { // 先试真,再试假 std::vector<int> next = assignment; next[var] = val; if (dpll(clauses, next)) { assignment = next; return true; } } return false; }逻辑说明:每次递归传的是clauses的副本和assignment的副本,这样回溯天然由函数栈完成,不用手动撤销赋值,代价是拷贝开销。参数上,分支顺序{1, -1}对可满足算例影响大,如果算例明显偏向某极性,反过来试可能更快。想提速可以把clauses改成引用加 trail 记录,但课设规模下副本写法更不容易出错。数独模块的SudokuDPLL就是复用这套框架,只是编码不同。
4. 数独转 SAT:挖洞法生成与 CNF 编码
4.1 挖洞法生成终盘与初盘
数独模块先用挖洞法生成一个合法终盘,再随机挖掉若干格得到初盘。终盘生成常见做法是先填好第一行,然后通过行变换、列变换、数字置换得到随机终盘,比回溯填满整个盘面快得多。挖洞时要注意唯一解约束:每挖一个洞都要验证剩下的盘面是否仍有唯一解,否则玩家可能遇到多解数独。课设规模下如果只求“能解”,可以放宽唯一解校验,但你要清楚这个取舍。
// Sudoku.cpp 挖洞示意:从终盘随机挖洞,保留 clueCount 个提示数 // board 为 9x9 终盘,0 表示空格 int clueCount = 30; // 保留提示数,越少越难 while (clueCount < 81) { int r = rand() % 9, c = rand() % 9; if (board[r][c] == 0) continue; int backup = board[r][c]; board[r][c] = 0; // 若要求唯一解,这里调用求解器验证解的数量,多解则回填 clueCount++; }参数说明:clueCount决定难度,常见初盘保留 25 到 35 个提示数,低于 17 个提示数不可能有唯一解(这是数独的已知下界)。挖洞顺序用rand()前记得srand(time(nullptr)),否则每次生成的盘面一样,调试时反而方便,发布时要改。
4.2 数独的 CNF 编码规则
把数独转成 SAT,核心是给每个格子每个数字定义一个布尔变元,然后加三类约束子句。变元编号常用x(r,c,d) = 81*(r-1) + 9*(c-1) + d,r、c、d 都从 1 到 9,总共 729 个变元。
| 约束类型 | 含义 | 子句规模 |
|---|---|---|
| 单元格至少一个数字 | 每格 1 到 9 至少填一个 | 81 个子句,每个 9 文字 |
| 单元格至多一个数字 | 每格不能同时填两个数字 | 每格 C(9,2)=36 对,共 2916 个二元子句 |
| 行/列/宫不重复 | 同一行、列、宫内同一数字只能出现一次 | 每单元每数字一条,共 729 条 |
// 数独转 CNF 示意:以“每格至少一个数字”为例 std::ofstream out("sudoku.cnf"); out << "p cnf 729 " << totalClauses << "\n"; for (int r = 1; r <= 9; r++) for (int c = 1; c <= 9; c++) { for (int d = 1; d <= 9; d++) out << var(r, c, d) << " "; out << "0\n"; // 行尾 0,DIMACS 格式要求 } // 初盘已知格:直接加单元子句锁定该格数字 for (每个已知格 (r,c,d)) out << var(r, c, d) << " 0\n";逻辑说明:var(r,c,d)就是上面那个编号公式。已知格用单元子句锁定,DPLL 一进来单元传播就能把这些值定下来,大幅缩小搜索空间。参数上,totalClauses要算准,写错了有些解析器会直接报错,虽然这个工程的CnfParser可能不校验,但养成习惯。编码完把.cnf交给CnfParser读回,再走SudokuDPLL,整条链路和 SAT 分支完全一致。
5. 避坑与排查:编译、解析、性能三类翻车
5.1 编译期重复定义与头文件循环包含
现象:multiple definition of 'xxx'或redefinition of class。原因:Global.h里直接定义了全局变量或函数体,被多个 cpp 包含后每个编译单元都生成一份定义。解决:头文件里只放extern声明和函数原型,变量定义放到某一个 cpp 里;类定义加#pragma once或 include guard,防止循环包含。
5.2 CNF 解析后子句数对不上
现象:程序读problem1-20.cnf后打印的子句数和文件头p cnf声明的对不上,求解结果错。原因:DIMACS 允许一行多个子句,也允许子句跨行,只按行切会把一个子句拆成两个。解决:改成按 token 流读,遇到 0 就结束当前子句,不依赖换行。解析完打印子句总数和numClauses比对,不一致就停下来查。
5.3 大算例递归爆栈
现象:跑7cnf20_90000_90000_7.shuffled-20.cnf时程序崩溃或报栈溢出。原因:DPLL 递归深度和变元数相关,100 变元最坏情况递归上百层,加上每层拷贝clauses副本,栈和堆压力都大。解决:把clauses改成引用传递加 trail 手动回溯,减少拷贝;或者调大栈限制ulimit -s 65536再跑。课设规模下先确认是不是死循环导致的假爆栈。
5.4 数独多解却当成唯一解输出
现象:同一个初盘,程序给出一个解,但手工检查发现还有别的解。原因:挖洞时没做唯一解校验,或者 CNF 编码漏了“至多一个数字”约束。解决:补上 2916 条二元子句;如果要求唯一解,挖洞后调用求解器统计解的数量,多于一个就回填该格重新挖。
5.5 输出赋值与变元编号错位
现象:求解器返回 TRUE,但把赋值代回原公式发现有子句为假。原因:assignment数组下标和 DIMACS 变元编号差 1,或者输出时把极性弄反。解决:统一用 1 基下标,输出前写一个小校验函数,遍历所有子句确认至少一个文字为真,这个后悔药比事后手查省事得多。
6. 验证与提速:从算例回归到分支启发式
跑通之后别急着交差,先做一轮回归验证。压缩包里的算例分两类:sat-20.cnf、problem1-20.cnf这类名字带 sat 或规模小的,预期可满足;unsat-5cnf-30.cnf这类预期不可满足。逐个跑,记录返回值和耗时,可满足的还要把赋值代回公式校验一遍。这一步能同时验证解析、求解、输出三个环节。
# 批量跑算例并记录结果,time 看耗时 for f in *.cnf; do echo "=== $f ===" /usr/bin/time -f "%e s" ./sat_solver < input_$f.txt done如果菜单是交互式的,没法直接喂文件名,就临时改Start.cpp把文件名写死,或者加一个命令行参数分支。验证通过后想提速,优先动三个地方:一是分支启发式,把“选第一个未赋值变元”换成出现频率最高的变元(类似 DLIS),对随机 3-SAT 提升明显;二是把clauses拷贝改成引用加 trail,省掉每层递归的深拷贝;三是单元传播时记录每个子句的未赋值文字数,避免每次全量扫描。我一般会先加一个计时打点,确认瓶颈在传播还是分支,再决定改哪里,不然容易优化了个不痛不痒的地方。
从那以后我每次拿到这种课设级 SAT 工程,都强制先跑一遍小算例回归、再跑一遍大算例看耗时,两步都过了才敢动内核。希望帮到你。
本文还有配套的精品资源,点击获取