Skip to content

Commit 20da672

Browse files
authored
Add Traveling Salesman Problem (TSP) (#536)
1 parent ed242f4 commit 20da672

File tree

5 files changed

+394
-65
lines changed

5 files changed

+394
-65
lines changed
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
using Algorithms.Problems.TravelingSalesman;
2+
using NUnit.Framework;
3+
4+
namespace Algorithms.Tests.Problems.TravelingSalesman;
5+
6+
/// <summary>
7+
/// Unit tests for TravelingSalesmanSolver. Covers brute-force and nearest neighbor methods, including edge cases and invalid input.
8+
/// </summary>
9+
[TestFixture]
10+
public class TravelingSalesmanSolverTests
11+
{
12+
/// <summary>
13+
/// Tests brute-force TSP solver on a 4-city symmetric distance matrix with known optimal route.
14+
/// </summary>
15+
[Test]
16+
public void SolveBruteForce_KnownOptimalRoute_ReturnsCorrectResult()
17+
{
18+
// Distance matrix for 4 cities (symmetric, triangle inequality holds)
19+
double[,] matrix =
20+
{
21+
{ 0, 10, 15, 20 },
22+
{ 10, 0, 35, 25 },
23+
{ 15, 35, 0, 30 },
24+
{ 20, 25, 30, 0 }
25+
};
26+
var (route, distance) = TravelingSalesmanSolver.SolveBruteForce(matrix);
27+
// Optimal route: 0 -> 1 -> 3 -> 2 -> 0, total distance = 80
28+
Assert.That(distance, Is.EqualTo(80));
29+
Assert.That(route, Is.EquivalentTo(new[] { 0, 1, 3, 2, 0 }));
30+
}
31+
32+
/// <summary>
33+
/// Tests nearest neighbor heuristic on the same matrix. May not be optimal.
34+
/// </summary>
35+
[Test]
36+
public void SolveNearestNeighbor_Heuristic_ReturnsFeasibleRoute()
37+
{
38+
double[,] matrix =
39+
{
40+
{ 0, 10, 15, 20 },
41+
{ 10, 0, 35, 25 },
42+
{ 15, 35, 0, 30 },
43+
{ 20, 25, 30, 0 }
44+
};
45+
var (route, distance) = TravelingSalesmanSolver.SolveNearestNeighbor(matrix, 0);
46+
// Route: 0 -> 1 -> 3 -> 2 -> 0, total distance = 80
47+
Assert.That(route.Length, Is.EqualTo(5));
48+
Assert.That(route.First(), Is.EqualTo(0));
49+
Assert.That(route.Last(), Is.EqualTo(0));
50+
Assert.That(distance, Is.GreaterThanOrEqualTo(80)); // Heuristic may be optimal or suboptimal
51+
}
52+
53+
/// <summary>
54+
/// Tests nearest neighbor with invalid start index.
55+
/// </summary>
56+
[Test]
57+
public void SolveNearestNeighbor_InvalidStart_ThrowsException()
58+
{
59+
double[,] matrix =
60+
{
61+
{ 0, 1 },
62+
{ 1, 0 }
63+
};
64+
Assert.Throws<ArgumentOutOfRangeException>(() => TravelingSalesmanSolver.SolveNearestNeighbor(matrix, -1));
65+
Assert.Throws<ArgumentOutOfRangeException>(() => TravelingSalesmanSolver.SolveNearestNeighbor(matrix, 2));
66+
}
67+
68+
/// <summary>
69+
/// Tests nearest neighbor when no unvisited cities remain (should throw InvalidOperationException).
70+
/// </summary>
71+
[Test]
72+
public void SolveNearestNeighbor_NoUnvisitedCities_ThrowsException()
73+
{
74+
// Construct a matrix where one city cannot be reached (simulate unreachable city)
75+
double[,] matrix =
76+
{
77+
{ 0, double.MaxValue, 1 },
78+
{ double.MaxValue, 0, double.MaxValue },
79+
{ 1, double.MaxValue, 0 }
80+
};
81+
// Start at city 0, city 1 is unreachable from both 0 and 2
82+
Assert.Throws<InvalidOperationException>(() => TravelingSalesmanSolver.SolveNearestNeighbor(matrix, 0));
83+
}
84+
85+
/// <summary>
86+
/// Tests brute-force and nearest neighbor with non-square matrix.
87+
/// </summary>
88+
[Test]
89+
public void NonSquareMatrix_ThrowsException()
90+
{
91+
double[,] matrix = new double[2, 3];
92+
Assert.Throws<ArgumentException>(() => TravelingSalesmanSolver.SolveBruteForce(matrix));
93+
Assert.Throws<ArgumentException>(() => TravelingSalesmanSolver.SolveNearestNeighbor(matrix, 0));
94+
}
95+
96+
/// <summary>
97+
/// Tests brute-force with less than two cities (invalid case).
98+
/// </summary>
99+
[Test]
100+
public void SolveBruteForce_TooFewCities_ThrowsException()
101+
{
102+
double[,] matrix = { { 0 } };
103+
Assert.Throws<ArgumentException>(() => TravelingSalesmanSolver.SolveBruteForce(matrix));
104+
}
105+
106+
/// <summary>
107+
/// Tests nearest neighbor with only two cities (trivial case).
108+
/// </summary>
109+
[Test]
110+
public void SolveNearestNeighbor_TwoCities_ReturnsCorrectRoute()
111+
{
112+
double[,] matrix =
113+
{
114+
{ 0, 5 },
115+
{ 5, 0 }
116+
};
117+
var (route, distance) = TravelingSalesmanSolver.SolveNearestNeighbor(matrix, 0);
118+
Assert.That(route, Is.EquivalentTo(new[] { 0, 1, 0 }));
119+
Assert.That(distance, Is.EqualTo(10));
120+
}
121+
}

Algorithms.Tests/RecommenderSystem/CollaborativeFilteringTests.cs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,5 +89,71 @@ public void PredictRating_WithOtherUserHavingRatedTargetItem_ShouldCalculateSimi
8989
Assert.That(predictedRating, Is.Not.EqualTo(0.0d));
9090
Assert.That(predictedRating, Is.EqualTo(3.5d).Within(0.01));
9191
}
92+
93+
[Test]
94+
public void PredictRating_TargetUserNotExist_ThrowsOrReturnsZero()
95+
{
96+
Assert.Throws<KeyNotFoundException>(() => recommender!.PredictRating("item1", "nonexistentUser", testRatings));
97+
}
98+
99+
[Test]
100+
public void PredictRating_RatingsEmpty_ReturnsZero()
101+
{
102+
var emptyRatings = new Dictionary<string, Dictionary<string, double>>();
103+
Assert.Throws<KeyNotFoundException>(() => recommender!.PredictRating("item1", "user1", emptyRatings));
104+
}
105+
106+
[Test]
107+
public void PredictRating_NoOtherUserRatedTargetItem_ReturnsZero()
108+
{
109+
var ratings = new Dictionary<string, Dictionary<string, double>>
110+
{
111+
["user1"] = new() { ["item1"] = 5.0 },
112+
["user2"] = new() { ["item2"] = 4.0 }
113+
};
114+
var recommenderLocal = new CollaborativeFiltering(mockSimilarityCalculator!.Object);
115+
var result = recommenderLocal.PredictRating("item2", "user1", ratings);
116+
Assert.That(result, Is.EqualTo(0));
117+
}
118+
119+
[Test]
120+
public void CalculateSimilarity_EmptyDictionaries_ReturnsZero()
121+
{
122+
var recommenderLocal = new CollaborativeFiltering(mockSimilarityCalculator!.Object);
123+
var result = recommenderLocal.CalculateSimilarity(new Dictionary<string, double>(), new Dictionary<string, double>());
124+
Assert.That(result, Is.EqualTo(0));
125+
}
126+
127+
[Test]
128+
public void CalculateSimilarity_OneCommonItem_ReturnsZero()
129+
{
130+
var recommenderLocal = new CollaborativeFiltering(mockSimilarityCalculator!.Object);
131+
var dict1 = new Dictionary<string, double> { ["item1"] = 5.0 };
132+
var dict2 = new Dictionary<string, double> { ["item1"] = 5.0 };
133+
var result = recommenderLocal.CalculateSimilarity(dict1, dict2);
134+
Assert.That(result, Is.EqualTo(0));
135+
}
136+
137+
[Test]
138+
public void PredictRating_MultipleUsersWeightedSum_CorrectCalculation()
139+
{
140+
var ratings = new Dictionary<string, Dictionary<string, double>>
141+
{
142+
["user1"] = new() { ["item1"] = 5.0 },
143+
["user2"] = new() { ["item1"] = 2.0 },
144+
["user3"] = new() { ["item1"] = 8.0 }
145+
};
146+
var mockSim = new Mock<ISimilarityCalculator>();
147+
mockSim.Setup(s => s.CalculateSimilarity(It.IsAny<Dictionary<string, double>>(), ratings["user2"]))
148+
.Returns(-0.5);
149+
mockSim.Setup(s => s.CalculateSimilarity(It.IsAny<Dictionary<string, double>>(), ratings["user3"]))
150+
.Returns(1.0);
151+
var recommenderLocal = new CollaborativeFiltering(mockSim.Object);
152+
var result = recommenderLocal.PredictRating("item1", "user1", ratings);
153+
// weightedSum = (-0.5*2.0) + (1.0*8.0) = -1.0 + 8.0 = 7.0
154+
// totalSimilarity = 0.5 + 1.0 = 1.5
155+
// result = 7.0 / 1.5 = 4.666...
156+
Assert.That(result, Is.EqualTo(4.666).Within(0.01));
157+
}
92158
}
93159
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
namespace Algorithms.Problems.TravelingSalesman;
2+
3+
/// <summary>
4+
/// Provides methods to solve the Traveling Salesman Problem (TSP) using brute-force and nearest neighbor heuristics.
5+
/// The TSP is a classic optimization problem in which a salesman must visit each city exactly once and return to the starting city, minimizing the total travel distance.
6+
/// </summary>
7+
public static class TravelingSalesmanSolver
8+
{
9+
/// <summary>
10+
/// Solves the TSP using brute-force search. This method checks all possible permutations of cities to find the shortest possible route.
11+
/// WARNING: This approach is only feasible for small numbers of cities due to factorial time complexity.
12+
/// </summary>
13+
/// <param name="distanceMatrix">A square matrix where element [i, j] represents the distance from city i to city j.</param>
14+
/// <returns>A tuple containing the minimal route (as an array of city indices) and the minimal total distance.</returns>
15+
public static (int[] Route, double Distance) SolveBruteForce(double[,] distanceMatrix)
16+
{
17+
int n = distanceMatrix.GetLength(0);
18+
if (n != distanceMatrix.GetLength(1))
19+
{
20+
throw new ArgumentException("Distance matrix must be square.");
21+
}
22+
23+
if (n < 2)
24+
{
25+
throw new ArgumentException("At least two cities are required.");
26+
}
27+
28+
var cities = Enumerable.Range(0, n).ToArray();
29+
double minDistance = double.MaxValue;
30+
int[]? bestRoute = null;
31+
32+
foreach (var perm in Permute(cities.Skip(1).ToArray()))
33+
{
34+
var route = new int[n + 1];
35+
route[0] = 0;
36+
for (int i = 0; i < perm.Length; i++)
37+
{
38+
route[i + 1] = perm[i];
39+
}
40+
41+
// Ensure route ends at city 0
42+
route[n] = 0;
43+
44+
double dist = 0;
45+
for (int i = 0; i < n; i++)
46+
{
47+
dist += distanceMatrix[route[i], route[i + 1]];
48+
}
49+
50+
if (dist < minDistance)
51+
{
52+
minDistance = dist;
53+
bestRoute = (int[])route.Clone();
54+
}
55+
}
56+
57+
return (bestRoute ?? Array.Empty<int>(), minDistance);
58+
}
59+
60+
/// <summary>
61+
/// Solves the TSP using the nearest neighbor heuristic. This method builds a route by always visiting the nearest unvisited city next.
62+
/// This approach is much faster but may not find the optimal solution.
63+
/// </summary>
64+
/// <param name="distanceMatrix">A square matrix where element [i, j] represents the distance from city i to city j.</param>
65+
/// <param name="start">The starting city index.</param>
66+
/// <returns>A tuple containing the route (as an array of city indices) and the total distance.</returns>
67+
public static (int[] Route, double Distance) SolveNearestNeighbor(double[,] distanceMatrix, int start = 0)
68+
{
69+
int n = distanceMatrix.GetLength(0);
70+
if (n != distanceMatrix.GetLength(1))
71+
{
72+
throw new ArgumentException("Distance matrix must be square.");
73+
}
74+
75+
if (start < 0 || start >= n)
76+
{
77+
throw new ArgumentOutOfRangeException(nameof(start));
78+
}
79+
80+
var visited = new bool[n];
81+
var route = new List<int> { start };
82+
visited[start] = true;
83+
double totalDistance = 0;
84+
int current = start;
85+
for (int step = 1; step < n; step++)
86+
{
87+
double minDist = double.MaxValue;
88+
int next = -1;
89+
for (int j = 0; j < n; j++)
90+
{
91+
if (!visited[j] && distanceMatrix[current, j] < minDist)
92+
{
93+
minDist = distanceMatrix[current, j];
94+
next = j;
95+
}
96+
}
97+
98+
if (next == -1)
99+
{
100+
throw new InvalidOperationException("No unvisited cities remain.");
101+
}
102+
103+
route.Add(next);
104+
visited[next] = true;
105+
totalDistance += minDist;
106+
current = next;
107+
}
108+
109+
totalDistance += distanceMatrix[current, start];
110+
route.Add(start);
111+
return (route.ToArray(), totalDistance);
112+
}
113+
114+
/// <summary>
115+
/// Generates all permutations of the input array.
116+
/// Used for brute-force TSP solution.
117+
/// </summary>
118+
private static IEnumerable<int[]> Permute(int[] arr)
119+
{
120+
if (arr.Length == 1)
121+
{
122+
yield return arr;
123+
}
124+
else
125+
{
126+
for (int i = 0; i < arr.Length; i++)
127+
{
128+
var rest = arr.Where((_, idx) => idx != i).ToArray();
129+
foreach (var perm in Permute(rest))
130+
{
131+
yield return new[] { arr[i] }.Concat(perm).ToArray();
132+
}
133+
}
134+
}
135+
}
136+
}

0 commit comments

Comments
 (0)