Why Constraint Satisfaction Matters
Suppose several exams need time slots, and exams sharing students cannot overlap. Choosing each slot independently fails because one decision changes what remains possible elsewhere. The same pattern appears in map coloring, scheduling, configuration, and Sudoku: variables have domains, constraints restrict combinations, and a solution must satisfy every constraint at once.
A constraint satisfaction problem, or CSP, makes that structure explicit. Instead of generating complete assignments and testing them afterward, a solver reduces domains as decisions are made. Forward checking removes conflicts immediately adjacent to an assignment. Propagation carries the consequences farther. MRV, the degree heuristic, and LCV choose where to search and which value to try first.
This lesson builds a finite-domain binary CSP solver with both forward-checking and maintaining-arc-consistency modes. You will trace an LCV decision, watch AC-3 propagate beyond the latest variable's neighbors, and follow a failed branch whose domain deletions must be completely undone before a later branch can succeed.
A CSP asks for a value for every variable such that all domain restrictions and constraints hold. Efficient search repeatedly chooses a constrained variable, tries a promising value, removes values that have lost necessary support, and restores those removals before trying a sibling. Ordering heuristics reduce wasted search; sound propagation and exact restoration preserve correctness.
Core Concepts and Terminology
Write a finite CSP as variables $X_0,\ldots,X_{n-1}$, initial domains $D_0,\ldots,D_{n-1}$, and constraints. The implementation handles binary constraints: a relation $R_{ij}$ lists allowed pairs $(a,b)$ for variables $X_i,X_j$. Unary restrictions are represented by initial domains. Higher-arity constraints require another representation or an appropriate encoding.
The constraint graph has one vertex per variable and an edge when two variables share a relation. Its graph edge is undirected, but its relation can be asymmetric: $X_i\lt X_j$ does not mean $X_j\lt X_i$. When processing the reverse arc, swap the coordinates of every allowed pair. The programs construct that transpose automatically.
For an arc $X_i\to X_j$, a value $a\in D_i$ has support when some $b\in D_j$ satisfies $(a,b)\in R_{ij}$. The arc is consistent when every current value of $X_i$ has such support. A value with no support can never appear in a complete solution extending the current branch, so removing it is sound. If any domain becomes empty, the branch is impossible.
Arc consistency checks local supports, not all constraints simultaneously. A triangle of pairwise-different variables with domains $\{0,1\}$ is arc consistent: either value at either endpoint has a different neighbor value as support. Nevertheless, the triangle cannot be colored with two values.
| Term | Meaning and example |
|---|---|
| Variable and domain | An exam and its possible slots, or a map region with colors $\{0,1,2\}$. |
| Binary relation | Allowed value pairs for two variables; inequality excludes pairs $(a,a)$. |
| Support | A currently allowed neighbor value that satisfies the relevant binary relation. |
| Forward checking, FC | After assigning one variable, remove incompatible values from its unassigned neighbors. |
| Propagation | Revisit affected constraints so one domain reduction can cause further reductions elsewhere. |
| AC-3 | A queue-based algorithm repeatedly revising directed arcs until consistency is reached or a domain empties. |
| MAC | Maintaining arc consistency: run propagation during backtracking, rather than only once before search. |
| MRV | Minimum remaining values: choose the unassigned variable with the smallest current domain. |
| Degree heuristic | Break an MRV tie by choosing the variable linked to the most still-unassigned neighbors. |
| LCV | Least constraining value: try the candidate estimated to remove the fewest neighbor-domain values. |
| Trail and checkpoint | A reversible log of old domain masks; rollback restores exactly the state at a saved trail length. |
| Singleton domain | One value remains, but the implementation still records an explicit assignment before declaring the variable assigned. |
The output is either SAT with one complete assignment or UNSAT after all required alternatives are excluded. This is a feasibility solver, not a counting, uniqueness, or optimization solver. No node budget is imposed; exhausting a separate budget would require an explicit incomplete result.
How Constraint Satisfaction Works, Step by Step
The solver maintains current domain masks, an assignment array using $-1$ for unassigned variables, and a trail of pairs (variable, old_mask). Every domain change records its previous mask before deletion. A checkpoint is just the current trail length. Restoring records in reverse order reconstructs the parent state even if propagation changed the same variable several times.
Choose a variable. Among unassigned variables, minimize current domain size. Among ties, maximize the number of unassigned graph neighbors. Break any remaining tie by variable index. Already assigned neighbors do not contribute to this dynamic degree.
Order its values. For candidate $a$ at variable $X$, let $S_{XY}(a)$ be the values of neighbor $Y$ allowed with $a$. The LCV score is
$$\operatorname{score}(a)=\sum_{Y\text{ unassigned neighbor of }X}\lvert D(Y)\setminus S_{XY}(a)\rvert.$$
Try smaller scores first, breaking ties by numeric value. This score predicts direct neighbor deletions; it does not simulate all future AC-3 consequences or prove that a candidate can lead to a solution.
Assign and forward-check. Save a checkpoint, mark $X=a$, and restrict its domain to $\{a\}$. Check constraints against assigned neighbors and intersect each unassigned neighbor's domain with its compatible-value mask. A wipeout rejects the attempt immediately.
Propagate when using MAC. Initialize an AC-3 queue with both directed arcs of every constraint edge. Revising $X\to Y$ removes every value of $X$ without support in $D(Y)$. If $D(X)$ changes, queue each incoming arc $Z\to X$ with $Z\ne Y$: those variables may have depended on a removed support. Stop on an empty domain or when the queue is empty.
The following map-coloring snapshot shows the difference. After assigning South Australia SA=0, its mainland neighbors have domains $\{1,2\}$. Assigning NT=1 directly forces WA=2 and Q=2. AC-3 then uses Q=2 to force NSW=1, which forces V=2; those last reductions are beyond NT's direct forward-checking step.
Recurse and undo. Enter a child only when filtering succeeds. At a complete assignment, save a copy of the answer. On every return, restore the trail to the checkpoint and set the selected variable back to unassigned, including when a solution was found. Root preprocessing also uses the trail, so the function can restore every original domain before returning.
flowchart TD
A["Validate domains and relation tables"] --> B["In MAC mode, run root AC-3"]
B --> C{"Any empty domain?"}
C -->|Yes| D["Restore root trail; return UNSAT"]
C -->|No| E["Enter DFS; count one node"]
E --> F{"All variables assigned?"}
F -->|Yes| G["Copy complete answer"]
F -->|No| H["MRV, degree tie-break, then LCV order"]
H --> I["Checkpoint; assign next value; forward-check"]
I --> J{"Forward checking succeeds?"}
J -->|No| L["Undo to checkpoint; unassign variable"]
J -->|Yes| Q["In MAC mode, propagate with AC-3"]
Q --> K{"Propagation succeeds?"}
K -->|Yes| E
K -->|No| L
G --> L
L --> M{"Solution saved?"}
M -->|Yes| N["Unwind callers; restore root state; return SAT"]
M -->|No| O{"More ordered values?"}
O -->|Yes| I
O -->|No| P["Return failure; caller restores its checkpoint"]
The flowchart summarizes recursive calls: after a child returns, its caller performs the checkpoint restoration. An empty AC-3 queue means all queued consistency requirements have been processed. It is not an assignment and is not, by itself, a proof of satisfiability.
Worked Example with a Full Trace
Use variables $A,B,C$, indexed $0,1,2$, and impose $A\ne B$, $A\ne C$, $B\ne C$. Let initial domains be $D(A)=\{0,1\}$, $D(B)=\{0,1,2\}$, and $D(C)=\{0,2\}$. With bit $2^a$ representing value $a$, their masks are $[3,7,5]$.
Root AC-3 revises all six arcs without deleting a value. MRV ties $A$ and $C$ at size $2$, and their unassigned degrees are both $2$, so index $0$ selects $A$. The two LCV scores differ:
| Candidate for $A$ | Values removed from $B$ | Values removed from $C$ | LCV score | Order |
|---|---|---|---|---|
| $0$ | $\{0\}$ | $\{0\}$ | $2$ | Second |
| $1$ | $\{1\}$ | None | $1$ | First |
The complete successful descent is:
| Entered DFS node | Explicit assignment | Current domains $A;B;C$ | Decision and effect | Cumulative deletions / arc revisions |
|---|---|---|---|---|
| $1$ | None | $\{0,1\};\{0,1,2\};\{0,2\}$ | Choose $A=1$. Restrict $A$ and remove $1$ from $B$. | $0$ / $6$ before the attempt |
| $2$ | $A=1$ | $\{1\};\{0,2\};\{0,2\}$ | Choose $B$ by the final index tie-break. Values $0,2$ each score $1$; try $0$. | $2$ / $12$ |
| $3$ | $A=1,B=0$ | $\{1\};\{0\};\{2\}$ | Choose forced value $C=2$. | $4$ / $18$ |
| $4$ | $A=1,B=0,C=2$ | $\{1\};\{0\};\{2\}$ | Copy answer $[1,0,2]$. | $4$ / $24$ |
The deletion counter includes restricting the chosen variable itself, so choosing $A=1$ removes one value from $A$ and one from $B$. Choosing $B=0$ removes one from $B$ and one from $C$. Choosing already-singleton $C=2$ deletes nothing. This implementation initializes all six arcs after each decision, even when no further pruning occurs.
Unwinding is part of the trace. Undoing the $C$ decision changes its assignment back to unassigned, but its domain remains $\{2\}$ because that restriction came from the parent. Undoing the $B$ decision restores $D(B)=D(C)=\{0,2\}$. Undoing $A$ restores all original masks $[3,7,5]$. The saved answer remains $[1,0,2]$, while historical work counters remain unchanged.
Now give all three triangle variables domains $\{0,1\}$. Root AC-3 still removes nothing. Trying $A=0$ forward-checks both neighbors to $\{1\}$; revising $B\to C$ then empties $D(B)$ because equality is forbidden. Undo and try $A=1$; the symmetric conflict follows. MAC reports UNSAT with $1$ DFS node, $2$ candidate attempts, $8$ cumulative value deletions, and $14$ arc revisions. Forward checking alone needs $3$ DFS nodes and $4$ attempts because it reaches the conflict after explicitly choosing the next variable.
Trace It by Hand
At the asymmetric example's root, predict whether $A=0$ or $A=1$ comes first. Value $0$ removes one option from each neighbor, while value $1$ removes only $B$'s value $1$, so LCV chooses $1$. After the solution is copied, predict $C$'s domain when only its own assignment is undone: it is still $\{2\}$. Restoring it to its original $\{0,2\}$ at that point would erase a restriction made by the parent $B=0$ branch.
Implementation in Python, C++, and Java
Each panel contains the same generic binary-relation solver and six sample runs. Variables use indices $0$ through $n-1$, with $0\le n\le12$. Values use $0$ through $d-1$, with $1\le d\le8$. Initial domains are bit masks; an empty mask is a valid unsatisfiable input. An empty variable set with no constraints is satisfiable and has an empty assignment.
Each constraint edge supplies its two variable indices and an allowed-pair list. An empty relation forbids all endpoint combinations. Self-constraints and repeated edges, including a repeated edge in reverse order, are rejected. Combine multiple restrictions on the same pair into their intersection before calling the solver. Repeated pairs inside one allowed-pair list are harmless duplicates. All constraints must be represented: the absence of an edge means there is no binary restriction between those variables.
FC mode uses forward checking after each assignment. MAC mode additionally runs root AC-3 and restarts AC-3 from all directed arcs after each successful forward-checking step. More selective queue initialization is possible, but the full queue makes this implementation and its counters easier to reproduce. Both modes use MRV, unassigned-neighbor degree, and direct-deletion LCV.
The returned counters are historical totals: nodes counts entered DFS states, attempts counts tried values, pruned counts deleted domain values including decision restrictions, and revisions counts dequeued AC-3 arcs, even when nothing changes. Repeated deletions in different branches are counted repeatedly; undo does not subtract work. A root filtering failure can return with zero DFS nodes.
The restore fixture has variables $A,B,C,D,E,F$. The last five begin with domain $\{0,1,2\}$, while $A$ begins with $\{0,1\}$. Variables $B,C,D$ form an inequality triangle. When $A=0$, its relations restrict each of them to $\{0,1\}$ and leave $E,F$ unrestricted. When $A=1$, they keep all three values but $E,F$ must be $0$. LCV scores are $3$ for $A=0$ and $4$ for $A=1$, so the first choice is globally impossible despite its smaller local score. Correct rollback lets the later choice find $[1,0,1,2,0,0]$.
The map fixture uses order [WA,NT,SA,Q,NSW,V,T] and the usual mainland adjacency edges listed directly in the program. Initial degree selects SA, which has five neighbors. Tasmania T is unconstrained. The code returns one valid coloring, not a geographically drawn map or a unique solution.
Use Python 3.10 or later for int.bit_count(), compile C++ with g++ -std=c++14 -O2 main.cpp -o csp, or compile Java 17 or later with javac Main.java and run java Main. All three programs print these thirteen lines:
asymmetric / FC: SAT, nodes=4, attempts=3, pruned=4, revisions=0assignment = [1,0,2]asymmetric / MAC: SAT, nodes=4, attempts=3, pruned=4, revisions=24assignment = [1,0,2]triangle2 / FC: UNSAT, nodes=3, attempts=4, pruned=8, revisions=0assignment = []triangle2 / MAC: UNSAT, nodes=1, attempts=2, pruned=8, revisions=14assignment = []restore / MAC: SAT, nodes=8, attempts=9, pruned=23, revisions=150assignment = [1,0,1,2,0,0]Australia / MAC: SAT, nodes=8, attempts=7, pruned=14, revisions=147assignment = [2,1,0,2,1,2,0]checks = PASS
Complexity, Correctness, and Invariants
Let $n$ be the number of variables, $d$ the value-universe size, $e$ the number of binary edges, and $\Delta$ the maximum graph degree. The supported domains and support sets fit in one word. The following operation bounds treat their bit operations and bounded-word population counts as constant cost.
MRV with dynamic degree costs $O(n+e)$ per DFS node. Scoring all values for a selected variable costs $O(d\Delta)$, followed by $O(d\log(d+1))$ sorting. Forward checking one attempted value costs $O(1+\Delta)$ because each neighbor filter is one mask intersection plus reversible bookkeeping.
A pair-by-pair AC-3 implementation commonly has an $O(ed^3)$ bound. Here support masks make one arc revision cost $O(d)$: loop over the tail's candidate values and test each support mask against the head domain. During one AC-3 invocation, each domain can shrink strictly at most $d$ times. A shrink queues incoming arcs, so there are $O(ed)$ arc visits overall, plus initialization. Thus this implementation has an $O(n+ed^2)$ bound per AC-3 call.
Search still has a potentially exponential tree. At depth $k$, at most $d^k$ assignments can have been attempted along distinct branches, regardless of the variable-ordering policy. The full node bound is $\sum_{k=0}^n d^k$, which is $O(d^n)$ for $d\ge2$ and $O(n+1)$ when $d=1$. Propagation and heuristics can dramatically reduce actual work without making general binary CSP solving polynomial.
| Technique | Work and role | What it does not guarantee |
|---|---|---|
| Plain backtracking | Checks compatibility with committed assignments; potentially explores an exponential tree. | Early discovery of conflicts between still-unassigned variables. |
| Forward checking | $O(1+\Delta)$ per attempted value with these masks; detects direct neighbor wipeouts. | Arc consistency between all remaining variables. |
| AC-3 preprocessing | $O(n+ed^2)$ here; removes unsupported values before search. | A solution when every resulting domain is nonempty. |
| MAC during search | Adds an AC-3 invocation after each successful FC step. | Fewer total operations on every instance; propagation overhead can exceed saved search work. |
| MRV and degree | $O(n+e)$ per node; choose a small domain, then high remaining degree. | A globally best variable ordering or any pruning by themselves. |
| Direct-deletion LCV | $O(d\Delta+d\log(d+1))$ per selected variable. | A feasible value or the true amount of future propagation. |
| Trail-based restoration | Constant work per saved or restored mask record. | Correctness if some assignment or domain mutation bypasses the restoration protocol. |
Let $T$ be the number of DFS entries and $K$ the number of tried candidates. An informative MAC work bound, excluding model construction, is
$$O\!\left(n+ed^2+T\bigl(n+e+d\Delta+d\log(d+1)\bigr)+K\bigl(\Delta+n+ed^2\bigr)\right).$$
The initial term covers root checks and propagation, and $K\le dT$ whenever search occurs. In FC mode, omit all AC-3 work and retain candidate filtering and undo costs. Model construction initializes the dense support table in $O(n^2d)$ time, reads $M$ supplied allowed-pair entries, and sorts neighbor lists in $O(e\log(n+1))$ time.
The actual representation uses $O(n^2d)$ words for its dense support masks and $O(n+e)$ adjacency storage. Along one active search path, at most $nd$ domain values can be removed, so its trail has $O(nd)$ records; recursive value-order lists also use $O(nd)$ space. The non-deduplicated AC-3 queue has an $O(ed)$ bound. Excluding the caller's relation lists, logically live auxiliary storage is therefore $O(n^2d+nd+ed+n+e)$. A sparse relation-table representation can reduce the dense-table term.
Best-case behavior depends on the input and objective. An initial empty domain or a root propagation contradiction needs no DFS entries, although validation and model construction still cost work. An easy satisfiable instance still uses $n+1$ DFS entries here because singleton domains are explicitly assigned. There is no general average-case bound without an input distribution; fewer nodes alone do not establish a faster solver.
Every domain deletion preserves all complete solutions extending the current explicit assignment: forward checking removes values incompatible with a committed neighbor, and AC-3 removes values lacking necessary support. MRV, degree, and LCV only reorder the remaining search. Restoring each old mask and assignment to its checkpoint isolates siblings, so a complete returned assignment is valid and exhaustive failure proves unsatisfiability.
Soundness at a leaf follows because each committed value belongs to its domain and every binary edge was checked when its later endpoint was assigned. Completeness follows because no sound deletion removes an extendable solution and every surviving value is considered unless a full answer has already been copied. Search depth is at most $n$; propagation terminates because domains shrink monotonically within an invocation and new queue work is introduced only after a shrink.
Undo must restore the parent domains, not blindly restore every variable's original domain. A parent may already have made legitimate reductions. Saving old masks handles this distinction automatically. Root propagation is also trailed, letting the implementation restore its own entire working state before returning either verdict.
Variants, Pitfalls, and Optimizations
The most damaging bug is incomplete rollback. In the restore fixture, the failed $A=0$ branch removes value $2$ from all of $B,C,D$. Restoring only $A$ leaves those deletions behind. The later $A=1$ branch would then wrongly face a two-color triangle, losing the valid completion that uses a third value. The correct checkpoint restores all three neighbor domains as well as $A$.
Other design choices need separate reasoning:
- Incremental propagation: once the parent is arc consistent, initialize only arcs affected by the decision and the preceding FC deletions. Seed every relevant changed domain; propagating only from the assigned variable can miss reductions made in its neighbors.
- Queue deduplication and support caching: avoid repeated pending arcs or remember useful support locations. Any cached support must be checked against current domains, and branch-dependent metadata must remain valid after rollback.
- Stronger constraints: a global all-different propagator can detect Hall-set effects that a decomposition into pairwise inequalities misses. Arc consistency of the binary decomposition is a different, generally weaker filtering condition.
- Constraint decomposition: higher-arity relations can be encoded with auxiliary variables or decomposed where justified. A poor encoding can enlarge domains, weaken propagation, or change the problem if it omits relationships.
- Disconnected components: solve independent graph components separately and combine their assignments. The example's Tasmania variable is independent of the mainland coloring; the basic solver keeps it in the common search loop for transparency.
- Learning and backjumping: record explanations for conflicts and avoid rediscovering equivalent failures. Learned constraints must be logically implied, and backjump targets must match the recorded dependencies.
- Alternative search: min-conflicts and other local methods can be useful for finding solutions, but a failure to find one within a limit is not a proof of
UNSAT. This backtracking solver reports failure only after complete exclusion. - Counting and optimization: enumerating solutions, proving uniqueness, and minimizing a cost require different stopping conditions. The first satisfying assignment does not answer those questions.
Arc consistency does not imply satisfiability, and an LCV score does not certify a successful branch. Record every domain change, including decisions and root preprocessing, and restore it to the correct checkpoint on both failure and success. Reverse a relation by transposing its allowed pairs, and never turn a timeout or incomplete search into an unsatisfiability claim.
Practice Problems and Real-World Use
Compare small instances against exhaustive assignment enumeration, using the original domains and allowed-pair lists as the independent specification. Test asymmetric relations, reversed edge orientations, empty domains, empty relations, disconnected graphs, singleton domains, and the empty CSP. FC and MAC must agree on satisfiability, even when their first returned assignment or work differs.
Check each returned assignment directly, preserve every input, and audit current domains after each deletion and rollback. For a stronger oracle on small instances, enumerate every completion extending the current assignment and verify that propagation has not removed any value used by those completions. Use the restore fixture to ensure a failed early branch cannot poison the later successful branch.
| Problem or application | Idea tested | Difficulty |
|---|---|---|
| Asymmetric-domain triangle | MRV ties, direct-deletion LCV scores, and deterministic value ordering. | Intermediate |
| Two-color odd cycle | Arc-consistent domains can still describe an unsatisfiable global problem. | Intermediate |
| Rollback regression instance | Restore every neighbor deletion after a locally promising branch fails. | Intermediate |
| Map or conflict-graph coloring | Binary inequality constraints, degree selection, and disconnected components. | Intermediate |
| Exam timetabling | Slot domains, incompatibility edges, and the distinction between feasibility and schedule quality. | Advanced |
| Product configuration | Extensional compatibility tables, asymmetric allowed pairs, and propagation chains. | Advanced |
| Sudoku with all-different constraints | Compare global propagation against a pairwise binary encoding. | Advanced |
| Solver-engine experiments | Measure nodes, attempts, deletions, revisions, and wall-clock cost independently. | Advanced |
For further study, see the AIMA CSP implementation and examples for backtracking, filtering, and map-coloring formulations. The solver here uses deterministic ordering, bit-mask support tables, explicit domain checkpoints, and the precise counter definitions above so its behavior can be checked across languages.