Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions Algorithms.Tests/Problems/KnightTour/OpenKnightTourTests.cs
Original file line number Diff line number Diff line change
@@ -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<int, (int r, int c)> MapVisitOrder(int[,] board)
{
var n = board.GetLength(0);
var map = new Dictionary<int, (int r, int c)>(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<ArgumentException>(() => solver.Tour(0));
Assert.Throws<ArgumentException>(() => solver.Tour(-1));
Assert.Throws<ArgumentException>(() => 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<ArgumentException>(() => 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);
}

/// <summary>
/// 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.
/// </summary>
[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<int>(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)));
}
}
}
177 changes: 177 additions & 0 deletions Algorithms/Problems/KnightTour/OpenKnightTour.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
namespace Algorithms.Problems.KnightTour;

/// <summary>
/// Computes a (single) Knight's Tour on an <c>n × n</c> chessboard using
/// depth-first search (DFS) with backtracking.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// The algorithm is a plain backtracking search—no heuristics (e.g., Warnsdorff’s rule)
/// are applied. As a result, runtime can grow exponentially with <c>n</c> and become
/// impractical on larger boards.
/// </para>
/// <para>
/// <b>Solvability (square boards):</b>
/// A (non-closed) tour exists for <c>n = 1</c> and for all <c>n ≥ 5</c>.
/// There is no tour for <c>n ∈ {2, 3, 4}</c>. This implementation throws an
/// <see cref="ArgumentException"/> if no tour is found.
/// </para>
/// <para>
/// <b>Coordinate convention:</b> The board is indexed as <c>[row, column]</c>,
/// zero-based, with <c>(0,0)</c> in the top-left corner.
/// </para>
/// </remarks>
public sealed class OpenKnightTour
{
/// <summary>
/// Attempts to find a Knight's Tour on an <c>n × n</c> board.
/// </summary>
/// <param name="n">Board size (number of rows/columns). Must be positive.</param>
/// <returns>
/// A 2D array of size <c>n × n</c> where each cell contains the
/// 1-based visit order (from <c>1</c> to <c>n*n</c>) of the knight.
/// </returns>
/// <exception cref="ArgumentException">
/// Thrown when <paramref name="n"/> ≤ 0, or when no tour exists / is found for the given <paramref name="n"/>.
/// </exception>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// <b>Performance:</b> 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.
/// </para>
/// </remarks>
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}.");
}

/// <summary>
/// Recursively extends the current partial tour from <paramref name="pos"/> after placing
/// move number <paramref name="current"/> in that position.
/// </summary>
/// <param name="board">The board with placed move numbers; <c>0</c> means unvisited.</param>
/// <param name="pos">Current knight position (<c>Row</c>, <c>Col</c>).</param>
/// <param name="current">The move number just placed at <paramref name="pos"/>.</param>
/// <returns><c>true</c> if a full tour is completed; <c>false</c> otherwise.</returns>
/// <remarks>
/// 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 <c>0</c> and tries the next candidate.
/// </remarks>
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;
}

/// <summary>
/// Computes all legal knight moves from <paramref name="position"/> on an <c>n × n</c> board.
/// </summary>
/// <param name="position">Current position (<c>R</c>, <c>C</c>).</param>
/// <param name="n">Board dimension (rows = columns = <paramref name="n"/>).</param>
/// <returns>
/// An enumeration of on-board destination coordinates. Order is fixed and unoptimized:
/// <c>(+1,+2), (-1,+2), (+1,-2), (-1,-2), (+2,+1), (+2,-1), (-2,+1), (-2,-1)</c>.
/// </returns>
/// <remarks>
/// 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.
/// </remarks>
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);
}
}
}

/// <summary>
/// Checks whether the tour is complete; i.e., every cell is non-zero.
/// </summary>
/// <param name="board">The board to check.</param>
/// <returns><c>true</c> if all cells have been visited; otherwise, <c>false</c>.</returns>
/// <remarks>
/// A complete board means the knight has visited exactly <c>n × n</c> distinct cells.
/// </remarks>
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;
}
}
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down