diff --git a/SudoScript.Core.Test/BoardTests.cs b/SudoScript.Core.Test/BoardTests.cs new file mode 100644 index 0000000..811d013 --- /dev/null +++ b/SudoScript.Core.Test/BoardTests.cs @@ -0,0 +1,136 @@ +using NUnit.Framework; +using SudoScript.Core; +using SudoScript.Core.Data; + +namespace SudoScript.Core.Test; + +internal sealed class BoardTests +{ + [Test] + public void ValidateBoardInvalidRuleTest() + { + Board board = Util.CreateStandardEmpty(); + board[5, 1].Digit = 2; + board[4, 8].Digit = 7; + board[5, 2].Digit = 2; + board[8, 4].Digit = 4; + + Assert.That(board.Validate(), Is.False); + } + + [Test] + public void ValidateBoardValidRuleTest() + { + Board board = Util.CreateStandardEmpty(); + board[5, 1].Digit = 2; + board[4, 8].Digit = 7; + board[8, 4].Digit = 4; + board[5, 2].Digit = 3; + + Assert.That(board.Validate(), Is.True); + } + + [Test] + public void ValidateBoardNoCandidatesTest() + { + Board board = Util.CreateStandardEmpty(); + + board[9, 9].EliminateCandidate(1); + board[9, 9].EliminateCandidate(2); + board[9, 9].EliminateCandidate(3); + board[9, 9].EliminateCandidate(4); + board[9, 9].EliminateCandidate(5); + board[9, 9].EliminateCandidate(6); + board[9, 9].EliminateCandidate(7); + board[9, 9].EliminateCandidate(8); + board[9, 9].EliminateCandidate(9); + + Assert.That(board.Validate(), Is.False); + } + + [Test] + public void BoardEqualsTest() + { + Board board = Util.CreateStandardEmpty(); + board[5, 1].Digit = 2; + board[4, 8].Digit = 7; + board[5, 2].Digit = 3; + board[8, 4].Digit = 4; + + Board board2 = Util.CreateStandardEmpty(); + board2[5, 1].Digit = 2; + board2[4, 8].Digit = 7; + board2[5, 2].Digit = 3; + board2[8, 4].Digit = 4; + + Assert.That(board2, Is.EqualTo(board)); + } + + [Test] + public void BoardNotEqualsTest() + { + Board board = Util.CreateStandardEmpty(); + board[1, 1].Digit = 2; + board[4, 8].Digit = 7; + board[5, 3].Digit = 3; + board[8, 4].Digit = 4; + + Board board2 = Util.CreateStandardEmpty(); + board2[1, 1].Digit = 1; + board2[4, 8].Digit = 7; + board2[5, 3].Digit = 3; + board2[8, 4].Digit = 4; + + Assert.That(board2, Is.Not.EqualTo(board)); + } + + [Test] + public void BoardHashTest() + { + Board board = Util.CreateStandardEmpty(); + board[5, 1].Digit = 2; + board[4, 8].Digit = 7; + board[5, 2].Digit = 3; + board[8, 4].Digit = 4; + + Board board2 = Util.CreateStandardEmpty(); + board2[5, 1].Digit = 2; + board2[4, 8].Digit = 7; + board2[5, 2].Digit = 3; + board2[8, 4].Digit = 4; + + Assert.That(board2.GetHashCode(), Is.EqualTo(board.GetHashCode())); + } + + [Test] + public void BoardHashNotEqualTest() + { + Board board = Util.CreateStandardEmpty(); + board[5, 1].Digit = 2; + board[4, 8].Digit = 7; + board[5, 2].Digit = 3; + board[8, 4].Digit = 4; + + Board board2 = Util.CreateStandardEmpty(); + board2[5, 1].Digit = 2; + board2[4, 9].Digit = 7; + board2[5, 2].Digit = 3; + board2[8, 4].Digit = 4; + + Assert.That(board2.GetHashCode(), Is.Not.EqualTo(board.GetHashCode())); + } + + [Test] + public void CloneTest() + { + Board board = Util.CreateStandardEmpty(); + board[5, 1].Digit = 2; + board[4, 8].Digit = 7; + board[5, 2].Digit = 3; + board[8, 4].Digit = 4; + + Board clonedBoard = board.Clone(); + + Assert.That(board, Is.EqualTo(clonedBoard)); + } +} diff --git a/SudoScript.Core.Test/SolverTests.cs b/SudoScript.Core.Test/SolverTests.cs index 5797310..3ac6863 100644 --- a/SudoScript.Core.Test/SolverTests.cs +++ b/SudoScript.Core.Test/SolverTests.cs @@ -1,22 +1,14 @@ using NUnit.Framework; +using StandardLibrary; using SudoScript.Core; using SudoScript.Core.Data; +using System.Data; namespace SudoScript.Core.Test; internal sealed class SolverTests { - [Test()] - public void CanSolveEmptySudoku() - { - Board board = Util.CreateStandardEmpty(); - Assert.DoesNotThrow(() => board = Solver.Solve(board)); - Assert.IsTrue(board.ValidateRules()); - Assert.IsFalse(board.Cells().Any(c => c.Digit == Cell.EmptyDigit)); - } - - [Test] - public void CanSolveGeneratedSudoku() + public Board CreateEasyBoard() { Board board = Util.CreateStandardEmpty(); // Sudoku givens generated by https://sudoku.com/ @@ -39,6 +31,7 @@ public void CanSolveGeneratedSudoku() board[8, 4].Digit = 8; board[3, 5].Digit = 4; + board[4, 7].Digit = 2; board[3, 6].Digit = 6; board[5, 6].Digit = 7; @@ -50,6 +43,7 @@ public void CanSolveGeneratedSudoku() board[8, 7].Digit = 3; board[9, 7].Digit = 5; + board[3, 9].Digit = 1; board[2, 8].Digit = 9; board[4, 8].Digit = 7; board[5, 8].Digit = 4; @@ -59,6 +53,23 @@ public void CanSolveGeneratedSudoku() board[7, 9].Digit = 9; board[8, 9].Digit = 7; + return board; + } + + [Test()] + public void CanSolveEmptySudoku() + { + Board board = Util.CreateStandardEmpty(); + Assert.DoesNotThrow(() => board = Solver.Solve(board)); + Assert.IsTrue(board.ValidateRules()); + Assert.IsFalse(board.Cells().Any(c => c.Digit == Cell.EmptyDigit)); + } + + [Test] + public void CanSolveGeneratedSudoku() + { + Board board = CreateEasyBoard(); + Console.WriteLine(board.ToString()); Console.WriteLine("-------------------------------------------------"); @@ -68,4 +79,56 @@ public void CanSolveGeneratedSudoku() Console.WriteLine(board.ToString()); } + + [Test] + public void IsSatisfactoryTest() + { + Board board = CreateEasyBoard(); + + Assert.IsTrue(Solver.IsSatisfactory(board)); + } + + [Test] + public void IsNotSatisfactoryTest() + { + Board board = Util.CreateStandardEmpty(); + + Assert.IsFalse(Solver.IsSatisfactory(board)); + } + + [Test] + public void FindSolutionsTwoUniqueCellsTest() + { + + Board board = new(new List{ new Cell(1, 1), new Cell(1, 2) }, + new List { + new Unit(new List { + new CellReference(1, 1), + new CellReference(1,2) }, + new List { new Unique { } })}); + + List? boardList = Solver.FindSolutions(board); + List? randomBoardList = Solver.FindSolutions(board, int.MaxValue, true); + + Assert.IsNotNull(boardList); + Assert.That(boardList.Count(), Is.EqualTo(72)); + Assert.IsNotNull(randomBoardList); + Assert.That(boardList.Count(), Is.EqualTo(randomBoardList.Count())); + } + + [Test] + public void FindNumberOfSolutionsTest() + { + Board board = new(new List { new Cell(1, 1), new Cell(1, 2) }, + new List { + new Unit(new List { + new CellReference(1, 1), + new CellReference(1,2) }, + new List { new Unique { } })}); + + List? boardList = Solver.FindSolutions(board, 25); + + Assert.IsNotNull(boardList); + Assert.That(boardList.Count(), Is.EqualTo(25)); + } } diff --git a/SudoScript.Core/Data/Board.cs b/SudoScript.Core/Data/Board.cs index 3f31d53..d12942e 100644 --- a/SudoScript.Core/Data/Board.cs +++ b/SudoScript.Core/Data/Board.cs @@ -88,6 +88,22 @@ public bool ValidateRules() return true; } + public bool Validate() + { + if ((_cells == null) || !this.ValidateRules()) + { + return false; + } + foreach (Cell cell in this.Cells()) + { + if (cell.CandidateCount < 1) + { + return false; + } + } + return true; + } + public bool IsSolved() { return ValidateRules() && Cells().All(c => c.Digit != Cell.EmptyDigit); @@ -120,4 +136,45 @@ public override string ToString() return s.ToString(); } + + public override bool Equals(object? obj) + { + if (obj == null || GetType() != obj.GetType()) + { + return false; + } + + Board other = (Board)obj; + + if (other.Cells().Count() != _cells.Count) + { + return false; + } + + foreach (Cell cell1 in _cells.Values) + { + if (!other.TryGetCell(cell1.X, cell1.Y, out Cell? cell2)) + { + // Compared board does not contain cell with these coordinates + return false; + } + // Compare cells + if (!cell1.Equals(cell2)) + { + return false; + } + } + + return true; + } + + public override int GetHashCode() + { + int hash = 17; + foreach (var cell in _cells.Values) + { + hash = hash * 31 + cell.GetHashCode(); + } + return hash; + } } diff --git a/SudoScript.Core/Data/Cell.cs b/SudoScript.Core/Data/Cell.cs index 8774e4d..b7623b4 100644 --- a/SudoScript.Core/Data/Cell.cs +++ b/SudoScript.Core/Data/Cell.cs @@ -142,4 +142,46 @@ public string ToString(string format) .Replace("Y", Y.ToString()); } + public override bool Equals(object? obj) + { + if (obj == null || GetType() != obj.GetType()) + { + return false; + } + + Cell other = (Cell)obj; + + // Check coordinates and digit + if (!((this.Y == other.Y) && (this.X == other.X) && (this.Digit == other.Digit))) + { + return false; + } + + // Check candidates + for (int i = 1; i <= 9; i++) + { + if (this.HasCandidate(i) != other.HasCandidate(i)) + { + return false; + } + if (this.IsGiven != other.IsGiven) + { + return false; + } + } + return true; + } + + public override int GetHashCode() + { + unchecked + { + int hash = 17; + hash = hash * 23 + X.GetHashCode(); + hash = hash * 23 + Y.GetHashCode(); + hash = hash * 23 + Digit.GetHashCode(); + return hash; + } + } + } diff --git a/SudoScript.Core/Solver.cs b/SudoScript.Core/Solver.cs index ffcccf0..f5a9293 100644 --- a/SudoScript.Core/Solver.cs +++ b/SudoScript.Core/Solver.cs @@ -1,4 +1,5 @@ -using System.Diagnostics.CodeAnalysis; +using System; +using System.Diagnostics.CodeAnalysis; using SudoScript.Core.Data; namespace SudoScript.Core; @@ -20,8 +21,8 @@ public static Board Solve(Board board) private static bool SolveRec(Board board, [NotNullWhen(true)] out Board? solvedBoard) { - // Eliminate candidates from all rules untill nothing changes. - while (board.EliminateCandidates()) ; + // Eliminate candidates from all rules until nothing changes. + while (board.EliminateCandidates()); // We hit an invalid state, and must backtrack. if (!board.ValidateRules()) @@ -42,14 +43,14 @@ private static bool SolveRec(Board board, [NotNullWhen(true)] out Board? solvedB orderedCells = orderedCells.SkipWhile(c => c.CandidateCount <= 1); // The first cell contains the smallest amount of candidates. int lowestCandidateCount = orderedCells.FirstOrDefault()?.CandidateCount ?? 1; - // Take all cells with the least amount of candidates. - orderedCells = orderedCells.TakeWhile(c => c.CandidateCount == lowestCandidateCount); // If there are no cells with more than 1 candidate, the board is solved. if (lowestCandidateCount == 1) { solvedBoard = board; return true; } + // Take all cells with the least amount of candidates. + orderedCells = orderedCells.TakeWhile(c => c.CandidateCount == lowestCandidateCount); Cell cell = orderedCells.First(); foreach (int candidate in cell.Candidates()) @@ -71,14 +72,96 @@ private static bool SolveRec(Board board, [NotNullWhen(true)] out Board? solvedB return false; } + /// + /// Finds a number of solutions to a given board. + /// + /// + /// Whether the solved boards should be randomized. + /// Maximum number of solutions that should be in the returned list. Zero means unlimited. + /// + public static List? FindSolutions(Board board, int limit = int.MaxValue, bool random = false) + { + HashSet result = SolveRecAll(board, 0, limit, random); + // If a limit is specified and the result count exceeds it, return only the correct number of solutions. + if (result.Count > limit) + { + return result.Take(limit).ToList(); + } + return result.ToList(); + } + + private static HashSet SolveRecAll(Board board, int solutionCount, int limit, bool random) + { + HashSet solutions = new HashSet(); + // Eliminate candidates from all rules until nothing changes. + while (board.EliminateCandidates()); + // We hit an invalid state, and must backtrack. + if (!board.Validate()) + { + return solutions; + } + // If the board is solved, return it. + if (board.IsSolved()) + { + return new HashSet { board }; + } + + // Pick cell to collapse. + Cell cell; + if (random) + { + int randomIndex = new Random().Next(0, board.Cells().Count()); + cell = board.Cells().Skip(randomIndex).First(); + } + else + { + IEnumerable orderedCells = board.Cells() + .OrderBy(c => c.CandidateCount); + // Skip all cells with less than 2 candidates. + orderedCells = orderedCells.SkipWhile(c => c.CandidateCount <= 1); + cell = orderedCells.First(); + } + + foreach (int candidate in cell.Candidates()) + { + // Return if the limit of solutions has been reached + + if ((solutions.Count() + solutionCount) >= limit) + { + return solutions; + } + + // Create a clone of the board for backtracking. + Board clonedBoard = board.Clone(); + Cell clonedCell = clonedBoard[cell.X, cell.Y]; + + // Collapse the cell with a digit. + clonedCell.Digit = candidate; + + // Call solve on the new board. + HashSet subSolutions = SolveRecAll(clonedBoard, (solutionCount + solutions.Count()), limit, random); + solutions.UnionWith(subSolutions); + } + return solutions; + } + public static Board GenerateSolveable(Board board) { throw new NotImplementedException(); } - public static bool IsSatisfactory(Board board) + /// + /// Checks if the board can be solved by just using the EliminateCandidates methods from units. + /// + /// + /// True if the board can be solved without trial and error guessing. + public static bool IsSatisfactory(Board board) // Certain methods for eliminating candidates using inference are not currently implemented. Implementing them would make this function more acurate. { - throw new NotImplementedException(); + // Eliminate candidates from all rules untill nothing changes. + while (board.EliminateCandidates()); + // If the board is solved, it does not require trial and error. + return board.IsSolved(); + } public static bool IsProper(Board board)