ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SAT求解器课设实践:C++数据结构与DPLL回溯算法实现

SAT求解器课设实践:C++数据结构与DPLL回溯算法实现 简介华中科技大学数据结构课程设计中的SAT求解器项目围绕可满足性判定问题完整实现了基于DPLL框架的求解器并将同一框架扩展用于数独谜题建模与求解。内容包括系统总体设计、模块划分、数据结构定义与核心流程说明适合正在完成算法类课程设计、对SAT求解或数独自动求解感兴趣的高年级本科生参考。压缩包共30个文件以cpp源码、h头文件、cnf算例文件为主附有运行截图、说明文档与多组有解/无解测试样例整体约342KB。作者在CnfParser、DPLLSolver、Sudoku等模块中分别完成CNF解析、变元赋值推理与数独生成转换便于对照源码理解经典算法从文件解析到结果输出的完整落地过程。该资源已有39人浏览学习适合想快速掌握DPLL实现细节、借鉴文件解析与问题转换写法或作为课程设计报告对照素材的读者使用。1. SAT问题放进数据结构课程设计一个不拼算法拼内存模型的题目“SAT”这三个字母一出现多数人会把它当成一个算法题给定一个合取范式CNF判断存不存在一组变量赋值让公式为真。可它被放进“华中科技大学数据结构课程设计2018”这个题目里时考察点就变了不是看你会不会DPLL而是看你怎么用数据结构把公式存明白、让回溯把状态还回来。我在做这个课设时第一版暴力枚举只跑到n40就慢得像死机最后发现拖后腿的既不是分支策略也不是剪枝不足而是回溯时全量复制赋值数组。这篇笔记写给正在做类似课设、或者想找一份可复现的SAT求解落地路径的同学把数据模型、剪枝算法、主循环和踩坑一条条说清楚。2. 用数组表达布尔公式课程设计的数据结构选型与实现拿到题目后先别急着写求解算法第一步是把公式装进内存。SAT的标准输入是DIMACS格式本质上是若干子句的集合子句由文字组成。变量个数n可能到几十甚至几百子句数m通常在千以内这种稀疏结构决定了数据结构选型的方向用连续内存的数组和vector而不是链表和矩阵。这里没有玄学选型对错在n100、m1000时一测便知。2.1 文字、子句与合取范式三个概念如何映射成三个结构先统一术语。布尔变量x的取值是true或false文字是“x为真”或“x为假”这种断言一个子句是若干文字的或析取CNF是若干子句的与合取。SAT问题问的是是否存在一组变量赋值让所有子句同时为真。DIMACS格式里变量编号从1到n文字用整数表示。正数表示“该变量取真”负数表示“该变量取假”。每个子句独占一行末尾用0结束。例如1 -2 0表示子句 (x1 ∨ ¬x2)。映射到内存时我建议用三个结构概念数据结构说明变量取值vectorint assignment(n1, 0)0未赋值1true-1false下标1~n子句集合vectorvectorint clausesclauses[i]存第i个子句的文字列表文字索引vectorvectorint litToClauses下标0~2n-1存包含该文字的所有子句号assignment用vector 而不是bool数组因为回溯和剪枝都需要“未赋值”这第三种状态。子句存储用vectorvector 每个子句是连续内存遍历时CPU缓存友好链表虽然插入删除方便但SAT求解过程中子句集合几乎不变主要操作是反复遍历子句做检查指针跳来跳去的代价完全没必要。邻接矩阵更省子句长度通常只有2~3矩阵会浪费大量空间。2.2 从DIMACS文件到内存解析器的写法与边界处理课程设计一般会给一个.cnf输入文件所以要先写解析器把它读成上面的结构。常见做法是fgets逐行读遇到0表示一个子句结束。#include cstdio #include vector using namespace std; struct CNF { int n; vectorvectorint clauses; }; bool parseDimacs(const char* path, CNF cnf) { FILE* fp fopen(path, r); if (!fp) return false; int n 0, m 0; char buf[256]; // 跳过 c 开头的注释行读取 p cnf 头 while (fgets(buf, sizeof(buf), fp)) { if (buf[0] c) continue; if (sscanf(buf, p cnf %d %d, n, m) 2) break; } if (n 0 || m 0) { fclose(fp); return false; } cnf.n n; cnf.clauses.reserve(m); vectorint clause; int lit; while (fscanf(fp, %d, lit) 1) { if (lit 0) { if (!clause.empty()) cnf.clauses.push_back(clause); clause.clear(); } else { clause.push_back(lit); } } fclose(fp); return true; }解析的逻辑很直白先读“p cnf n m”拿到变量数和子句数再逐个读整数遇到文字写入clause遇到0结束一个子句。有个容易忽略的细节以c开头的注释行可能出现在文件任意位置不只p cnf之前所以不能只读第一行。参数说明fgets的buf给256字节足够装下p cnf行sscanf返回值等于2才认为是成功解析到n和m。reserve(m)预分配子句数组避免解析过程中反复realloc。判断clause为空再push是为了防止连续两个0产生空子句——空子句表示永假后续处理不好会直接导致错误判定。2.3 回溯友好的变量状态与观察表数组为什么比链表更合适赋值数组有了接下来是SAT求解里最核心的操作回溯。递归搜索中设置一个变量后失败要恢复原状。如果每次回溯都把整个assignment复制一遍n60时一次复制就够喝一壶的。正确做法是用一个trail栈记录赋值顺序回溯时倒序撤销vectorint assignment; // 1..n, 0未赋值, 1true, -1false vectorint trail; // 记录赋值顺序变量号 void assignVar(int var, int val) { assignment[var] val; trail.push_back(var); } void undoOne() { if (!trail.empty()) { int var trail.back(); trail.pop_back(); assignment[var] 0; } }trail就是回溯栈记录“我按什么顺序改了哪些变量”撤销时倒序恢复时间复杂度O(回溯深度)。这个设计是后面单元传播和冲突回退的基础比链表方案简单且稳定。如果要在单元传播里快速找出受某个文字影响的子句再加一层索引int litIndex(int lit) { // 文字 - 0..2n-1 return lit 0 ? (lit - 1) * 2 : (-lit - 1) * 2 1; } // 构建索引 vectorvectorint litToClauses(2 * n); for (int ci 0; ci (int)clauses.size(); ci) { for (int lit : clauses[ci]) { litToClauses[litIndex(lit)].push_back(ci); } }litIndex把正文字映射到偶数下标、负文字映射到奇数下标一个变量的两个状态各占一个桶。建立索引后给某个变量赋值时只需要遍历该文字对应的子句检查有没有子句变满足或变冲突不必每次全表扫描。注意这里与两观察文字watch literals不同。litToClauses是静态的“文字→子句”索引观察表是运行时动态维护的指针结构。课程设计时间紧可以先只做索引它能满足单元传播的大部分需求。3. 从暴力枚举到DPLL课程设计要求的算法深度数据结构定了算法才谈得上。最简单的SAT解法是暴力枚举每个变量先试true再试false走完整棵二叉决策树。但暴力枚举只能对付n20的小样例课程设计通常会用n60甚至更大的隐藏用例卡时间所以必须引入剪枝算法。下面从暴力开始一步步改成DPLL每一步都有明确理由。3.1 暴力枚举的规模线为什么n60就撑不住暴力枚举的递归可以写成这样bool bruteDFS(int var) { if (var n) return evaluateAllClauses(); assignment[var] 1; if (bruteDFS(var 1)) return true; assignment[var] -1; if (bruteDFS(var 1)) return true; assignment[var] 0; return false; }这段代码逻辑上没问题但它的搜索树是一棵深度为n的满二叉树叶子数是2的n次方。n20时约一百万个叶子毫秒级n40时一万亿任何课设机器都等不了n60更是想都不用想。很多同学以为评测机会给足时间实际上课程设计的隐藏用例就是用来把不剪枝的版本卡到超时的。另一个被忽略的问题是evaluateAllClauses每次都把所有子句从头算一遍这一层O(m)的乘法让总复杂度变成O(m * 2^n)。要控制住评估必须是增量的——只在变量赋值变化时更新受影响子句的状态而不是全量重算。3.2 剪枝抓手单元传播与纯文字规则暴力枚举的第一刀是单元传播。如果一个子句只剩一个文字还没定真假而且其他文字都已经为假那么这个文字必须被赋值为真否则子句一定不满足。这就是单子句传播unit propagation。它是由公式强制推导出来的赋值后不会产生分支只是向前推进。实现思路每次赋值之后扫描可能受影响的子句。若子句已经满足什么都不做若子句恰好只剩一个未赋值文字就把该文字对应的变量按“令子句为真”的方向赋值记进trail继续传播。这是一个队列式的连锁反应可能一口气推出好几个变量。配合2.3的litToClauses索引遍历范围能缩小到确实受影响的子句速度提升明显。第二个剪枝是纯文字规则。如果某个变量在所有未满足子句里只以正文字出现或者只以负文字出现那直接把变量设为那个方向一定不会让任何子句从真变假。比如变量x只出现在(x∨y)和(x∨¬z)里把x设为真两个子句立刻满足不会引入冲突。这条规则在随机3-SAT实例里效果一般但在手工构造的题和带大量对称性的样例上有奇效实现成本也很低。用一个小例子看效果。对公式 (x∨¬y)∧(¬x∨y)∧(x∨y)暴力分支需要探索多个叶节点而单元传播能一路推完第三个子句是单元子句强制x为真第一个子句变为¬y强制y为假第二个子句已经满足。三步出结果不用回溯。3.3 DPLL的递归骨架选变量、决策、冲突回退把暴力DFS、单元传播和纯文字规则合起来就是DPLL算法。DPLL的全称是Davis-Putnam-Logemann-Loveland是SAT求解的经典回溯框架对一个数据结构课程设计来说做到这个深度完全够。递归骨架bool dpll() { while (unitPropagate() || pureLiteral()) { // 不断做强制推导直到没有可推的 } if (conflict) return false; if (allSatisfied()) return true; // 决策选一个没赋值的变量尝试两个值 int var pickDecisionVariable(); assignment[var] 1; if (dpll()) return true; undoUntil(trailSizeBeforeDecision); assignment[var] -1; if (dpll()) return true; undoUntil(trailSizeBeforeDecision); return false; }这个骨架的关键在于单元传播和回溯是两个方向相反的阶段传播是在当前决策层内不断增加强制赋值回溯则是把整层决策引发的赋值一次性撤掉。trailBefore记录了进入决策前trail的长度撤销时能一次退干净而不是只撤一个变量。pickDecisionVariable选哪个变量对性能影响很大。常见做法是按变量号顺序选实现简单但效果平庸更好的做法是统计每个变量在子句里的出现次数出现越多越优先决策因为让它先定值能更快满足更多子句。课程设计的规模下变量按出现次数降序排列就够了不必上复杂启发式。注意纯文字规则里的“只出现在未满足子句”这个前提不能丢。子句已经满足后其中的文字就不再参与判断否则会把已满足公式拖回去剪枝变成错误剪枝。4. 用C实现SAT主循环现场恢复与三个关键参数骨架已经有了能提交运行的版本还差三件事可编译的C主循环、把几个影响速度和正确性的参数定下来、以及用随机实例验证。我见过太多人栽在这一步算法思路对但代码里状态恢复不干净同样的输入跑两次结果不一样。4.1 主循环怎么写trail栈与时间戳现场恢复2.3已经给了trail栈的基本接口现在把它完整串进Solver类。下面这个版本覆盖DPLL的核心决策、单子句传播、回溯、可满足判断。纯文字规则接口一样后面按注释位置补即可。class Solver { public: int n; vectorvectorint clauses; vectorint assignment; // 0 未赋值, 1 true, -1 false vectorint trail; // 赋值轨迹 int decisionLevel 0; // litToClauses 来自 2.3 的索引构建 vectorvectorint litToClauses; bool unitPropagate() { // 遍历trail中的每个赋值增量检查受影响的子句 for (int i 0; i (int)trail.size(); i) { int var trail[i]; int val assignment[var]; int lit (val 1) ? var : -var; for (int ci : litToClauses[litIndex(lit)]) { if (!updateClauseState(ci, var, val)) return false; // 冲突 } } return true; } bool dpll() { if (!unitPropagate()) return false; if (allSatisfied()) return true; int var pickDecisionVariable(); int levelBefore decisionLevel; int trailBefore trail.size(); // 分支1var true assignment[var] 1; trail.push_back(var); decisionLevel; if (dpll()) return true; // 回溯撤掉本层及以下引入的所有赋值 while ((int)trail.size() trailBefore) { int v trail.back(); trail.pop_back(); assignment[v] 0; } decisionLevel levelBefore; // 分支2var false assignment[var] -1; trail.push_back(var); decisionLevel; return dpll(); } };unitPropagate的循环次数会动态增长传播过程中新赋值也被push进trail循环条件自然跟到“没有新推导为止”。updateClauseState负责维护子句状态它要判断赋值后子句是否满足、是否只剩一个未赋值文字成为新单元、以及所有文字都为假时置冲突标记。这个函数是观察表之外最常见的性能热点写的时候用局部变量缓存clauses[ci]的引用别每次访问都做两次vector下标。参数说明decisionLevel只在决策进入时增加单元传播产生的赋值不提高层级。回溯时用trailBefore和levelBefore恢复到决策入口恢复顺序是先弹trail再归零assignment。注意分支1回退后trail长度已经回到trailBefore分支2只push一个变量递归返回时由上层继续清栈层级关系不会乱。4.2 三个必调参数决策顺序、传播扫描方式、回溯粒度参数名称、推荐取值和作用参数取值示例作用与影响决策变量顺序出现次数降序 vs 编号顺序前者能让更多子句提前满足搜索树小一个量级后者写起来简单但容易慢传播扫描方式全表扫 vs 文字索引 vs 观察表全表扫简单但O(m)乘以传播次数很亏文字索引代码少、适合课程设计观察表最快但实现复杂度高回溯粒度单变量撤 vs 按决策层撤按决策层撤能一次清掉一整层避免残留半层状态导致的诡异误判决策顺序在3.3已提过这里补一个快速实现读入子句后统计每个变量出现次数生成一个order数组按次数降序排序pickDecisionVariable时按order顺序找第一个未赋值的变量即可。这个静态排序不用在搜索过程中动态调整性价比很高。传播扫描方式我建议直接用litToClauses索引。它只比全表扫多一个二维数组却能把单次传播的代价从O(m)降到O(受影响的子句数)。观察表先别碰课程设计的时间预算通常撑不住调试观察表的两条动态不变量。回溯粒度直接决定trail的接口。按决策层撤看着简单但实现时容易忘掉撤销赋值的同时要把辅助状态改回去。如果每次回溯只恢复assignment数组到下一次传播时子句状态计数是错的结果忽对忽错。这就是很多课程设计“换个输入就翻车”的根源。4.3 性能验证用随机3-SAT实例测出可提交的配置写完主循环别急着交用随机实例量一下。随机3-SAT是最常见的测试来源每个子句随机选3个不同变量随机取正/负。生成器和测量可以合在一个几十行的程序里。// 生成 n 个变量、m 个子句的随机 3-SAT写入 out.cnf void genRandom3SAT(int n, int m, const char* path) { FILE* fp fopen(path, w); fprintf(fp, p cnf %d %d\n, n, m); for (int i 0; i m; i) { int vars[3]; for (int k 0; k 3; k) { int var rand() % n 1; vars[k] var; fprintf(fp, %d , (rand() % 2) ? -var : var); } fprintf(fp, 0\n); } fclose(fp); }注意参数含义n是变量数m是子句数。m和n的比例决定可满足性。n30、m100左右是典型的“难区域”会卡住没剪枝的暴力枚举n60、m200能看出单元传播是否真正生效。测的时候固定随机种子前后两次对比才有意义。测量指标不必太复杂记录两点一是求解成功后的递归深度和总决策次数二是耗时用clock()包住solve()调用。决策次数比时间更稳定适合对比不同剪枝策略。如果n50的随机实例几秒内还出不来先回看4.2的表多半是决策顺序或传播扫描方式没选对。5. 课程设计避坑指南数据规模、边界条件与性能陷阱主循环跑通只是第一步真正让课程设计从高分掉到及格线的往往是几个隐蔽的边界错误。这章写我实际踩过的五种坑每条按现象、原因、解决展开写代码时对照检查一遍。5.1 变量下标从1开始最常见的越界翻车现象程序在输入规模稍大时随机崩溃有时n40跑得好好的n80就段错误。原因DIMACS变量编号从1开始而数组习惯从0开始。很多人写assignment[var]时直接用var做下标var80时越界写坏了相邻变量状态。更隐蔽的是循环写法“for (int i 0; i n; i)”把assignment[0]也卷进去传播时用0号变量去查索引行为完全随机。解决数组统一分配n1assignment[0]永远闲置。调试阶段写一个validateVar(int v)断言1 v v n提交前再关。所有涉及变量下标的地方只写1到n的循环不写n再去取0。5.2 回溯只恢复了赋值没恢复辅助状态现象同一个样例第一次运行正确第二次加了个变量顺序后结果就错了或者单测都能过放到完整用例集里错两个边界用例。原因trail回退时如果只改assignmentunitPropagate里维护的子句状态不会自动复原。比如一个子句本来有两个未赋值文字决策后变成单元子句回溯后它应该恢复成两个未赋值文字但计数还停留在1后续传播就可能把一个不该判成单元的子句当单元处理把公式推成错误结果。解决把辅助状态也纳入trail或在回退时重算。课程设计规模下重算可接受每次回退后对相关子句重新数一遍未赋值文字虽然O(m)一次但决策层通常不多。更稳妥的做法是把子句状态变化前后的差异记录下来回退时按差异恢复。5.3 把“未赋值”当成“满足”导致误判现象函数返回true但手动代入赋值发现有一个子句是假的报告“SAT”实际上应该是UNSAT。原因allSatisfied()实现时想偷懒检查子句时看到“文字是未赋值变量”就当成可以满足。这个逻辑在局部看没错一个子句里有未赋值文字的确还有可能被满足。但“还有可能被满足”不等于“已经满足”。搜索结束条件必须是所有子句里至少有一个文字对应真而不是包含未赋值变量。解决allSatisfied的正确写法是遍历每个子句检查是否有文字变量被赋值为true如果没有返回未满足。判定用赋值而不是文字是否存在。类似的错误也存在于冲突判定里只有当一个子句的所有文字都为假时才叫冲突包含未赋值文字的子句不冲突。5.4 递归深度等于决策层数n200时栈会溢出现象变量数300的实例跑起来很慢还直接报栈溢出在main里加了大数组后更明显。原因DPLL用递归实现递归深度等于未决策完的变量数。n300时递归深度也可能到300层每个栈帧里又塞了局部变量和引用默认栈空间容易被击穿。同时main函数里的几个大vector如果开在栈上比如vector order(100000)也会加剧。解决把大对象移到Solver类成员堆上配合release编译能撑到n300左右。setrlimit或编译器参数调栈大小只是拖延。更根本的做法是把递归改成显式栈但课程设计里通常不用。我习惯在调试阶段把递归深度输出到日志超过阈值就提醒自己该换迭代版。5.5 DIMACS解析被注释行和尾部0搞崩现象文件解析后变量数正确子句数少了一个或者出现空子句导致传播时直接冲突。原因以c开头的注释行可能在文件任意位置不只p cnf之前。如果解析代码把注释行也当成子句读进去整行注释文字全被当整数处理子句错位。另一个坑是文件里如果连续两个0或者行尾0后面跟着多余空白fscanf会读入多余0产生空子句。解决解析时用fgets逐行读跳过buf[0]c的行读到整数0时先判断clause是否为空再push。解析结束后做一个合法性检查if ((int)cnf.clauses.size() ! m) { // 子句数不匹配说明解析有遗漏 return false; } for (auto c : cnf.clauses) { if (c.empty()) return false; }这步检查成本极低能让后续调试免去大量无意义的找错时间。子句数为0的公式在SAT语义里是“平凡可满足”但如果你解析时把注释行吞掉导致子句全空问题就不在公式而在解析器。6. 让它跑得更快对拍验证与升级剪枝的正确姿势6.1 用暴力枚举当裁判随机实例对拍是唯一的后悔药算法写完后最担心的一件事是“剪枝剪错了把本来可满足的公式判成不可满足”。这种错误靠肉眼根本看不出来。我的习惯是保留一个暴力枚举器当裁判随机生成一批小规模实例把DPLL的结果和暴力结果对拍。n不超过20时暴力完全跑得动对拍几百个实例只要结果不一致就说明剪枝逻辑破坏了完备性。这也是我能给的最有价值的建议暴力枚举别删改成validate函数留在工程里。它既是裁判也是你改任何参数后的回归测试基线。6.2 观察字面量、VSIDS与时间戳恢复三个值得做的升级方向如果基础版已经稳定还想冲更高分有三个升级方向按性价比排列第一个是观察字面量watch literals。它把传播从“扫所有相关子句”变成“只扫观察位置”性能提升明显但代价是维护两条观察不变量很麻烦回溯时经常要恢复观察位置。第二个是动态决策启发式比如VSIDS每次冲突后给相关变量加权重决策时优先选权重高的变量。VSIDS对难样例很有效但对课程设计的小规模数据不一定有压倒性优势实现复杂度更像“加分项”。第三个是时间戳恢复给每个赋值打一个递增的全局时间戳回溯时用时间戳判断是否需要恢复某个变量避免反复遍历trail。我的建议是基础版交付后先做对拍再做观察表VSIDS看时间预算决定。最怕一上来就追复杂算法结果基础程序都跑不稳定。如果你卡在传播或回溯的错误里试着把剪枝一层层关掉从纯暴力开始一步步加回来每一步跑一遍对拍很快能定位到是哪一层引入的问题——这个习惯帮我省了至少两天调试时间希望帮到你。本文还有配套的精品资源点击获取
返回列表