2-SAT (2-Satisfiability) — Middle Level¶
One-line summary: 2-SAT reduces a 2-CNF satisfiability problem to strongly connected components on a
2n-vertex implication graph. Two literals in the same SCC are logically equivalent; if a variable and its negation are forced equivalent, no assignment can hold and the formula is UNSAT. Otherwise a satisfying assignment falls out of the reverse-topological order of the SCC condensation, all inO(n + m). This file goes deeper into why the criterion is correct, how to model real constraints (at-most-one, implications, conditional forcing), and how 2-SAT compares to general SAT and to CSP solvers.
Table of Contents¶
- Recap & Mental Model
- Why "Same SCC" Means Contradiction
- Why Reverse-Topological Order Recovers an Assignment
- Comparison: 2-SAT vs General SAT vs CSP
- Advanced Modeling Patterns
- Tarjan vs Kosaraju for 2-SAT
- Full Solver with Assignment Recovery
- Performance Analysis
- Testing Strategy
- Edge Cases & Pitfalls
- Summary
Recap & Mental Model¶
A 2-CNF formula is an AND of clauses, each an OR of at most two literals. The junior file established the core pipeline:
- Encode literals as vertices:
xi = true → 2i,xi = false → 2i+1, andneg(v) = v ^ 1. - Build the implication graph: each clause
(a ∨ b)addsneg(a) → bandneg(b) → a. - Compute SCCs with a linear algorithm (Tarjan or Kosaraju).
- Decide: SAT iff no variable
xhas both literals in one SCC. - Recover an assignment:
xi = trueiffcomp[2i] < comp[2i+1](with Tarjan's reverse-topo numbering).
At middle level the interesting questions are: why is each of those steps correct? and how do I express a real-world constraint as 2-CNF clauses? Both come down to thinking of edges as forced consequences.
The implication graph has a crucial symmetry: it is skew-symmetric. Whenever there is an edge u → w, there is also the edge neg(w) → neg(u) (the contrapositive — added automatically because every clause contributes both implications). This means: if p can reach q, then neg(q) can reach neg(p). We will lean on this property repeatedly.
Why "Same SCC" Means Contradiction¶
Claim. The formula is unsatisfiable if and only if some variable x has x and ¬x in the same SCC.
Edges are forcing. An edge p → q means "if p is true, q must be true." A path p → ... → q chains those forces: p true forces q true. Within an SCC, every vertex reaches every other, so all literals in one SCC are forced to share a single truth value — they are logically equivalent.
The contradiction direction. Suppose x and ¬x are in the same SCC. Then x forces ¬x (a path exists), and ¬x forces x. Now try to satisfy the formula:
- If you set
x = true, the pathx → ... → ¬xforces¬xto be true as well — meaningxis false. Contradiction. - If you set
x = false(i.e.¬x = true), the path¬x → ... → xforcesx = true. Contradiction.
No value of x is consistent, so the formula is UNSAT.
The satisfiable direction (intuition). Suppose no variable has both literals in one SCC. Then in the condensation (the DAG of SCCs), the literal x and its negation ¬x always live in distinct super-nodes. The skew-symmetry guarantees these two super-nodes are "mirror images": if SCC C contains a set of literals, the SCC neg(C) containing all their negations is a different component, and it sits at the mirrored position in the topological order. We can consistently pick, for each mirror pair, the one that is later in topological order, and this choice never forces a contradiction because implications only point forward in topological order. That is precisely what the assignment-recovery rule does. The fully rigorous proof is in professional.md.
Why Reverse-Topological Order Recovers an Assignment¶
The assignment rule "xi = true iff comp[2i] < comp[2i+1]" can look like magic. Here is the reasoning.
Topological order of the condensation. Collapse each SCC into a super-node; the result is a DAG. A topological order lists super-nodes so that every edge points from an earlier node to a later node. Equivalently, if SCC A can reach SCC B, then A comes before B.
Tarjan's numbering is reverse-topological. Tarjan finalizes (pops) an SCC only after exploring everything reachable from it. So the first SCC it numbers (id 0) is a sink (reaches nothing new), and the last it numbers (highest id) is a source. Thus: smaller comp id = later in topological order = more downstream.
The rule. For a variable xi, its two literal-vertices 2i (true) and 2i+1 (false) lie in two different SCCs (we already checked SAT). We want to choose the literal whose SCC is downstream — later in topological order — because picking a downstream literal cannot force any upstream literal (edges go forward). Downstream = smaller comp id. So:
pick xi = true when comp[2i] < comp[2i+1] (true literal is downstream)
pick xi = false when comp[2i+1] < comp[2i] (false literal is downstream)
Why "downstream" is safe. If you set a literal L to true, the only literals it forces true are those reachable from L — i.e. downstream of L. If L itself is the most-downstream of its mirror pair, then everything it forces is even further downstream, and (by skew-symmetry) you never force the negation of an already-chosen literal. The choice is internally consistent across all variables simultaneously. Again, the formal invariant proof lives in professional.md.
Convention warning. If you compute SCCs with Kosaraju and number components in the order the second DFS discovers them, the order may be forward-topological, flipping the comparison to
comp[2i] > comp[2i+1]. The safe move: pick one SCC routine, derive its ordering once, and fuzz-test the comparison against a brute-force checker.
Comparison: 2-SAT vs General SAT vs CSP¶
| Dimension | 2-SAT | General SAT (k-SAT, k≥3) | CSP (constraint satisfaction) |
|---|---|---|---|
| Clause size | ≤ 2 literals | any | arbitrary constraints over finite domains |
| Domains | boolean | boolean | any finite domain (colors, ints, ...) |
| Complexity | P (linear O(n+m)); NL-complete | NP-complete | NP-complete in general |
| Algorithm | implication graph + SCC | DPLL/CDCL backtracking SAT solvers | backtracking + constraint propagation (AC-3, forward checking) |
| Witness | always recoverable in linear time | recoverable but exponential worst case | recoverable but exponential worst case |
| Counting solutions | #P-complete (hard even though decision is easy) | #P-complete | #P-complete |
| Optimization variant | MAX-2-SAT is NP-hard | MAX-SAT NP-hard | weighted CSP NP-hard |
Takeaways.
- 2-SAT is the largest clause-size for which satisfiability is in P. Bump to 3 and you cross into NP-complete.
- Even though deciding 2-SAT is easy, counting its solutions or maximizing satisfied clauses is hard. Easiness of the decision problem does not carry over to the optimization and counting variants.
- If your problem has only binary variables and only "at least one of two" constraints, model it as 2-SAT and solve in linear time. If constraints are larger or domains non-boolean, reach for a CSP/SAT solver.
Advanced Modeling Patterns¶
The art of 2-SAT is modeling: translating real constraints into clauses. Here are the workhorse patterns. Throughout, add_clause(a, va, b, vb) means "(xa == va) OR (xb == vb)."
Pattern A — Force a literal¶
force xi = true : add_clause(i, true, i, true) # (xi ∨ xi)
force xi = false : add_clause(i, false, i, false) # (¬xi ∨ ¬xi)
Pattern B — Implication xa ⇒ xb¶
"If a is chosen, then b must be chosen" is the clause (¬xa ∨ xb):
This generalizes: xa ⇒ ¬xb is (¬xa ∨ ¬xb) = add_clause(a, false, b, false).
Pattern C — At most one of a pair (mutual exclusion)¶
"xa and xb not both true" = (¬xa ∨ ¬xb):
Pattern D — Exactly one of a pair (XOR over two)¶
Combine "at least one" and "at most one":
This is the "must differ" constraint used in 2-coloring: edge endpoints take opposite colors.
Pattern E — At most one over many (the linear "implication chain")¶
"At most one of x0, x1, ..., xk is true" naively needs O(k^2) pairwise clauses. The standard trick uses auxiliary prefix variables p0..pk where pi means "some xj with j ≤ i is true":
xi ⇒ pi (¬xi ∨ pi)
p(i-1) ⇒ pi (¬p(i-1) ∨ pi) # prefix is monotone
xi ⇒ ¬p(i-1) (¬xi ∨ ¬p(i-1)) # if xi true, nothing before it was true
This encodes at-most-one with O(k) clauses and O(k) extra variables — important when k is large.
Pattern F — Binary scheduling / placement¶
Each task has two possible slots, modeled as one boolean (true = slot A, false = slot B). Conflict rules become clauses:
- "tasks
iandjcannot both be in slot A" →(¬xi ∨ ¬xj). - "if task
iis in slot A, taskjmust be in slot B" →(¬xi ∨ ¬xj).
This is how 2-SAT models "choose one of two" puzzles: interval scheduling onto two machines, placing labels above/below points on a map, seating into one of two rooms.
Pattern G — Conditional forcing under reachability¶
Because of skew-symmetry, once you add xa ⇒ xb, the contrapositive ¬xb ⇒ ¬xa is automatically present. You never add contrapositives by hand — the two-edges-per-clause construction does it for you. This is why each clause is exactly two edges, never four.
Tarjan vs Kosaraju for 2-SAT¶
Both compute SCCs in O(n + m); the choice affects only constants and the assignment-reading convention.
| Aspect | Tarjan | Kosaraju |
|---|---|---|
| DFS passes | one | two (graph + transpose) |
| Extra storage | index[], low[], on-stack flag, explicit stack | finish-order stack + transpose adjacency |
| SCC numbering | reverse-topological (sink first) | order of second DFS (forward-topological if you process by decreasing finish time) |
| Assignment rule | comp[2i] < comp[2i+1] | typically comp[2i] > comp[2i+1] (verify!) |
| Builds transpose? | no | yes (extra O(n+m) memory) |
| Typical constant factor | faster (single pass, no transpose) | slightly slower (two passes) |
For 2-SAT in contests and production, iterative Tarjan is the common choice: one pass, no transpose graph, and its reverse-topological numbering pairs naturally with the comp[2i] < comp[2i+1] rule. Kosaraju is sometimes preferred for clarity since each DFS is plain. Whichever you choose, lock in the numbering convention and fuzz-test the comparison.
Full Solver with Assignment Recovery¶
This is a complete, reusable 2-SAT solver in each language, including a small demo that builds a formula, solves it, and verifies the recovered assignment by re-evaluating every clause.
Go¶
package main
import "fmt"
type clause struct {
a int
va bool
b int
vb bool
}
type TwoSAT struct {
n int
adj [][]int
stored []clause // kept only for verification in the demo
}
func NewTwoSAT(n int) *TwoSAT { return &TwoSAT{n: n, adj: make([][]int, 2*n)} }
func node(v int, val bool) int {
if val {
return 2 * v
}
return 2*v + 1
}
func neg(x int) int { return x ^ 1 }
func (t *TwoSAT) AddClause(a int, va bool, b int, vb bool) {
t.stored = append(t.stored, clause{a, va, b, vb})
x, y := node(a, va), node(b, vb)
t.adj[neg(x)] = append(t.adj[neg(x)], y)
t.adj[neg(y)] = append(t.adj[neg(y)], x)
}
func (t *TwoSAT) Solve() ([]bool, bool) {
N := 2 * t.n
index := make([]int, N)
low := make([]int, N)
onStack := make([]bool, N)
comp := make([]int, N)
for i := range index {
index[i], comp[i] = -1, -1
}
var stack []int
idx, cc := 0, 0
for s := 0; s < N; s++ {
if index[s] != -1 {
continue
}
type fr struct{ v, pi int }
st := []fr{{s, 0}}
for len(st) > 0 {
f := &st[len(st)-1]
v := f.v
if f.pi == 0 {
index[v], low[v] = idx, idx
idx++
stack = append(stack, v)
onStack[v] = true
}
if f.pi < len(t.adj[v]) {
w := t.adj[v][f.pi]
f.pi++
if index[w] == -1 {
st = append(st, fr{w, 0})
} else if onStack[w] && index[w] < low[v] {
low[v] = index[w]
}
continue
}
if low[v] == index[v] {
for {
w := stack[len(stack)-1]
stack = stack[:len(stack)-1]
onStack[w] = false
comp[w] = cc
if w == v {
break
}
}
cc++
}
st = st[:len(st)-1]
if len(st) > 0 {
p := st[len(st)-1].v
if low[v] < low[p] {
low[p] = low[v]
}
}
}
}
assign := make([]bool, t.n)
for i := 0; i < t.n; i++ {
if comp[2*i] == comp[2*i+1] {
return nil, false
}
assign[i] = comp[2*i] < comp[2*i+1]
}
return assign, true
}
func (t *TwoSAT) verify(a []bool) bool {
for _, c := range t.stored {
if a[c.a] == c.va || a[c.b] == c.vb {
continue
}
return false
}
return true
}
func main() {
ts := NewTwoSAT(3)
ts.AddClause(0, true, 1, false) // (x0 ∨ ¬x1)
ts.AddClause(1, true, 2, false) // (x1 ∨ ¬x2)
ts.AddClause(0, false, 2, true) // (¬x0 ∨ x2)
a, ok := ts.Solve()
if !ok {
fmt.Println("UNSAT")
return
}
fmt.Println("SAT:", a, "verified:", ts.verify(a))
}
Java¶
import java.util.*;
public class TwoSAT {
private final int n;
private final List<List<Integer>> adj;
private final List<int[]> stored = new ArrayList<>(); // {a, va, b, vb}
public TwoSAT(int n) {
this.n = n;
adj = new ArrayList<>();
for (int i = 0; i < 2 * n; i++) adj.add(new ArrayList<>());
}
private static int node(int v, boolean val) { return val ? 2 * v : 2 * v + 1; }
private static int neg(int x) { return x ^ 1; }
public void addClause(int a, boolean va, int b, boolean vb) {
stored.add(new int[]{a, va ? 1 : 0, b, vb ? 1 : 0});
int x = node(a, va), y = node(b, vb);
adj.get(neg(x)).add(y);
adj.get(neg(y)).add(x);
}
public boolean[] solve() {
int N = 2 * n;
int[] index = new int[N], low = new int[N], comp = new int[N];
boolean[] onStack = new boolean[N];
Arrays.fill(index, -1);
Arrays.fill(comp, -1);
Deque<Integer> stack = new ArrayDeque<>();
int[] idx = {0}, cc = {0};
for (int s = 0; s < N; s++) {
if (index[s] != -1) continue;
Deque<int[]> st = new ArrayDeque<>();
st.push(new int[]{s, 0});
while (!st.isEmpty()) {
int[] f = st.peek();
int v = f[0];
if (f[1] == 0) {
index[v] = low[v] = idx[0]++;
stack.push(v);
onStack[v] = true;
}
if (f[1] < adj.get(v).size()) {
int w = adj.get(v).get(f[1]++);
if (index[w] == -1) st.push(new int[]{w, 0});
else if (onStack[w]) low[v] = Math.min(low[v], index[w]);
continue;
}
if (low[v] == index[v]) {
while (true) {
int w = stack.pop();
onStack[w] = false;
comp[w] = cc[0];
if (w == v) break;
}
cc[0]++;
}
st.pop();
if (!st.isEmpty()) low[st.peek()[0]] = Math.min(low[st.peek()[0]], low[v]);
}
}
boolean[] assign = new boolean[n];
for (int i = 0; i < n; i++) {
if (comp[2 * i] == comp[2 * i + 1]) return null;
assign[i] = comp[2 * i] < comp[2 * i + 1];
}
return assign;
}
public boolean verify(boolean[] a) {
for (int[] c : stored)
if (!(a[c[0]] == (c[1] == 1) || a[c[2]] == (c[3] == 1))) return false;
return true;
}
public static void main(String[] args) {
TwoSAT ts = new TwoSAT(3);
ts.addClause(0, true, 1, false);
ts.addClause(1, true, 2, false);
ts.addClause(0, false, 2, true);
boolean[] a = ts.solve();
if (a == null) System.out.println("UNSAT");
else System.out.println("SAT: " + Arrays.toString(a) + " verified: " + ts.verify(a));
}
}
Python¶
class TwoSAT:
def __init__(self, n):
self.n = n
self.adj = [[] for _ in range(2 * n)]
self.stored = [] # (a, va, b, vb) kept for verification
@staticmethod
def _node(v, val):
return 2 * v if val else 2 * v + 1
@staticmethod
def _neg(x):
return x ^ 1
def add_clause(self, a, va, b, vb):
self.stored.append((a, va, b, vb))
x, y = self._node(a, va), self._node(b, vb)
self.adj[self._neg(x)].append(y)
self.adj[self._neg(y)].append(x)
def solve(self):
N = 2 * self.n
index = [-1] * N
low = [0] * N
comp = [-1] * N
on_stack = [False] * N
stack = []
idx = cc = 0
for s in range(N):
if index[s] != -1:
continue
st = [(s, 0)]
while st:
v, pi = st[-1]
if pi == 0:
index[v] = low[v] = idx
idx += 1
stack.append(v)
on_stack[v] = True
if pi < len(self.adj[v]):
w = self.adj[v][pi]
st[-1] = (v, pi + 1)
if index[w] == -1:
st.append((w, 0))
elif on_stack[w]:
low[v] = min(low[v], index[w])
continue
if low[v] == index[v]:
while True:
w = stack.pop()
on_stack[w] = False
comp[w] = cc
if w == v:
break
cc += 1
st.pop()
if st:
low[st[-1][0]] = min(low[st[-1][0]], low[v])
assign = [False] * self.n
for i in range(self.n):
if comp[2 * i] == comp[2 * i + 1]:
return None
assign[i] = comp[2 * i] < comp[2 * i + 1]
return assign
def verify(self, a):
return all(a[c[0]] == c[1] or a[c[2]] == c[3] for c in self.stored)
if __name__ == "__main__":
ts = TwoSAT(3)
ts.add_clause(0, True, 1, False) # (x0 ∨ ¬x1)
ts.add_clause(1, True, 2, False) # (x1 ∨ ¬x2)
ts.add_clause(0, False, 2, True) # (¬x0 ∨ x2)
a = ts.solve()
if a is None:
print("UNSAT")
else:
print("SAT:", a, "verified:", ts.verify(a))
Run: go run main.go | javac TwoSAT.java && java TwoSAT | python twosat.py
Performance Analysis¶
Time. Building the graph is O(n + m) — 2m edge appends. The SCC pass visits each of 2n vertices and each of 2m edges once, also O(n + m). The criterion check and assignment recovery are O(n). Total: O(n + m), linear in formula size — optimal, since you must read every clause.
Space. The adjacency lists hold 2m edges; Tarjan's arrays (index, low, comp, onStack) and the explicit DFS/SCC stacks are O(n). Total O(n + m).
Constant factors. Iterative Tarjan does a single pass with no transpose graph; Kosaraju does two passes and builds the transpose (roughly 2× memory for edges). In practice both are fast; the iterative-Tarjan version handles n, m up to several million comfortably within a second.
Cache behavior. The graph is the dominant memory cost. Using flat arrays for adjacency (CSR-style: an offsets[] array plus a packed edges[] array) gives far better locality than List<List<Integer>> boxing — worth doing when m is large. See professional.md for the cache discussion.
Scaling failure point. Recursive Tarjan overflows the call stack around 2n ≈ 10^5–10^6 depending on the runtime; the iterative version shown here avoids that entirely.
Testing Strategy¶
The two bugs that actually bite are edge direction and assignment-reading direction. Both are invisible on tiny examples and catastrophic at scale. The defense:
- Brute-force oracle. For
n ≤ ~20, enumerate all2^nassignments and check satisfiability directly. - Fuzz. Generate thousands of random small formulas, compare your solver's SAT/UNSAT verdict to the oracle, and when SAT, re-evaluate every clause under the recovered assignment.
- Targeted cases. Empty formula (SAT), forced-variable unit clauses, self-contradiction
(a) ∧ (¬a)(UNSAT), isolated variables.
This exact harness is how the direction bug in this topic's reference solver was caught and fixed — the verdict matched on tiny inputs but the assignment failed verification, pinpointing the < vs > comparison.
Edge Cases & Pitfalls¶
- Direction of implication.
(a ∨ b)is¬a ⇒ band¬b ⇒ a. Reversing it silently produces a wrong (often always-SAT or always-UNSAT) solver. - Assignment comparison vs SCC numbering.
comp[2i] < comp[2i+1]is correct for Tarjan's reverse-topo numbering; flip it for Kosaraju forward order. Verify, never assume. - Unit clauses. Model
(a)as(a ∨ a). Forgetting leaves the variable unconstrained. - At-most-one over many. Pairwise clauses are
O(k^2); use the prefix-variable encoding (Pattern E) for large groups. - 3-literal clauses. Not 2-SAT. No amount of cleverness with the implication graph makes 3-SAT linear — it is NP-complete.
- Counting / optimization. Deciding 2-SAT is easy; counting solutions (#2-SAT) and MAX-2-SAT are hard. Do not assume the easy decision extends.
Summary¶
The middle-level mastery of 2-SAT is two-fold. First, the why: edges are forced consequences, an SCC is a set of mutually-forced (logically equivalent) literals, a variable trapped in one SCC with its negation is a contradiction (UNSAT), and the reverse-topological SCC order lets you pick, for each variable, the downstream literal — a globally consistent assignment. Second, the modeling: real binary-choice constraints (force, implication, mutual exclusion, exactly-one, at-most-one over many, two-slot scheduling) all translate into a handful of clause patterns, and the skew-symmetric implication graph adds every contrapositive for free. With those two pieces, 2-SAT becomes a sharp, linear-time tool for a surprisingly wide class of "choose one of two under constraints" problems. The hard part is always the modeling; the solver itself is a single SCC pass.