Skip to content

Commit e477ce2

Browse files
author
Kwstas Kalafatis
committed
Add implementation for Open Knight Tour
1 parent 7be6d4e commit e477ce2

File tree

2 files changed

+340
-0
lines changed

2 files changed

+340
-0
lines changed
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
using Algorithms.Problems.KnightTour;
2+
3+
namespace Algorithms.Tests.Problems.KnightTour
4+
{
5+
[TestFixture]
6+
public sealed class OpenKnightTourTests
7+
{
8+
private static bool IsKnightMove((int r, int c) a, (int r, int c) b)
9+
{
10+
var dr = Math.Abs(a.r - b.r);
11+
var dc = Math.Abs(a.c - b.c);
12+
return (dr == 1 && dc == 2) || (dr == 2 && dc == 1);
13+
}
14+
15+
private static Dictionary<int, (int r, int c)> MapVisitOrder(int[,] board)
16+
{
17+
var n = board.GetLength(0);
18+
var map = new Dictionary<int, (int r, int c)>(n * n);
19+
for (var r = 0; r < n; r++)
20+
{
21+
for (var c = 0; c < n; c++)
22+
{
23+
var v = board[r, c];
24+
if (v <= 0) continue; // ignore zeros in partial/invalid boards
25+
if (!map.TryAdd(v, (r, c)))
26+
{
27+
throw new AssertionException($"Duplicate visit number detected: {v}.");
28+
}
29+
}
30+
}
31+
return map;
32+
}
33+
34+
private static void AssertIsValidTour(int[,] board)
35+
{
36+
var n = board.GetLength(0);
37+
Assert.That(board.GetLength(1), Is.EqualTo(n), "Board must be square.");
38+
39+
// 1) All cells visited and within [1..n*n]
40+
int min = int.MaxValue, max = int.MinValue;
41+
var seen = new bool[n * n + 1]; // 1..n*n
42+
for (var r = 0; r < n; r++)
43+
{
44+
for (var c = 0; c < n; c++)
45+
{
46+
var v = board[r, c];
47+
Assert.That(v, Is.InRange(1, n * n),
48+
$"Cell [{r},{c}] has out-of-range value {v}.");
49+
Assert.That(seen[v], Is.False, $"Duplicate value {v} found.");
50+
seen[v] = true;
51+
if (v < min) min = v;
52+
if (v > max) max = v;
53+
}
54+
}
55+
Assert.That(min, Is.EqualTo(1), "Tour must start at 1.");
56+
Assert.That(max, Is.EqualTo(n * n), "Tour must end at n*n.");
57+
58+
// 2) Each successive step is a legal knight move
59+
var pos = MapVisitOrder(board); // throws if duplicates
60+
for (var step = 1; step < n * n; step++)
61+
{
62+
var a = pos[step];
63+
var b = pos[step + 1];
64+
Assert.That(IsKnightMove(a, b),
65+
$"Step {step}->{step + 1} is not a legal knight move: {a} -> {b}.");
66+
}
67+
}
68+
69+
[Test]
70+
public void Tour_Throws_On_NonPositiveN()
71+
{
72+
var solver = new OpenKnightTour();
73+
74+
Assert.Throws<ArgumentException>(() => solver.Tour(0));
75+
Assert.Throws<ArgumentException>(() => solver.Tour(-1));
76+
Assert.Throws<ArgumentException>(() => solver.Tour(-5));
77+
}
78+
79+
[TestCase(2)]
80+
[TestCase(3)]
81+
[TestCase(4)]
82+
public void Tour_Throws_On_Unsolvable_N_2_3_4(int n)
83+
{
84+
var solver = new OpenKnightTour();
85+
var ex = Assert.Throws<ArgumentException>(() => solver.Tour(n));
86+
}
87+
88+
[Test]
89+
public void Tour_Returns_Valid_1x1()
90+
{
91+
var solver = new OpenKnightTour();
92+
var board = solver.Tour(1);
93+
94+
Assert.That(board.GetLength(0), Is.EqualTo(1));
95+
Assert.That(board.GetLength(1), Is.EqualTo(1));
96+
Assert.That(board[0, 0], Is.EqualTo(1));
97+
AssertIsValidTour(board);
98+
}
99+
100+
/// <summary>
101+
/// The plain backtracking search can take some time on 5x5 depending on move ordering,
102+
/// but should still be manageable. We mark it as "Slow" and add a generous timeout.
103+
/// </summary>
104+
[Test, Category("Slow"), CancelAfterAttribute(30000)]
105+
public void Tour_Returns_Valid_5x5()
106+
{
107+
var solver = new OpenKnightTour();
108+
var board = solver.Tour(5);
109+
110+
// Shape checks
111+
Assert.That(board.GetLength(0), Is.EqualTo(5));
112+
Assert.That(board.GetLength(1), Is.EqualTo(5));
113+
114+
// Structural validity checks
115+
AssertIsValidTour(board);
116+
}
117+
118+
[Test]
119+
public void Tour_Fills_All_Cells_No_Zeros_On_Successful_Boards()
120+
{
121+
var solver = new OpenKnightTour();
122+
var board = solver.Tour(5);
123+
124+
for (var r = 0; r < board.GetLength(0); r++)
125+
{
126+
for (var c = 0; c < board.GetLength(1); c++)
127+
{
128+
Assert.That(board[r, c], Is.Not.EqualTo(0),
129+
$"Found unvisited cell at [{r},{c}].");
130+
}
131+
}
132+
}
133+
134+
[Test]
135+
public void Tour_Produces_Values_In_Valid_Range_And_Unique()
136+
{
137+
var solver = new OpenKnightTour();
138+
var n = 5;
139+
var board = solver.Tour(n);
140+
141+
var values = new List<int>(n * n);
142+
for (var r = 0; r < n; r++)
143+
{
144+
for (var c = 0; c < n; c++)
145+
{
146+
values.Add(board[r, c]);
147+
}
148+
}
149+
150+
values.Sort();
151+
// Expect [1..n*n]
152+
var expected = Enumerable.Range(1, n * n).ToArray();
153+
Assert.That(values, Is.EqualTo(expected),
154+
"Board must contain each number exactly once from 1 to n*n.");
155+
}
156+
157+
[Test]
158+
public void Tour_Returns_Square_Array()
159+
{
160+
var solver = new OpenKnightTour();
161+
var board = solver.Tour(5);
162+
163+
Assert.That(board.GetLength(0), Is.EqualTo(board.GetLength(1)));
164+
}
165+
}
166+
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
namespace Algorithms.Problems.KnightTour;
2+
3+
/// <summary>
4+
/// Computes a (single) Knight's Tour on an <c>n × n</c> chessboard using
5+
/// depth-first search (DFS) with backtracking.
6+
/// </summary>
7+
/// <remarks>
8+
/// <para>
9+
/// A Knight's Tour is a sequence of knight moves that visits every square exactly once.
10+
/// This implementation returns the first tour it finds (if any), starting from whichever
11+
/// starting cell leads to a solution first. It explores every board square as a potential
12+
/// starting position in row-major order.
13+
/// </para>
14+
/// <para>
15+
/// The algorithm is a plain backtracking search—no heuristics (e.g., Warnsdorff’s rule)
16+
/// are applied. As a result, runtime can grow exponentially with <c>n</c> and become
17+
/// impractical on larger boards.
18+
/// </para>
19+
/// <para>
20+
/// <b>Solvability (square boards):</b>
21+
/// A (non-closed) tour exists for <c>n = 1</c> and for all <c>n ≥ 5</c>.
22+
/// There is no tour for <c>n ∈ {2, 3, 4}</c>. This implementation throws an
23+
/// <see cref="ArgumentException"/> if no tour is found.
24+
/// </para>
25+
/// <para>
26+
/// <b>Coordinate convention:</b> The board is indexed as <c>[row, column]</c>,
27+
/// zero-based, with <c>(0,0)</c> in the top-left corner.
28+
/// </para>
29+
/// </remarks>
30+
public sealed class OpenKnightTour
31+
{
32+
/// <summary>
33+
/// Attempts to find a Knight's Tour on an <c>n × n</c> board.
34+
/// </summary>
35+
/// <param name="n">Board size (number of rows/columns). Must be positive.</param>
36+
/// <returns>
37+
/// A 2D array of size <c>n × n</c> where each cell contains the
38+
/// 1-based visit order (from <c>1</c> to <c>n*n</c>) of the knight.
39+
/// </returns>
40+
/// <exception cref="ArgumentException">
41+
/// Thrown when <paramref name="n"/> ≤ 0, or when no tour exists / is found for the given <paramref name="n"/>.
42+
/// </exception>
43+
/// <remarks>
44+
/// <para>
45+
/// This routine tries every square as a starting point. As soon as a complete tour is found,
46+
/// the filled board is returned. If no tour is found, an exception is thrown.
47+
/// </para>
48+
/// <para>
49+
/// <b>Performance:</b> Exponential in the worst case. For larger boards, consider adding
50+
/// Warnsdorff’s heuristic (choose next moves with the fewest onward moves) or a hybrid approach.
51+
/// </para>
52+
/// </remarks>
53+
public int[,] Tour(int n)
54+
{
55+
if (n <= 0)
56+
{
57+
throw new ArgumentException("Board size must be positive.", nameof(n));
58+
}
59+
60+
var board = new int[n, n];
61+
62+
// Try every square as a starting point.
63+
for (var r = 0; r < n; r++)
64+
{
65+
for (var c = 0; c < n; c++)
66+
{
67+
board[r, c] = 1; // first step
68+
if (KnightTourHelper(board, (r, c), 1))
69+
{
70+
return board;
71+
}
72+
73+
board[r, c] = 0; // backtrack and try next start
74+
}
75+
}
76+
77+
throw new ArgumentException($"Knight Tour cannot be performed on a board of size {n}.");
78+
}
79+
80+
/// <summary>
81+
/// Recursively extends the current partial tour from <paramref name="pos"/> after placing
82+
/// move number <paramref name="current"/> in that position.
83+
/// </summary>
84+
/// <param name="board">The board with placed move numbers; <c>0</c> means unvisited.</param>
85+
/// <param name="pos">Current knight position (<c>Row</c>, <c>Col</c>).</param>
86+
/// <param name="current">The move number just placed at <paramref name="pos"/>.</param>
87+
/// <returns><c>true</c> if a full tour is completed; <c>false</c> otherwise.</returns>
88+
/// <remarks>
89+
/// Tries each legal next move in a fixed order (no heuristics). If a move leads to a dead end,
90+
/// it backtracks by resetting the target cell to <c>0</c> and tries the next candidate.
91+
/// </remarks>
92+
private bool KnightTourHelper(int[,] board, (int Row, int Col) pos, int current)
93+
{
94+
if (IsComplete(board))
95+
{
96+
return true;
97+
}
98+
99+
foreach (var (nr, nc) in GetValidMoves(pos, board.GetLength(0)))
100+
{
101+
if (board[nr, nc] == 0)
102+
{
103+
board[nr, nc] = current + 1;
104+
105+
if (KnightTourHelper(board, (nr, nc), current + 1))
106+
{
107+
return true;
108+
}
109+
110+
board[nr, nc] = 0; // backtrack
111+
}
112+
}
113+
114+
return false;
115+
}
116+
117+
/// <summary>
118+
/// Computes all legal knight moves from <paramref name="position"/> on an <c>n × n</c> board.
119+
/// </summary>
120+
/// <param name="position">Current position (<c>R</c>, <c>C</c>).</param>
121+
/// <param name="n">Board dimension (rows = columns = <paramref name="n"/>).</param>
122+
/// <returns>
123+
/// An enumeration of on-board destination coordinates. Order is fixed and unoptimized:
124+
/// <c>(+1,+2), (-1,+2), (+1,-2), (-1,-2), (+2,+1), (+2,-1), (-2,+1), (-2,-1)</c>.
125+
/// </returns>
126+
/// <remarks>
127+
/// Keeping a deterministic order makes the search reproducible, but it’s not necessarily fast.
128+
/// To accelerate, pre-sort by onward-degree (Warnsdorff) or by a custom heuristic.
129+
/// </remarks>
130+
private IEnumerable<(int R, int C)> GetValidMoves((int R, int C) position, int n)
131+
{
132+
int r = position.R, c = position.C;
133+
134+
var candidates = new (int Dr, int Dc)[]
135+
{
136+
(1, 2), (-1, 2), (1, -2), (-1, -2),
137+
(2, 1), (2, -1), (-2, 1), (-2, -1),
138+
};
139+
140+
foreach (var (dr, dc) in candidates)
141+
{
142+
int nr = r + dr, nc = c + dc;
143+
if (nr >= 0 && nr < n && nc >= 0 && nc < n)
144+
{
145+
yield return (nr, nc);
146+
}
147+
}
148+
}
149+
150+
/// <summary>
151+
/// Checks whether the tour is complete; i.e., every cell is non-zero.
152+
/// </summary>
153+
/// <param name="board">The board to check.</param>
154+
/// <returns><c>true</c> if all cells have been visited; otherwise, <c>false</c>.</returns>
155+
/// <remarks>
156+
/// A complete board means the knight has visited exactly <c>n × n</c> distinct cells.
157+
/// </remarks>
158+
private bool IsComplete(int[,] board)
159+
{
160+
var n = board.GetLength(0);
161+
for (var row = 0; row < n; row++)
162+
{
163+
for (var col = 0; col < n; col++)
164+
{
165+
if (board[row, col] == 0)
166+
{
167+
return false;
168+
}
169+
}
170+
}
171+
172+
return true;
173+
}
174+
}

0 commit comments

Comments
 (0)