Lab: Sudoku CSP Solver

Unit 4: Optimization and Constraint Satisfaction — Sudoku CSP Solver Lab (125 points)

Lab Overview

In this lab, you will build a complete Sudoku solver from scratch using constraint satisfaction techniques. Starting from an empty SudokuCSP class, you will implement the full pipeline: CSP formulation, basic backtracking, the MRV heuristic, and AC-3 arc consistency. By combining all four components, your solver will handle Easy, Medium, and Hard puzzles efficiently.

Before starting, review:

Learning Objectives

By completing this lab, you will be able to:

  1. Represent Sudoku as a formal CSP with variables, domains, and constraints

  2. Implement backtracking search for constraint satisfaction

  3. Apply the Minimum Remaining Values (MRV) heuristic for smarter variable selection

  4. Implement the AC-3 algorithm for enforcing arc consistency

  5. Combine all techniques into an efficient, working Sudoku solver

Assignment Overview

Part Focus Points

Part 1

CSP Formulation

20

Part 2

Basic Backtracking

25

Part 3

MRV Heuristic

30

Part 4

AC-3 Algorithm

30

Part 5

Complete Solver

20

Total

125

Setup: Open the Notebook

Download the starter notebook:

Open in Google Colab:

  1. Go to colab.research.google.com

  2. Click File → Upload notebook

  3. Upload the downloaded .ipynb file

  4. Begin coding in the TODO sections

Part 1: CSP Formulation (20 points)

The SudokuCSP class needs three foundational methods that establish the problem’s variables, domains, and constraints.

The Sudoku CSP

A 9×9 Sudoku puzzle is formulated as:

  • Variables: 81 cells, each indexed by (row, col) where row, col ∈ {0, …​, 8}

  • Domains: {1, 2, 3, 4, 5, 6, 7, 8, 9} for empty cells; {given_digit} for pre-filled cells

  • Constraints: All-different for each row (9), each column (9), and each 3×3 box (9) — 27 constraints total

TODO #1: Initialize Domains (5 points)

def _initialize_domains(self):
    """
    Set up initial domains for all cells.

    For empty cells (board value == 0): domain = {1, 2, 3, 4, 5, 6, 7, 8, 9}
    For filled cells (board value != 0): domain = {board_value}

    Store domains in self.domains as a dict: {(row, col): set_of_values}

    TODO: Implement using nested loops over range(self.size)
    """
    pass

TODO #2: Get Neighbors (10 points)

def get_neighbors(self, cell):
    """
    Return the set of all cells that share a constraint with `cell`.

    Neighbors include all cells in:
    - The same row
    - The same column
    - The same 3×3 box

    Parameters:
        cell (tuple): (row, col)

    Returns:
        set: All (row, col) tuples that constrain this cell (excluding cell itself)

    TODO: Implement using three loops (row, column, box)
    """
    pass

Finding the 3×3 box:

