Cairn
algorithms · backtracking · exponential worst case, but a forced variable never needs to be tried both ways

back to Backtracking

DPLL (Boolean Satisfiability)

Every other Backtracking entry on this site tries a candidate, and if it's rejected, undoes it and tries the next one — a queen placement, a Sudoku digit, a color, always guessed and then checked. Boolean satisfiability asks a differently-shaped question: given a set of boolean variables and a formula built from them as a conjunction of clauses (an AND of ORs — each clause a disjunction of variables or their negations, all clauses must hold at once), is there an assignment of true/false to every variable that satisfies the whole formula? 2-SAT already answers this site's version of that question for clauses capped at exactly two literals, in linear time, by reducing it to Strongly Connected Components — that shortcut only exists because of the two-literal cap. Allow clauses of three or more literals and the shortcut disappears entirely: general boolean satisfiability is NP-complete, and this page's algorithm, DPLL (Davis–Putnam–Logemann–Loveland), searches for a satisfying assignment the same guess-and-backtrack way N-Queens searches for a placement. What makes it a different kind of backtracking, not just a new problem: before ever guessing a variable's value, DPLL applies two inference rules that can force a variable's value outright, with a proof that no other value could work — skipping the trial entirely, something no other Backtracking entry on this site does even once.

Try it

Five variables, x₁ through x₅, and seven clauses:

C1: (x₄ ∨ ¬x₃)   C2: (¬x₁ ∨ x₂)   C3: (¬x₄ ∨ ¬x₂)   C4: (¬x₂ ∨ x₄)
C5: (¬x₃ ∨ ¬x₅ ∨ ¬x₂)   C6: (x₅ ∨ ¬x₃ ∨ ¬x₄)   C7: (x₄ ∨ ¬x₂ ∨ x₁)

Press Step or Run. At each node, the search first tries to force a value with no guessing at all: a unit clause (only one literal left unresolved) forces that literal true; a pure literal (a variable appearing with only one sign across every not-yet-satisfied clause) can be set to satisfy all of them, since the opposite sign never appears to conflict. Only when neither rule applies does it decide: pick the first unassigned variable, try true, recurse, and if that whole branch fails, backtrack and try false.

clauses:

status: searching
step 0
Press Step or Run.

Why it works

Both shortcut rules are sound because each one only ever makes a move that every satisfying assignment consistent with what's already fixed would have to agree with anyway — neither one is a guess. A unit clause with one literal left unresolved and every other literal already false has exactly one way left to be satisfied: the last literal must be true. There's no other possibility to weigh, so forcing it costs nothing and loses no solutions. A pure literal is sound for a different reason: if a variable never appears negated anywhere among the clauses still unsatisfied, then setting it to satisfy its own sign can only ever help — every clause it appears in gets satisfied outright, and every clause it doesn't appear in is untouched, so no clause that was still satisfiable before becomes unsatisfiable after. Neither rule can ever throw away a solution that was reachable; they only skip trials whose outcome was already decided.

Run against this page's own seven clauses, the search finds x₁ = x₂ = x₃ = false — with x₄ and x₅ left unassigned. That's not a display bug: every clause is already satisfied without them, and DPLL stops the instant the whole formula is satisfied rather than filling in every variable for its own sake. The path there, straight from the demo's own reference implementation: x₃ is pure-negative from the start (it only ever appears as ¬x₃, in C1/C5/C6) and gets set false immediately, satisfying all three at once with zero search. Nothing else is forced yet, so the search decides x₁ = true — which unit-propagates x₂ = true from C2, which unit-propagates x₄ = false from C3, which leaves C4 (¬x₂ ∨ x₄) with both literals false: a conflict. Backtrack, try x₁ = false instead — C2 is satisfied outright, and now x₂ appears only negated across every clause still open (C3, C4, C7), so it's pure and gets set false, satisfying all three at once. Done: 5 search nodes total. A plain backtracking search over the same formula — guess every variable in order, check all seven clauses only once every variable has a value — visits 52 nodes to reach the same conclusion: the two shortcut rules cut node count by 90.4% here, purely by proving some trials unnecessary before ever running them.

Reference implementation

// Literal encoding: variable v as a non-negative integer; the literal "v is true" is the
// number v itself, the literal "v is false" is the bitwise complement ~v (always negative).
// litVar() undoes either encoding back to the plain variable index; litSign() reports which one it was.
function litVar(l) { return l < 0 ? ~l : l; }
function litSign(l) { return l >= 0; }

// Evaluate one clause against the current partial assignment (a sparse object, var index -> bool).
function evalClause(clause, assign) {
  const unresolved = [];
  for (const lit of clause) {
    const v = litVar(lit);
    if (assign[v] === undefined) { unresolved.push(lit); continue; }
    const value = litSign(lit) ? assign[v] : !assign[v];
    if (value) return { status: 'sat' };            // already satisfied by this literal
  }
  if (unresolved.length === 0) return { status: 'conflict' };  // every literal false: unsatisfiable
  if (unresolved.length === 1) return { status: 'unit', lit: unresolved[0] };
  return { status: 'unresolved', lits: unresolved };
}

