Constraint Satisfaction
Solve problems with specific placement rules using backtracking with validation
Why Constraint Satisfaction Changes How You Think About Problems
Have you ever stared at a LeetCode problem and thought, "I know I need to try different combinations, but there are just too many to check"? That feeling — the creeping dread of exponential possibilities — is exactly the moment Constraint Satisfaction walks in and changes everything. Before you dive deeper into this lesson, grab the free flashcards linked in this series; they'll make the vocabulary click faster than you'd expect. The ideas in this section aren't just academic — they're the difference between a solution that times out and one that runs in milliseconds. Understanding why that gap exists is what this section is all about.
Let's start with a question: when you solve a jigsaw puzzle, do you pick up a random piece and try it in every single slot until something fits? Of course not. You look at the shape, the color, the edge pattern, and you immediately eliminate entire regions of the board. That instinctive filtering is constraint satisfaction thinking, and once you see it in algorithm problems, you'll never approach combinatorial puzzles the same way again.
The Problem With 'Try Everything'
Imagine you're placing 8 queens on a chessboard such that no two queens attack each other — the classic N-Queens problem. A pure brute-force approach would enumerate every possible way to place 8 queens on 64 squares. How many ways are there?
C(64, 8) = 4,426,165,368 possibilities
That's over 4 billion combinations to check. Even at a million checks per second, that's more than an hour of computation for a single puzzle. Now scale that to N=20, N=50, or imagine a Sudoku board — the numbers become astronomically large, well beyond what any computer can brute-force in reasonable time.
This is the combinatorial explosion problem. The search space grows so fast — typically exponentially or factorially — that raw enumeration collapses under its own weight. The core question becomes: can we be smarter about which combinations we even bother to try?
🎯 Key Principle: Brute-force fails not because computers are slow, but because the search space grows faster than any hardware improvement can compensate for. The solution is to never generate invalid states in the first place.
What Actually Makes a Problem a CSP
Not every hard problem is a Constraint Satisfaction Problem (CSP), but a surprising number of classic algorithmic challenges are. A problem qualifies as a CSP when you can define three things precisely:
🧠 Variables — the unknowns you're trying to assign values to. In N-Queens, each row is a variable representing "which column does the queen in this row go?"
📚 Domains — the set of possible values each variable can take. For N-Queens, each queen's domain is columns 1 through N.
🔧 Constraints — the rules that valid assignments must satisfy. For N-Queens: no two queens share a column, and no two queens share a diagonal.
Once you can name all three components, you've transformed a vague "find a valid arrangement" problem into a structured search with a clear shape. This structure is powerful because it tells you exactly where you can prune.
CSP = (Variables, Domains, Constraints)
V D C
N-Queens Example (N=4):
V = {Row1, Row2, Row3, Row4}
D = {1, 2, 3, 4} (column positions)
C = {
no two queens share same column,
no two queens share same diagonal (|r1-r2| ≠ |c1-c2|)
}
💡 Mental Model: Think of variables as blank form fields, domains as the dropdown options for each field, and constraints as the validation rules that reject invalid form submissions. A complete, valid form submission is your solution.
How Constraints Prune the Search Space
Here's where the magic happens. When you assign a value to a variable and immediately check constraints, you can often eliminate entire branches of the search tree without ever exploring them. This is called constraint propagation, and it's the intellectual core of why CSP thinking is so powerful.
Let's trace through a tiny 4-Queens example to feel this:
Step 1: Place queen in Row 1, Column 1
Q . . . Constraint check: OK so far
. . . .
. . . . Now, for Row 2:
. . . . - Column 1 is ELIMINATED (same column)
- Column 2 is ELIMINATED (diagonal)
Valid options: Column 3, Column 4
→ 2 options remain instead of 4
Step 2: Place queen in Row 2, Column 3
Q . . . Constraint check: OK
. . Q . Now, for Row 3:
. . . . - Column 1 ELIMINATED (diagonal from Row1)
. . . . - Column 3 ELIMINATED (same column as Row2)
- Column 4 ELIMINATED (diagonal from Row2)
Valid options: Column 2 only... wait
- Column 2 ELIMINATED (diagonal from Row1)
→ 0 options remain! BACKTRACK immediately.
We didn't explore the subtree under Row2-Column3 at all — we detected the dead end after just two assignments. This is the essence of early termination through constraint checking. In the brute-force approach, we'd have descended much deeper before discovering this path leads nowhere.
🤔 Did you know? In practice, good CSP implementations with constraint propagation can reduce the effective search space by orders of magnitude — sometimes from billions of nodes to just thousands. The N-Queens problem for N=8 has only 92 valid solutions out of those 4+ billion combinations, and smart CSP solvers find them by examining a tiny fraction of possibilities.
LeetCode Patterns That Are Really CSPs in Disguise
One of the most valuable skills you can develop is pattern recognition — the ability to look at a new problem and immediately say "this is a CSP" before you write a single line of code. Here are the problem signatures that should trigger CSP thinking:
📋 Quick Reference Card:
| 🎯 Problem Signal | 📚 CSP Component | 🔧 Example |
|---|---|---|
| 🔒 "Place items such that no two conflict" | Constraints between variables | N-Queens, Graph Coloring |
| 🔒 "Fill in the blanks to satisfy all rules" | All three components explicit | Sudoku, Crossword |
| 🔒 "Find all valid arrangements" | Full CSP enumeration | Permutations with constraints |
| 🔒 "Check if assignment is possible" | CSP feasibility | Course scheduling, map coloring |
| 🔒 "Partition into groups satisfying rules" | Variable = item, Domain = group | Team assignment, bin packing |
Let's look at Sudoku through the CSP lens:
- Variables: Each empty cell (up to 81 variables)
- Domain: Digits 1–9 for each cell
- Constraints: Each digit appears exactly once per row, once per column, once per 3×3 box
And Word Search (finding a word in a grid of letters):
- Variables: Each letter position in the target word
- Domain: Each cell in the grid
- Constraints: Letters must match, cells must be adjacent, no cell reused
These aren't just analogies — the same solving techniques (backtracking with constraint checking) work across all of them. When you learn to solve one CSP properly, you've learned the skeleton of the solution for all of them.
## The skeleton of a CSP solver - this pattern applies to N-Queens,
## Sudoku, Word Search, and virtually every CSP problem
def solve_csp(variables, domains, constraints, assignment={}):
"""
Generic CSP backtracking solver.
- variables: list of variable names
- domains: dict mapping each variable to its possible values
- constraints: function(var, value, assignment) -> bool
- assignment: current partial assignment (variable -> value)
"""
# Base case: all variables assigned — we found a solution!
if len(assignment) == len(variables):
return assignment
# Select next unassigned variable (simple version: pick first one)
unassigned = [v for v in variables if v not in assignment]
var = unassigned[0]
# Try each value in the variable's domain
for value in domains[var]:
# CHECK CONSTRAINTS BEFORE GOING DEEPER
if constraints(var, value, assignment):
# Make assignment
assignment[var] = value
# Recurse into deeper search
result = solve_csp(variables, domains, constraints, assignment)
if result is not None:
return result # Found a valid complete assignment!
# Constraint violated downstream — backtrack
del assignment[var]
# No valid value found for this variable — signal failure
return None
Notice that the constraint check happens before the recursive call. This is the critical structural decision that separates CSP-aware code from brute force. We only recurse into branches that are still potentially valid.
The Mental Shift: From Generation to Elimination
Here's the philosophical core of everything in this lesson series. Brute force thinks in terms of generation: "Let me create all possibilities, then filter the valid ones." CSP thinking is the opposite — it thinks in terms of elimination: "Given what I've already committed to, what is now impossible?"
❌ Wrong thinking: "I'll generate every possible board state and check which ones are valid."
✅ Correct thinking: "I'll make one decision at a time, and after each decision I'll immediately eliminate everything that's now impossible. I'll only ever consider states that are still potentially valid."
This shift is subtle but profound. It means the moment you make an assignment that creates a conflict, you detect it immediately and reverse course. You never build on top of a broken foundation.
Brute Force Search Tree (N=4 Queens):
[Start]
/ | | \
C1 C2 C3 C4 ← Row 1 choices
/|\ /|\ ...
C1 C2 C3 C4 ← Row 2 choices (ALL tried, most invalid)
...
Total nodes explored: 4^4 = 256
CSP-Aware Search Tree (N=4 Queens):
[Start]
/ | | \
C1 C2 C3 C4 ← Row 1 choices
| | | |
C3 C4 C1 C2 ← Only VALID Row 2 choices (pruned!)
...
Total nodes explored: ~20 (92% reduction!)
🧠 Mnemonic: Think of CSP as "Commit, Check, Correct." You commit to a choice, check if constraints still hold, and correct (backtrack) if they don't. Never explore past a broken constraint.
A Concrete Taste: N-Queens in Python
Let's look at the N-Queens problem implemented with CSP thinking to make this tangible. Even without optimizations, the constraint-checking approach is dramatically more efficient than brute force:
def solve_n_queens(n):
"""
N-Queens using CSP backtracking.
State: queens[row] = column position of queen in that row
Constraints: no shared column, no shared diagonal
"""
solutions = []
queens = [] # queens[i] = column of queen in row i
def is_valid(row, col):
"""Check if placing a queen at (row, col) conflicts with existing queens."""
for prev_row, prev_col in enumerate(queens):
# Same column constraint
if prev_col == col:
return False
# Diagonal constraint: |row difference| == |col difference|
if abs(prev_row - row) == abs(prev_col - col):
return False
return True
def backtrack(row):
# Base case: placed queens in all rows successfully
if row == n:
# Convert to standard output format
board = []
for col in queens:
board.append('.' * col + 'Q' + '.' * (n - col - 1))
solutions.append(board)
return
# Try each column for the current row
for col in range(n):
if is_valid(row, col): # ← CSP constraint check BEFORE recursing
queens.append(col) # Commit
backtrack(row + 1) # Explore
queens.pop() # Unclaim (backtrack)
backtrack(0)
return solutions
## Usage
result = solve_n_queens(4)
print(f"Found {len(result)} solutions for 4-Queens")
for board in result:
print('\n'.join(board))
print('---')
This code is elegant because it mirrors the CSP structure directly. Each recursive call handles one variable (one row). The domain is all column positions 0 to n-1. The is_valid function encodes the constraints. The queens.pop() is the backtracking step — undoing the last assignment when a dead end is reached.
💡 Pro Tip: Notice that is_valid is called before backtrack(row + 1). This is the constraint check that prunes branches early. Moving this check even one line later would mean you dive deeper into dead-end branches before discovering they're invalid.
Why This Changes Everything Going Forward
Here's the honest truth about why this lesson exists as the foundation of this entire series: every technique that follows — forward checking, arc consistency, constraint propagation, variable ordering heuristics — is just a more sophisticated answer to the same question: how do we eliminate impossibilities even earlier?
When you see Sudoku later in this series, you'll immediately recognize: 81 variables, domain {1-9}, row/column/box constraints. When you see Word Search, you'll think: variables are word positions, domain is grid cells, constraints are adjacency and character matching. When you encounter a new problem you've never seen before, you'll ask the three CSP questions instinctively:
- 🧠 What are my variables?
- 📚 What is each variable's domain?
- 🔧 What are the constraints between them?
If you can answer those three questions, you have a structured path to a solution — one that doesn't require you to invent new algorithms from scratch, but rather apply a battle-tested framework that has solved some of the most famous puzzles in computer science.
🎯 Key Principle: The power of CSP framing isn't just efficiency — it's clarity. A problem that felt like a chaotic maze becomes a structured search through a well-defined space. Clarity enables optimization, and optimization enables solutions that actually run within time limits.
The remaining sections of this lesson will build on this foundation systematically: you'll formalize the structure, understand the backtracking algorithm that powers the search, learn the pruning techniques that make it fast, and study the pitfalls that trip up even experienced developers. By the end, you won't just understand how to solve N-Queens or Sudoku — you'll have a transferable framework for every CSP you'll ever encounter.
The mental shift has already begun. You're no longer thinking "try everything." You're thinking "eliminate impossibilities early." That's the mindset of someone who solves hard problems efficiently.
The Anatomy of a Constraint Satisfaction Problem
Before you can solve a constraint satisfaction problem efficiently, you need to speak its language. Just as a surgeon needs to name every instrument and every procedure before stepping into the operating room, you need a precise vocabulary for the components of a CSP before you can reason clearly about solutions. This section builds that vocabulary from the ground up, transforming fuzzy intuitions into a rigorous framework you can apply to any combinatorial puzzle you encounter on LeetCode or in a technical interview.
Every constraint satisfaction problem — whether it's placing queens on a chessboard, coloring a map, scheduling meetings, or solving Sudoku — shares the same three-part skeleton. Once you can identify these three parts in any problem, you've already done a significant portion of the intellectual work.
The Three Core Components of a CSP
A Constraint Satisfaction Problem is formally defined as a triple (X, D, C) where:
- X is a set of variables — the decisions that need to be made
- D is a set of domains — the possible values each variable can take
- C is a set of constraints — the rules that restrict which combinations of values are valid
A solution is an assignment of values to all variables such that every constraint is satisfied simultaneously. This sounds simple, but the interactions between constraints across many variables are what make CSPs both challenging and interesting.
🎯 Key Principle: Every CSP is a search for a complete and consistent assignment. Complete means every variable has a value. Consistent means no constraint is violated.
Variables: What Decisions Need to Be Made?
Identifying variables is the first act of CSP modeling, and it requires you to ask a single clarifying question: what choices am I making? Variables represent the unknowns — the slots that need to be filled in.
Consider the classic N-Queens problem: place N queens on an N×N chessboard so that no two queens attack each other. A natural choice of variables is Q1, Q2, ..., QN, where each Qi represents the row position of the queen placed in column i. Notice that by structuring variables this way — one per column — we've already encoded an implicit constraint: exactly one queen per column. This is a modeling decision, and good modeling choices reduce the work your solver has to do.
💡 Pro Tip: The way you define your variables has a massive impact on solver efficiency. Choosing variables that naturally encode some constraints (like "one queen per column") shrinks your search space before the algorithm even starts.
For a Sudoku puzzle, variables are even more straightforward: each empty cell is a variable. We can index them as Cell(row, col) for every empty position on the board. The 81 cells of a 9×9 grid yield up to 81 variables, though pre-filled cells become fixed constants rather than true decision variables.
## Representing variables for an N-Queens problem
## Variables: queen_col[i] = the row where the queen in column i is placed
## For N=4, we have 4 variables: queen_col[0], queen_col[1], queen_col[2], queen_col[3]
def initialize_variables(n):
"""
Returns a list representing our variables.
Index = column, Value = row (to be determined by the solver)
-1 means 'unassigned'
"""
return [-1] * n # Each entry is a variable; -1 means unassigned
## For a 4-Queens problem:
## variables = [-1, -1, -1, -1]
## variables[0] = row of queen in column 0
## variables[1] = row of queen in column 1
## ... and so on
The code above captures something important: variables start unassigned. The job of your CSP solver is to assign values to each variable one by one until a complete, consistent solution is found — or to determine that none exists.
Domains: What Values Can Each Variable Take?
The domain of a variable is the set of values it's allowed to take before any constraints are applied. Think of it as the universe of possibilities for that variable, narrowed only by the most basic definitional rules.
For N-Queens:
- Domain of each
Qi={0, 1, 2, ..., N-1}(any row on the board)
For Sudoku:
- Domain of each
Cell(r, c)={1, 2, 3, 4, 5, 6, 7, 8, 9}
For a graph coloring problem with 3 colors:
- Domain of each node variable =
{red, green, blue}
Domains can be discrete and finite (as in all examples above), discrete and infinite (integers), or even continuous — though for LeetCode and competitive programming, we almost always deal with finite discrete domains.
🧠 Mnemonic: Think of variables as boxes and domains as the set of items that could go in each box. The constraint system determines which specific item actually ends up in which box.
⚠️ Common Mistake: Mistake 1 — Confusing the domain with valid assignments. The domain represents candidate values before constraints are applied. A value being in the domain doesn't mean it's part of a valid solution — it means it's eligible to be considered. ⚠️
One of the most powerful ideas in CSP solving — which we'll explore deeply in the pruning strategies section — is domain reduction: dynamically shrinking domains as assignments are made, so we never waste time exploring values that are already ruled out.
Constraints: The Rules That Shape the Solution Space
Constraints are the heart of a CSP. They are the conditions that a valid solution must satisfy, and they come in three flavors based on how many variables they involve.
Unary Constraints
A unary constraint involves a single variable and restricts the values that variable can take. In practice, unary constraints are often folded directly into the domain definition.
Example: In a scheduling problem, if meeting M1 cannot be held on Mondays, that's a unary constraint: M1 ≠ Monday. Rather than tracking it as an explicit constraint, you'd simply remove Monday from M1's domain upfront.
Binary Constraints
A binary constraint involves exactly two variables and specifies which combinations of their values are allowed or forbidden. These are the most common type in classic CSP problems.
For N-Queens, the constraint between queens Qi and Qj (in columns i and j) is:
Qi ≠ Qj (not in the same row)
|Qi - Qj| ≠ |i - j| (not on the same diagonal)
For Sudoku, the constraint between two cells in the same row is simply: Cell(r, c1) ≠ Cell(r, c2).
Global Constraints
A global constraint involves an arbitrary number of variables and captures a higher-level relationship that would be cumbersome to express purely as binary constraints. The classic example is AllDifferent(X1, X2, ..., Xk), which asserts that all listed variables must take distinct values.
Sudoku's row, column, and box constraints are each instances of AllDifferent over 9 variables. While you could express this as 36 binary ≠ constraints for each group of 9, the global AllDifferent formulation enables specialized, highly efficient propagation algorithms.
## Constraint representations in Python
## --- Unary Constraint ---
## (Usually handled by shrinking the domain directly)
def apply_unary_constraints(domain, forbidden_values):
"""Remove forbidden values from a variable's domain."""
return [v for v in domain if v not in forbidden_values]
## Example: Queen in column 0 cannot be placed in row 2
queen_0_domain = list(range(4)) # [0, 1, 2, 3]
queen_0_domain = apply_unary_constraints(queen_0_domain, forbidden_values=[2])
## Result: [0, 1, 3]
## --- Binary Constraint for N-Queens ---
def queens_are_compatible(row_i, col_i, row_j, col_j):
"""
Returns True if queens at (row_i, col_i) and (row_j, col_j)
do not attack each other.
"""
same_row = (row_i == row_j)
same_diagonal = (abs(row_i - row_j) == abs(col_i - col_j))
return not same_row and not same_diagonal
## --- Global AllDifferent Constraint ---
def all_different(values):
"""
Returns True if all values in the list are distinct.
Classic global constraint for Sudoku rows, columns, boxes.
"""
assigned = [v for v in values if v is not None] # Skip unassigned
return len(assigned) == len(set(assigned))
## Example: Check a Sudoku row
row = [5, 3, None, None, 7, None, None, None, None]
print(all_different(row)) # True — no duplicates among assigned values
This code demonstrates something crucial: constraints are just boolean-valued functions over variable assignments. Given a (partial) assignment, a constraint either holds or it doesn't. Your solver will call these functions constantly as it explores the search space.
💡 Real-World Example: Database query optimization uses constraint-like reasoning. When you write WHERE age > 18 AND country = 'US', those WHERE clauses are effectively unary constraints on table columns. The query planner applies them early to prune rows — exactly the same logic as domain reduction in CSPs.
Constraint Graphs: Visualizing Variable Relationships
Once you have variables and constraints defined, there's an enormously helpful mental model for understanding their relationships: the constraint graph.
In a constraint graph:
- Each node represents a variable
- Each edge represents a binary constraint between two variables
- (Global constraints involving k>2 variables can be represented using hyperedges, but we'll simplify to binary for now)
Here's the constraint graph for a simplified 4-Queens problem (only showing a subset of constraints for clarity):
Q0 ---- Q1 ---- Q2 ---- Q3
| \ / | \ / | \ / |
| \/ | \/ | \/ |
| /\ | /\ | /\ |
| / \ | / \ | / \ |
Q0 ---- Q1 ---- Q2 ---- Q3
(Every pair of queens has a constraint — the graph is fully connected)
Q0 ------- Q1
| \ / |
| \ / |
| / \ |
| / \ |
Q2 ------- Q3
For a map coloring problem (Australia, simplified):
Western_Australia (WA)
|
|--- Northern_Territory (NT)
| |
| |--- Queensland (Q)
| |
|--- South_Australia (SA)
|
+---------+
| |
Victoria(V) NSW
The constraint graph tells you at a glance which variables interact. This has practical algorithmic implications:
- Isolated nodes (no edges) can be assigned independently — solve them trivially.
- Densely connected subgraphs are the hard parts — variables in dense clusters are tightly coupled and require careful ordering.
- Tree-structured graphs (no cycles) can be solved in linear time using a specialized algorithm — a dramatic win over general exponential search!
🎯 Key Principle: The structure of the constraint graph often determines which algorithm to use. Tree-structured CSPs are solved in O(n·d²) time. General graphs require exponential search in the worst case. Identifying graph structure before choosing a solver is a professional-level skill.
💡 Mental Model: Think of the constraint graph like a social network. Variables that share constraints are "friends" who must coordinate their choices. Variables with no shared constraints are strangers who don't care about each other. Your solver's job is to help all the friends agree.
Translating a Problem Statement into a Formal CSP
Let's now practice the complete translation process with a concrete example. We'll take Sudoku — a problem most people know intuitively — and formally model it as a CSP, then represent that model in code.
The Problem Statement: Given a 9×9 grid partially filled with digits 1–9, fill in the remaining cells such that:
- Every row contains each digit 1–9 exactly once
- Every column contains each digit 1–9 exactly once
- Every 3×3 sub-box contains each digit 1–9 exactly once
Step 1: Identify the Variables
Every empty cell is a variable. We index them as Cell(r, c) where r is the row (0–8) and c is the column (0–8).
Step 2: Define the Domains
For each empty cell: Domain = {1, 2, 3, 4, 5, 6, 7, 8, 9}
For pre-filled cells: Domain = {given_value} (singleton, effectively a constant)
Step 3: Enumerate the Constraints
- Row constraints: For each row
r,AllDifferent(Cell(r,0), Cell(r,1), ..., Cell(r,8))— 9 global constraints - Column constraints: For each column
c,AllDifferent(Cell(0,c), Cell(1,c), ..., Cell(8,c))— 9 global constraints - Box constraints: For each 3×3 box,
AllDifferentover the 9 cells in that box — 9 global constraints - Total: 27 global
AllDifferentconstraints
class SudokuCSP:
"""
A formal CSP model for Sudoku.
Demonstrates the three-component structure: Variables, Domains, Constraints.
"""
def __init__(self, board):
"""
board: 9x9 list of lists. 0 represents an empty cell.
"""
self.board = board
# --- VARIABLES ---
# All empty cells are decision variables
self.variables = [
(r, c)
for r in range(9)
for c in range(9)
if board[r][c] == 0
]
# --- DOMAINS ---
# Start with full domain {1..9} for each variable
# Pre-filled cells aren't variables — they're constants
self.domains = {}
for (r, c) in self.variables:
self.domains[(r, c)] = set(range(1, 10))
# Apply immediate domain reduction from pre-filled values
self._initialize_domains()
# --- CONSTRAINTS ---
# Build constraint groups (each group must be AllDifferent)
self.constraint_groups = self._build_constraint_groups()
def _initialize_domains(self):
"""Reduce domains based on pre-filled cell values."""
for r in range(9):
for c in range(9):
val = self.board[r][c]
if val != 0: # Pre-filled cell — it's a constant
# Remove this value from all peers' domains
for peer in self._get_peers(r, c):
if peer in self.domains:
self.domains[peer].discard(val)
def _get_peers(self, r, c):
"""Return all cells that share a constraint group with (r, c)."""
peers = set()
# Same row
for col in range(9):
if col != c:
peers.add((r, col))
# Same column
for row in range(9):
if row != r:
peers.add((row, c))
# Same 3x3 box
box_r, box_c = 3 * (r // 3), 3 * (c // 3)
for dr in range(3):
for dc in range(3):
nr, nc = box_r + dr, box_c + dc
if (nr, nc) != (r, c):
peers.add((nr, nc))
return peers
def _build_constraint_groups(self):
"""Build the 27 AllDifferent groups (9 rows + 9 cols + 9 boxes)."""
groups = []
# Row groups
for r in range(9):
groups.append([(r, c) for c in range(9)])
# Column groups
for c in range(9):
groups.append([(r, c) for r in range(9)])
# Box groups
for br in range(3):
for bc in range(3):
group = [
(br*3 + dr, bc*3 + dc)
for dr in range(3)
for dc in range(3)
]
groups.append(group)
return groups
def is_consistent(self, var, value, assignment):
"""Check if assigning 'value' to 'var' is consistent with current assignment."""
r, c = var
for peer in self._get_peers(r, c):
# Peer's value comes from assignment OR the original board
peer_val = assignment.get(peer) or self.board[peer[0]][peer[1]]
if peer_val == value:
return False # Conflict!
return True
This code is dense but instructive. Notice how the three CSP components map directly to class attributes: self.variables, self.domains, and self.constraint_groups. The is_consistent method is the constraint checker — it returns True if a candidate assignment doesn't violate any binary constraint with already-assigned peers.
⚠️ Common Mistake: Mistake 2 — Forgetting to include pre-filled cells when checking constraints. The variables are only the empty cells, but constraints involve all cells — filled and empty alike. When checking consistency, always look up a cell's value from either the assignment dictionary or the original board. The code above handles this in is_consistent with the line peer_val = assignment.get(peer) or self.board[...]. ⚠️
Putting It All Together: The CSP Translation Checklist
Here's a summary of the translation process as a reusable reference:
📋 Quick Reference Card: CSP Translation Process
| 🔧 Step | 🎯 Question to Ask | 📚 Sudoku Example |
|---|---|---|
| 🔒 Variables | What decisions need to be made? | Empty cells (r, c) |
| 📦 Domains | What values can each variable take? | {1, 2, 3, 4, 5, 6, 7, 8, 9} |
| ⚙️ Constraints | What rules must hold? | AllDifferent per row/col/box |
| 🗺️ Graph | Which variables interact? | Cells in same row/col/box |
| ✅ Solution | Complete + Consistent assignment | All 81 cells filled, no repeats |
🤔 Did you know? The formal CSP framework was developed in the 1970s and 1980s in the AI research community. Problems like map coloring, scheduling, and constraint logic programming (Prolog) all contributed to formalizing the vocabulary we use today. Modern SAT solvers and SMT solvers used in hardware verification and software analysis are direct descendants of this line of thinking.
Why This Vocabulary Matters
You might be wondering: why spend so much time on formal definitions when you could just start coding a solution? The answer is that imprecise thinking produces imprecise code. When you sit down with a backtracking problem and haven't explicitly identified your variables, domains, and constraints, you end up writing tangled code that mixes these concerns together. Bugs appear because your consistency check accidentally modifies a domain. Your domain isn't correctly initialized, so you explore impossible values. Your variables aren't cleanly separated, so undoing an assignment (backtracking) becomes a nightmare.
The vocabulary from this section is the foundation for everything that follows. In the next section, we'll build the backtracking engine that operates on exactly this structure — iterating over variables, sampling from domains, and checking constraints at each step. In the pruning strategies section, we'll add domain reduction algorithms that shrink domains dynamically during search. All of that work lands cleanly because we have clean abstractions to build on.
❌ Wrong thinking: "I'll just write a recursive function that tries all possibilities. I don't need to formally model anything."
✅ Correct thinking: "I'll identify my variables, their domains, and my constraints explicitly. Then my recursive function will have clear responsibilities and my code will be maintainable and debuggable."
The three-part structure (X, D, C) isn't academic ceremony — it's the engineering blueprint that separates clean, extensible CSP code from spaghetti that works on small inputs and mysteriously fails on larger ones. Carry this blueprint with you into every problem you encounter.
Backtracking as the Engine of CSP Solving
If Constraint Satisfaction Problems are the map, then backtracking is the engine that drives you through it. Every time you solve a Sudoku, place queens on a chessboard, or schedule tasks without conflicts, you're implicitly performing a search through an enormous space of possibilities. Backtracking is the algorithm that makes this search systematic, complete, and — when done well — surprisingly efficient. Understanding it deeply is not just useful for LeetCode; it's a fundamental shift in how you reason about recursive problem-solving.
The Decision Tree: A Mental Model for the Search Space
Before we write a single line of code, let's build the right mental picture. Imagine you're solving a 4-Queens problem: placing 4 queens on a 4×4 board so no two attack each other. At every step, you're making a choice — which column to place the queen in the current row. Each choice leads to a new state, and from that state you make another choice, and so on.
This structure is a decision tree. Every node in the tree represents a partial or complete assignment of values to variables. Every edge represents one assignment decision. Leaf nodes are either complete solutions or dead ends.
Root (no queens placed)
├── Col 1
│ ├── Col 1 ✗ (conflict)
│ ├── Col 2
│ │ ├── Col 1 ✗
│ │ ├── Col 2 ✗
│ │ ├── Col 3 ✗
│ │ └── Col 4
│ │ └── ... → SOLUTION ✓
│ ├── Col 3
│ │ └── ... (pruned branches)
│ └── Col 4
│ └── ...
├── Col 2
│ └── ...
...
The key insight is that pruned branches — subtrees we decide to skip entirely — represent constraint violations detected early. Instead of exploring every leaf in a tree that might have millions of nodes, we cut off entire branches the moment we detect a conflict. This is the power of backtracking.
🎯 Key Principle: Nodes in the decision tree are states (partial assignments). Edges are assignments (variable ← value). Pruned branches are constraint violations caught before we waste time exploring them.
The Backtracking Template: Choose, Explore, Unchoose
Every backtracking solution ever written follows the same three-step recursive rhythm. Memorize this pattern because you'll apply it across dozens of problems:
- Choose — pick the next variable and assign it a value from its domain
- Explore — recurse deeper with this assignment in place
- Unchoose — undo the assignment and try the next value
This "choose, explore, unchoose" cycle is not just a coding trick — it's a precise traversal of the decision tree. The unchoose step is what makes this backtracking rather than just recursion: you are literally walking back up the tree to try a different branch.
Here's the canonical template in Python:
def backtrack(state, variables, assignment):
# Base case: all variables assigned → found a solution
if is_complete(assignment):
process_solution(assignment)
return
# Choose the next unassigned variable
var = select_unassigned_variable(variables, assignment)
# Iterate over its domain values
for value in get_domain(var):
# Check constraints BEFORE going deeper (eager checking)
if is_consistent(var, value, assignment):
# CHOOSE: make the assignment
assignment[var] = value
apply_to_state(state, var, value) # mutate state
# EXPLORE: recurse
backtrack(state, variables, assignment)
# UNCHOOSE: undo the assignment
del assignment[var]
undo_state(state, var, value) # restore state
Notice how the constraint check (is_consistent) happens before the recursive call. This is eager constraint checking and it is almost always the right approach. We'll contrast this with lazy checking in a moment.
🧠 Mnemonic: Think of backtracking as "try, commit, retreat." You try a value, commit to it by going deeper, and retreat (unchoose) when you hit a dead end. Like a careful hiker who marks the trail so they can find their way back.
Eager vs. Lazy Constraint Checking
One of the most consequential decisions in a backtracking implementation is when you check constraints. There are two philosophies:
Lazy constraint checking means you only verify constraints at the leaf nodes — when all variables have been assigned. This is equivalent to generating every possible complete assignment and then filtering. It works, but it wastes enormous effort exploring assignments you know are invalid long before you reach the leaves.
Eager constraint checking means you verify constraints at every step, as soon as you make an assignment. The moment you detect a violation, you skip that branch entirely.
❌ Wrong thinking: "I'll just generate all possibilities and filter at the end — it's simpler."
✅ Correct thinking: "I'll check constraints as early as possible to kill invalid branches before they grow."
Let's make this concrete with the N-Queens problem. Here's the contrast between the two approaches:
## LAZY: check constraints only when board is full (bad)
def solve_queens_lazy(board, row, n, solutions):
if row == n:
if is_valid_complete_board(board): # check too late!
solutions.append(board[:])
return
for col in range(n):
board[row] = col
solve_queens_lazy(board, row + 1, n, solutions)
## EAGER: check constraints at each placement (good)
def solve_queens_eager(board, row, n, solutions):
if row == n:
solutions.append(board[:]) # if we got here, it's already valid
return
for col in range(n):
if not is_attacking(board, row, col): # check BEFORE recursing
board[row] = col # choose
solve_queens_eager(board, row + 1, n, solutions) # explore
board[row] = -1 # unchoose
def is_attacking(board, row, col):
for prev_row in range(row):
prev_col = board[prev_row]
# Same column or same diagonal?
if prev_col == col or abs(prev_col - col) == abs(prev_row - row):
return True
return False
With eager checking on an 8×8 board, we examine roughly 2,000 nodes. The lazy version would need to examine 8⁸ = 16,777,216 complete assignments. The difference isn't marginal — it's orders of magnitude.
⚠️ Common Mistake: Placing the constraint check inside the base case rather than before the recursive call. If you only check validity when the assignment is complete, you've already wasted all the work to get there.
State Representation: Mutation vs. Copying
One of the most practical decisions when implementing backtracking is how to represent and manage state. There are two dominant strategies, each with distinct tradeoffs.
In-Place Mutation with Undo
In this strategy, you maintain a single mutable data structure (a board, a list, a set) and modify it directly. When you "unchoose," you manually reverse each modification. This approach is memory-efficient since you only ever hold one copy of the state.
The 4-Queens board array in the example above uses this strategy: board[row] = col to assign, board[row] = -1 to undo. For problems with complex state (like a Sudoku grid), you must carefully undo every change you made during the "choose" step.
## In-place mutation: track changes explicitly to undo them
def backtrack_inplace(grid, pos, used_in_row, used_in_col, used_in_box):
if pos == 81: # all cells filled
return True
row, col = pos // 9, pos % 9
if grid[row][col] != 0: # pre-filled cell, skip
return backtrack_inplace(grid, pos + 1, ...)
box = (row // 3) * 3 + (col // 3)
for num in range(1, 10):
if num not in used_in_row[row] and num not in used_in_col[col] \
and num not in used_in_box[box]:
# CHOOSE
grid[row][col] = num
used_in_row[row].add(num)
used_in_col[col].add(num)
used_in_box[box].add(num)
# EXPLORE
if backtrack_inplace(grid, pos + 1, ...):
return True
# UNCHOOSE — undo ALL changes
grid[row][col] = 0
used_in_row[row].remove(num)
used_in_col[col].remove(num)
used_in_box[box].remove(num)
return False
Copying State
In this strategy, you pass an immutable snapshot to each recursive call. No undo step is needed because each recursive frame has its own copy. This is conceptually simpler but can be expensive in memory and time if the state is large.
## Copying approach: pass new state to each recursive call
def backtrack_copy(path, remaining, result):
if not remaining:
result.append(path[:]) # record complete solution
return
for i, item in enumerate(remaining):
# CHOOSE + EXPLORE — no undo needed, we pass new objects
backtrack_copy(
path + [item], # new list (copy)
remaining[:i] + remaining[i+1:], # new remaining (copy)
result
)
# No UNCHOOSE needed — original path/remaining unchanged
📋 Quick Reference Card: State Strategies
| 🔧 In-Place Mutation | 📋 Copying | |
|---|---|---|
| 🧠 Complexity | O(1) extra space per level | O(n) extra space per level |
| 🔧 Undo needed | ✅ Yes — explicitly reverse changes | ❌ No |
| 🎯 Best for | Large state (grids, boards) | Small state (paths, subsets) |
| ⚠️ Risk | Forgetting to undo a change | High memory use for deep recursion |
💡 Pro Tip: For most LeetCode grid/board problems, prefer in-place mutation. For path/subset generation problems, copying is often cleaner and less error-prone.
Complexity Analysis: With and Without Pruning
Understanding the complexity of backtracking is essential for knowing when it will pass within time limits — and for explaining your solution in an interview.
Without Constraint Pruning
In the worst case (no pruning), backtracking degenerates into a brute-force enumeration. If you have n variables each with domain size d, the decision tree has d^n leaves. The total number of nodes in the tree (including internal nodes) is bounded by O(d^n), and the work at each node is typically O(1) to O(n). So the time complexity is O(n · d^n).
For example:
- Permutations of n elements: n! time (since d shrinks as you assign)
- N-Queens without pruning: O(n^n) time
- Subsets of n elements: O(2^n) time
With Constraint Pruning
With eager constraint checking, we prune branches early. The exact improvement depends on how often constraints eliminate values, but the worst case remains exponential — constraints don't change the theoretical bound, only the constant factors and average case.
However, in practice the difference is dramatic:
Without pruning (N=8 queens): ~16,777,216 nodes explored
With pruning (N=8 queens): ~2,057 nodes explored
Speedup: ~8,000x
The pruning factor is problem-specific but can be characterized as follows. If each constraint eliminates fraction f of the domain at each level, the effective branching factor drops from d to d(1-f), and the tree has approximately O((d(1-f))^n) nodes. When f is even modestly large (say 0.5), the impact compounds across depth.
🎯 Key Principle: The theoretical worst-case complexity of backtracking is exponential regardless of pruning. But pruning transforms an infeasible exponential into a practical one by eliminating enormous portions of the search space.
Space Complexity
The space complexity of backtracking is determined by the depth of the recursion, not the total number of nodes. Each recursive frame sits on the call stack, and you only ever have at most n frames active simultaneously (one per variable assignment level). So space complexity is O(n) for the call stack, plus whatever space your state representation requires.
⚠️ Common Mistake: Confusing the number of recursive calls made (which is exponential) with the maximum call stack depth at any moment (which is linear). Python's default recursion limit is 1000 — for deep problems, you may need sys.setrecursionlimit() or an iterative approach.
Time complexity summary:
Problem Type | Without Pruning | With Pruning (typical)
----------------------|-----------------|------------------------
Permutations (n) | O(n · n!) | O(n · n!) worst case
N-Queens (n) | O(n^n) | Much smaller in practice
Subsets (n) | O(2^n) | O(2^n) worst case
Sudoku (81 cells) | O(9^81) | O(9^m) where m << 81
Space complexity: O(n) call stack depth in all cases
(plus state storage, which varies by representation)
Putting It Together: A Complete Backtracking Example
Let's cement all of these ideas with a complete, annotated solution to the Combination Sum problem (LeetCode #39): given a list of distinct integers candidates and a target, find all unique combinations that sum to the target, where each number can be used unlimited times.
def combinationSum(candidates: list, target: int) -> list:
results = []
def backtrack(start: int, current: list, remaining: int):
# Base case: constraint satisfied completely
if remaining == 0:
results.append(current[:]) # record a copy of current path
return
# Pruning: if remaining goes negative, no point continuing
# (This is eager constraint checking — stop before recursing deeper)
if remaining < 0:
return
for i in range(start, len(candidates)):
candidate = candidates[i]
# Eager constraint check: skip if candidate exceeds remaining
if candidate > remaining:
continue # domain pruning
# CHOOSE
current.append(candidate)
# EXPLORE — pass i (not i+1) to allow reuse of same element
backtrack(i, current, remaining - candidate)
# UNCHOOSE — in-place mutation with undo
current.pop()
backtrack(0, [], target)
return results
## Example:
## combinationSum([2, 3, 6, 7], 7)
## → [[2, 2, 3], [7]]
Trace through this carefully:
- State: the
currentlist is mutated in-place and undone withpop() - Eager checking: we skip candidates larger than
remainingbefore recursing - Choose/Explore/Unchoose: the three-step pattern appears explicitly
- Base case:
remaining == 0means we've satisfied the sum constraint exactly
💡 Mental Model: Think of the recursion stack as a path through the decision tree. At each node, you try one candidate (choose), walk down an edge (explore), then walk back up (unchoose) to try the next candidate. The remaining < 0 check prunes the subtree the moment we overshoot the target.
The Complete Picture
Backtracking is best understood as a systematic traversal of a decision tree, where constraint checking acts as a gate that prevents exploring subtrees we already know are invalid. The choose-explore-unchoose template is your reusable skeleton; state representation (mutation vs. copy) is your engineering choice; and eager constraint checking is your non-negotiable performance habit.
The decision tree visualization isn't just a learning device — it's a debugging tool. When your backtracking solution finds wrong answers, ask: is the tree being pruned too aggressively (valid branches cut off)? When it's too slow, ask: is it being pruned eagerly enough (invalid branches explored unnecessarily)?
In the next section, we'll layer sophisticated pruning strategies on top of this foundation — techniques like forward checking, constraint propagation, and heuristic variable ordering — that transform backtracking from theoretically correct to practically fast. But none of those optimizations make sense without the solid foundation you've just built here.
🤔 Did you know? The term "backtracking" was coined by D.H. Lehmer in the 1950s, and the algorithm was formalized for constraint satisfaction by researchers studying theorem proving. The same algorithm that solves Sudoku also powers many industrial scheduling systems and logic programming languages like Prolog.
Pruning Strategies That Make Backtracking Practical
Naked backtracking is honest work: it explores every possibility and retreats when it hits a dead end. But on real problems — Sudoku, graph coloring, exam scheduling — honest work without cleverness leads to combinatorial explosion. A 9×9 Sudoku board has roughly 6.7 × 10²¹ possible digit arrangements before any constraints are applied. Pure backtracking, even with immediate constraint checking, can still visit millions of nodes. The pruning strategies in this section are what separate a solution that runs in milliseconds from one that never finishes.
Think of these techniques as intelligence layered on top of the basic backtracking engine from the previous section. The engine stays the same — you still recurse, assign, check, and retreat — but now you're doing far more work before committing to an assignment, so you fail earlier and more decisively.
Forward Checking: Proactive Dead-End Detection
Forward checking is the first and most impactful pruning technique. The core idea is simple: the moment you assign a value to a variable, immediately scan all unassigned variables that share a constraint with it and eliminate any domain values that are now impossible.
Without forward checking, you only discover a conflict when you actually try to assign the conflicting variable. Forward checking catches that conflict one step earlier — before you've committed another variable — which can prune entire subtrees of the search space.
💡 Mental Model: Imagine you're filling seats at a dinner party with strict seating rules. The moment you seat Guest A, you immediately cross off all seats that Guest B cannot now occupy, rather than waiting until it's B's turn and discovering there's nowhere to put them.
Here's how the effect looks on a three-variable coloring problem:
Variables: X, Y, Z | Colors: {R, G, B}
Constraints: X≠Y, X≠Z, Y≠Z
BEFORE forward checking assigns X = R:
Domain(Y) = {R, G, B}
Domain(Z) = {R, G, B}
AFTER forward checking assigns X = R:
Domain(Y) = {G, B} ← R pruned (conflicts with X)
Domain(Z) = {G, B} ← R pruned (conflicts with X)
If Y = G is assigned next:
Domain(Z) = {B} ← forward checking narrows to one value
→ Z = B is forced. No search needed!
In that last step, forward checking reduced a choice to a certainty. The algorithm doesn't need to explore two branches for Z; it knows the answer immediately. This is the power of proactive domain reduction.
⚠️ Common Mistake: Forgetting to restore pruned domain values when you backtrack. If you permanently remove a value from a domain during forward checking, then backtrack past the assignment that caused the removal, that domain is now corrupted. Always save the set of pruned values per assignment and restore them on undo.
Constraint Propagation: Letting the Ripple Spread
Forward checking looks one step ahead. Constraint propagation goes further — it cascades the effects of an assignment through the entire network of related variables, not just the immediate neighbors.
The most powerful and widely used form is Arc Consistency (AC-3). An arc (X → Y) is considered consistent if for every value in Domain(X), there exists at least one value in Domain(Y) that satisfies the constraint between them. AC-3 enforces this for every arc in the problem.
🎯 Key Principle: If a value has no support — no compatible partner in a neighboring variable's domain — it can never be part of a valid solution. Remove it now, not later.
AC-3 Propagation Example:
Variables: A, B, C
Domains: A={1,2,3}, B={1,2,3}, C={1,2,3}
Constraints: A < B, B < C
Step 1: Check arc A→B (A < B)
For A=3: no value in B satisfies 3 < B (B max is 3). Remove 3 from A.
A = {1,2}
Step 2: Check arc B→C (B < C)
For B=3: no value in C satisfies 3 < C. Remove 3 from B.
B = {1,2}
Step 3: B changed — re-check arc A→B
For A=2: B needs a value > 2, B={1,2}, none works. Remove 2 from A.
A = {1}
Step 4: A changed — nothing new to propagate.
Final domains: A={1}, B={2}, C={3} ← Entire solution derived without guessing!
The AC-3 algorithm uses a queue of arcs. Whenever a domain shrinks, all arcs pointing into that variable are re-added to the queue for re-examination. This is the "cascading" effect — a single assignment in Sudoku can trigger a chain of forced values across the board, exactly what expert human solvers do mentally.
🤔 Did you know? Modern Sudoku solvers using full constraint propagation can solve "easy" and "medium" puzzles without any guessing at all — pure propagation reduces every variable to a single-value domain.
Variable Ordering Heuristics: Choosing Which Variable to Assign Next
Naive backtracking assigns variables in a fixed order (left to right, top to bottom). But the order in which you assign variables has an enormous impact on how quickly failures are discovered. Two heuristics dominate this space.
Minimum Remaining Values (MRV)
The Minimum Remaining Values (MRV) heuristic — also called the "fail-first" heuristic — says: always assign the variable with the fewest legal values remaining in its domain.
🧠 Mnemonic: "Most constrained first" — pick the variable that's closest to being stuck.
The reasoning is elegant: if a variable has only one legal value left, it must take that value. Assigning it now costs nothing in terms of branching and might trigger useful propagation. If a variable has zero legal values, you've found a dead end — and you want to find dead ends as early as possible, before you've committed many other assignments.
Sudoku MRV Example:
[ ] [5] [ ] | [ ] [ ] [ ] | [ ] [ ] [ ]
[6] [ ] [ ] | [1] [9] [5] | [ ] [ ] [ ]
[ ] [9] [8] | [ ] [ ] [ ] | [ ] [6] [ ]
─────────────────────────────────────────
[ ] [ ] [ ] | [ ] [6] [ ] | [ ] [ ] [3]
[4] [ ] [ ] | [8] [ ] [3] | [ ] [ ] [1]
[7] [ ] [ ] | [ ] [2] [ ] | [ ] [ ] [6]
─────────────────────────────────────────
[ ] [6] [ ] | [ ] [ ] [ ] | [2] [8] [ ]
[ ] [ ] [ ] | [4] [1] [9] | [ ] [ ] [5]
[ ] [ ] [ ] | [ ] [8] [ ] | [ ] [7] [9]
Cell (row=0,col=0): domain = {1,2,3,4,7} → 5 values
Cell (row=2,col=0): domain = {1,2,3} → 3 values
Cell (row=4,col=1): domain = {2} → 1 value ← MRV picks this!
MRV would immediately pick the cell with only one possible value, assign it, propagate, and discover more forced cells. This cascades until MRV has to make a real choice — and at that point, it picks the most constrained remaining cell.
Degree Heuristic
What happens when multiple variables tie for the smallest domain size? The Degree heuristic breaks ties: choose the variable involved in the most constraints with other unassigned variables.
The intuition: a high-degree variable influences the most other variables. Assigning it now triggers the most forward checking and propagation, giving you the most information.
💡 Real-World Example: In a map-coloring problem, the country that borders the most other uncolored countries should be colored first. Its assignment immediately restricts the most other options, so any failure is detected quickly.
📋 Quick Reference Card: Variable Ordering Heuristics
| Heuristic | 📌 Rule | 🎯 Goal | 🔧 Use When |
|--------------|----------------------------------|----------------------------|------------------------------|
| MRV | 🔢 Fewest legal values remaining | 🏁 Fail fast | 🔒 Always (primary choice) |
| Degree | 🔗 Most constraints with others | 📡 Maximize propagation | ⚖️ Break MRV ties |
| Fixed order | ➡️ Left-to-right / top-to-bottom | — | ❌ Avoid for hard problems |
Value Ordering Heuristics: Choosing Which Value to Try First
Once you've chosen a variable, you still need to decide the order in which to try its domain values. The Least Constraining Value (LCV) heuristic says: choose the value that rules out the fewest options for neighboring unassigned variables.
❌ Wrong thinking: "Try the smallest value first — at least the order is consistent." ✅ Correct thinking: "Try the value that leaves neighbors the most flexibility, maximizing the chance that the current partial assignment can be extended to a solution."
LCV is the inverse of MRV in spirit. MRV says "pick the variable most likely to fail"; LCV says "when assigning that variable, pick the value least likely to cause a neighbor to fail."
LCV Example: Graph coloring, assigning color to node A
Node A neighbors: B (domain={R,G,B}), C (domain={R,G})
Option 1: Assign A = R
→ Removes R from B: B={G,B} (2 values remain)
→ Removes R from C: C={G} (1 value remains)
Total ruled out: 2 values
Option 2: Assign A = G
→ Removes G from B: B={R,B} (2 values remain)
→ Removes G from C: C={R} (1 value remains)
Total ruled out: 2 values
Option 3: Assign A = B
→ Removes B from B: B={R,G} (2 values remain)
→ No effect on C: C={R,G} (2 values remain)
Total ruled out: 1 value ← LCV picks this!
Assigning A = Blue removes only one option total from neighbors, leaving them the most room to maneuver. LCV is especially valuable when you're making guesses near the top of the search tree — a bad choice there spawns an enormous failed subtree.
🎯 Key Principle: MRV + Degree + LCV work as a team. MRV chooses which variable (fail-first). Degree breaks ties in MRV. LCV chooses which value for that variable (succeed-first).
Implementing a Reusable CSP Skeleton with Pruning
Theory lands best when it lives in code. Below is a complete, reusable CSP backtracking skeleton in Python that integrates forward checking, MRV, and LCV. Read the comments carefully — each pruning technique is clearly marked.
from collections import defaultdict
from copy import deepcopy
class CSP:
def __init__(self, variables, domains, constraints):
"""
variables : list of variable names
domains : dict {var: [list of possible values]}
constraints: dict {(var1, var2): callable(val1, val2) -> bool}
"""
self.variables = variables
self.domains = {v: list(d) for v, d in domains.items()}
self.constraints = constraints
# Build neighbor map for fast lookup
self.neighbors = defaultdict(set)
for (v1, v2) in constraints:
self.neighbors[v1].add(v2)
self.neighbors[v2].add(v1)
def is_consistent(self, var, value, assignment):
"""Check if assigning value to var violates any constraint."""
for neighbor in self.neighbors[var]:
if neighbor in assignment:
constraint_fn = self.constraints.get(
(var, neighbor)) or self.constraints.get((neighbor, var))
if constraint_fn and not constraint_fn(value, assignment[neighbor]):
return False
return True
def forward_check(self, var, value, domains):
"""
Prune domains of unassigned neighbors.
Returns a dict of {neighbor: [pruned values]} for undo on backtrack.
Returns None if a domain becomes empty (dead end detected).
"""
pruned = defaultdict(list)
for neighbor in self.neighbors[var]:
if neighbor not in self.current_assignment:
constraint_fn = self.constraints.get(
(var, neighbor)) or self.constraints.get((neighbor, var))
if constraint_fn:
for neighbor_val in list(domains[neighbor]):
if not constraint_fn(value, neighbor_val):
domains[neighbor].remove(neighbor_val)
pruned[neighbor].append(neighbor_val)
if not domains[neighbor]: # Domain wiped out!
return None # Signal dead end
return pruned
def select_unassigned_variable(self, assignment, domains):
"""MRV heuristic, with Degree as tiebreaker."""
unassigned = [v for v in self.variables if v not in assignment]
# MRV: variable with fewest remaining domain values
min_remaining = min(len(domains[v]) for v in unassigned)
mrv_candidates = [v for v in unassigned
if len(domains[v]) == min_remaining]
if len(mrv_candidates) == 1:
return mrv_candidates[0]
# Degree tiebreaker: most constraints with unassigned neighbors
return max(mrv_candidates,
key=lambda v: sum(1 for n in self.neighbors[v]
if n not in assignment))
def order_domain_values(self, var, assignment, domains):
"""LCV heuristic: values that prune fewest neighbor options first."""
def count_conflicts(value):
total = 0
for neighbor in self.neighbors[var]:
if neighbor not in assignment:
constraint_fn = self.constraints.get(
(var, neighbor)) or self.constraints.get((neighbor, var))
if constraint_fn:
total += sum(
1 for nval in domains[neighbor]
if not constraint_fn(value, nval)
)
return total
return sorted(domains[var], key=count_conflicts)
def backtrack(self, assignment, domains):
"""Core recursive backtracking with forward checking."""
if len(assignment) == len(self.variables):
return assignment # Solution found!
self.current_assignment = assignment # needed by forward_check
var = self.select_unassigned_variable(assignment, domains)
for value in self.order_domain_values(var, assignment, domains):
if self.is_consistent(var, value, assignment):
assignment[var] = value
domains_copy = deepcopy(domains) # Snapshot before pruning
pruned = self.forward_check(var, value, domains_copy)
if pruned is not None: # No domain became empty
result = self.backtrack(assignment, domains_copy)
if result:
return result
# Backtrack: remove assignment (domain restored via deepcopy)
del assignment[var]
return None # Trigger backtrack in caller
def solve(self):
return self.backtrack({}, deepcopy(self.domains))
## ─── Usage: 4-queens problem ───────────────────────────────────────
variables = ['Q1', 'Q2', 'Q3', 'Q4'] # One queen per column
domains = {q: [1, 2, 3, 4] for q in variables} # Row assignments
def queens_constraint(row1, row2, col_offset=None):
# Actual col_offset is baked in via closure below
return True # placeholder; see constraint dict below
constraints = {}
cols = ['Q1', 'Q2', 'Q3', 'Q4']
for i in range(len(cols)):
for j in range(i + 1, len(cols)):
col_diff = j - i
# Closure captures col_diff correctly
def make_constraint(cd):
return lambda r1, r2: r1 != r2 and abs(r1 - r2) != cd
constraints[(cols[i], cols[j])] = make_constraint(col_diff)
csp = CSP(variables, domains, constraints)
solution = csp.solve()
print(solution) # e.g., {'Q1': 2, 'Q2': 4, 'Q3': 1, 'Q4': 3}
This Python skeleton does five concrete things:
- 🧠 MRV via
select_unassigned_variable— always attacks the most constrained variable. - 🔗 Degree heuristic as a tiebreaker in the same function.
- 🎯 LCV via
order_domain_values— tries the least disruptive value first. - 🔧 Forward checking via
forward_check— prunes neighbor domains immediately. - 🔒 Safe undo via
deepcopyof domains before each assignment — guarantees clean backtracking.
⚠️ Common Mistake: Using deepcopy at every recursive level is correct for clarity but expensive for large domains. In production CSP solvers, track pruned values explicitly in a stack and restore them on backtrack, as shown in the pruned dict pattern inside forward_check. Both approaches are correct; the explicit undo stack is faster.
Now here is the equivalent Java skeleton, focusing on the forwardCheck and selectMRV methods that plug into a standard backtracking loop:
import java.util.*;
public class CSPSolver {
private final List<String> variables;
private final Map<String, List<Integer>> domains;
private final Map<String, Set<String>> neighbors;
// constraint: (var1, val1, var2, val2) -> compatible?
private final ConstraintChecker checker;
@FunctionalInterface
interface ConstraintChecker {
boolean isCompatible(String v1, int val1, String v2, int val2);
}
public CSPSolver(List<String> variables,
Map<String, List<Integer>> domains,
Map<String, Set<String>> neighbors,
ConstraintChecker checker) {
this.variables = variables;
this.domains = domains;
this.neighbors = neighbors;
this.checker = checker;
}
/** MRV: pick unassigned variable with smallest domain. */
private String selectMRV(Map<String, Integer> assignment,
Map<String, List<Integer>> currentDomains) {
String chosen = null;
int minSize = Integer.MAX_VALUE;
int maxDegree = -1;
for (String var : variables) {
if (assignment.containsKey(var)) continue;
int domainSize = currentDomains.get(var).size();
int degree = (int) neighbors.getOrDefault(var, Set.of())
.stream()
.filter(n -> !assignment.containsKey(n))
.count();
// MRV first, Degree as tiebreaker
if (domainSize < minSize ||
(domainSize == minSize && degree > maxDegree)) {
chosen = var;
minSize = domainSize;
maxDegree = degree;
}
}
return chosen;
}
/**
* Forward check: prune values from neighbors inconsistent with (var=value).
* Returns map of pruned values per neighbor, or null if a domain empties.
*/
private Map<String, List<Integer>> forwardCheck(
String var, int value,
Map<String, Integer> assignment,
Map<String, List<Integer>> currentDomains) {
Map<String, List<Integer>> pruned = new HashMap<>();
for (String neighbor : neighbors.getOrDefault(var, Set.of())) {
if (assignment.containsKey(neighbor)) continue;
List<Integer> neighborDomain = currentDomains.get(neighbor);
List<Integer> prunedVals = new ArrayList<>();
Iterator<Integer> it = neighborDomain.iterator();
while (it.hasNext()) {
int nVal = it.next();
if (!checker.isCompatible(var, value, neighbor, nVal)) {
it.remove(); // Prune incompatible value
prunedVals.add(nVal);
}
}
if (!prunedVals.isEmpty()) pruned.put(neighbor, prunedVals);
if (neighborDomain.isEmpty()) return null; // Dead end!
}
return pruned;
}
/** Restore pruned values on backtrack. */
private void restorePruned(Map<String, List<Integer>> pruned,
Map<String, List<Integer>> currentDomains) {
for (Map.Entry<String, List<Integer>> entry : pruned.entrySet()) {
currentDomains.get(entry.getKey()).addAll(entry.getValue());
}
}
public Map<String, Integer> solve() {
// Deep-copy domains for safe mutation during search
Map<String, List<Integer>> workingDomains = new HashMap<>();
for (Map.Entry<String, List<Integer>> e : domains.entrySet())
workingDomains.put(e.getKey(), new ArrayList<>(e.getValue()));
return backtrack(new HashMap<>(), workingDomains);
}
private Map<String, Integer> backtrack(
Map<String, Integer> assignment,
Map<String, List<Integer>> currentDomains) {
if (assignment.size() == variables.size()) return assignment;
String var = selectMRV(assignment, currentDomains);
// LCV: sort values by how few options they remove from neighbors
List<Integer> orderedValues = new ArrayList<>(currentDomains.get(var));
orderedValues.sort(Comparator.comparingInt(val ->
neighbors.getOrDefault(var, Set.of()).stream()
.filter(n -> !assignment.containsKey(n))
.mapToInt(n -> (int) currentDomains.get(n).stream()
.filter(nv -> !checker.isCompatible(var, val, n, nv))
.count())
.sum()));
for (int value : orderedValues) {
// Consistency check
boolean consistent = assignment.entrySet().stream()
.filter(e -> neighbors.getOrDefault(var, Set.of())
.contains(e.getKey()))
.allMatch(e -> checker.isCompatible(
var, value, e.getKey(), e.getValue()));
if (!consistent) continue;
assignment.put(var, value);
Map<String, List<Integer>> pruned =
forwardCheck(var, value, assignment, currentDomains);
if (pruned != null) { // No domain was emptied
Map<String, Integer> result =
backtrack(assignment, currentDomains);
if (result != null) return result;
}
// Undo: remove assignment and restore pruned domain values
assignment.remove(var);
if (pruned != null) restorePruned(pruned, currentDomains);
}
return null; // Backtrack
}
}
The Java version makes the undo pattern explicit: forwardCheck removes values and records what it removed; restorePruned puts them back when the attempt fails. This explicit stack of pruned sets is the production-quality pattern — no deepcopy overhead, just clean add/remove operations.
How These Techniques Stack: A Combined Impact View
Each technique compounds the others. Forward checking catches dead ends one level early. Constraint propagation cascades that detection further. MRV ensures you discover failures at the shallowest possible point. LCV maximizes the chance each committed choice survives scrutiny.
Search tree nodes explored (approximate, N-Queens, N=20):
Naive backtracking: ~500,000+ nodes
+ Forward checking: ~10,000 nodes
+ MRV: ~200 nodes
+ MRV + LCV: ~50 nodes
+ Full AC-3 propagation: ~15 nodes
↑ orders of magnitude reduction
💡 Pro Tip: When implementing these for a LeetCode problem, you rarely need the full generalized CSP skeleton. Instead, identify which pruning strategies apply to your specific problem and inline them. For N-Queens, forward checking on columns and diagonals is sufficient. For Sudoku, forward checking plus row/column/box propagation usually solves it without full AC-3. Layer complexity only as needed.
Summary: The Pruning Hierarchy
When you sit down to implement a CSP solution, apply techniques in this order of increasing power and cost:
┌──────────────────────────────────────────────────────┐
│ 1. Constraint check at assignment (cheapest) │
│ 2. Forward checking: prune neighbors │
│ 3. Variable ordering: MRV + Degree │
│ 4. Value ordering: LCV │
│ 5. Full constraint propagation: AC-3 (costliest) │
└──────────────────────────────────────────────────────┘
Each layer reduces search space multiplicatively
Mastering these five layers transforms backtracking from a brute-force last resort into a surgical search strategy. In the next section, we'll examine the common implementation pitfalls that trip up developers even when they understand the theory — the subtle bugs that make a correct algorithm produce wrong answers or run forever.
Common Pitfalls When Implementing CSP Backtracking
You have a solid grasp of what CSPs are, how backtracking explores the decision tree, and which pruning strategies keep that search manageable. Now comes the humbling part: translating that understanding into working code. Even experienced developers who understand CSP theory fluently will ship buggy backtracking implementations, often repeatedly, because the same class of mistakes keeps appearing. These pitfalls are not random — they cluster around a handful of fundamental misunderstandings about how state, recursion, and constraint checking interact.
This section is a field guide to those mistakes. For each pitfall, you will see exactly what goes wrong, why it feels correct at first glance, and how a small, targeted fix restores correctness. Read these not as a list of warnings to memorize, but as a map of the conceptual territory where bugs live.
Pitfall 1: Failing to Undo State Changes on Backtrack
Incomplete state restoration is the single most common CSP bug, and it corrupts your search in ways that are maddeningly difficult to debug because the output can look almost right. The root cause is forgetting that backtracking requires your program to literally undo the last decision before trying the next one.
Imagine you are solving Sudoku. You place a 5 in cell (2, 3), recurse, fail, and backtrack. If that 5 is still recorded in your board when you try the next candidate, every subsequent constraint check operates on a corrupted state — the board reflects a mixture of committed decisions and abandoned ones.
❌ Wrong thinking: "After the recursive call returns False, I just move to the next candidate in the loop."
✅ Correct thinking: "After the recursive call returns False, I must explicitly erase every change I made before trying the next candidate."
Here is the bug in a simplified N-Queens solver:
## ❌ BROKEN: Missing state restoration
def solve(row, queens_in_cols, board, n):
if row == n:
return True
for col in range(n):
if is_valid(row, col, queens_in_cols):
board[row][col] = 'Q'
queens_in_cols.add(col) # state change #1
# Forgot to track diagonals, but let's focus on the missing undo
if solve(row + 1, queens_in_cols, board, n):
return True
# ⚠️ BUG: queens_in_cols still contains `col` after failure!
# board[row][col] is still 'Q'!
return False
## ✅ FIXED: Explicit undo after recursive failure
def solve(row, queens_in_cols, board, n):
if row == n:
return True
for col in range(n):
if is_valid(row, col, queens_in_cols):
board[row][col] = 'Q' # place
queens_in_cols.add(col) # update state
if solve(row + 1, queens_in_cols, board, n):
return True
board[row][col] = '.' # undo place
queens_in_cols.remove(col) # undo state update ← KEY FIX
return False
Notice the symmetry: every place operation has a matching undo place operation, and every state update has a matching state rollback. This symmetry is not stylistic — it is a correctness invariant.
🎯 Key Principle: The state of your data structures when you enter a recursive call must be identical to the state when you exit that call, regardless of whether it succeeded or failed.
⚠️ Common Mistake — Mistake 1: Mutating a shared object (like a set or dictionary) and forgetting to restore it. Using immutable snapshots (e.g., passing a new set each time) avoids this bug but costs memory and copy time. For performance-sensitive problems, the explicit add/remove pattern is preferred, but it demands discipline.
🧠 Mnemonic: "Place, recurse, erase." Every branch of your backtracker follows this three-beat rhythm without exception.
Pitfall 2: Checking Constraints Too Late — Pruning After Instead of Before
The second pitfall is subtler. Your constraint checks may be logically correct but placed at the wrong moment in the recursion, causing your code to do enormous amounts of unnecessary work before discovering a dead end.
Consider the difference between these two mental models:
❌ Late-check model (generate, then test):
1. Pick a value for the current variable
2. Recurse into the next variable
3. (Eventually, at the bottom of the tree) check if the whole assignment is valid
✅ Early-check model (constrain before recursing):
1. Check if the candidate value violates any constraint NOW
2. If it does, skip it immediately — do not recurse
3. Only recurse when the partial assignment is still valid
The late-check model can still find correct answers, but it explores branches that are provably invalid from the moment the decision is made. In a problem with 9×9 Sudoku cells and 9 candidates each, the difference between these approaches is the difference between exploring millions of nodes and exploring thousands.
## ❌ SLOW: Constraint check happens at the leaf, not at the branch
def solve_slow(board, pos, n):
if pos == n * n:
return is_fully_valid(board, n) # constraint check at the very end!
row, col = pos // n, pos % n
if board[row][col] != 0: # pre-filled cell
return solve_slow(board, pos + 1, n)
for digit in range(1, n + 1):
board[row][col] = digit # try every digit regardless
if solve_slow(board, pos + 1, n):
return True
board[row][col] = 0
return False
## ✅ FAST: Constraint check prunes BEFORE the recursive call
def solve_fast(board, pos, n):
if pos == n * n:
return True # if we got here, all placements were valid — done!
row, col = pos // n, pos % n
if board[row][col] != 0:
return solve_fast(board, pos + 1, n)
for digit in range(1, n + 1):
if is_valid_placement(board, row, col, digit, n): # ← prune HERE
board[row][col] = digit
if solve_fast(board, pos + 1, n):
return True
board[row][col] = 0
return False
The fast version only enters the recursive call when the partial assignment is still consistent. This is constraint propagation at the branch point, not at the leaf.
💡 Pro Tip: A useful heuristic is to ask: "At the moment I am about to recurse, do I already know this branch will fail?" If yes, prune. Every early exit saves you the entire subtree rooted at that branch.
The ASCII diagram below shows the difference in tree exploration:
Late-check (explores everything): Early-check (prunes immediately):
root root
/ | \ \ / | \
1 2 3 4 1 2 4 ← 3 pruned at root
/| |\ | / |
.. .. .. .. .. ..
(reaches leaves to check) (invalid branches never open)
⚠️ Common Mistake — Mistake 2: Validating only the complete assignment rather than incrementally validating each decision. This turns an O(n) pruning opportunity into a full-depth search.
Pitfall 3: Confusing the Termination Condition
The base case of your recursive backtracker defines when you have found a complete, valid assignment. Getting this wrong produces one of two failure modes: either you return True too early (accepting invalid partial assignments) or you never return True at all (failing to recognize a correct solution when you reach it).
The key question is: what exactly constitutes a complete assignment for this problem?
| Problem | Complete Assignment Means |
|---|---|
| 🔲 N-Queens | One queen placed in each of N rows |
| 🔢 Sudoku | Every cell contains a digit 1–9 |
| 🗺️ Graph Coloring | Every node has been assigned a color |
| 🔤 Word Search | All characters of the target word placed |
A recurring mistake is terminating on the number of steps taken rather than checking whether the assignment is genuinely complete. These often coincide but can diverge when cells are pre-filled, when the variable order is non-sequential, or when multiple variables share a step.
## ❌ WRONG: Terminates when we reach cell n*n, but this skips checking
## whether the last cell was actually assigned!
def solve(board, pos, n):
if pos == n * n + 1: # off-by-one! pos == n*n means the LAST cell
return True # but we process it and return before assigning
...
## ❌ ALSO WRONG: Terminates when queens_placed == n, but doesn't verify
## that the queens don't attack each other (constraint check missed)
def solve(row, queens_placed, n):
if queens_placed == n: # counts queens but ignores validity
return True
...
## ✅ CORRECT: Terminates when row == n, meaning all rows are filled
## AND since we only place valid queens, the solution is guaranteed valid
def solve(row, n, cols, diag1, diag2):
if row == n: # processed rows 0..n-1, all valid placements
return True # this is a genuine complete valid assignment
for col in range(n):
if col not in cols and (row - col) not in diag1 and (row + col) not in diag2:
cols.add(col)
diag1.add(row - col)
diag2.add(row + col)
if solve(row + 1, n, cols, diag1, diag2):
return True
cols.remove(col)
diag1.remove(row - col)
diag2.remove(row + col)
return False
💡 Mental Model: Think of your recursion as a depth counter. When the depth equals the total number of variables, you have made one decision per variable. If every decision passed its constraint check at placement time, the terminal state is both complete and valid — return True is justified.
🤔 Did you know? Many interview candidates lose points not because their backtracking logic is wrong, but because their base case returns the wrong thing — for example, returning the board instead of True, or returning None by accident because a return statement was accidentally omitted.
Pitfall 4: Off-by-One and Boundary Errors in Domain and Grid Iteration
Off-by-one errors are the bane of all grid-based programming, and CSP backtracking on grids is especially vulnerable because you are typically iterating over rows, columns, and value domains simultaneously — three independent ranges that each carry their own boundary.
The most common manifestations in CSP code:
🔧 Domain boundary errors: Using range(1, n) instead of range(1, n+1) for a 1-to-n digit domain, silently excluding the value n from consideration. A Sudoku solver that never tries the digit 9 will fail on any puzzle requiring a 9 but produce no error — it will simply report no solution.
🔧 Grid traversal errors: Mapping a linear position pos to (row, col) with row = pos // n and col = pos % n is correct — but using n+1 or n-1 as the divisor is a latent bug waiting for an edge case.
🔧 Neighbor/adjacency errors: When checking adjacent cells (up, down, left, right), forgetting to bounds-check before accessing board[r][c] causes index-out-of-range crashes — or worse, wrapping around in languages that allow negative indices (like Python lists).
## ❌ WRONG: Domain excludes the value `n` and neighbor access is unchecked
def get_valid_digits(board, row, col, n):
used = set()
for digit in range(1, n): # BUG: should be range(1, n + 1)
used.add(board[row][digit]) # BUG: iterating col not checking row
return [d for d in range(1, n) if d not in used]
## ❌ WRONG: Neighbor check without boundary validation
def count_conflicts(board, row, col, value, directions):
conflicts = 0
for dr, dc in directions:
# Python won't raise an error for board[-1][col] — it wraps!
if board[row + dr][col + dc] == value:
conflicts += 1
return conflicts
## ✅ CORRECT: Full boundary check before grid access
def count_conflicts(board, row, col, value, directions, n):
conflicts = 0
for dr, dc in directions:
nr, nc = row + dr, col + dc
if 0 <= nr < n and 0 <= nc < n: # boundary guard
if board[nr][nc] == value:
conflicts += 1
return conflicts
⚠️ Common Mistake — Mistake 3: In Python specifically, negative list indices do not raise errors — board[-1][col] silently accesses the last row. This means missing a bounds check produces wrong answers, not crashes, making the bug extremely hard to spot.
💡 Pro Tip: Write a small helper function in_bounds(r, c, n) that returns True if 0 <= r < n and 0 <= c < n, and call it before every grid access. This single habit eliminates an entire class of bugs.
📋 Quick Reference Card: Off-by-one checklist for CSP grid problems
| 🔒 Concern | ❌ Wrong | ✅ Correct |
|---|---|---|
| 🔢 Digit domain (1 to n) | range(1, n) |
range(1, n + 1) |
| 🔲 Grid rows/cols (0-indexed) | range(1, n) |
range(n) or range(0, n) |
| 🗺️ Neighbor bounds check | No check | 0 <= r < n and 0 <= c < n |
| 📍 Linear-to-grid mapping | pos // (n+1) |
pos // n |
| ✅ Termination row check | row == n - 1 |
row == n |
Pitfall 5: Redundant Constraint Checks That Recompute Known Information
Once your backtracker is logically correct, the next enemy is redundant computation — checking constraints you have already established, or recomputing derived state that could have been maintained incrementally.
The most common form is scanning entire rows, columns, and boxes on every placement decision when that information is already encoded in your tracking sets. Consider a Sudoku solver that calls is_valid(board, row, col, digit) by scanning the entire row and column every time:
## ❌ SLOW: Re-scanning entire row/column/box on every candidate
def is_valid(board, row, col, digit, n):
box_size = int(n ** 0.5)
# Scans all n cells in the row — O(n) work
if digit in board[row]:
return False
# Scans all n cells in the column — O(n) work
if digit in [board[r][col] for r in range(n)]:
return False
# Scans all box_size² cells in the box — O(box_size²) work
box_row, box_col = (row // box_size) * box_size, (col // box_size) * box_size
for r in range(box_row, box_row + box_size):
for c in range(box_col, box_col + box_size):
if board[r][c] == digit:
return False
return True
## ✅ FAST: Maintain sets that encode this information incrementally
def solve_with_sets(board, pos, n, rows, cols, boxes):
# rows[r], cols[c], boxes[b] are sets of used digits — O(1) lookups
if pos == n * n:
return True
row, col = pos // n, pos % n
box_size = int(n ** 0.5)
box_idx = (row // box_size) * box_size + (col // box_size)
if board[row][col] != 0: # pre-filled
return solve_with_sets(board, pos + 1, n, rows, cols, boxes)
for digit in range(1, n + 1):
if digit not in rows[row] and digit not in cols[col] and digit not in boxes[box_idx]:
# Place
board[row][col] = digit
rows[row].add(digit)
cols[col].add(digit)
boxes[box_idx].add(digit)
if solve_with_sets(board, pos + 1, n, rows, cols, boxes):
return True
# Undo — symmetrical restoration
board[row][col] = 0
rows[row].remove(digit)
cols[col].remove(digit)
boxes[box_idx].remove(digit)
return False
The fast version performs an O(1) membership test per candidate instead of an O(n) scan. For a 9×9 Sudoku, each is_valid call in the slow version does up to 27 comparisons. With the set-based approach, it does exactly 3. Across a full search that may evaluate millions of candidates, this difference is dramatic.
🎯 Key Principle: State that is checked repeatedly should be maintained incrementally. Add to your tracking structures when you place a value; remove from them when you undo. Checking should always be O(1).
⚠️ Common Mistake — Mistake 4: Building the tracking sets fresh at the start of each recursive call by scanning the board. This is even worse than scanning in the constraint check — it repeats O(n²) work on every single recursive invocation.
💡 Real-World Example: This exact optimization is what separates a Sudoku solver that takes 10 seconds from one that takes 10 milliseconds on hard puzzles. The algorithm is the same; the data structure is different.
Connecting the Pitfalls: A Unified Mental Model
These five pitfalls are not independent — they share a common thread. Each one represents a mismatch between your mental model of the algorithm and what the code actually does. You imagine the board being cleanly reset; the code leaves a 5 sitting in cell (2,3). You imagine constraint checks happening early; the code checks at the leaf. You imagine your domain spanning 1 to 9; the code iterates 1 to 8.
The most powerful habit you can build is the symmetric review: after writing any backtracking function, read it line by line and for every state mutation, ask "where is the matching undo?" For every constraint check, ask "is this the earliest possible moment?" For every loop, ask "does this range include all values I intend it to include?"
Backtracking Implementation Health Check:
┌─────────────────────────────────────────────────────┐
│ For each recursive branch: │
│ │
│ 1. [ ] Constraint checked BEFORE recursing │
│ 2. [ ] State mutated BEFORE recursive call │
│ 3. [ ] State RESTORED after recursive call │
│ 4. [ ] Base case captures COMPLETE assignment │
│ 5. [ ] All domain ranges use correct bounds │
│ 6. [ ] Grid accesses guarded by bounds checks │
│ 7. [ ] Constraint checks use O(1) structures │
└─────────────────────────────────────────────────────┘
Carrying this checklist into your next CSP implementation will catch the majority of bugs before they become debugging sessions. The pitfalls described here appear across N-Queens, Sudoku, graph coloring, word search, and every other CSP variant you will encounter — the specific variable names change, but the failure modes do not.
⚠️ Common Mistake — Mistake 5: Treating backtracking bugs as logic errors and spending hours rethinking the algorithm, when in fact the algorithm is correct and the bug is mechanical — a missing remove, a range(n) that should be range(n+1), or a constraint check that lives three levels too deep. Before rethinking your approach, run the symmetric review.
🧠 Mnemonic: PRUNE-PLACE-RECURSE-ERASE — the four verbs of correct backtracking, in order. If your code omits any of them or puts them out of sequence, you have found your bug.
Key Takeaways and Your CSP Problem-Solving Checklist
You have traveled a significant distance in this lesson. You started with a vague intuition that some problems feel harder than others, and you now have a precise vocabulary, a search algorithm, optimization techniques, and a catalog of pitfalls to avoid. Before you move into the specialized problem lessons — N-Queens, Sudoku Solver, and Word Search — this section crystallizes everything into a reusable framework you can carry into any interview or competitive programming challenge.
Think of this section as your field manual. Every concept introduced in the five preceding sections converges here into a single, actionable system.
Recap: The Three-Component CSP Model
Every constraint satisfaction problem, no matter how it is dressed up in a problem statement, is secretly the same animal. Learning to recognize that animal is the single most valuable skill this lesson has tried to build.
Variables are the unknowns you are trying to assign values to. Domains are the finite sets of candidate values each variable may take. Constraints are the rules that legal assignments must not violate. Together, these three components define the entire solution space.
┌─────────────────────────────────────────────────────┐
│ THE CSP TRIPLE │
│ │
│ Variables ──────► What needs a value? │
│ │ │
│ ▼ │
│ Domains ──────► What values are possible? │
│ │ │
│ ▼ │
│ Constraints ─────► What rules must hold? │
│ │
│ A solution is a complete, consistent assignment. │
└─────────────────────────────────────────────────────┘
When you read a new problem, train yourself to ask three diagnostic questions immediately:
- 🧠 "What am I assigning?" — These are your variables.
- 📚 "What are my options at each step?" — These are your domains.
- 🔧 "What would make a partial assignment illegal?" — These are your constraints.
If you can answer all three, you have successfully modeled the problem as a CSP, and the rest is mechanical application of the solving machinery.
💡 Mental Model: Think of CSP modeling like filling out a form. The blank fields are variables, the acceptable entries for each field are domains, and the form's validation rules are constraints. Submitting the form is finding a solution.
🤔 Did you know? The same three-component model used here underpins industrial-strength solvers used in chip design, airline scheduling, and protein folding research. The LeetCode problems you are about to solve use exactly the same mathematical backbone as those systems.
The Four-Step Problem-Solving Approach
Modeling the problem is only half the battle. Once you have your three components, you need a repeatable procedure for translating that model into working code. The four-step approach below is that procedure.
Step 1 — Define Your Variables
Make the list of variables explicit. In N-Queens, one variable per row. In Sudoku, one variable per empty cell. In Word Search, the sequence of grid positions that spell the target word. Write them down before touching any code.
Step 2 — Define Your Domains
For each variable, specify what values are in play. Domains can be static (the digits 1–9 for every Sudoku cell) or dynamic (only positions not yet visited for Word Search). Smaller initial domains mean less work later.
Step 3 — Define Your Constraints
Enumerate the rules. Distinguish between unary constraints (a variable's value restricted independently) and binary constraints (two variables must relate in a specific way). Writing these out explicitly prevents the single most common bug: constraint logic scattered randomly through the code.
Step 4 — Apply Backtracking with Pruning
Now write the search. Assign one variable at a time, check constraints immediately after each assignment, and prune branches that violate any constraint before recursing deeper. This is the engine. The pruning strategies — forward checking, arc consistency, ordered variable and value selection — are the fuel efficiency upgrades bolted onto that engine.
FOUR-STEP PIPELINE
PROBLEM STEP 1 STEP 2 STEP 3 STEP 4
STATEMENT ───► IDENTIFY ────► SPECIFY ─────► ENUMERATE ────► SEARCH
Variables Domains Constraints + Prune
🎯 Key Principle: The four steps are sequential for analysis but interleaved in code. You define variables and domains when you set up data structures, you check constraints inside the recursive function, and you prune by returning early. The mental separation, however, keeps your thinking clear.
Quick-Reference Backtracking Template
The following template is the single most reusable artifact from this entire lesson. Study it until you can reproduce its structure from memory. Every CSP solution you write in the upcoming sub-topic lessons will be a specialization of this skeleton.
def solve_csp(assignment, variables, domains, constraints):
"""
Generic CSP backtracking solver.
- assignment: dict mapping variable -> value for already-assigned variables
- variables: ordered list of all variables
- domains: dict mapping variable -> list of candidate values
- constraints: callable(var, value, assignment) -> bool
Returns True if a complete solution is found, False otherwise.
"""
# BASE CASE: all variables assigned → solution found
if len(assignment) == len(variables):
return True
# VARIABLE SELECTION: pick the next unassigned variable
# (swap in MRV heuristic here for a performance boost)
unassigned = [v for v in variables if v not in assignment]
var = unassigned[0]
# VALUE ITERATION: try each value in the variable's domain
for value in domains[var]:
# CONSTRAINT CHECK: only proceed if the assignment is consistent
if constraints(var, value, assignment):
# ASSIGN: tentatively place the value
assignment[var] = value
# RECURSE: attempt to extend this partial assignment
if solve_csp(assignment, variables, domains, constraints):
return True # solution found deeper in the tree
# BACKTRACK: this value led to a dead end — undo and try next
del assignment[var]
# FAILURE: no value worked for this variable
return False
This template captures the essential rhythm of backtracking: assign → check → recurse → undo. Every LeetCode CSP problem maps onto this rhythm. The differences between problems are entirely contained in how you define variables, domains, and constraints — the template itself never changes.
For problems where the state is a mutable grid rather than a dictionary (Sudoku, Word Search), a small adaptation replaces the assignment dictionary with direct mutation of the board plus an undo step:
def backtrack(board, position_info):
"""
Adapted template for grid-based CSP problems (e.g., Sudoku, Word Search).
Direct board mutation + explicit undo replaces the assignment dictionary.
"""
# BASE CASE: check if the goal condition is met
if goal_reached(board, position_info):
return True
# Select the next cell or position to fill
next_pos = select_next(board, position_info)
for candidate in get_candidates(board, next_pos):
# Check all relevant constraints before placing
if is_consistent(board, next_pos, candidate):
# PLACE: mutate the board
place(board, next_pos, candidate)
# RECURSE
if backtrack(board, advance(position_info)):
return True
# UNDO: restore the board to its previous state
undo(board, next_pos)
return False
💡 Pro Tip: The undo step is where developers most frequently introduce bugs. If your board-based solution produces wrong answers intermittently, the first thing to audit is whether every mutation in place has a corresponding reversal in undo. They must be exact inverses.
⚠️ Common Mistake — Mistake 1: Forgetting to implement a deep copy or undo when the state is a mutable object. If you pass a list to a recursive call and modify it without undoing, sibling branches of the search tree see corrupted state. Either undo explicitly or pass immutable snapshots.
How the Upcoming Lessons Specialize This Framework
The three sub-topic lessons waiting for you — N-Queens, Sudoku Solver, and Word Search — are not new algorithms. They are each a specific instantiation of the general framework you now hold. Understanding their relationship to the template before you encounter them will make the code in those lessons feel familiar rather than novel.
GENERAL CSP FRAMEWORK
│
┌─────┴──────┬────────────────┐
▼ ▼ ▼
N-Queens Sudoku Solver Word Search
N-Queens
- Variables: One per row (which column does the queen in this row occupy?)
- Domains: Columns 0 through N-1
- Constraints: No two queens share a column, row, or diagonal
- Specialization: The constraint function checks column, and both diagonal directions via absolute-difference arithmetic
- Pruning opportunity: Tracking used columns and diagonals in sets reduces each constraint check from O(N) to O(1)
Sudoku Solver
- Variables: Each empty cell in the 9×9 grid
- Domains: Digits 1–9, reduced per cell by row/column/box occupancy
- Constraints: Each digit appears exactly once per row, column, and 3×3 box
- Specialization: The
is_consistentfunction checks three overlapping constraint groups simultaneously - Pruning opportunity: Pre-computing which digits are already used in each row, column, and box dramatically shrinks domains before recursion begins
Word Search
- Variables: The sequence of grid positions that spell the word
- Domains: The four (or eight) neighboring cells of the current position
- Constraints: The cell's character must match the next character in the target word, and the cell must not have been visited in the current path
- Specialization: The visited-cell constraint requires tracking a path set that is added to on descent and removed from on ascent
- Pruning opportunity: Immediately returning
Falsewhen the current cell's character does not match eliminates entire subtrees before any recursion occurs
📋 Quick Reference Card: Framework Specializations
| 🔒 Problem | 📦 Variables | 🎯 Domains | ⚙️ Key Constraint | ✂️ Main Pruning |
|---|---|---|---|---|
| 🏰 N-Queens | Row positions | Column indices | No diagonal conflicts | Set-based O(1) checks |
| 🔢 Sudoku | Empty cells | Digits 1–9 | Row/col/box uniqueness | Pre-compute used sets |
| 🔤 Word Search | Path positions | Adjacent cells | Char match + no revisit | Char mismatch early exit |
🎯 Key Principle: When you encounter any new CSP-style LeetCode problem beyond these three, your first move is to fill in this same table for that problem. If you can complete all five columns, you can write the solution.
What You Now Understand That You Didn't Before
It is worth being explicit about the conceptual distance you have covered. Before this lesson, you likely approached combinatorial problems one of two ways: brute force every combination, or hope that a clever trick occurred to you. Both approaches are fragile under interview pressure.
You now have a principled alternative:
- 🧠 You can recognize when a problem is a CSP by identifying its three components
- 📚 You can model any CSP systematically before writing a single line of code
- 🔧 You can implement backtracking from a reusable template rather than reinventing it each time
- 🎯 You can optimize that backtracking with pruning techniques grounded in formal reasoning
- 🔒 You can debug CSP code by checking a specific list of known failure modes
This is the difference between treating each problem as a unique puzzle and treating it as a member of a well-understood family. The former requires inspiration; the latter requires application.
❌ Wrong thinking: "I need to memorize the solution to N-Queens, then the solution to Sudoku, then the solution to Word Search."
✅ Correct thinking: "I need to understand the CSP framework deeply enough that N-Queens, Sudoku, and Word Search each take about ten minutes to model and twenty minutes to implement."
🧠 Mnemonic — VDCB: Variables, Domains, Constraints, Backtrack. Say it before you open a new problem. It is your checklist in four letters.
Summary Comparison Table
| 📘 Concept | 💡 Core Idea | ⚡ Why It Matters |
|---|---|---|
| 🔒 Variables | What needs a value | Defines the search structure |
| 📦 Domains | Legal candidate values per variable | Bounds the branching factor |
| ⚙️ Constraints | Rules consistent assignments must satisfy | The pruning entry point |
| 🔁 Backtracking | Recursive assign-check-undo search | Explores all solutions without redundancy |
| ✂️ Pruning | Eliminating branches before full expansion | Transforms exponential into tractable |
| 📏 MRV Heuristic | Assign the most constrained variable first | Detects failures early |
| 🎯 Forward Checking | Update peer domains after each assignment | Catches future failures immediately |
⚠️ Critical Point to Remember: Backtracking without constraint checking is just depth-first search over all permutations — it has no advantage over brute force. The constraint check inside the loop, executed before recursing, is what makes backtracking efficient. Never omit it.
⚠️ Critical Point to Remember: The undo step is not optional. Mutable state that is not restored between branches corrupts the entire search tree silently. If your solution produces wrong answers on some test cases but not others, a missing or incorrect undo is the most likely culprit.
⚠️ Critical Point to Remember: Over-constraining — writing a constraint function that rejects valid assignments — is as harmful as under-constraining. Both produce wrong answers. Test your constraint function in isolation before integrating it into the backtracker.
Your CSP Problem-Solving Checklist
Print this out, save it to your notes, or commit it to memory. Before writing any code on a new CSP problem, run through every item.
Phase 1: Modeling (before any code)
- 🧠 I have identified what the variables are
- 📚 I have specified the domain for each variable
- 🔧 I have listed every constraint that must hold
- 🎯 I have determined whether constraints are unary, binary, or higher-order
- 🔒 I have estimated the size of the brute-force search space to confirm pruning is necessary
Phase 2: Implementation
- 🧠 My backtracking function has a clear base case that returns
Trueon a complete solution - 📚 The constraint check occurs before the recursive call, not after
- 🔧 Every mutation to mutable state has a corresponding undo on all code paths after the recursive call
- 🎯 I am not copying the entire state on every recursive call (unless domain size demands it)
- 🔒 I have applied at least one pruning strategy beyond basic constraint checking
Phase 3: Verification
- 🧠 I have traced the algorithm manually on a small input
- 📚 I have tested the constraint function independently on at least one valid and one invalid case
- 🔧 I have confirmed the base case triggers correctly
- 🎯 I have checked that the return value propagates correctly up the call stack
- 🔒 I have considered whether the problem asks for one solution, all solutions, or a count
💡 Pro Tip: The most common interview mistake is jumping to Phase 2 without completing Phase 1. Spending three minutes modeling on paper before typing consistently produces cleaner, faster implementations than starting to code immediately.
Self-Assessment: Are You Ready for the Sub-Topic Lessons?
Answer the following questions. If any answer is uncertain, the linked section number tells you where to review.
Question 1: A problem asks you to assign colors to nodes in a graph so that no two adjacent nodes share a color. What are the variables, domains, and constraints?
Expected answer direction: Variables = graph nodes, Domains = available colors, Constraints = adjacent nodes must differ in color. If this took more than 20 seconds, revisit Section 2.
Question 2: In the generic backtracking template, where exactly does the constraint check belong relative to the recursive call, and why?
Expected answer direction: Before the recursive call, inside the value-iteration loop. This prunes the subtree rooted at an inconsistent assignment before any work is done on it. If uncertain, revisit Section 3.
Question 3: You have a partially implemented Sudoku solver that produces the correct answer on easy puzzles but times out on hard ones. Which two optimizations would you apply first?
Expected answer direction: (1) Pre-compute used-value sets for rows, columns, and boxes to make constraint checks O(1), and (2) use the MRV heuristic to select the cell with fewest remaining candidates next. If uncertain, revisit Section 4.
Question 4: Your Word Search solution finds no path even when one exists. What is the most likely bug?
Expected answer direction: The visited set is not being un-marked during backtracking, so valid positions are being excluded on subsequent paths. If uncertain, revisit Section 5.
Question 5: What does VDCB stand for, and in what order do you apply the four steps?
Expected answer direction: Variables, Domains, Constraints, Backtrack — applied in that sequential order for analysis. This should be instantaneous; if not, re-read this section.
🤔 Did you know? Research on expert programmers shows that the highest-performing developers spend proportionally more time on problem modeling and less time on actual coding than novice developers. The checklist above is a formalization of that expert behavior.
Practical Next Steps
With this foundation firmly in place, here is how to make the most of the three sub-topic lessons that follow:
Before opening each sub-topic lesson, take five minutes to apply the four-step approach and the VDCB checklist to that problem on your own. Write down your model before reading the lesson's solution. Comparing your model to the lesson's model is where the deepest learning occurs.
After implementing each solution, go back to the generic backtracking template and explicitly identify which lines of your solution correspond to which lines of the template. This reinforces the framework rather than treating each solution as a standalone artifact.
After completing all three sub-topic lessons, return to this checklist and attempt one additional CSP problem of your choice — Combination Sum, Permutations, or Letter Combinations of a Phone Number are excellent candidates. Apply the full checklist independently. That exercise will confirm that you have internalized a transferable framework, not just memorized three specific solutions.
💡 Real-World Example: The same backtracking framework you are building here is used in compiler register allocation, where the variables are program values, the domains are machine registers, and the constraints encode which values are live simultaneously. Every time a compiler successfully generates machine code for a complex function, it is running a specialized version of exactly the template you studied in this lesson.
You are ready. The three specialized problems ahead are waiting, and you now have the lens to see all three as variations of a single, elegant idea. Trust the framework, apply the checklist, and the solutions will follow.