diff --git a/README.md b/README.md new file mode 100644 index 0000000..05973f8 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# sudoku-ai + +`sudoku-ai` solves plain-text Sudoku files from the command line: + +```sh +./sudoku-ai +``` + +Input is a whitespace-separated grid. Use `0` for empty cells and decimal +values for filled cells: + +```text +0 2 3 0 0 0 4 5 0 +0 0 0 0 0 0 0 0 0 +0 0 0 0 0 0 0 0 0 +0 0 0 0 0 0 0 0 0 +0 0 0 0 0 0 0 0 0 +0 0 0 0 0 0 0 0 0 +0 0 0 0 0 0 0 0 0 +0 0 0 0 0 0 0 0 0 +0 0 0 0 0 0 0 0 0 +``` + +The grid width must equal the grid height, and the size must have square +blocks. That supports layouts such as 4x4 with 2x2 blocks, 9x9 with 3x3 +blocks, and 16x16 with 4x4 blocks. + +## Project Decomposition + +- Command-line interface: `src/main.rs` + - Parses the `./sudoku-ai ` argument shape. + - Reads puzzle text from disk. + - Prints the solved grid or a user-facing error. +- Solver library: `src/lib.rs` + - Plain-text parser + - Ignores blank lines. + - Validates square dimensions, block shape, and value ranges. + - Constraint model + - Stores each candidate set in a `u128` bit mask. + - Precomputes row, column, and block units for each cell. + - Precomputes peer cells for fast candidate elimination. + - Deduction engine + - Propagates placed values to all peers. + - Fills naked singles when a cell has one candidate. + - Fills hidden singles when a unit has one possible place for a value. + - Search engine + - Runs deduction until it stalls. + - Chooses the unsolved cell with the fewest candidates. + - Tries high-impact candidates first, then resumes deduction after each + assumption. diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..125896e --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,625 @@ +use std::{error::Error, fmt}; + +type Mask = u128; + +/// Solves a Sudoku puzzle from plain whitespace-separated text. +/// +/// Empty cells must be written as `0`. Filled cells use decimal values from +/// `1` through the puzzle size, so a 16x16 puzzle uses `1` through `16`. +/// +/// # Errors +/// +/// Returns an error if the text is malformed, the size is unsupported, the +/// givens contradict Sudoku rules, or no solution can be found. +pub fn solve_text(input: &str) -> Result { + let puzzle = Puzzle::parse(input)?; + let solver = Solver::new(puzzle.size, puzzle.block_side); + solver.solve(&puzzle) +} + +/// A solved Sudoku grid. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SolvedGrid { + size: usize, + values: Vec, +} + +impl SolvedGrid { + /// Formats the solved grid as plain rows of decimal numbers. + #[must_use] + pub fn format_plain(&self) -> String { + let width = self.size.to_string().len(); + let mut output = String::new(); + + for row in 0..self.size { + if row > 0 { + output.push('\n'); + } + + for column in 0..self.size { + if column > 0 { + output.push(' '); + } + push_padded_number( + &mut output, + self.values[cell_index(self.size, row, column)], + width, + ); + } + } + + output + } + + /// Returns the solved value at the given zero-based row and column. + #[must_use] + pub fn value_at(&self, row: usize, column: usize) -> Option { + (row < self.size && column < self.size) + .then(|| self.values[cell_index(self.size, row, column)]) + } + + /// Returns the width and height of the solved grid. + #[must_use] + pub const fn size(&self) -> usize { + self.size + } +} + +/// Errors returned while parsing or solving a Sudoku puzzle. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SudokuError { + /// The puzzle text could not be parsed into a square Sudoku grid. + Parse(String), + /// The puzzle is structurally valid text, but breaks Sudoku constraints. + Invalid(String), + /// The puzzle has no solution under Sudoku constraints. + NoSolution, +} + +impl fmt::Display for SudokuError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Parse(message) => write!(formatter, "parse error: {message}"), + Self::Invalid(message) => write!(formatter, "invalid puzzle: {message}"), + Self::NoSolution => write!(formatter, "no solution found"), + } + } +} + +impl Error for SudokuError {} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct Puzzle { + size: usize, + block_side: usize, + cells: Vec>, +} + +impl Puzzle { + fn parse(input: &str) -> Result { + let rows = parse_rows(input)?; + let size = rows.len(); + + if size == 0 { + return Err(SudokuError::Parse(String::from("the puzzle is empty"))); + } + + let Some(block_side) = block_side_for(size) else { + return Err(SudokuError::Parse(format!( + "grid size {size} is not a square block layout" + ))); + }; + + if size > Mask::BITS as usize { + return Err(SudokuError::Parse(format!( + "grid size {size} is too large; at most {} symbols are supported", + Mask::BITS + ))); + } + + let mut cells = Vec::with_capacity(size * size); + for (row_index, row) in rows.into_iter().enumerate() { + if row.len() != size { + return Err(SudokuError::Parse(format!( + "row {} has {} values, but the grid has {size} rows", + row_index + 1, + row.len() + ))); + } + + for value in row { + if value > size { + return Err(SudokuError::Parse(format!( + "value {value} is outside the allowed range 0..={size}" + ))); + } + cells.push((value != 0).then_some(value)); + } + } + + Ok(Self { + size, + block_side, + cells, + }) + } +} + +fn parse_rows(input: &str) -> Result>, SudokuError> { + let mut rows = Vec::new(); + + for (line_index, line) in input.lines().enumerate() { + if line.trim().is_empty() { + continue; + } + + let mut row = Vec::new(); + for (column_index, token) in line.split_whitespace().enumerate() { + let value = token.parse::().map_err(|error| { + SudokuError::Parse(format!( + "line {}, value {} is not a number: {error}", + line_index + 1, + column_index + 1 + )) + })?; + row.push(value); + } + rows.push(row); + } + + Ok(rows) +} + +fn block_side_for(size: usize) -> Option { + let mut block_side = 1; + + while block_side <= size / block_side { + let square = block_side * block_side; + if square == size { + return Some(block_side); + } + block_side += 1; + } + + None +} + +#[derive(Clone, Debug)] +struct Solver { + size: usize, + units: Vec>, + cell_units: Vec<[usize; 3]>, + peers: Vec>, + full_mask: Mask, +} + +impl Solver { + fn new(size: usize, block_side: usize) -> Self { + let cell_count = size * size; + let mut units = Vec::with_capacity(size * 3); + let mut cell_units = vec![[0; 3]; cell_count]; + + for row in 0..size { + let unit_index = units.len(); + let unit = (0..size) + .map(|column| cell_index(size, row, column)) + .collect(); + for column in 0..size { + cell_units[cell_index(size, row, column)][0] = unit_index; + } + units.push(unit); + } + + for column in 0..size { + let unit_index = units.len(); + let unit = (0..size).map(|row| cell_index(size, row, column)).collect(); + for row in 0..size { + cell_units[cell_index(size, row, column)][1] = unit_index; + } + units.push(unit); + } + + for block_row in 0..block_side { + for block_column in 0..block_side { + let unit_index = units.len(); + let mut unit = Vec::with_capacity(size); + for row_offset in 0..block_side { + for column_offset in 0..block_side { + let row = block_row * block_side + row_offset; + let column = block_column * block_side + column_offset; + let cell = cell_index(size, row, column); + cell_units[cell][2] = unit_index; + unit.push(cell); + } + } + units.push(unit); + } + } + + let peers = build_peers(size, &units, &cell_units); + + Self { + size, + units, + cell_units, + peers, + full_mask: full_mask(size), + } + } + + fn solve(&self, puzzle: &Puzzle) -> Result { + let state = self.initial_state(puzzle)?; + let solved = self.search(state).ok_or(SudokuError::NoSolution)?; + Ok(SolvedGrid { + size: self.size, + values: solved + .values + .into_iter() + .map(|value| value.ok_or(SudokuError::NoSolution)) + .collect::, _>>()?, + }) + } + + fn initial_state(&self, puzzle: &Puzzle) -> Result { + let mut state = State { + values: vec![None; self.size * self.size], + candidates: vec![self.full_mask; self.size * self.size], + }; + + for (cell, value) in puzzle.cells.iter().copied().enumerate() { + if let Some(value) = value { + self.assign(&mut state, cell, value) + .map_err(|Contradiction| { + SudokuError::Invalid(format!( + "given value {value} at row {}, column {} conflicts with another given", + cell / self.size + 1, + cell % self.size + 1 + )) + })?; + } + } + + Ok(state) + } + + fn search(&self, mut state: State) -> Option { + self.deduce(&mut state).ok()?; + + if state.values.iter().all(Option::is_some) { + return Some(state); + } + + let cell = self.choose_branch_cell(&state)?; + for value in self.ordered_candidates(&state, cell) { + let mut next = state.clone(); + if self.assign(&mut next, cell, value).is_ok() + && let Some(solution) = self.search(next) + { + return Some(solution); + } + } + + None + } + + fn deduce(&self, state: &mut State) -> Result<(), Contradiction> { + loop { + let mut progressed = false; + + for cell in 0..state.values.len() { + if state.values[cell].is_none() { + match state.candidates[cell].count_ones() { + 0 => return Err(Contradiction), + 1 => { + let value = only_value(state.candidates[cell]); + self.assign(state, cell, value)?; + progressed = true; + } + _ => {} + } + } + } + + for unit in &self.units { + for value in 1..=self.size { + if let Some(cell) = hidden_single_cell(state, unit, value)? { + self.assign(state, cell, value)?; + progressed = true; + } + } + } + + if !progressed { + return Ok(()); + } + } + } + + fn assign(&self, state: &mut State, cell: usize, value: usize) -> Result<(), Contradiction> { + let bit = value_bit(value); + + if state.candidates[cell] & bit == 0 { + return Err(Contradiction); + } + + if let Some(existing) = state.values[cell] { + return (existing == value).then_some(()).ok_or(Contradiction); + } + + for &unit_index in &self.cell_units[cell] { + if self.units[unit_index] + .iter() + .any(|&peer| peer != cell && state.values[peer] == Some(value)) + { + return Err(Contradiction); + } + } + + state.values[cell] = Some(value); + state.candidates[cell] = bit; + + for &peer in &self.peers[cell] { + if state.values[peer] == Some(value) { + return Err(Contradiction); + } + + if state.values[peer].is_none() && state.candidates[peer] & bit != 0 { + state.candidates[peer] &= !bit; + if state.candidates[peer] == 0 { + return Err(Contradiction); + } + } + } + + Ok(()) + } + + fn choose_branch_cell(&self, state: &State) -> Option { + let mut best = None; + + for cell in 0..state.values.len() { + if state.values[cell].is_none() { + let candidate_count = state.candidates[cell].count_ones(); + let open_peer_count = self.peers[cell] + .iter() + .filter(|&&peer| state.values[peer].is_none()) + .count(); + + let should_replace = match best { + None => true, + Some((_, best_candidate_count, best_open_peer_count)) => { + candidate_count < best_candidate_count + || (candidate_count == best_candidate_count + && open_peer_count > best_open_peer_count) + } + }; + + if should_replace { + best = Some((cell, candidate_count, open_peer_count)); + } + } + } + + best.map(|(cell, _, _)| cell) + } + + fn ordered_candidates(&self, state: &State, cell: usize) -> Vec { + let mut candidates = mask_values(state.candidates[cell], self.size); + candidates.sort_by(|left, right| { + let right_impact = self.candidate_impact(state, cell, *right); + let left_impact = self.candidate_impact(state, cell, *left); + right_impact.cmp(&left_impact).then_with(|| left.cmp(right)) + }); + candidates + } + + fn candidate_impact(&self, state: &State, cell: usize, value: usize) -> usize { + let bit = value_bit(value); + self.peers[cell] + .iter() + .filter(|&&peer| state.values[peer].is_none() && state.candidates[peer] & bit != 0) + .count() + } +} + +#[derive(Clone, Debug)] +struct State { + values: Vec>, + candidates: Vec, +} + +#[derive(Clone, Copy, Debug)] +struct Contradiction; + +fn build_peers(size: usize, units: &[Vec], cell_units: &[[usize; 3]]) -> Vec> { + let mut peers = Vec::with_capacity(size * size); + + for unit_indexes in cell_units { + let mut seen = vec![false; size * size]; + for &unit_index in unit_indexes { + for &cell in &units[unit_index] { + seen[cell] = true; + } + } + + let cell = peers.len(); + seen[cell] = false; + peers.push( + seen.into_iter() + .enumerate() + .filter_map(|(candidate, is_peer)| is_peer.then_some(candidate)) + .collect(), + ); + } + + peers +} + +fn hidden_single_cell( + state: &State, + unit: &[usize], + value: usize, +) -> Result, Contradiction> { + let bit = value_bit(value); + let mut candidate_cell = None; + let mut candidate_count = 0; + + for &cell in unit { + match state.values[cell] { + Some(existing) if existing == value => return Ok(None), + None if state.candidates[cell] & bit != 0 => { + candidate_cell = Some(cell); + candidate_count += 1; + } + Some(_) | None => {} + } + } + + match candidate_count { + 0 => Err(Contradiction), + 1 => Ok(candidate_cell), + _ => Ok(None), + } +} + +const fn cell_index(size: usize, row: usize, column: usize) -> usize { + row * size + column +} + +fn full_mask(size: usize) -> Mask { + if size == Mask::BITS as usize { + Mask::MAX + } else { + (1 << size) - 1 + } +} + +const fn value_bit(value: usize) -> Mask { + 1 << (value - 1) +} + +fn only_value(mask: Mask) -> usize { + mask.trailing_zeros() as usize + 1 +} + +fn mask_values(mask: Mask, size: usize) -> Vec { + (1..=size) + .filter(|&value| mask & value_bit(value) != 0) + .collect() +} + +fn push_padded_number(output: &mut String, value: usize, width: usize) { + let value = value.to_string(); + for _ in value.len()..width { + output.push(' '); + } + output.push_str(&value); +} + +#[cfg(test)] +mod tests { + use super::{SudokuError, solve_text}; + + #[test] + fn solves_standard_nine_by_nine() { + let puzzle = "\ +5 3 0 0 7 0 0 0 0 +6 0 0 1 9 5 0 0 0 +0 9 8 0 0 0 0 6 0 +8 0 0 0 6 0 0 0 3 +4 0 0 8 0 3 0 0 1 +7 0 0 0 2 0 0 0 6 +0 6 0 0 0 0 2 8 0 +0 0 0 4 1 9 0 0 5 +0 0 0 0 8 0 0 7 9 +"; + + let solved = solve_text(puzzle).expect("expected puzzle to solve"); + + assert_eq!(solved.value_at(0, 0), Some(5)); + assert_eq!(solved.value_at(8, 8), Some(9)); + assert_eq!( + solved.format_plain(), + "\ +5 3 4 6 7 8 9 1 2 +6 7 2 1 9 5 3 4 8 +1 9 8 3 4 2 5 6 7 +8 5 9 7 6 1 4 2 3 +4 2 6 8 5 3 7 9 1 +7 1 3 9 2 4 8 5 6 +9 6 1 5 3 7 2 8 4 +2 8 7 4 1 9 6 3 5 +3 4 5 2 8 6 1 7 9" + ); + } + + #[test] + fn solves_sixteen_by_sixteen() { + let mut puzzle = sixteen_by_sixteen_solution(); + puzzle[0][0] = 0; + puzzle[5][7] = 0; + puzzle[15][15] = 0; + + let input = grid_to_text(&puzzle); + let solved = solve_text(&input).expect("expected 16x16 puzzle to solve"); + + assert_eq!(solved.size(), 16); + assert_eq!(solved.value_at(0, 0), Some(1)); + assert_eq!(solved.value_at(5, 7), Some(13)); + assert_eq!(solved.value_at(15, 15), Some(15)); + } + + #[test] + fn rejects_duplicate_givens() { + let puzzle = "\ +1 1 0 0 +0 0 0 0 +0 0 0 0 +0 0 0 0 +"; + + let error = solve_text(puzzle).expect_err("duplicate row givens must be invalid"); + + assert!(matches!(error, SudokuError::Invalid(_))); + } + + #[test] + fn rejects_non_square_block_layouts() { + let puzzle = "\ +0 0 +0 0 +"; + + let error = solve_text(puzzle).expect_err("2x2 is not a valid block layout"); + + assert_eq!( + error, + SudokuError::Parse(String::from("grid size 2 is not a square block layout")) + ); + } + + fn sixteen_by_sixteen_solution() -> Vec> { + let size = 16; + let block_side = 4; + + (0..size) + .map(|row| { + (0..size) + .map(|column| (row * block_side + row / block_side + column) % size + 1) + .collect() + }) + .collect() + } + + fn grid_to_text(grid: &[Vec]) -> String { + grid.iter() + .map(|row| { + row.iter() + .map(usize::to_string) + .collect::>() + .join(" ") + }) + .collect::>() + .join("\n") + } +} diff --git a/src/main.rs b/src/main.rs index e7a11a9..a76a4fc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,65 @@ +use std::{env, fmt, fs, io, process}; + +use sudoku_ai::SudokuError; + fn main() { - println!("Hello, world!"); + match run(env::args()) { + Ok(solution) => println!("{solution}"), + Err(error) => { + eprintln!("{error}"); + process::exit(error.exit_code()); + } + } +} + +fn run(args: impl IntoIterator) -> Result { + let mut args = args.into_iter(); + let program = args.next().unwrap_or_else(|| String::from("sudoku-ai")); + + let Some(path) = args.next() else { + return Err(AppError::Usage { program }); + }; + + if args.next().is_some() { + return Err(AppError::Usage { program }); + } + + let input = fs::read_to_string(&path).map_err(|source| AppError::Io { + path: path.clone(), + source, + })?; + let solved = sudoku_ai::solve_text(&input)?; + Ok(solved.format_plain()) +} + +#[derive(Debug)] +enum AppError { + Usage { program: String }, + Io { path: String, source: io::Error }, + Sudoku(SudokuError), +} + +impl AppError { + const fn exit_code(&self) -> i32 { + match self { + Self::Usage { .. } => 2, + Self::Io { .. } | Self::Sudoku(_) => 1, + } + } +} + +impl From for AppError { + fn from(error: SudokuError) -> Self { + Self::Sudoku(error) + } +} + +impl fmt::Display for AppError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Usage { program } => write!(formatter, "usage: {program} "), + Self::Io { path, source } => write!(formatter, "failed to read {path}: {source}"), + Self::Sudoku(error) => write!(formatter, "{error}"), + } + } }