CSP Solver Walkthrough
Unit 4: Optimization and Constraint Satisfaction — CSP Solver Walkthrough
|
This walkthrough is an optional companion to the Sudoku CSP Solver Lab. Review it after completing and submitting your own solution. |
Overview
This walkthrough presents complete implementations of all lab components, with explanations of the design choices and the combinatorial effects that make each addition dramatically more efficient.
The complete solver integrates three powerful techniques:
| Technique | What It Does | Impact |
|---|---|---|
AC-3 |
Propagates constraints to reduce domains |
Often solves easy puzzles without any search |
MRV |
Chooses the most constrained variable first |
~100x speedup on hard puzzles |
Backtracking |
Systematic search with early failure detection |
Completeness guarantee |
Part 1: CSP Formulation
Initialize Domains
def _initialize_domains(self):
"""Set up initial domains for all cells."""
for i in range(self.size):
for j in range(self.size):
if self.board[i][j] == 0:
self.domains[(i, j)] = {1, 2, 3, 4, 5, 6, 7, 8, 9}
else:
self.domains[(i, j)] = {self.board[i][j]}
Get Neighbors
def get_neighbors(self, cell):
"""Return all cells constrained with cell."""
row, col = cell
neighbors = set()
# Same row
for j in range(self.size):
if j != col:
neighbors.add((row, j))
# Same column
for i in range(self.size):
if i != row:
neighbors.add((i, col))
# Same 3×3 box
box_row = (row // 3) * 3
box_col = (col // 3) * 3
for i in range(box_row, box_row + 3):
for j in range(box_col, box_col + 3):
if (i, j) != cell:
neighbors.add((i, j))
return neighbors # exactly 20 cells for every interior cell
Part 2: Backtracking Search
def backtracking_search(self):
return self._backtrack_basic()
def _backtrack_basic(self):
# Find an unassigned variable
cell = self.select_unassigned_variable_basic()
if cell is None:
return True # all assigned -- solution found
original_domain = self.domains[cell].copy()
for value in original_domain:
if self.is_consistent(cell, value):
self.domains[cell] = {value} # assign
self.backtracks_basic += 1
if self._backtrack_basic():
return True
self.domains[cell] = original_domain.copy() # undo
return False # no valid value found
Part 3: MRV Variable Selection
def select_unassigned_variable_mrv(self):
"""Choose unassigned variable with smallest domain (MRV + degree tiebreaking)."""
unassigned = [(cell, len(domain))
for cell, domain in self.domains.items()
if len(domain) > 1]
if not unassigned:
return None
# Find minimum domain size
min_size = min(size for _, size in unassigned)
# Among tied cells, use degree heuristic (most unassigned neighbors)
candidates = [cell for cell, size in unassigned if size == min_size]
if len(candidates) == 1:
return candidates[0]
# Degree heuristic: count unassigned neighbors
def degree(cell):
return sum(1 for n in self.get_neighbors(cell)
if len(self.domains[n]) > 1)
return max(candidates, key=degree)
Part 4: AC-3 Algorithm
REVISE
def revise(self, xi, xj):
"""Make arc (xi → xj) consistent. Remove unsupported values from xi."""
revised = False
for x in list(self.domains[xi]): # copy to allow safe removal
# For all-different: x has support if xj has any value != x
if not any(y != x for y in self.domains[xj]):
self.domains[xi].remove(x)
revised = True
return revised
AC-3
def ac3(self):
"""Enforce arc consistency using AC-3."""
from collections import deque
# Initialize queue with all arcs
queue = deque()
for cell in self.domains:
for neighbor in self.get_neighbors(cell):
queue.append((cell, neighbor))
while queue:
xi, xj = queue.popleft()
if self.revise(xi, xj):
if len(self.domains[xi]) == 0:
return False # empty domain: contradiction
# xi's domain shrank -- re-check all arcs pointing to xi
for xk in self.get_neighbors(xi):
if xk != xj:
queue.append((xk, xi))
return True
Part 5: Complete Solver
def solve_sudoku(self):
"""Integrated solver: AC-3 preprocessing + MRV backtracking."""
# Phase 1: AC-3 preprocessing
if not self.ac3():
return False # contradiction detected
# Check if AC-3 solved everything
if all(len(d) == 1 for d in self.domains.values()):
return True # solved without search
# Phase 2: Backtracking with MRV
return self._backtrack_mrv()
def _backtrack_mrv(self):
cell = self.select_unassigned_variable_mrv()
if cell is None:
return True
original_domain = self.domains[cell].copy()
# Save entire domain state for rollback (AC-3 modifies many domains)
saved_domains = {c: d.copy() for c, d in self.domains.items()}
for value in sorted(original_domain):
if self.is_consistent(cell, value):
self.domains[cell] = {value}
self.backtracks += 1
# Run AC-3 after each assignment (MAC algorithm)
if self.ac3():
result = self._backtrack_mrv()
if result:
return True
# Restore full domain state (AC-3 may have modified many domains)
for c, d in saved_domains.items():
self.domains[c] = d.copy()
return False
Performance Results
| Puzzle | Technique | Time | Backtracks |
|---|---|---|---|
Easy |
AC-3 only (no search needed) |
< 0.001s |
0 |
Medium |
AC-3 + MRV backtracking |
< 0.05s |
~8 |
Hard |
AC-3 + MRV backtracking |
< 0.3s |
~47 |
The dramatic performance comes from the combination of techniques:
-
AC-3 alone reduces domains so much that easy puzzles require no search at all
-
MRV ensures that when search is needed, the hardest variables are tackled first
-
Together, they collapse the 9^81 ≈ 2 × 10^77 naive state space into tens of backtracks
This is the power of the CSP framework: general-purpose algorithms that exploit problem structure automatically.
Lab code adapted from aima-python, MIT License, Copyright 2016 aima-python contributors.
This work is licensed under CC BY-SA 4.0.