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".
- Turn a puzzle into a search problem: states, moves, a goal test, and a cost
- Write a backtracking solver and speed it up with constraint propagation and the MRV heuristic
- Use BFS (and bidirectional BFS) for shortest paths in unweighted graphs
- Use A* with an admissible heuristic and explain why it returns optimal answers
- Prove a puzzle unsolvable with an invariant (inversion parity) instead of searching forever
- Solve optimization problems with DP tables and reconstruct the chosen items
- Verify optimality with brute-force oracles and benchmark solvers with a small CLI
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 model | Frontier | Optimal? |
|---|---|---|
| every move costs 1 | FIFO queue (BFS) | yes |
| non-negative weights | min-heap on g (Dijkstra) | yes |
weights + admissible h | min-heap on g + h (A*) | yes |
| "any solution" over choices | call 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_gridhandles both formats and raisesSudokuErroron bad inputis_valid,is_solved, andcandidatespasssolvesolves the classic and hard puzzles without mutating the input- Unsolvable and invalid boards return
None count_solutionsrespectslimitand 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.
neighborsreturns sorted one-letter variants that are in the dictionary- BFS finds
hit → … → cogin 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_mazepads ragged rows and rejects bad mazesMaze.costandMaze.neighborshandle walls, digits, and the board edge- Unit-cost mazes match BFS; weighted mazes take the cheaper detour
- Unreachable goals return
None;render_pathdraws 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_boardvalidates permutations;inversionsandis_solvablepassmanhattan,apply_move, andapply_movespasssolvereturns""for the goal andNonefor 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_callreturns a non-negative floatmainprints a header plus one row per solver and returns 0--onlyfilters the rows
Rubric
| Criterion | Weight | Excellent | Needs work |
|---|---|---|---|
| Correctness | 40% | All 64 tests pass, including optimality checks against BFS and brute force | Solvers return answers that are valid but not optimal, or crash on edge cases |
| Algorithm choice | 20% | BFS for unit costs, A* with an admissible heuristic for weights, DP with reconstruction, propagation + MRV in sudoku | Exhaustive search where a better technique applies; sudoku takes seconds |
| Edge cases | 15% | Unsolvable boards, unreachable goals, empty inputs, and invalid input raise or return as specified | Infinite loops or generic exceptions on bad input |
| Code quality | 15% | Typed, documented, ruff-clean; small helpers; no mutation of inputs | Long functions, duplicated logic, missing type hints |
| Analysis | 10% | You can state each solver's time and space complexity and justify optimality | Complexity 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
- Hidden singles: fill a digit that fits only one cell in a row, column, or box. Count guesses before and after.
- Puzzle generator: start from a solved grid, remove clues while
count_solutionsstays 1, and grade difficulty by the number of guesses needed. - All shortest ladders: keep every parent at the same BFS depth and enumerate paths.
- Diagonal mazes: add diagonal moves and switch to the octile heuristic; prove admissibility.
- 15-puzzle: on even widths the solvability rule also involves the blank's row; implement it and try IDA* to save memory.
- Benchmark JSON: add
--jsonoutput and compare BFS vs bidirectional BFS on a large word list.
Reflection quiz
Next up: Part 5 · M36 Metaprogramming.