Turns out, sometimes a hammer is not the best tool for the job.
So in my last post, I haughtily dismissed SAT as burdensome and opaque formulation for solving puzzles like Flow Free. Since then I’ve learned a few things thanks to @asmeurer, whose thought-provoking comments pointed me towards some interesting sources on the topic.
To succinctly summarize the previous post, I wrote an automated solver for the popular Flow Free mobile puzzle app in C, based on tree search. Although you can naturally frame Flow Free as a constraint satisfaction problem (CSP), I wrote that I preferred tree search instead, because:
- I spent years in grad school coding up search-based planners with A*.
- When you have a hammer, the whole world looks like a nail.
The program I presented ended up running pretty quickly, solving all of the puzzles in my test suite in under 1.6 seconds per puzzle, while weighing in in at 1,910 physical lines of C code.1
And that would have been the happy ending to the story, had asmeurer’s comments not prodded me to try out the CSP/SAT solution. The new program, written in Python, has a worst-case solution time of about 0.4 seconds on my test cases, using 3x less code than the C solver.2
As I recently learned, dependency resolution is another problem that can also be posed as either graph search or as SAT.3 In a nutshell, the objective is to decide what additional packages to install or uninstall when a user wants to run some desired piece of software. The figures below, from this paper, show how dependency/conflict relationships between packages can be represented in both ways:
Indeed in 2013, the Python distribution Anaconda switched its conda package management tool to resolving dependencies using SAT, thanks to developer Ilan Schnell. The underlying engine is the C-based PicoSAT solver, made accessible from Python by Schnell’s pycosat module. As a warm-up to solving the dependency problem, he used pycosat to write a Sudoku solver, which was a helpful and instructive example as I began writing my pycosat-based Flow Free solver.
Conjunctive normal form
The thorniest part of reducing a general CSP to a SAT problem is that the constraints must typically be expressed in conjunctive normal form (CNF). CNF consists of a number of OR-clauses, all of which must be satisfied conjunctively (i.e. they are all AND-ed together). Each OR-clause is a disjunction over one or more literals consisting of single logical variables or their negations.
Converting to CNF can be a bit tricky: the logical statement (i.e. “ and are either both true or both false”) gets represented as the two clauses
whereas the Boolean exclusive-or (XOR) of the two variables, , must be represented as
Logical implications like become the single clause , and negations of AND-clauses such as (that is, “it is not the case that both and are true”) are straightforwardly converted through DeMorgan’s law to .
Reducing Flow Free to SAT
With this basic understanding of CNF, we’re ready to begin reducing Flow Free to SAT. By the way, the discussion in this section pretty much follows the recipe for a SAT-based solver outlined in the StackOverflow answer I linked to in my last post, but it fills in a lot more details that I had to figure out on on my own.
One by one, we will see how to encode the following list of constraints into CNF:
- Every cell is assigned a single color.
- The color of every endpoint cell is known and specified.
- Every endpoint cell has exactly one neighbor which matches its color.
- The flow through every non-endpoint cell matches exactly one of the six direction types.
- The neighbors of a cell specified by its direction type must match its color.
- The neighbors of a cell not specified by its direction type must not match its color.
Let’s define some variables and indices to get started. Say that our puzzle has cells and colors. We will use , , , to refer to to individual cells, so for instance we have cell . Similarly, we will use the indices to refer to individual colors. Finally, we can define six “direction types” that specify which of a non-endpoint cell’s neighbors are connected to it by some flow (we can conveniently illustrate them with Unicode box drawing characters). The six types are:
|Direction type||Unicode char||Description|
We will use to index these direction types. With that setup in mind, let’s address the first constraint on our list above.
Every cell is assigned a single color. We will create Boolean variables to encode the color of each cell: if the SAT solution sets to true, that means that cell has color . These color variables are handled slightly differently in endpoint cells (i.e. the colored dots that are initially filled in before the puzzle is solved) and normal free cells. Let’s consider normal cells first.
Clearly every cell must have some color, so for each non-endpoint cell , we can write the clause:
Also, for each non-endpoint cell , we know that no two color variables can be true, so we get the clauses:
Each one of these comes from negating the AND clause for two different colors and . Note we have possible combinations of two colors for each cell.
The color of every endpoint cell is known and specified. Since their colors are specified initially, for each endpoint cell , we know its true color , so we can simply assert the clause . We can also specify that none of the other colors are present, so for all .
Every endpoint cell has exactly one neighbor which matches its color. This is the neighbor through which a flow originates or terminates. Let us assume endpoint cell has known color and neighbor cells , , , and . Since at least one neighbor of the cell has color , we can write the clause
Furthermore, no two neighbors of the endpoint cell both share its color, so we have , and all the other two-way combinations of the neighbors (i.e. and , and , and , and so on).
The flow through every non-endpoint cell matches exactly one of the
six direction types. Now, for each non-endpoint cell we introduce up
to six Boolean “direction type” variables that specify which two
neighbors of the cell are connected to it along a solution path. If
variable is true in the SAT solution, then some flow
connects cell to the two of its neighbors specified by type
from the table above. Note that some cells can not assume all of
the direction types – for instance, it is impossible for a flow to
┘ direction type anywhere in the top row or leftmost column
of the puzzle (since those cells are missing the required neighbors
above or to the left).
Just like the color variables, we can generate clauses for the direction variables to ensure that:
- every non-endpoint cell cell has at least one direction type, and
- no two directions types are specified for any non-endpoint cell.
The former gives us one clause per cell; the latter gives up to 15 additional clauses – one for each possible pair of direction types present.
The neighbors of a cell specified by its direction type must match
its color. It’s time to consider the interaction between direction
type variables and color variables. Each direction type picks out two
neighbors of a cell; for example, direction type
┐ selects a
cell’s left neighbor and its bottom neighbor. So for any cell
and any direction type applied to it, we get two
neighbors and that must have the same color as cell
. That means, for all colors :
…bearing in mind that the indices and are jointly determined by the cell and the direction type . Massaging the statement above into CNF gives us four clauses:
The neighbors of a cell not specified by its direction type must not match its color. This is the constraint that prevents path self-contact. For any neighbor of which isn’t picked out by direction type , we can say . In CNF, this becomes the clause
…That’s it! We just generated quite a few variables and clauses: our puzzle of size with colors has color variables and about direction type variables, with clauses over just the color variables and an additional clauses for direction-color interactions. You may recall that SAT runtimes are, in the worst case, exponential in the number of variables. Should we be worried about prohibitively slow solution speeds? Spoiler alert: nope.
Dealing with cycles
One point that got raised in the StackOverflow answer is that this particular reduction to SAT does not prevent freestanding cycles from arising in the puzzle. Here’s an example – on the left below, we have the initial state of a 14x14 puzzle, and on the right, an invalid solution with a cycle (the 2x2 yellow square in the top left):
Let’s double-check whether this obviously bogus solution meets our constraints from before:
- Every cell is assigned a single color. ✓
- The color of every endpoint cell is known and specified. ✓
- Every endpoint cell has exactly one neighbor which matches its color. ✓
- The flow through every non-endpoint cell matches exactly one of the six direction types. ✓
- The neighbors of a cell specified by its direction type must match its color. ✓
- The neighbors of a cell not specified by its direction type must not match its color. ✓
Unfortunately, the cycle really is allowed. Like the StackOverflow commenter, the only way I can think to prevent them in the SAT specification would be to add O(N^2) additional variables to track whether each cell is rooted the starting point of some flow. I tried going down this path, and it really bogged down the PicoSAT solver.
Instead of adding the extra “rootedness” variables, my program works
incrementally by detecting cycles in the SAT solver’s output and
emitting additional clauses to prevent them. For instance, in response
to the bogus solution on the right above, it would add a clause saying
“either cell (4, 3) is not
┌ or (4, 4) is not
┐ or (5,4) is not
┘ or (5, 3) is not
└”. In other words, it is not the case that all
four cells have the direction types causing the cycle.
In the case of this particular puzzle, having been prohibited from forming the yellow cycle above, the incremental solver happily discovers one more cycle (left below) before finding the true solution (right):
It turns out that this kind of incremental solution of SAT problems, where the solver is invoked multiple times as variables and clauses are added, is an application area of sufficient interest that it now merits its own category at the yearly SAT competition.4
As the table below shows, although the C solver is faster than the
SAT-based Python solver on most puzzles (especially small ones), the
worst-case solution time for the Python solver is much better. Looking
at the time differences between them, the maximum in favor of the C
solver is 0.356 seconds, on
jumbo_14x14_01.txt – the only puzzle in
the test suite that actually necessitates cycle detection and
repair. On the other hand, the maximum difference in favor of the
Python solver is 1.197 seconds on the slowest puzzle for the C solver,
Add to that the fact that the Python program has far less code than the C version, and SAT starts to look really appealing. I’m going to close off this post with another screen grab of a Facebook exchange that I think really captures the learning experience of this week’s exercise:
Don’t get too attached to your hammers, kids!
Appendix: experimental data
|Puzzle||# SAT vars||# clauses||Python time||C time||Difference|
Code sizes computed using David A. Wheeler’s SLOCCount. ↩
Not exactly an apples-to-apples comparison. The C version emits SVGs (Python doesn’t), and has a lot more debugging and visualization features in general. But still. ↩
You knew there was a yearly SAT competition, right? ↩