Part 4 · Algorithms · Graded project

Project 4: Puzzle Solver Suite

Build the solver engine behind a puzzle game: backtracking for sudoku, breadth-first search for word ladders, A* for weighted mazes and the 8-puzzle, and dynamic programming for packing and scheduling. Every solver is checked for correctness and optimality, not just "it returned something".

⏱ ~8–12 hoursPrerequisites: M31, M32, M33, M34

The scenario

Brainteaser Studio ships a mobile puzzle app with daily sudoku, word ladders, mazes, and sliding-tile puzzles. Their level designers have three complaints. The sudoku generator sometimes publishes boards with two solutions, and players send angry emails. The maze editor lets designers paint "mud" tiles that cost more to cross, but the hint system ignores the cost and suggests slow routes. And the 8-puzzle's hint button occasionally sends players around in circles, because nobody ever checked that its suggestions lie on a shortest solution.

You have been hired to build puzzles, a library of solvers the studio can trust, and a benchmark tool so they can check that the solvers stay fast as levels grow. The studio's acceptance bar is strict: every shortest-path answer must be provably shortest, every optimization answer must match an exhaustive search on small inputs, and unsolvable puzzles must be reported as unsolvable rather than hanging.

Learning goals and feature spec

1. puzzles.sudoku

parse_grid accepts an 81-character line or a pretty grid (./0 for blanks, ignores whitespace and |-+), raising SudokuError (a ValueError) on bad input. is_valid, is_solved, candidates, solve (never mutates its input; None if unsolvable), and count_solutions(grid, limit=2) for uniqueness checks.

2. puzzles.word_ladder

neighbors(word, words) and ladder(start, end, words, *, bidirectional=False) returning the shortest ladder (both ends included), [start] when they are equal, or None. Case-insensitive, lowercase output; start need not be in the dictionary.

3. puzzles.maze

parse_maze builds a frozen Maze (rows, start, goal), padding ragged rows with walls and raising MazeError unless there is exactly one S and one G. Cells: # wall, ./space cost 1, digits cost their value. solve_maze returns (path, cost) or None; render_path draws *.

4. puzzles.sliding

Boards are 9-tuples with 0 as the blank and goal (1, …, 8, 0). make_board, inversions, is_solvable, manhattan, apply_move/apply_moves (letters UDLR move the blank), and solve returning an optimal move string or None.

5. puzzles.optimize

knapsack(items, capacity) with (weight, value) items returns (best_value, sorted_indexes). weighted_interval_scheduling(intervals) with half-open (start, end, weight) intervals returns (best_weight, sorted_indexes).

6. puzzles.bench

benchmarks() (provided), time_call(func, repeat) returning the best time in seconds, and main(argv) with --repeat and repeatable --only, printing a solver header and one name ms row per solver.

Architecture

Each module is independent; the benchmark CLI is the only module that imports the others. Three solvers share one idea, best-first or breadth-first search over states, but each picks the right frontier for its cost model.

