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
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
using Algorithms.Problems.TravelingSalesman;
using NUnit.Framework;

namespace Algorithms.Tests.Problems.TravelingSalesman;

/// <summary>
/// Unit tests for TravelingSalesmanSolver. Covers brute-force and nearest neighbor methods, including edge cases and invalid input.
/// </summary>
[TestFixture]
public class TravelingSalesmanSolverTests
{
/// <summary>
/// Tests brute-force TSP solver on a 4-city symmetric distance matrix with known optimal route.
/// </summary>
[Test]
public void SolveBruteForce_KnownOptimalRoute_ReturnsCorrectResult()
{
// Distance matrix for 4 cities (symmetric, triangle inequality holds)
double[,] matrix =
{
{ 0, 10, 15, 20 },
{ 10, 0, 35, 25 },
{ 15, 35, 0, 30 },
{ 20, 25, 30, 0 }
};
var (route, distance) = TravelingSalesmanSolver.SolveBruteForce(matrix);
// Optimal route: 0 -> 1 -> 3 -> 2 -> 0, total distance = 80
Assert.That(distance, Is.EqualTo(80));
Assert.That(route, Is.EquivalentTo(new[] { 0, 1, 3, 2, 0 }));
}

/// <summary>
/// Tests nearest neighbor heuristic on the same matrix. May not be optimal.
/// </summary>
[Test]
public void SolveNearestNeighbor_Heuristic_ReturnsFeasibleRoute()
{
double[,] matrix =
{
{ 0, 10, 15, 20 },
{ 10, 0, 35, 25 },
{ 15, 35, 0, 30 },
{ 20, 25, 30, 0 }
};
var (route, distance) = TravelingSalesmanSolver.SolveNearestNeighbor(matrix, 0);
// Route: 0 -> 1 -> 3 -> 2 -> 0, total distance = 80
Assert.That(route.Length, Is.EqualTo(5));
Assert.That(route.First(), Is.EqualTo(0));
Assert.That(route.Last(), Is.EqualTo(0));
Assert.That(distance, Is.GreaterThanOrEqualTo(80)); // Heuristic may be optimal or suboptimal
}

/// <summary>
/// Tests nearest neighbor with invalid start index.
/// </summary>
[Test]
public void SolveNearestNeighbor_InvalidStart_ThrowsException()
{
double[,] matrix =
{
{ 0, 1 },
{ 1, 0 }
};
Assert.Throws<ArgumentOutOfRangeException>(() => TravelingSalesmanSolver.SolveNearestNeighbor(matrix, -1));
Assert.Throws<ArgumentOutOfRangeException>(() => TravelingSalesmanSolver.SolveNearestNeighbor(matrix, 2));
}

/// <summary>
/// Tests nearest neighbor when no unvisited cities remain (should throw InvalidOperationException).
/// </summary>
[Test]
public void SolveNearestNeighbor_NoUnvisitedCities_ThrowsException()
{
// Construct a matrix where one city cannot be reached (simulate unreachable city)
double[,] matrix =
{
{ 0, double.MaxValue, 1 },
{ double.MaxValue, 0, double.MaxValue },
{ 1, double.MaxValue, 0 }
};
// Start at city 0, city 1 is unreachable from both 0 and 2
Assert.Throws<InvalidOperationException>(() => TravelingSalesmanSolver.SolveNearestNeighbor(matrix, 0));
}

/// <summary>
/// Tests brute-force and nearest neighbor with non-square matrix.
/// </summary>
[Test]
public void NonSquareMatrix_ThrowsException()
{
double[,] matrix = new double[2, 3];
Assert.Throws<ArgumentException>(() => TravelingSalesmanSolver.SolveBruteForce(matrix));
Assert.Throws<ArgumentException>(() => TravelingSalesmanSolver.SolveNearestNeighbor(matrix, 0));
}

/// <summary>
/// Tests brute-force with less than two cities (invalid case).
/// </summary>
[Test]
public void SolveBruteForce_TooFewCities_ThrowsException()
{
double[,] matrix = { { 0 } };
Assert.Throws<ArgumentException>(() => TravelingSalesmanSolver.SolveBruteForce(matrix));
}

/// <summary>
/// Tests nearest neighbor with only two cities (trivial case).
/// </summary>
[Test]
public void SolveNearestNeighbor_TwoCities_ReturnsCorrectRoute()
{
double[,] matrix =
{
{ 0, 5 },
{ 5, 0 }
};
var (route, distance) = TravelingSalesmanSolver.SolveNearestNeighbor(matrix, 0);
Assert.That(route, Is.EquivalentTo(new[] { 0, 1, 0 }));
Assert.That(distance, Is.EqualTo(10));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -89,5 +89,71 @@ public void PredictRating_WithOtherUserHavingRatedTargetItem_ShouldCalculateSimi
Assert.That(predictedRating, Is.Not.EqualTo(0.0d));
Assert.That(predictedRating, Is.EqualTo(3.5d).Within(0.01));
}

[Test]
public void PredictRating_TargetUserNotExist_ThrowsOrReturnsZero()
{
Assert.Throws<KeyNotFoundException>(() => recommender!.PredictRating("item1", "nonexistentUser", testRatings));
}

[Test]
public void PredictRating_RatingsEmpty_ReturnsZero()
{
var emptyRatings = new Dictionary<string, Dictionary<string, double>>();
Assert.Throws<KeyNotFoundException>(() => recommender!.PredictRating("item1", "user1", emptyRatings));
}

[Test]
public void PredictRating_NoOtherUserRatedTargetItem_ReturnsZero()
{
var ratings = new Dictionary<string, Dictionary<string, double>>
{
["user1"] = new() { ["item1"] = 5.0 },
["user2"] = new() { ["item2"] = 4.0 }
};
var recommenderLocal = new CollaborativeFiltering(mockSimilarityCalculator!.Object);
var result = recommenderLocal.PredictRating("item2", "user1", ratings);
Assert.That(result, Is.EqualTo(0));
}

[Test]
public void CalculateSimilarity_EmptyDictionaries_ReturnsZero()
{
var recommenderLocal = new CollaborativeFiltering(mockSimilarityCalculator!.Object);
var result = recommenderLocal.CalculateSimilarity(new Dictionary<string, double>(), new Dictionary<string, double>());
Assert.That(result, Is.EqualTo(0));
}

[Test]
public void CalculateSimilarity_OneCommonItem_ReturnsZero()
{
var recommenderLocal = new CollaborativeFiltering(mockSimilarityCalculator!.Object);
var dict1 = new Dictionary<string, double> { ["item1"] = 5.0 };
var dict2 = new Dictionary<string, double> { ["item1"] = 5.0 };
var result = recommenderLocal.CalculateSimilarity(dict1, dict2);
Assert.That(result, Is.EqualTo(0));
}

[Test]
public void PredictRating_MultipleUsersWeightedSum_CorrectCalculation()
{
var ratings = new Dictionary<string, Dictionary<string, double>>
{
["user1"] = new() { ["item1"] = 5.0 },
["user2"] = new() { ["item1"] = 2.0 },
["user3"] = new() { ["item1"] = 8.0 }
};
var mockSim = new Mock<ISimilarityCalculator>();
mockSim.Setup(s => s.CalculateSimilarity(It.IsAny<Dictionary<string, double>>(), ratings["user2"]))
.Returns(-0.5);
mockSim.Setup(s => s.CalculateSimilarity(It.IsAny<Dictionary<string, double>>(), ratings["user3"]))
.Returns(1.0);
var recommenderLocal = new CollaborativeFiltering(mockSim.Object);
var result = recommenderLocal.PredictRating("item1", "user1", ratings);
// weightedSum = (-0.5*2.0) + (1.0*8.0) = -1.0 + 8.0 = 7.0
// totalSimilarity = 0.5 + 1.0 = 1.5
// result = 7.0 / 1.5 = 4.666...
Assert.That(result, Is.EqualTo(4.666).Within(0.01));
}
}
}
136 changes: 136 additions & 0 deletions Algorithms/Problems/TravelingSalesman/TravelingSalesmanSolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
namespace Algorithms.Problems.TravelingSalesman;

