Skip to content

Commit 3da79de

Browse files
authored
Add implementation for Open Knight Tour (#547)
1 parent 7be6d4e commit 3da79de

File tree

3 files changed

+358
-0
lines changed

3 files changed

+358
-0
lines changed
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
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)
25+
{
26+
continue;
27+
}
28+
// ignore zeros in partial/invalid boards
29+
if (!map.TryAdd(v, (r, c)))
30+
{
31+
throw new AssertionException($"Duplicate visit number detected: {v}.");
32+
}
33+
}
34+
}
35+
return map;
36+
}
37+
38+
private static void AssertIsValidTour(int[,] board)
39+
{
40+
var n = board.GetLength(0);
41+
Assert.That(board.GetLength(1), Is.EqualTo(n), "Board must be square.");
42+
43+
// 1) All cells visited and within [1..n*n]
44+
int min = int.MaxValue;
45+
int max = int.MinValue;
46+
47+
var seen = new bool[n * n + 1]; // 1..n*n
48+
for (var r = 0; r < n; r++)
49+
{
50+
for (var c = 0; c < n; c++)
51+
{
52+
var v = board[r, c];
53+
Assert.That(v, Is.InRange(1, n * n),
54+
$"Cell [{r},{c}] has out-of-range value {v}.");
55+
Assert.That(seen[v], Is.False, $"Duplicate value {v} found.");
56+
seen[v] = true;
57+
if (v < min)
58+
{
59+
min = v;
60+
}
61+
62+
if (v > max)
63+
{
64+
max = v;
65+
}
66+
}
67+
}
68+
Assert.That(min, Is.EqualTo(1), "Tour must start at 1.");
69+
Assert.That(max, Is.EqualTo(n * n), "Tour must end at n*n.");
70+
71+
// 2) Each successive step is a legal knight move
72+
var pos = MapVisitOrder(board); // throws if duplicates
73+
for (var step = 1; step < n * n; step++)
74+
{
75+
var a = pos[step];
76+
var b = pos[step + 1];
77+
Assert.That(IsKnightMove(a, b),
78+
$"Step {step}->{step + 1} is not a legal knight move: {a} -> {b}.");
79+
}
80+
}
81+
82+
[Test]
83+
public void Tour_Throws_On_NonPositiveN()
84+
{
85+
var solver = new OpenKnightTour();
86+
87+
Assert.Throws<ArgumentException>(() => solver.Tour(0));
88+
Assert.Throws<ArgumentException>(() => solver.Tour(-1));
89+
Assert.Throws<ArgumentException>(() => solver.Tour(-5));
90+
}
91+
92+
[TestCase(2)]
93+
[TestCase(3)]
94+
[TestCase(4)]
95+
public void Tour_Throws_On_Unsolvable_N_2_3_4(int n)
96+
{
97+
var solver = new OpenKnightTour();
98+
Assert.Throws<ArgumentException>(() => solver.Tour(n));
99+
}
100+
101+
[Test]
102+
public void Tour_Returns_Valid_1x1()
103+
{
104+
var solver = new OpenKnightTour();
105+
var board = solver.Tour(1);
106+
107+
Assert.That(board.GetLength(0), Is.EqualTo(1));
108+
Assert.That(board.GetLength(1), Is.EqualTo(1));
109+
Assert.That(board[0, 0], Is.EqualTo(1));
110+
AssertIsValidTour(board);
111+
}
112+
113+
/// <summary>
114+
/// The plain backtracking search can take some time on 5x5 depending on move ordering,
115+
/// but should still be manageable. We mark it as "Slow" and add a generous timeout.
116+
/// </summary>
117+
[Test, Category("Slow"), CancelAfterAttribute(30000)]
118+
public void Tour_Returns_Valid_5x5()
119+
{
120+
var solver = new OpenKnightTour();
121+
var board = solver.Tour(5);
122+
123+
// Shape checks
124+
Assert.That(board.GetLength(0), Is.EqualTo(5));
125+
Assert.That(board.GetLength(1), Is.EqualTo(5));
126+
127+
// Structural validity checks
128+
AssertIsValidTour(board);
129+
}
130+
131+
[Test]
132+
public void Tour_Fills_All_Cells_No_Zeros_On_Successful_Boards()
133+
{
134+
var solver = new OpenKnightTour();
135+
var board = solver.Tour(5);
136+
137+
for (var r = 0; r < board.GetLength(0); r++)
138+
{
139+
for (var c = 0; c < board.GetLength(1); c++)
140+
{
141+
Assert.That(board[r, c], Is.Not.EqualTo(0),
142+
$"Found unvisited cell at [{r},{c}].");
143+
}
144+
}
145+
}
146+
147+
[Test]
148+
public void Tour_Produces_Values_In_Valid_Range_And_Unique()
149+
{
150+
var solver = new OpenKnightTour();
151+
var n = 5;
152+
var board = solver.Tour(n);
153+
154+
var values = new List<int>(n * n);
155+
for (var r = 0; r < n; r++)
156+
{
157+
for (var c = 0; c < n; c++)
158+
{
159+
values.Add(board[r, c]);
160+
}
161+
}
162+
163+
values.Sort();
164+
// Expect [1..n*n]
165+
var expected = Enumerable.Range(1, n * n).ToArray();
166+
Assert.That(values, Is.EqualTo(expected),
167+
"Board must contain each number exactly once from 1 to n*n.");
168+
}
169+
170+
[Test]
171+
public void Tour_Returns_Square_Array()
172+
{
173+
var solver = new OpenKnightTour();
174+
var board = solver.Tour(5);
175+
176+
Assert.That(board.GetLength(0), Is.EqualTo(board.GetLength(1)));
177+
}
178+
}
179+
}
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
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+
var r = position.R;
133+
var c = position.C;
134+
135+
var candidates = new (int Dr, int Dc)[]
136+
{
137+
(1, 2), (-1, 2), (1, -2), (-1, -2),
138+
(2, 1), (2, -1), (-2, 1), (-2, -1),
139+
};
140+
141+
foreach (var (dr, dc) in candidates)
142+
{
143+
var nr = r + dr;
144+
var nc = c + dc;
145+
146+
if (nr >= 0 && nr < n && nc >= 0 && nc < n)
147+
{
148+
yield return (nr, nc);
149+
}
150+
}
151+
}
152+
153+
/// <summary>
154+
/// Checks whether the tour is complete; i.e., every cell is non-zero.
155+
/// </summary>
156+
/// <param name="board">The board to check.</param>
157+
/// <returns><c>true</c> if all cells have been visited; otherwise, <c>false</c>.</returns>
158+
/// <remarks>
159+
/// A complete board means the knight has visited exactly <c>n × n</c> distinct cells.
160+
/// </remarks>
161+
private bool IsComplete(int[,] board)
162+
{
163+
var n = board.GetLength(0);
164+
for (var row = 0; row < n; row++)
165+
{
166+
for (var col = 0; col < n; col++)
167+
{
168+
if (board[row, col] == 0)
169+
{
170+
return false;
171+
}
172+
}
173+
}
174+
175+
return true;
176+
}
177+
}

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,8 @@ find more than one implementation for the same objective but using different alg
246246
* [Proposer](./Algorithms/Problems/StableMarriage/Proposer.cs)
247247
* [N-Queens](./Algorithms/Problems/NQueens)
248248
* [Backtracking](./Algorithms/Problems/NQueens/BacktrackingNQueensSolver.cs)
249+
* [Knight Tour](./Algorithms/Problems/KnightTour/)
250+
* [Open Knight Tour](./Algorithms/Problems/KnightTour/OpenKnightTour.cs)
249251
* [Dynamic Programming](./Algorithms/Problems/DynamicProgramming)
250252
* [Coin Change](./Algorithms/Problems/DynamicProgramming/CoinChange/DynamicCoinChangeSolver.cs)
251253
* [Levenshtein Distance](./Algorithms/Problems/DynamicProgramming/LevenshteinDistance/LevenshteinDistance.cs)

0 commit comments

Comments
 (0)