flowchart TB
  CLI["bench.main()
argparse + perf_counter"] --> S[sudoku] CLI --> W[word_ladder] CLI --> M[maze] CLI --> P[sliding] CLI --> O[optimize] S --> BT["backtracking + naked singles + MRV"] W --> BFS["BFS / bidirectional BFS
collections.deque"] M --> AS["A* with Manhattan heuristic
heapq"] P --> AS P --> INV["inversion parity invariant"] O --> DP["DP tables + traceback
bisect for p(j)"]

Which frontier when?

Cost modelFrontierOptimal?
every move costs 1FIFO queue (BFS)yes
non-negative weightsmin-heap on g (Dijkstra)yes
weights + admissible hmin-heap on g + h (A*)yes
"any solution" over choicescall stack (DFS/backtracking)n/a

Try the search family

Draw walls and compare BFS, Dijkstra, greedy best-first, and A*. Notice that greedy is fast but can return a longer path, while A* expands fewer cells than Dijkstra and stays optimal.

Getting started with Pixi

pixi run projects            # list every project
pixi run start p04           # copy the starter into work/p04_puzzle_solver/
pixi run grade p04           # run all 64 grading tests against your work/ copy
pixi run grade p04 -- -k sudoku     # grade a single milestone
cd work/p04_puzzle_solver && pixi run python -m puzzles.bench --repeat 5

The starter contains every public function with its signature, type hints, Google-style docstring, and TODO comments; each body is raise NotImplementedError. You may add private helpers (names starting with _) wherever you like, but keep the public signatures exactly as given because the tests call them.

Milestones

Milestone 1: Sudoku (backtracking with propagation)

Represent the board as nine lists of nine ints. A cell's peers are the 20 other cells in its row, column, and 3×3 box; write a helper that yields them, and both is_valid and candidates become one-liners. The solver is a depth-first search over choices (M33): pick an empty cell, try each candidate, recurse, and undo on failure. Two techniques make it fast:

  • Constraint propagation (naked singles): repeatedly fill any cell with exactly one candidate. If some empty cell has zero candidates, this branch is dead: backtrack immediately.
  • MRV (minimum remaining values): branch on the cell with the fewest candidates. A cell with two options splits the search in two; a cell with nine splits it nine ways.

count_solutions(grid, limit) reuses the same search but keeps going after the first solution, stopping at limit. Uniqueness is simply count_solutions(grid, 2) == 1: you never need to enumerate all solutions of an empty board (there are about 6.67 × 1021).

The same place-check-undo loop drives N-queens; step through it to watch pruning cut whole subtrees:

  • parse_grid handles both formats and raises SudokuError on bad input
  • is_valid, is_solved, and candidates pass
  • solve solves the classic and hard puzzles without mutating the input
  • Unsolvable and invalid boards return None
  • count_solutions respects limit and detects a two-solution board

Milestone 2: Word ladder (BFS and bidirectional BFS)

Words are vertices; an edge joins two words that differ in one letter. Every edge costs 1, so BFS finds a shortest ladder: it explores all ladders of length k before any of length k + 1. Record a parents dict when you first discover each word and walk it backwards from end to rebuild the path. Generating neighbors by trying all 26 letters in every position costs O(26·L) set lookups per word, far cheaper than comparing against the whole dictionary.

Bidirectional BFS grows one frontier from each end, always expanding the smaller one a full layer at a time, and stops when they touch. If a single search explores bd words, two half-depth searches explore about 2·bd/2: on a real dictionary that is the difference between seconds and milliseconds.

  • neighbors returns sorted one-letter variants that are in the dictionary
  • BFS finds hit → … → cog in 5 words
  • Bidirectional BFS returns ladders of the same (optimal) length
  • Missing end words, disconnected words, equal words, and length mismatches are handled

Milestone 3: Weighted mazes (A*)

Entering a cell costs 1, or the digit's value for "mud" cells. With unequal costs BFS is no longer optimal, so use A*: a min-heap ordered by f = g + h, where g is the cost so far and h is the Manhattan distance to the goal. Because every step costs at least 1, Manhattan distance never overestimates the true remaining cost: it is admissible, and with an admissible (and here also consistent) heuristic the first time A* pops the goal its cost is optimal.

frontier = [(manhattan(start, goal), 0, start)]   # (f, g, cell)
while frontier:
    f, g, cell = heapq.heappop(frontier)
    if cell == goal: ...                         # rebuild the path from parents
    if g > best[cell]: continue                  # stale entry: skip it (lazy deletion)
    for nxt, step in maze.neighbors(cell): ...   # relax edges, push (g2 + h, g2, nxt)
  • parse_maze pads ragged rows and rejects bad mazes
  • Maze.cost and Maze.neighbors handle walls, digits, and the board edge
  • Unit-cost mazes match BFS; weighted mazes take the cheaper detour
  • Unreachable goals return None; render_path draws the route

Milestone 4: The 8-puzzle (invariants and A*)

Half of all 9! tile arrangements cannot be solved, and a naive search would explore all 181,440 reachable states before giving up. An invariant settles it instantly. Read the tiles row by row, ignoring the blank, and count inversions (pairs in the wrong order). A horizontal move does not change the reading order; a vertical move jumps one tile over two others, changing the count by −2, 0, or +2. So the parity of the inversion count never changes, and since the goal has 0 inversions, only boards with an even count are solvable.

For solvable boards, run A* where each state is a 9-tuple (hashable, so it can key a dict) and h is the sum of each tile's Manhattan distance to its home. Every move shifts one tile by one square, so h drops by at most 1 per move: admissible. The board (8, 6, 7, 2, 5, 4, 3, 0, 1) is one of the hardest: 31 moves.

  • make_board validates permutations; inversions and is_solvable pass
  • manhattan, apply_move, and apply_moves pass
  • solve returns "" for the goal and None for unsolvable boards
  • The hardest board solves in exactly 31 moves; random scrambles match BFS

Milestone 5: Knapsack and weighted interval scheduling (DP)

For 0/1 knapsack, best[i][w] is the best value using the first i items with capacity w: either skip item i (best[i-1][w]) or take it (best[i-1][w - weight] + value). Keep the full table so you can walk backwards: whenever best[i][w] != best[i-1][w], item i was taken.

For weighted interval scheduling, sort by end time. Let p(j) be the number of intervals that end no later than interval j starts; because the end times are sorted, bisect_right finds it in O(log n). Then best[j] = max(best[j-1], weight_j + best[p(j)]) and the whole thing runs in O(n log n). Greedy by earliest end time is optimal only when every weight is equal (M33); weights break it.

  • Knapsack returns the textbook answer and consistent item indexes
  • Knapsack matches brute force on 25 random instances
  • Interval scheduling treats touching intervals as compatible and prefers one heavy interval
  • Interval scheduling matches brute force on 25 random instances

Milestone 6: Benchmark CLI

time_call measures with time.perf_counter() (monotonic, high resolution) and reports the best of several runs, because the minimum is the run least disturbed by other processes. main uses argparse and returns an exit code instead of calling sys.exit, which keeps it testable.

solver                best ms
sudoku                  0.295
word_ladder             0.031
word_ladder_bidir       0.023
maze                    0.029
sliding                50.603
knapsack                0.019
intervals               0.009
  • time_call returns a non-negative float
  • main prints a header plus one row per solver and returns 0
  • --only filters the rows

Rubric

CriterionWeightExcellentNeeds work
Correctness40%All 64 tests pass, including optimality checks against BFS and brute forceSolvers return answers that are valid but not optimal, or crash on edge cases
Algorithm choice20%BFS for unit costs, A* with an admissible heuristic for weights, DP with reconstruction, propagation + MRV in sudokuExhaustive search where a better technique applies; sudoku takes seconds
Edge cases15%Unsolvable boards, unreachable goals, empty inputs, and invalid input raise or return as specifiedInfinite loops or generic exceptions on bad input
Code quality15%Typed, documented, ruff-clean; small helpers; no mutation of inputsLong functions, duplicated logic, missing type hints
Analysis10%You can state each solver's time and space complexity and justify optimalityComplexity or optimality claims are missing or wrong
pixi run grade p04
pixi run ruff check work/p04_puzzle_solver
pixi run ruff format --check work/p04_puzzle_solver
pixi run mypy --strict work/p04_puzzle_solver/puzzles

Hints

Sudoku: my solver is slow on the hard puzzle

Check that you (1) propagate naked singles before branching, (2) branch on the cell with the fewest candidates, and (3) return as soon as a cell has zero candidates. Copy the grid per branch ([row[:] for row in grid]) so propagation in one branch cannot leak into another.

Sudoku: how do I build a two-solution board for testing?

Take a solved grid and find four cells forming a rectangle that read x y / y x and lie in exactly two boxes. Blank them: both orientations satisfy every row, column, and box, so the board has two solutions.

Word ladder: my bidirectional ladder is one step too long

Expand a whole layer at a time and check for a meeting the moment you discover a word that the other side has already seen. When joining, the meeting word must appear once: take the front path to the meeting word, then the back path reversed, without its first element.

A*: I get a non-optimal path

Only stop when the goal is popped from the heap, not when it is first pushed. Pushing records a candidate cost; popping proves it is the smallest. Also skip stale entries whose g is larger than the best known cost for that cell.

8-puzzle: heapq complains about comparing tuples

If two entries tie on f and g, Python compares the boards next, which works for tuples but makes the order arbitrary. Add an increasing counter as a tie-breaker: (f, g, counter, board).

Interval scheduling: reconstructing the choice

Walk j from n down. If best[j] != best[j-1], interval j was taken, so record it and jump to p(j); otherwise move to j − 1.

Stretch goals

Reflection quiz

Next up: Part 5 · M36 Metaprogramming.