/// <summary>
/// Provides methods to solve the Traveling Salesman Problem (TSP) using brute-force and nearest neighbor heuristics.
/// 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.
/// </summary>
public static class TravelingSalesmanSolver
{
/// <summary>
/// Solves the TSP using brute-force search. This method checks all possible permutations of cities to find the shortest possible route.
/// WARNING: This approach is only feasible for small numbers of cities due to factorial time complexity.
/// </summary>
/// <param name="distanceMatrix">A square matrix where element [i, j] represents the distance from city i to city j.</param>
/// <returns>A tuple containing the minimal route (as an array of city indices) and the minimal total distance.</returns>
public static (int[] Route, double Distance) SolveBruteForce(double[,] distanceMatrix)
{
int n = distanceMatrix.GetLength(0);
if (n != distanceMatrix.GetLength(1))
{
throw new ArgumentException("Distance matrix must be square.");
}

if (n < 2)
{
throw new ArgumentException("At least two cities are required.");
}

var cities = Enumerable.Range(0, n).ToArray();
double minDistance = double.MaxValue;
int[]? bestRoute = null;

foreach (var perm in Permute(cities.Skip(1).ToArray()))
{
var route = new int[n + 1];
route[0] = 0;
for (int i = 0; i < perm.Length; i++)
{
route[i + 1] = perm[i];
}

// Ensure route ends at city 0
route[n] = 0;

double dist = 0;
for (int i = 0; i < n; i++)
{
dist += distanceMatrix[route[i], route[i + 1]];
}

if (dist < minDistance)
{
minDistance = dist;
bestRoute = (int[])route.Clone();
}
}

return (bestRoute ?? Array.Empty<int>(), minDistance);
}

/// <summary>
/// Solves the TSP using the nearest neighbor heuristic. This method builds a route by always visiting the nearest unvisited city next.
/// This approach is much faster but may not find the optimal solution.
/// </summary>
/// <param name="distanceMatrix">A square matrix where element [i, j] represents the distance from city i to city j.</param>
/// <param name="start">The starting city index.</param>
/// <returns>A tuple containing the route (as an array of city indices) and the total distance.</returns>
public static (int[] Route, double Distance) SolveNearestNeighbor(double[,] distanceMatrix, int start = 0)
{
int n = distanceMatrix.GetLength(0);
if (n != distanceMatrix.GetLength(1))
{
throw new ArgumentException("Distance matrix must be square.");
}

if (start < 0 || start >= n)
{
throw new ArgumentOutOfRangeException(nameof(start));
}

var visited = new bool[n];
var route = new List<int> { start };
visited[start] = true;
double totalDistance = 0;
int current = start;
for (int step = 1; step < n; step++)
{
double minDist = double.MaxValue;
int next = -1;
for (int j = 0; j < n; j++)
{
if (!visited[j] && distanceMatrix[current, j] < minDist)
{
minDist = distanceMatrix[current, j];
next = j;
}
}

if (next == -1)
{
throw new InvalidOperationException("No unvisited cities remain.");
}

route.Add(next);
visited[next] = true;
totalDistance += minDist;
current = next;
}

totalDistance += distanceMatrix[current, start];
route.Add(start);
return (route.ToArray(), totalDistance);
}

/// <summary>
/// Generates all permutations of the input array.
/// Used for brute-force TSP solution.
/// </summary>
private static IEnumerable<int[]> Permute(int[] arr)
{
if (arr.Length == 1)
{
yield return arr;
}
else
{
for (int i = 0; i < arr.Length; i++)
{
var rest = arr.Where((_, idx) => idx != i).ToArray();
foreach (var perm in Permute(rest))
{
yield return new[] { arr[i] }.Concat(perm).ToArray();
}
}
}
}
}
Loading