diff --git a/Algorithms.Tests/Problems/KnightTour/OpenKnightTourTests.cs b/Algorithms.Tests/Problems/KnightTour/OpenKnightTourTests.cs new file mode 100644 index 00000000..ece4e34d --- /dev/null +++ b/Algorithms.Tests/Problems/KnightTour/OpenKnightTourTests.cs @@ -0,0 +1,179 @@ +using Algorithms.Problems.KnightTour; + +namespace Algorithms.Tests.Problems.KnightTour +{ + [TestFixture] + public sealed class OpenKnightTourTests + { + private static bool IsKnightMove((int r, int c) a, (int r, int c) b) + { + var dr = Math.Abs(a.r - b.r); + var dc = Math.Abs(a.c - b.c); + return (dr == 1 && dc == 2) || (dr == 2 && dc == 1); + } + + private static Dictionary MapVisitOrder(int[,] board) + { + var n = board.GetLength(0); + var map = new Dictionary(n * n); + for (var r = 0; r < n; r++) + { + for (var c = 0; c < n; c++) + { + var v = board[r, c]; + if (v <= 0) + { + continue; + } + // ignore zeros in partial/invalid boards + if (!map.TryAdd(v, (r, c))) + { + throw new AssertionException($"Duplicate visit number detected: {v}."); + } + } + } + return map; + } + + private static void AssertIsValidTour(int[,] board) + { + var n = board.GetLength(0); + Assert.That(board.GetLength(1), Is.EqualTo(n), "Board must be square."); + + // 1) All cells visited and within [1..n*n] + int min = int.MaxValue; + int max = int.MinValue; + + var seen = new bool[n * n + 1]; // 1..n*n + for (var r = 0; r < n; r++) + { + for (var c = 0; c < n; c++) + { + var v = board[r, c]; + Assert.That(v, Is.InRange(1, n * n), + $"Cell [{r},{c}] has out-of-range value {v}."); + Assert.That(seen[v], Is.False, $"Duplicate value {v} found."); + seen[v] = true; + if (v < min) + { + min = v; + } + + if (v > max) + { + max = v; + } + } + } + Assert.That(min, Is.EqualTo(1), "Tour must start at 1."); + Assert.That(max, Is.EqualTo(n * n), "Tour must end at n*n."); + + // 2) Each successive step is a legal knight move + var pos = MapVisitOrder(board); // throws if duplicates + for (var step = 1; step < n * n; step++) + { + var a = pos[step]; + var b = pos[step + 1]; + Assert.That(IsKnightMove(a, b), + $"Step {step}->{step + 1} is not a legal knight move: {a} -> {b}."); + } + } + + [Test] + public void Tour_Throws_On_NonPositiveN() + { + var solver = new OpenKnightTour(); + + Assert.Throws(() => solver.Tour(0)); + Assert.Throws(() => solver.Tour(-1)); + Assert.Throws(() => solver.Tour(-5)); + } + + [TestCase(2)] + [TestCase(3)] + [TestCase(4)] + public void Tour_Throws_On_Unsolvable_N_2_3_4(int n) + { + var solver = new OpenKnightTour(); + Assert.Throws(() => solver.Tour(n)); + } + + [Test] + public void Tour_Returns_Valid_1x1() + { + var solver = new OpenKnightTour(); + var board = solver.Tour(1); + + Assert.That(board.GetLength(0), Is.EqualTo(1)); + Assert.That(board.GetLength(1), Is.EqualTo(1)); + Assert.That(board[0, 0], Is.EqualTo(1)); + AssertIsValidTour(board); + } + + /// + /// The plain backtracking search can take some time on 5x5 depending on move ordering, + /// but should still be manageable. We mark it as "Slow" and add a generous timeout. + /// + [Test, Category("Slow"), CancelAfterAttribute(30000)] + public void Tour_Returns_Valid_5x5() + { + var solver = new OpenKnightTour(); + var board = solver.Tour(5); + + // Shape checks + Assert.That(board.GetLength(0), Is.EqualTo(5)); + Assert.That(board.GetLength(1), Is.EqualTo(5)); + + // Structural validity checks + AssertIsValidTour(board); + } + + [Test] + public void Tour_Fills_All_Cells_No_Zeros_On_Successful_Boards() + { + var solver = new OpenKnightTour(); + var board = solver.Tour(5); + + for (var r = 0; r < board.GetLength(0); r++) + { + for (var c = 0; c < board.GetLength(1); c++) + { + Assert.That(board[r, c], Is.Not.EqualTo(0), + $"Found unvisited cell at [{r},{c}]."); + } + } + } + + [Test] + public void Tour_Produces_Values_In_Valid_Range_And_Unique() + { + var solver = new OpenKnightTour(); + var n = 5; + var board = solver.Tour(n); + + var values = new List(n * n); + for (var r = 0; r < n; r++) + { + for (var c = 0; c < n; c++) + { + values.Add(board[r, c]); + } + } + + values.Sort(); + // Expect [1..n*n] + var expected = Enumerable.Range(1, n * n).ToArray(); + Assert.That(values, Is.EqualTo(expected), + "Board must contain each number exactly once from 1 to n*n."); + } + + [Test] + public void Tour_Returns_Square_Array() + { + var solver = new OpenKnightTour(); + var board = solver.Tour(5); + + Assert.That(board.GetLength(0), Is.EqualTo(board.GetLength(1))); + } + } +} diff --git a/Algorithms/Problems/KnightTour/OpenKnightTour.cs b/Algorithms/Problems/KnightTour/OpenKnightTour.cs new file mode 100644 index 00000000..72e49b04 --- /dev/null +++ b/Algorithms/Problems/KnightTour/OpenKnightTour.cs @@ -0,0 +1,177 @@ +namespace Algorithms.Problems.KnightTour; + +/// +/// Computes a (single) Knight's Tour on an n × n chessboard using +/// depth-first search (DFS) with backtracking. +/// +/// +/// +/// A Knight's Tour is a sequence of knight moves that visits every square exactly once. +/// This implementation returns the first tour it finds (if any), starting from whichever +/// starting cell leads to a solution first. It explores every board square as a potential +/// starting position in row-major order. +/// +/// +/// The algorithm is a plain backtracking search—no heuristics (e.g., Warnsdorff’s rule) +/// are applied. As a result, runtime can grow exponentially with n and become +/// impractical on larger boards. +/// +/// +/// Solvability (square boards): +/// A (non-closed) tour exists for n = 1 and for all n ≥ 5. +/// There is no tour for n ∈ {2, 3, 4}. This implementation throws an +/// if no tour is found. +/// +/// +/// Coordinate convention: The board is indexed as [row, column], +/// zero-based, with (0,0) in the top-left corner. +/// +/// +public sealed class OpenKnightTour +{ + /// + /// Attempts to find a Knight's Tour on an n × n board. + /// + /// Board size (number of rows/columns). Must be positive. + /// + /// A 2D array of size n × n where each cell contains the + /// 1-based visit order (from 1 to n*n) of the knight. + /// + /// + /// Thrown when ≤ 0, or when no tour exists / is found for the given . + /// + /// + /// + /// This routine tries every square as a starting point. As soon as a complete tour is found, + /// the filled board is returned. If no tour is found, an exception is thrown. + /// + /// + /// Performance: Exponential in the worst case. For larger boards, consider adding + /// Warnsdorff’s heuristic (choose next moves with the fewest onward moves) or a hybrid approach. + /// + /// + public int[,] Tour(int n) + { + if (n <= 0) + { + throw new ArgumentException("Board size must be positive.", nameof(n)); + } + + var board = new int[n, n]; + + // Try every square as a starting point. + for (var r = 0; r < n; r++) + { + for (var c = 0; c < n; c++) + { + board[r, c] = 1; // first step + if (KnightTourHelper(board, (r, c), 1)) + { + return board; + } + + board[r, c] = 0; // backtrack and try next start + } + } + + throw new ArgumentException($"Knight Tour cannot be performed on a board of size {n}."); + } + + /// + /// Recursively extends the current partial tour from after placing + /// move number in that position. + /// + /// The board with placed move numbers; 0 means unvisited. + /// Current knight position (Row, Col). + /// The move number just placed at . + /// true if a full tour is completed; false otherwise. + /// + /// Tries each legal next move in a fixed order (no heuristics). If a move leads to a dead end, + /// it backtracks by resetting the target cell to 0 and tries the next candidate. + /// + private bool KnightTourHelper(int[,] board, (int Row, int Col) pos, int current) + { + if (IsComplete(board)) + { + return true; + } + + foreach (var (nr, nc) in GetValidMoves(pos, board.GetLength(0))) + { + if (board[nr, nc] == 0) + { + board[nr, nc] = current + 1; + + if (KnightTourHelper(board, (nr, nc), current + 1)) + { + return true; + } + + board[nr, nc] = 0; // backtrack + } + } + + return false; + } + + /// + /// Computes all legal knight moves from on an n × n board. + /// + /// Current position (R, C). + /// Board dimension (rows = columns = ). + /// + /// An enumeration of on-board destination coordinates. Order is fixed and unoptimized: + /// (+1,+2), (-1,+2), (+1,-2), (-1,-2), (+2,+1), (+2,-1), (-2,+1), (-2,-1). + /// + /// + /// Keeping a deterministic order makes the search reproducible, but it’s not necessarily fast. + /// To accelerate, pre-sort by onward-degree (Warnsdorff) or by a custom heuristic. + /// + private IEnumerable<(int R, int C)> GetValidMoves((int R, int C) position, int n) + { + var r = position.R; + var c = position.C; + + var candidates = new (int Dr, int Dc)[] + { + (1, 2), (-1, 2), (1, -2), (-1, -2), + (2, 1), (2, -1), (-2, 1), (-2, -1), + }; + + foreach (var (dr, dc) in candidates) + { + var nr = r + dr; + var nc = c + dc; + + if (nr >= 0 && nr < n && nc >= 0 && nc < n) + { + yield return (nr, nc); + } + } + } + + /// + /// Checks whether the tour is complete; i.e., every cell is non-zero. + /// + /// The board to check. + /// true if all cells have been visited; otherwise, false. + /// + /// A complete board means the knight has visited exactly n × n distinct cells. + /// + private bool IsComplete(int[,] board) + { + var n = board.GetLength(0); + for (var row = 0; row < n; row++) + { + for (var col = 0; col < n; col++) + { + if (board[row, col] == 0) + { + return false; + } + } + } + + return true; + } +} diff --git a/README.md b/README.md index db32a559..1b5de66b 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,8 @@ find more than one implementation for the same objective but using different alg * [Proposer](./Algorithms/Problems/StableMarriage/Proposer.cs) * [N-Queens](./Algorithms/Problems/NQueens) * [Backtracking](./Algorithms/Problems/NQueens/BacktrackingNQueensSolver.cs) + * [Knight Tour](./Algorithms/Problems/KnightTour/) + * [Open Knight Tour](./Algorithms/Problems/KnightTour/OpenKnightTour.cs) * [Dynamic Programming](./Algorithms/Problems/DynamicProgramming) * [Coin Change](./Algorithms/Problems/DynamicProgramming/CoinChange/DynamicCoinChangeSolver.cs) * [Levenshtein Distance](./Algorithms/Problems/DynamicProgramming/LevenshteinDistance/LevenshteinDistance.cs)