function dpll(nvars, clauses) {
  function solve(assign) {
    const assignedHere = [];

    // Propagate every unit clause to a fixpoint before ever guessing.
    let changed = true;
    while (changed) {
      changed = false;
      for (const clause of clauses) {
        const r = evalClause(clause, assign);
        if (r.status === 'conflict') { for (const v of assignedHere) delete assign[v]; return { sat: false }; }
        if (r.status === 'unit') {
          const v = litVar(r.lit);
          if (assign[v] === undefined) {
            assign[v] = litSign(r.lit);
            assignedHere.push(v);
            changed = true;
          }
        }
      }
    }

    // Is every clause satisfied? Also collect one still-unresolved clause's literals, in case not.
    let allSat = true, pending = null;
    for (const clause of clauses) {
      const r = evalClause(clause, assign);
      if (r.status === 'conflict') { for (const v of assignedHere) delete assign[v]; return { sat: false }; }
      if (r.status !== 'sat') { allSat = false; if (!pending) pending = r.lits; }
    }
    if (allSat) return { sat: true, assign: Object.assign({}, assign) };

    // Pure-literal elimination: a variable appearing with only one sign among unsatisfied clauses
    // can be set to satisfy every one of them, no trial needed.
    for (let v = 0; v < nvars; v++) {
      if (assign[v] !== undefined) continue;
      let pos = false, neg = false;
      for (const clause of clauses) {
        const r = evalClause(clause, assign);
        if (r.status === 'sat') continue;
        for (const lit of (r.status === 'unit' ? [r.lit] : r.lits)) {
          if (litVar(lit) === v) { if (litSign(lit)) pos = true; else neg = true; }
        }
      }
      if (pos !== neg) {
        assign[v] = pos; // true if only-positive, false if only-negative
        const res = solve(assign);
        if (!res.sat) delete assign[v];
        for (const u of assignedHere) delete assign[u];
        return res;
      }
    }

    // Nothing left to force — decide. Try true, and if the whole branch fails, backtrack to false.
    const v = litVar(pending[0]);
    for (const val of [true, false]) {
      assign[v] = val;
      const res = solve(assign);
      if (res.sat) return res;
      delete assign[v];
    }
    for (const u of assignedHere) delete assign[u];
    return { sat: false };
  }
  return solve({});
}

Pitfalls

Flipping which value a pure literal gets set to — the natural off-by-one mistake, since "appears only negated" and "set it true" sound like they should pair up — turns a sound shortcut into one that can report a satisfiable formula as unsatisfiable. A tiny hand-checkable example: three variables and three clauses, (x₁ ∨ ¬x₂), (¬x₂ ∨ ¬x₁), (¬x₂ ∨ x₃). x₂ appears only negated across all three, so the correct rule sets it false immediately — every clause is satisfied outright by ¬x₂ alone, regardless of x₁ or x₃, so the formula is trivially satisfiable. Flip the rule's sign and it sets x₂ = true instead: clause 1 now needs x₁ = true to survive (unit propagation), and clause 2 needs ¬x₁ — both x₁ and x₂ true at once, a direct conflict. Because pure-literal elimination is applied unconditionally, with no fallback branch the way a decision gets one, that conflict is never retried the other way: the search reports unsatisfiable for a formula that's actually satisfied by simply leaving x₂ false. Checked against a from-scratch brute-force oracle over 20,000 random 6-variable instances, this exact sign flip reports the wrong verdict on 34.6% of them — always the same direction, a real formula wrongly called unsatisfiable, never the reverse.

Checking for a unit clause in a single top-to-bottom sweep over the clause list, instead of looping back to a fixpoint, still finds the correct answer — just with more branching than necessary — because a forced assignment near the end of the sweep can't yet trigger a clause nearer the front that depended on it. Built to show it clearly: a chain of twelve variables, (x₁) as a unit clause, then (¬x₁ ∨ x₂), (¬x₂ ∨ x₃), and so on down the chain, listed in reverse order so each clause depends on a variable the sweep hasn't reached yet. Proper fixpoint propagation resolves the entire chain — all twelve variables — in a single search node, since it keeps re-scanning until nothing new is forced. A single sweep per node forces only the first link before stopping, then treats each remaining link as an ordinary decision to branch on (both true and false tried) instead of a forced consequence: 7 node visits instead of 1 on this chain, and the gap widens the longer the chain gets, even though both versions land on the identical satisfying assignment.

Complexity

Time: O(2n) in the worst case, the same order as every other search-and-backtrack entry on this site — boolean satisfiability with three or more literals per clause is NP-complete, so no known algorithm (this one included) avoids exponential worst-case time on adversarial input; unit propagation and pure-literal elimination shrink the constant factor in practice, sometimes dramatically as measured above, never the underlying order. Each node's own work is O(n·m) for n variables and m clauses (every clause re-evaluated against the current assignment, possibly several times per node during propagation). Space: O(n + m) for the assignment and the clause list, plus O(n) for the recursion stack — no separate table, same as every other entry here.

This site's guide, Choosing a Backtracking Strategy, compares this entry against the other ten Backtracking entries side by side, including why this is the first one able to skip a trial outright rather than just reject it quickly. 2-SAT is the special case this page's own general search doesn't need for clauses capped at two literals — that page's own implication-graph shortcut runs in linear time precisely because it never has to guess anything at all.