From d9e22342592ee1561a62069184cdb6f5589c42e9 Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Sat, 4 Oct 2025 22:18:42 +0700 Subject: [PATCH 1/2] The Traveling salesman problem (TSP) --- .../TravelingSalesmanSolverTests.cs | 104 ++++++++++++++ .../CollaborativeFilteringTests.cs | 66 +++++++++ .../TravelingSalesmanSolver.cs | 136 ++++++++++++++++++ .../CollaborativeFiltering.cs | 131 +++++++++-------- README.md | 2 + 5 files changed, 373 insertions(+), 66 deletions(-) create mode 100644 Algorithms.Tests/Problems/TravelingSalesman/TravelingSalesmanSolverTests.cs create mode 100644 Algorithms/Problems/TravelingSalesman/TravelingSalesmanSolver.cs diff --git a/Algorithms.Tests/Problems/TravelingSalesman/TravelingSalesmanSolverTests.cs b/Algorithms.Tests/Problems/TravelingSalesman/TravelingSalesmanSolverTests.cs new file mode 100644 index 00000000..8046e12d --- /dev/null +++ b/Algorithms.Tests/Problems/TravelingSalesman/TravelingSalesmanSolverTests.cs @@ -0,0 +1,104 @@ +using Algorithms.Problems.TravelingSalesman; +using NUnit.Framework; + +namespace Algorithms.Tests.Problems.TravelingSalesman; + +/// +/// Unit tests for TravelingSalesmanSolver. Covers brute-force and nearest neighbor methods, including edge cases and invalid input. +/// +[TestFixture] +public class TravelingSalesmanSolverTests +{ + /// + /// Tests brute-force TSP solver on a 4-city symmetric distance matrix with known optimal route. + /// + [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 })); + } + + /// + /// Tests nearest neighbor heuristic on the same matrix. May not be optimal. + /// + [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 + } + + /// + /// Tests nearest neighbor with invalid start index. + /// + [Test] + public void SolveNearestNeighbor_InvalidStart_ThrowsException() + { + double[,] matrix = + { + { 0, 1 }, + { 1, 0 } + }; + Assert.Throws(() => TravelingSalesmanSolver.SolveNearestNeighbor(matrix, -1)); + Assert.Throws(() => TravelingSalesmanSolver.SolveNearestNeighbor(matrix, 2)); + } + + /// + /// Tests brute-force and nearest neighbor with non-square matrix. + /// + [Test] + public void NonSquareMatrix_ThrowsException() + { + double[,] matrix = new double[2, 3]; + Assert.Throws(() => TravelingSalesmanSolver.SolveBruteForce(matrix)); + Assert.Throws(() => TravelingSalesmanSolver.SolveNearestNeighbor(matrix, 0)); + } + + /// + /// Tests brute-force with less than two cities (invalid case). + /// + [Test] + public void SolveBruteForce_TooFewCities_ThrowsException() + { + double[,] matrix = { { 0 } }; + Assert.Throws(() => TravelingSalesmanSolver.SolveBruteForce(matrix)); + } + + /// + /// Tests nearest neighbor with only two cities (trivial case). + /// + [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)); + } +} diff --git a/Algorithms.Tests/RecommenderSystem/CollaborativeFilteringTests.cs b/Algorithms.Tests/RecommenderSystem/CollaborativeFilteringTests.cs index 038b5596..9f511b42 100644 --- a/Algorithms.Tests/RecommenderSystem/CollaborativeFilteringTests.cs +++ b/Algorithms.Tests/RecommenderSystem/CollaborativeFilteringTests.cs @@ -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(() => recommender!.PredictRating("item1", "nonexistentUser", testRatings)); + } + + [Test] + public void PredictRating_RatingsEmpty_ReturnsZero() + { + var emptyRatings = new Dictionary>(); + Assert.Throws(() => recommender!.PredictRating("item1", "user1", emptyRatings)); + } + + [Test] + public void PredictRating_NoOtherUserRatedTargetItem_ReturnsZero() + { + var ratings = new Dictionary> + { + ["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(), new Dictionary()); + Assert.That(result, Is.EqualTo(0)); + } + + [Test] + public void CalculateSimilarity_OneCommonItem_ReturnsZero() + { + var recommenderLocal = new CollaborativeFiltering(mockSimilarityCalculator!.Object); + var dict1 = new Dictionary { ["item1"] = 5.0 }; + var dict2 = new Dictionary { ["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> + { + ["user1"] = new() { ["item1"] = 5.0 }, + ["user2"] = new() { ["item1"] = 2.0 }, + ["user3"] = new() { ["item1"] = 8.0 } + }; + var mockSim = new Mock(); + mockSim.Setup(s => s.CalculateSimilarity(It.IsAny>(), ratings["user2"])) + .Returns(-0.5); + mockSim.Setup(s => s.CalculateSimilarity(It.IsAny>(), 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)); + } } } diff --git a/Algorithms/Problems/TravelingSalesman/TravelingSalesmanSolver.cs b/Algorithms/Problems/TravelingSalesman/TravelingSalesmanSolver.cs new file mode 100644 index 00000000..c235db4e --- /dev/null +++ b/Algorithms/Problems/TravelingSalesman/TravelingSalesmanSolver.cs @@ -0,0 +1,136 @@ +namespace Algorithms.Problems.TravelingSalesman; + +/// +/// 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. +/// +public static class TravelingSalesmanSolver +{ + /// + /// 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. + /// + /// A square matrix where element [i, j] represents the distance from city i to city j. + /// A tuple containing the minimal route (as an array of city indices) and the minimal total distance. + 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(), minDistance); + } + + /// + /// 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. + /// + /// A square matrix where element [i, j] represents the distance from city i to city j. + /// The starting city index. + /// A tuple containing the route (as an array of city indices) and the total distance. + 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 { 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); + } + + /// + /// Generates all permutations of the input array. + /// Used for brute-force TSP solution. + /// + private static IEnumerable 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(); + } + } + } + } +} diff --git a/Algorithms/RecommenderSystem/CollaborativeFiltering.cs b/Algorithms/RecommenderSystem/CollaborativeFiltering.cs index 1a3c87cc..51989ad0 100644 --- a/Algorithms/RecommenderSystem/CollaborativeFiltering.cs +++ b/Algorithms/RecommenderSystem/CollaborativeFiltering.cs @@ -1,85 +1,84 @@ -namespace Algorithms.RecommenderSystem +namespace Algorithms.RecommenderSystem; + +public class CollaborativeFiltering { - public class CollaborativeFiltering + private readonly ISimilarityCalculator similarityCalculator; + + public CollaborativeFiltering(ISimilarityCalculator similarityCalculator) { - private readonly ISimilarityCalculator similarityCalculator; + this.similarityCalculator = similarityCalculator; + } - public CollaborativeFiltering(ISimilarityCalculator similarityCalculator) + /// + /// Method to calculate similarity between two users using Pearson correlation. + /// + /// Rating of User 1. + /// Rating of User 2. + /// double value to reflect the index of similarity between two users. + public double CalculateSimilarity(Dictionary user1Ratings, Dictionary user2Ratings) + { + var commonItems = user1Ratings.Keys.Intersect(user2Ratings.Keys).ToList(); + if (commonItems.Count == 0) { - this.similarityCalculator = similarityCalculator; + return 0; } - /// - /// Method to calculate similarity between two users using Pearson correlation. - /// - /// Rating of User 1. - /// Rating of User 2. - /// double value to reflect the index of similarity between two users. - public double CalculateSimilarity(Dictionary user1Ratings, Dictionary user2Ratings) - { - var commonItems = user1Ratings.Keys.Intersect(user2Ratings.Keys).ToList(); - if (commonItems.Count == 0) - { - return 0; - } + var user1Scores = commonItems.Select(item => user1Ratings[item]).ToArray(); + var user2Scores = commonItems.Select(item => user2Ratings[item]).ToArray(); - var user1Scores = commonItems.Select(item => user1Ratings[item]).ToArray(); - var user2Scores = commonItems.Select(item => user2Ratings[item]).ToArray(); + var avgUser1 = user1Scores.Average(); + var avgUser2 = user2Scores.Average(); - var avgUser1 = user1Scores.Average(); - var avgUser2 = user2Scores.Average(); + double numerator = 0; + double sumSquare1 = 0; + double sumSquare2 = 0; + double epsilon = 1e-10; - double numerator = 0; - double sumSquare1 = 0; - double sumSquare2 = 0; - double epsilon = 1e-10; + for (var i = 0; i < commonItems.Count; i++) + { + var diff1 = user1Scores[i] - avgUser1; + var diff2 = user2Scores[i] - avgUser2; - for (var i = 0; i < commonItems.Count; i++) - { - var diff1 = user1Scores[i] - avgUser1; - var diff2 = user2Scores[i] - avgUser2; + numerator += diff1 * diff2; + sumSquare1 += diff1 * diff1; + sumSquare2 += diff2 * diff2; + } - numerator += diff1 * diff2; - sumSquare1 += diff1 * diff1; - sumSquare2 += diff2 * diff2; - } + var denominator = Math.Sqrt(sumSquare1 * sumSquare2); + return Math.Abs(denominator) < epsilon ? 0 : numerator / denominator; + } - var denominator = Math.Sqrt(sumSquare1 * sumSquare2); - return Math.Abs(denominator) < epsilon ? 0 : numerator / denominator; - } + /// + /// Predict a rating for a specific item by a target user. + /// + /// The item for which the rating needs to be predicted. + /// The user for whom the rating is being predicted. + /// + /// A dictionary containing user ratings where: + /// - The key is the user's identifier (string). + /// - The value is another dictionary where the key is the item identifier (string), and the value is the rating given by the user (double). + /// + /// The predicted rating for the target item by the target user. + /// If there is insufficient data to predict a rating, the method returns 0. + /// + public double PredictRating(string targetItem, string targetUser, Dictionary> ratings) + { + var targetUserRatings = ratings[targetUser]; + double totalSimilarity = 0; + double weightedSum = 0; + double epsilon = 1e-10; - /// - /// Predict a rating for a specific item by a target user. - /// - /// The item for which the rating needs to be predicted. - /// The user for whom the rating is being predicted. - /// - /// A dictionary containing user ratings where: - /// - The key is the user's identifier (string). - /// - The value is another dictionary where the key is the item identifier (string), and the value is the rating given by the user (double). - /// - /// The predicted rating for the target item by the target user. - /// If there is insufficient data to predict a rating, the method returns 0. - /// - public double PredictRating(string targetItem, string targetUser, Dictionary> ratings) + foreach (var otherUser in ratings.Keys.Where(u => u != targetUser)) { - var targetUserRatings = ratings[targetUser]; - double totalSimilarity = 0; - double weightedSum = 0; - double epsilon = 1e-10; - - foreach (var otherUser in ratings.Keys.Where(u => u != targetUser)) + var otherUserRatings = ratings[otherUser]; + if (otherUserRatings.ContainsKey(targetItem)) { - var otherUserRatings = ratings[otherUser]; - if (otherUserRatings.ContainsKey(targetItem)) - { - var similarity = similarityCalculator.CalculateSimilarity(targetUserRatings, otherUserRatings); - totalSimilarity += Math.Abs(similarity); - weightedSum += similarity * otherUserRatings[targetItem]; - } + var similarity = similarityCalculator.CalculateSimilarity(targetUserRatings, otherUserRatings); + totalSimilarity += Math.Abs(similarity); + weightedSum += similarity * otherUserRatings[targetItem]; } - - return Math.Abs(totalSimilarity) < epsilon ? 0 : weightedSum / totalSimilarity; } + + return Math.Abs(totalSimilarity) < epsilon ? 0 : weightedSum / totalSimilarity; } } diff --git a/README.md b/README.md index cb1358d0..d750b78e 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,8 @@ find more than one implementation for the same objective but using different alg * [Dynamic Programming](./Algorithms/Problems/DynamicProgramming) * [Coin Change](./Algorithms/Problems/DynamicProgramming/CoinChange/DynamicCoinChangeSolver.cs) * [Levenshtein Distance](./Algorithms/Problems/DynamicProgramming/LevenshteinDistance/LevenshteinDistance.cs) + * [Traveling Salesman Problem (TSP)](./Algorithms/Problems/TravelingSalesman/TravelingSalesmanSolver.cs) + * [Brute-force and Nearest Neighbor algorithms](./Algorithms/Problems/TravelingSalesman/TravelingSalesmanSolver.cs) * [Data Structures](./DataStructures) * [Bag](./DataStructures/Bag) From a9fd194db1b90c61252283256ebe96b34b0b2a34 Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Sun, 5 Oct 2025 09:53:48 +0700 Subject: [PATCH 2/2] Add more unit test for TSP --- .../TravelingSalesmanSolverTests.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Algorithms.Tests/Problems/TravelingSalesman/TravelingSalesmanSolverTests.cs b/Algorithms.Tests/Problems/TravelingSalesman/TravelingSalesmanSolverTests.cs index 8046e12d..25b69625 100644 --- a/Algorithms.Tests/Problems/TravelingSalesman/TravelingSalesmanSolverTests.cs +++ b/Algorithms.Tests/Problems/TravelingSalesman/TravelingSalesmanSolverTests.cs @@ -64,6 +64,23 @@ public void SolveNearestNeighbor_InvalidStart_ThrowsException() Assert.Throws(() => TravelingSalesmanSolver.SolveNearestNeighbor(matrix, -1)); Assert.Throws(() => TravelingSalesmanSolver.SolveNearestNeighbor(matrix, 2)); } + + /// + /// Tests nearest neighbor when no unvisited cities remain (should throw InvalidOperationException). + /// + [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(() => TravelingSalesmanSolver.SolveNearestNeighbor(matrix, 0)); + } /// /// Tests brute-force and nearest neighbor with non-square matrix.