For a cell at (row, col), the top-left corner of its 3×3 box is at:

  • box_row = (row // 3) * 3

  • box_col = (col // 3) * 3

Then iterate i from box_row to box_row + 3, and j from box_col to box_col + 3.

Each cell has exactly 20 neighbors: 8 in the same row + 8 in the same column + 8 in the same box, minus the 4 cells counted twice (the intersection of row, column, and box).

TODO #3: Check Consistency (5 points)

def is_consistent(self, cell, value):
    """
    Return True if assigning `value` to `cell` is consistent with all
    currently assigned neighbors.

    A value is consistent if no neighbor with a fixed domain of size 1
    already has the same value.

    Parameters:
        cell (tuple): (row, col) to potentially assign
        value (int): Value to check

    Returns:
        bool: True if consistent, False if conflict detected

    TODO: Iterate over neighbors; check if any neighbor is assigned
          the same value
    """
    pass

Part 2: Basic Backtracking (25 points)

TODO #4: Select Unassigned Variable (Basic) (5 points)

def select_unassigned_variable_basic(self):
    """
    Return the first unassigned cell (domain size > 1).
    Scan cells in row-major order: (0,0), (0,1), ..., (8,8).

    Returns:
        tuple: (row, col) of first unassigned cell, or None if all assigned

    TODO: Scan all cells and return the first with domain size > 1
    """
    pass

TODO #5: Backtracking Search (20 points)

def backtracking_search(self):
    """
    Main backtracking search entry point.
    Uses select_unassigned_variable_basic() and is_consistent().

    Returns:
        bool: True if solution found, False if no solution exists

    When a value is assigned:
    - Set self.domains[cell] = {value}
    - Recurse
    - If recursion fails, restore the original domain (backtrack)

    TODO: Implement recursive backtracking
    """
    return self._backtrack_basic()

def _backtrack_basic(self):
    """Recursive backtracking helper."""
    pass

Backtracking pattern:

  1. Select an unassigned variable. If none, return True (complete assignment).

  2. For each value v in the variable’s domain:

    1. If is_consistent(cell, v): assign self.domains[cell] = {v}

    2. Recurse; if successful, return True

    3. Otherwise, restore the original domain (undo the assignment)

  3. Return False if no value worked

Part 3: MRV Heuristic (30 points)

TODO #6: MRV Variable Selection (30 points)

def select_unassigned_variable_mrv(self):
    """
    Return the unassigned cell with the fewest legal values (MRV heuristic).
    Break ties by choosing the cell with the most neighbors that are
    unassigned (degree heuristic).

    Returns:
        tuple: (row, col) of the most constrained unassigned cell,
               or None if all cells are assigned.

    TODO: Find the minimum domain size among unassigned cells,
          then apply degree heuristic tiebreaking.
    """
    pass

Degree heuristic tiebreaking:

When multiple cells tie for smallest domain, count how many of each cell’s neighbors are still unassigned (domain size > 1). Choose the cell with the highest count — it constrains more future assignments.

You can implement this in two passes: 1. Find min_size = minimum domain size among unassigned cells 2. Among all cells with domain_size == min_size, return the one with the most unassigned neighbors

Once select_unassigned_variable_mrv() is implemented, add a backtracking_search_mrv() method that is identical to your basic backtracking but calls select_unassigned_variable_mrv() instead of select_unassigned_variable_basic().

Part 4: AC-3 Algorithm (30 points)

TODO #7: REVISE Function (15 points)

def revise(self, xi, xj):
    """
    Make arc (xi → xj) arc-consistent.

    Remove from xi's domain any value that has no support in xj's domain.
    A value `x` in xi's domain has "support" if there exists some `y` in xj's
    domain such that x != y (the all-different constraint).

    Parameters:
        xi (tuple): (row, col) of the variable whose domain may be reduced
        xj (tuple): (row, col) of the neighbor variable

    Returns:
        bool: True if any value was removed from xi's domain, False otherwise

    TODO: For each value in xi's domain, check if it has any support in xj's
          domain. Remove values with no support.
    """
    pass

All-different constraint support:

For Sudoku’s all-different constraints, value x from xi has support in xj if xj’s domain contains at least one value y where x != y.

In other words: is there any value in xj’s domain that is different from x?

If xj’s domain = {5} and x = 5, then x has no support — remove x from xi’s domain.

If xj’s domain = {3, 7} and x = 5, then x has support (both 3 and 7 differ from 5) — keep x.

TODO #8: AC-3 Algorithm (15 points)

def ac3(self):
    """
    Enforce arc consistency across the entire CSP using the AC-3 algorithm.

    Initialize the queue with all arcs (xi, xj) for every constrained pair.
    Process arcs: if REVISE reduces xi's domain, add all arcs (xk, xi) back.

    Returns:
        bool: True if arc-consistent (no empty domains), False if contradiction

    TODO: Implement using a deque (collections.deque).
          Initialize queue with all (xi, xj) pairs where xi and xj are neighbors.
    """
    pass

Starting the queue:

from collections import deque
queue = deque()
for cell in all_cells:
    for neighbor in self.get_neighbors(cell):
        queue.append((cell, neighbor))

Then process: dequeue (xi, xj), call revise(xi, xj), and if revised, add all (xk, xi) back for each neighbor xk of xi (except xj).

Part 5: Complete Solver (20 points)

TODO #9: Integrated Solver (20 points)

def solve_sudoku(self):
    """
    Solve the Sudoku puzzle using the full pipeline:

    1. Run AC-3 as preprocessing (reduce domains before search)
    2. If AC-3 returns False, the puzzle has no solution
    3. If all domains have size 1 after AC-3, puzzle is solved
    4. Otherwise, run backtracking with MRV heuristic

    Returns:
        bool: True if solved successfully, False if no solution exists

    TODO: Implement the two-phase solver
    """
    pass

Expected Performance

Puzzle Difficulty Maximum Time Maximum Backtracks

Easy

< 0.01 seconds

< 10

Medium

< 0.1 seconds

< 100

Hard

< 1 second

< 1,000

Run validate_solution() after solving to verify all constraints are satisfied.

Grading Rubric

Component Function Points

CSP Formulation

_initialize_domains()

5

CSP Formulation

get_neighbors()

10

CSP Formulation

is_consistent()

5

Backtracking

select_unassigned_variable_basic()

5

Backtracking

backtracking_search()

20

MRV Heuristic

select_unassigned_variable_mrv()

30

AC-3

revise()

15

AC-3

ac3()

15

Complete Solver

solve_sudoku()

20

Total

125

Submission Instructions

Submitting Your Lab:

  1. Complete all TODO sections in the notebook

  2. Test your solver on all three puzzles (Easy, Medium, Hard)

  3. Verify solutions with validate_solution() — all three should return True

  4. Ensure timing meets the performance targets in the table above

  5. Download: File → Download → Download .ipynb

  6. Submit to Brightspace under Week 4: Sudoku CSP Solver Lab

Optional: Review the complete implementation with detailed explanations in the CSP Solver Walkthrough.


Lab code adapted from aima-python, MIT License, Copyright 2016 aima-python contributors.

This work is licensed under CC BY-SA 4.0.