CS1950Y Lecture 15: SAT Solving
Friday, March 2, 2018
Boolean Logic
We’re trying to find efficient ways to check if a Boolean formula is satisfiable or not. Here’s some of the terminology we’ll be using:
- A literal is a variable or negated variable, like
x
ornot x
- A clause is a disjunction of literals, like
x or not y or z
- A unit clause is a clause with only one literal
Why might we care about unit clauses? They’re great starting places! Since all clauses have to be true to satisfy the formula, a unit clause tells us exactly what value a variable should have. The core of the solver we’re going to build today is recognizing unit clauses and propagating that knowledge.
N-Queens
The Eight Queens problem is a classic AI constraint problem. Essentially, since queens can attack in so many ways, the goal is to place 8 queens on a board such that none of them are threatening any others. This is pretty reasonable to check - we just go through each queen and see if it can attack any other queens. The n-queens problem is a generalization of this where we want to put n queens on a n by n board.
This is actually pretty reasonable to put into a SAT solver. For each square, we have a variable that’s true if there’s a queen there and false if there isn’t. Then, we encode constraints about which squares can have queens if other squares have queens (it turns out diagonals are the hardest part). We won’t go into that in detail, but essentially, we can have at most one queen per row, column, or diagonal. If we look at which variables are true in the instance the SAT solver gives us, we have a solution to the problem.
This runs pretty quickly for 4 queens, and even for 8. There’s a pretty big blowup in the number of clauses (84 for 4 queens vs. 744 for 8), but it’s still doable. Even for 20 queens, it takes around 70ms. At 50 queens, it takes around a second. Remember, we’re using an off-the-shelf SAT solver! There’s no special domain knowledge we’re passing into it.
Off-the-shelf boolean SAT solvers are fast! Our encoding isn’t even all that clever. Someone asked why Alloy is so slow. One of the things that helps with n-queens is that it’s already expressed in CNF, but Alloy has to do a whole conversion process. The n-queens clauses are also not particularly wide, usually no more than n literals. As you increase the bounds for Alloy and have wider relationships, that slows things down a lot. Comparatively, it’s reasonably speedy for low bounds.
SAT Solvers
Let’s first go over our input. We’ll express formulas like this:
3
-3 -2
2 -1
In this format, each line is a separate clause. Positive numbers are positive literals, and negative numbers are negated literals. The formula above would be expressed like this:
x3 and (not x3 or not x2) and (x2 or not x1)
What’s something you could do to see if this is satisfiable?
- From the first clause, we know
x3
has to be true - That means that, to satisfy the second clause,
x2
must be false - Then, to satisfy the third clause,
x1
must be false.
Another approach is to write out the whole truth table and see if there are any satisfying instances in the rows. Alternately, we could build out a tree with each variable being true or false, and get the BDD from that.
In general, we don’t know if it’s possible to solve SAT in general in polynomial time. There’s been a lot of effort over the past 20 or so years to make SAT solving in the average case faster.
In the general case, we’re not even sure if we can do better than guessing and exponential time. For those of you who’ve taken 1010 or 157, SAT solving is an NP-complete problem. That means that if we have a polynomial-time general-case SAT solver, we have polynomial-time algorithms for all the other NP-complete (or easier) problems. So, if you have a polynomial-time SAT solver, you can also do integer factorization in polynomial-time, and most of our existing cryptosystems are broken.
As the solver we used for n-queens shows, we can still do rather well in the average case. The average case is average for a reason! Most of the time, we won’t need to wrestle with the worst case.
If all clauses are no wider than 2, for example, there’s a trick we can use to solve in linear time. The idea is to create a graph where each node is a variable or its negation. We then rewrite each of these or’s as implications, and add corresponding edges to the graph. For example, -1 -2
as an implication is 1 -> -2
, so we add an edge from 1
to -2
. With this graph, how do we know if the formula’s unsatisfiable? If we have a cycle containing a variable and its negation, then we know it’s unsatisfiable. Lots of the clauses in our n-queens encoding were of size 2 - modern SAT solvers can take advantage of structures in the formula like this to speed things up.
DPLL
For our SAT solver, we’re going to combine two ideas. One is finding unit clauses and propagating that knowledge through. The other is that we can branch and guess variable assignments randomly.
Let’s start by figuring out the type signature of our algorithm. Our input is a set of clauses, which is implicitly over a set of variables. For the output, we can’t just have true or false - we also need instances. Alloy couldn’t run off of a SAT solver that just produced true or false!
It’ll look something like this:
# Solve a formula `f` over the set of variables `vars
solve(f, vars):
# First, we propagate unit clauses - we want to learn as much as
# we can from them.
f = propagate(f)
# Now, suppose we have a formula where some variable appears only
# positively or negatively. If we only see one sign of a variable in
# the constraints, we may as well pick that sign - it'll satisfy
# some clauses for us, and picking the other sign won't get us
# anywhere. That's called pure literal elimination
f = pure-elim(f)
# What next? We've learned all we can from unit clauses and we've
# removed all pure literals. Now, we might've solved the problem
# already. If not, we have to guess.
# There's no way to satisfy an empty clause, which means the formula
# is unsatisfiable.
if f contains the empty clause:
return False
# f is consistent if we don't have any variables that appear with
# both signs in the unit clauses.
# If all the clauses are unit clauses, and there's one for each
# variable, we've solved it!
if f is a consistent set of unit clauses involving all variables in vars:
return True
# Here, we pick a random variable. This is where a lot of the tuning
# comes in. There are all sorts of heuristics we can use to decide
# what variable to use. In the assignment, you can do anything
# reasonable you'd like here.
x = pick-a-variable(f, vars)
# If assigning x to be true works, great! If not, we have to try
# assigning it to be false.
if solve(f + { x }) is sat:
return True
else:
return solve(f + { -x })