From 38157d7b5ea5ec55d0e14c95076f47893f32b5ba Mon Sep 17 00:00:00 2001 From: Johann150 Date: Sun, 8 Oct 2023 21:25:56 +0200 Subject: [PATCH] make the solver more generic The solver is now not specific to Sudoku but pretty much any game where a set of square tiles needs to be filled according to a specific rule. Since it is not specific to Sudoku any more, it is a bit less performant, but that is not a big issue in my use case, so is fine. --- Cargo.toml | 2 + src/board.rs | 112 +++++++++++++++++++++++++ src/constrain.rs | 118 +++++++++++++++++++++++++++ src/main.rs | 207 ++++++++++------------------------------------- src/rules.rs | 115 ++++++++++++++++++++++++++ 5 files changed, 388 insertions(+), 166 deletions(-) create mode 100644 src/board.rs create mode 100644 src/constrain.rs create mode 100644 src/rules.rs diff --git a/Cargo.toml b/Cargo.toml index 1bdaa39..010595a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,3 +6,5 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +log = "*" +simple_logger = "*" \ No newline at end of file diff --git a/src/board.rs b/src/board.rs new file mode 100644 index 0000000..3e1702e --- /dev/null +++ b/src/board.rs @@ -0,0 +1,112 @@ +use std::collections::HashMap; + +/** +Contains the values on a given board as well as a bit of metadata. +The actual tiles are stored in a `HashMap` to allow disparate data. +*/ +#[derive(Clone, Debug)] +pub struct Board { + width: usize, + height: usize, + num_options: usize, + + tiles: HashMap<(usize, usize), usize>, +} + +impl Board { + /** + Creates a new board. + + Panics if width or height are zero, or there are less than two options. + */ + pub fn new(width: usize, height: usize, num_options: usize) -> Self { + assert!(width != 0); + assert!(height != 0); + assert!(num_options >= 2); + + Self { + width, + height, + num_options, + tiles: HashMap::new(), + } + } + + pub fn get_width(&self) -> usize { + self.width + } + pub fn get_height(&self) -> usize { + self.height + } + pub fn get_num_options(&self) -> usize { + self.num_options + } + + /** + Set the value of a tile, or removes it if `None` is given. + + Returns the previous value or `None`. + + Panics if `x` or `y` are beyond the boards dimensions or `value` is greater or equal to `num_options`. + */ + pub fn set_tile(&mut self, x: usize, y: usize, value: Option) -> Option { + assert!(x < self.width); + assert!(y < self.height); + + if let Some(value) = value { + assert!(value < self.num_options); + self.tiles.insert((x, y), value) + } else { + self.tiles.remove(&(x, y)) + } + } + + /** + Get the value of a tile, or `None` if no value is known. + + Panics if `x` or `y` are beyond the boards dimensions. + */ + pub fn get_tile(&self, x: usize, y: usize) -> Option { + assert!(x < self.width); + assert!(y < self.height); + + self.tiles.get(&(x, y)).copied() + } + + /** + Get all the values of a column, in ascending y order. + If a value in one row is not present, `None` is given instead. + */ + pub fn get_column(&self, x: usize) -> Vec> { + (0..self.height) + .map(|y| self.tiles.get(&(x, y)).copied()) + .collect() + } + + /** + Get all the values of a row, in ascending x order. + If a value in one column is not present, `None` is given instead. + */ + pub fn get_row(&self, y: usize) -> Vec> { + (0..self.width) + .map(|x| self.tiles.get(&(x, y)).copied()) + .collect() + } +} + +impl std::fmt::Display for Board { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for x in 0..self.width { + for y in 0..self.height { + if let Some(value) = self.tiles.get(&(x, y)) { + write!(f, "{value}\t")?; + } else { + write!(f, "?\t")?; + } + } + writeln!(f)?; + } + + Ok(()) + } +} diff --git a/src/constrain.rs b/src/constrain.rs new file mode 100644 index 0000000..39fabdf --- /dev/null +++ b/src/constrain.rs @@ -0,0 +1,118 @@ +use crate::board::Board; +use std::collections::HashMap; + +pub type BoardCheckFunction = dyn Fn(&Board) -> bool; + +/** +Implementation of the actual solving algorithm. + +## Parameters +* `board`: The (incomplete) board to be solved, which will be modified to contain the solution. +* `check_fn`: A function that, given the board determines whether the board is valid, i.e. the rules of the game. + +## Return value +Returns `true` if the board has been solved. The initial given `board` will have been modified to contain it. + +Returns `false` if the rules as defined by `check_fn` do not allow any valid solution of the initial board. The value of `board` may nevertheless have been modified. +*/ +pub fn constrain_board(board: &mut Board, check_fn: &BoardCheckFunction) -> bool { + _constrain_board(board, check_fn, None) +} + +fn _constrain_board( + board: &mut Board, + check_fn: &BoardCheckFunction, + hints: Option>>, +) -> bool { + let mut anything_changed = true; + let mut memo = hints.unwrap_or_default(); + + while anything_changed { + anything_changed = false; + + for x in 0..board.get_width() { + for y in 0..board.get_height() { + if board.get_tile(x, y).is_some() { + continue; + } + + // Removing the item from the memo list since it will be replaced later + // if it still makes sense to memoize for this tile and this avoids cloning. + let mut valid = memo + .remove(&(x, y)) + .unwrap_or_else(|| (0..board.get_num_options()).collect()); + log::trace!("valid {valid:?}"); + + // only keep the options which result in a valid overall board + valid.retain(|&opt| { + board.set_tile(x, y, Some(opt)); + + check_fn(&board) + }); + log::trace!("still valid {valid:?}"); + + match valid.len() { + 0 => { + // invalid board + board.set_tile(x, y, None); + log::error!("no valid tiles at {x},{y}"); + return false; + } + 1 => { + // there is only one option + board.set_tile(x, y, valid.pop()); + anything_changed = true; + } + _ => { + // there are multiple options, restore the board for now and keep trying + board.set_tile(x, y, None); + memo.insert((x, y), valid); + } + } + } + } + } + + // After this loop is done, it is guaranteed that memo contains only valid options + // because all unfilled cells will have been checked. + + if memo.is_empty() { + // There aren't any memoized values, this must mean that there are no empty fields + // and we have already solved this board. + log::debug!("solved (before recursion)"); + return true; + } + + let old_board = board.clone(); + + // find the coordinate with the least amount of possibilities + let mut coords = memo + .iter() + .map(|(key, val)| (*key, val.len())) + .collect::>(); + coords.sort_unstable_by_key(|&t| t.1); + // SAFTEY: can unwrap because `memo` is not empty, so coords will have + // at least one entry + let best_coord = coords.first().copied().unwrap().0; + // SAFETY: can unwrap because we got this coordinate pair from memo + let valid = memo.remove(&best_coord).unwrap(); + + let (x, y) = best_coord; + log::info!("starting recursion at {x},{y}"); + + for opt in valid { + *board = old_board.clone(); + + board.set_tile(x, y, Some(opt)); + if _constrain_board(board, check_fn, Some(memo.clone())) { + // TODO: figure out how to find multiple solutions + log::debug!("solved (with recursion)"); + return true; + } + } + + // if we get here we tried all options and did not find one + *board = old_board; + log::error!("did not find a valid solution"); + false +} diff --git a/src/main.rs b/src/main.rs index e633504..1322a5e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,169 +1,44 @@ -struct Board([u8; 9 * 9]); -struct SuperBoard([bool; 9 * 9 * 9]); - -impl std::fmt::Display for Board { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - for i in 0..9 * 9 { - if i % 9 > 0 { - if i % 3 == 0 { - write!(f, "|")?; - } else { - write!(f, " ")?; - } - } else if i > 0 && i % (9 * 3) == 0 { - writeln!(f, "-----+-----+-----")?; - } - match self.0[i] { - 0 => { - write!(f, " ")?; - } - n => { - write!(f, "{n}")?; - } - } - if i % 9 == 8 { - writeln!(f)?; - } - } - - Ok(()) - } -} - -impl std::fmt::Display for SuperBoard { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - for j in 0..9 * 9 * 9 { - let col = j / 3 % 9; - let row = j / (9 * 9); - let n = j / 9 / 3 % 3 * 3 + j % 3; - - if j == 0 { - // start of grid - } else if j % 243 == 0 { - writeln!(f, "-----------+-----------+-----------")?; - } else if j % 81 == 0 { - writeln!(f, "...........|...........|...........")?; - } else if j % 27 == 0 { - // start of line - } else if j % 9 == 0 { - write!(f, "|")?; - } else if j % 3 == 0 { - write!(f, ".")?; - } - - let offset = n + (col * 9) + (row * 9 * 9); - if self.0[offset] { - write!(f, "{}", n + 1)?; - } else { - write!(f, " ")?; - } - - if j % 27 == 26 { - // end of line - writeln!(f)?; - } - } - - Ok(()) - } -} - -impl From<&Board> for SuperBoard { - fn from(board: &Board) -> SuperBoard { - let mut super_board = [false; 9 * 9 * 9]; - - for (i, &cell) in board.0.iter().enumerate() { - let super_i = i * 9; - match cell { - 0 => { - for i in 0..9 { - super_board[super_i + i] = true; - } - } - n => { - super_board[super_i + n as usize - 1] = true; - } - } - } - - SuperBoard(super_board) - } -} - -impl Board { - fn constrain(&mut self) -> Result { - let mut collapsed = self - .0 - .iter() - .enumerate() - .filter(|(_, &x)| x != 0) - .map(|(i, _)| i) - .collect::>(); - let mut super_board = SuperBoard::from(&*self); - - while let Some(i) = collapsed.pop() { - let value = self.0[i]; - assert!(value > 0); - let offset = (value - 1) as usize; - - // column - let col_start = i % 9; - let row_start = i - col_start; - let box_start = i / 27 * 27 + i % 9 / 3 * 3; - let to_constrain = (col_start..81) - .step_by(9) - .chain((row_start..81).take(9)) - .chain((box_start..81).step_by(9).take(3).flat_map(|x| x..x + 3)) - .collect::>(); - - for j in to_constrain.into_iter() { - if i == j { - continue; - } - - // safe to unwrap because none of the above can ever go outside the predefined bounds - let x = &mut super_board.0[j * 9 + offset]; - if *x { - *x = false; - - let cell = &super_board.0[(j * 9)..(j * 9 + 9)]; - match cell.iter().filter(|&&x| x).count() { - 0 => return Err(()), - 1 => { - self.0[j] = cell - .iter() - .enumerate() - .find(|(_, &x)| x) - .map(|(i, _)| i + 1) - .unwrap() as u8; - if !collapsed.contains(&j) { - collapsed.push(j); - } - } - _ => (), - } - } - } - } - - Ok(super_board) - } -} +pub mod board; +mod constrain; +mod rules; fn main() { - let mut board = Board([ - 0, 6, 0, /**/ 0, 0, 1, /**/ 0, 0, 3, - 8, 0, 0, /**/ 0, 0, 0, /**/ 0, 1, 9, - 5, 0, 2, /**/ 0, 7, 0, /**/ 8, 4, 0, - /**********************************/ - 4, 0, 6, /**/ 9, 0, 2, /**/ 3, 0, 5, - 0, 0, 0, /**/ 8, 0, 7, /**/ 0, 0, 0, - 1, 0, 7, /**/ 5, 0, 3, /**/ 4, 0, 8, - /**********************************/ - 0, 4, 1, /**/ 0, 9, 0, /**/ 5, 0, 2, - 9, 2, 0, /**/ 0, 0, 0, /**/ 0, 0, 7, - 6, 0, 0, /**/ 2, 0, 0, /**/ 0, 9, 0, - ]); - board.constrain().unwrap(); - println!("{}", board); + simple_logger::init_with_level(log::Level::Info).unwrap(); + + let mut board = board::Board::new(9, 9, 9); + board.set_tile(0, 1, Some(6 -1)); + board.set_tile(0, 2, Some(8 -1)); + board.set_tile(0, 4, Some(3 -1)); + board.set_tile(0, 7, Some(7 -1)); + board.set_tile(1, 0, Some(2 -1)); + board.set_tile(1, 3, Some(9 -1)); + board.set_tile(1, 6, Some(1 -1)); + board.set_tile(2, 1, Some(4 -1)); + board.set_tile(2, 4, Some(8 -1)); + board.set_tile(2, 5, Some(1 -1)); + board.set_tile(2, 8, Some(3 -1)); + board.set_tile(3, 2, Some(7 -1)); + board.set_tile(3, 6, Some(4 -1)); + board.set_tile(4, 3, Some(8 -1)); + board.set_tile(4, 4, Some(1 -1)); + board.set_tile(4, 5, Some(9 -1)); + board.set_tile(5, 2, Some(9 -1)); + board.set_tile(5, 6, Some(2 -1)); + board.set_tile(6, 0, Some(7 -1)); + board.set_tile(6, 3, Some(3 -1)); + board.set_tile(6, 4, Some(9 -1)); + board.set_tile(6, 7, Some(4 -1)); + board.set_tile(7, 2, Some(2 -1)); + board.set_tile(7, 5, Some(5 -1)); + board.set_tile(7, 8, Some(6 -1)); + board.set_tile(8, 1, Some(3 -1)); + board.set_tile(8, 4, Some(7 -1)); + board.set_tile(8, 6, Some(9 -1)); + board.set_tile(8, 7, Some(5 -1)); + + if constrain::constrain_board(&mut board, &rules::valid_board_sudoku) { + println!("{board}"); + } else { + eprintln!("could not solve board!"); + } } diff --git a/src/rules.rs b/src/rules.rs new file mode 100644 index 0000000..f8599a8 --- /dev/null +++ b/src/rules.rs @@ -0,0 +1,115 @@ +use std::collections::HashSet; + +use crate::board::Board; + +/** +Checks whether a board is valid according to the rules of the game +"Unruly" from Simon Tathams Portable Puzzle Collection. + +Only works on boards which have an even number of tiles in width and height, +and which have only two options. +*/ +pub fn valid_board_unruly(board: &Board) -> bool { + debug_assert!(board.get_num_options() == 2); + debug_assert!(board.get_width() % 2 == 0); + debug_assert!(board.get_height() % 2 == 0); + + fn valid_window(win: &[Option]) -> bool { + win[0].zip(win[1]).map_or(true, |(x, y)| x != y) + || win[1].zip(win[2]).map_or(true, |(x, y)| x != y) + } + + // check each column and row: + // * may only contain n/2 number of one option + // * may not contain more than 2 consecutives + for x in 0..board.get_width() { + let col = board.get_column(x); + for opt in [0, 1] { + if col.iter().filter(|&&x| x == Some(opt)).count() > (board.get_width() / 2) { + log::warn!("col {x} invalid: too many of {opt}"); + return false; + } + } + + if !col.windows(3).all(valid_window) { + log::warn!("col {x} invalid: invalid window"); + return false; + } + } + + for y in 0..board.get_height() { + let row = board.get_row(y); + for opt in [0, 1] { + if row.iter().filter(|&&x| x == Some(opt)).count() > (board.get_width() / 2) { + log::warn!("row {y} invalid: too many of {opt}"); + return false; + } + } + + if !row.windows(3).all(valid_window) { + log::warn!("row {y} invalid: invalid window"); + return false; + } + } + + true +} + +/** +Checks whether a board is valid according to the rules of ordinary 9x9 Sudoku: +Numbers must be unique in row, column and each 3x3 cell. +*/ +pub fn valid_board_sudoku(board: &Board) -> bool { + debug_assert!(board.get_num_options() == 9); + debug_assert!(board.get_width() == 9); + debug_assert!(board.get_height() == 9); + + fn get_cell(board: &Board, start_x: usize, start_y: usize) -> Vec> { + (start_x..start_x + 3) + .flat_map(|x| (start_y..start_y + 3).map(|y| (x, y)).collect::>()) + .map(|(x, y)| board.get_tile(x, y)) + .collect() + } + + // check columns: must be unique + for x in 0..9 { + let mut set = HashSet::new(); + if !board + .get_column(x) + .iter() + .filter_map(|&x| x) + .all(|x| set.insert(x)) + { + return false; + } + } + + // check rows: must be unique + for y in 0..9 { + let mut set = HashSet::new(); + if !board + .get_row(y) + .iter() + .filter_map(|&x| x) + .all(|x| set.insert(x)) + { + return false; + } + } + + // check 3x3 cells: must be unique + for x in [0,3,6] { + for y in [0,3,6] { + let mut set = HashSet::new(); + if !get_cell(board, x, y) + .iter() + .filter_map(|&x| x) + .all(|x| set.insert(x)) + { + return false; + } + } + } + + true +}