Rewrite TSP solver for improved path optimization and clarity

- Completely re-implemented the TSP algorithm in C++ for better path quality
- Added detailed comments and documentation to clarify each step
- Improved nearest neighbor, 2-opt, and relocation logic
- Enhanced handling of start/end point constraints
- Updated PathUtils.py docstring to accurately describe start point behavior
This commit is contained in:
Billy Huddleston
2025-10-19 17:12:08 -04:00
parent 945389c761
commit 6d26c3009f
2 changed files with 255 additions and 200 deletions

View File

@@ -28,7 +28,15 @@
namespace
{
// Euclidean distance between two points
/**
* @brief Calculate Euclidean distance between two points
*
* Used for 2-opt and relocation steps where actual distance matters for path length optimization.
*
* @param a First point
* @param b Second point
* @return Actual distance: sqrt(dx² + dy²)
*/
double dist(const TSPPoint& a, const TSPPoint& b)
{
double dx = a.x - b.x;
@@ -36,22 +44,251 @@ double dist(const TSPPoint& a, const TSPPoint& b)
return std::sqrt(dx * dx + dy * dy);
}
// Calculate total path length
double pathLength(const std::vector<TSPPoint>& points, const std::vector<int>& path)
/**
* @brief Calculate squared distance between two points (no sqrt)
*
* Used for nearest neighbor selection for performance (avoids expensive sqrt operation).
* Since we only need to compare distances, squared distance preserves ordering:
* if dist(A,B) < dist(A,C), then distSquared(A,B) < distSquared(A,C)
*
* @param a First point
* @param b Second point
* @return Squared distance: dx² + dy²
*/
double distSquared(const TSPPoint& a, const TSPPoint& b)
{
double total = 0.0;
size_t n = path.size();
for (size_t i = 0; i < n - 1; ++i) {
total += dist(points[path[i]], points[path[i + 1]]);
}
// Optionally, close the loop: total += dist(points[path[n-1]], points[path[0]]);
return total;
double dx = a.x - b.x;
double dy = a.y - b.y;
return dx * dx + dy * dy;
}
// 2-Opt swap
void twoOptSwap(std::vector<int>& path, size_t i, size_t k)
/**
* @brief Core TSP solver implementation using nearest neighbor + iterative improvement
*
* Algorithm steps:
* 1. Add temporary start/end points if specified
* 2. Build initial route using nearest neighbor heuristic
* 3. Optimize route with 2-opt and relocation moves
* 4. Remove temporary points and map back to original indices
*
* @param points Input points to visit
* @param startPoint Optional starting location constraint
* @param endPoint Optional ending location constraint
* @return Vector of indices representing optimized visit order
*/
std::vector<int> solve_impl(const std::vector<TSPPoint>& points,
const TSPPoint* startPoint,
const TSPPoint* endPoint)
{
std::reverse(path.begin() + static_cast<long>(i), path.begin() + static_cast<long>(k) + 1);
// ========================================================================
// STEP 1: Prepare point set with temporary start/end markers
// ========================================================================
// We insert temporary points to enforce start/end constraints.
// These will be removed after optimization and won't appear in final result.
std::vector<TSPPoint> pts = points;
int tempStartIdx = -1, tempEndIdx = -1;
if (startPoint) {
// Insert user-specified start point at beginning
pts.insert(pts.begin(), TSPPoint(startPoint->x, startPoint->y));
tempStartIdx = 0;
}
else if (!pts.empty()) {
// No start specified: duplicate first point as anchor
pts.insert(pts.begin(), TSPPoint(pts[0].x, pts[0].y));
tempStartIdx = 0;
}
if (endPoint) {
// Add user-specified end point at the end
pts.push_back(TSPPoint(endPoint->x, endPoint->y));
tempEndIdx = static_cast<int>(pts.size()) - 1;
}
// ========================================================================
// STEP 2: Build initial route using Nearest Neighbor algorithm
// ========================================================================
// Greedy approach: always visit the closest unvisited point next.
// This gives a decent initial solution quickly (O(n²) complexity).
//
// Tie-breaking rule:
// - If distances are within ±0.1, prefer point with y-value closer to start
// - This provides deterministic results when points are nearly equidistant
std::vector<int> route;
std::vector<bool> visited(pts.size(), false);
route.push_back(0); // Start from temp start point (index 0)
visited[0] = true;
for (size_t step = 1; step < pts.size(); ++step) {
double minDist = std::numeric_limits<double>::max();
int next = -1;
double nextYDiff = std::numeric_limits<double>::max();
// Find nearest unvisited neighbor
for (size_t i = 0; i < pts.size(); ++i) {
if (!visited[i]) {
// Use squared distance for speed (no sqrt needed for comparison)
double d = distSquared(pts[route.back()], pts[i]);
double yDiff = std::abs(pts[route.front()].y - pts[i].y);
// Tie-breaking logic:
if (d > minDist + 0.1) {
continue; // Clearly farther, skip
}
else if (d < minDist - 0.1) {
// Clearly closer, use it
minDist = d;
next = static_cast<int>(i);
nextYDiff = yDiff;
}
else if (yDiff < nextYDiff) {
// Tie: prefer point closer to start in Y-axis
minDist = d;
next = static_cast<int>(i);
nextYDiff = yDiff;
}
}
}
if (next == -1) {
break; // No more unvisited points
}
route.push_back(next);
visited[next] = true;
}
// Ensure temporary end point is at the end of route
if (tempEndIdx != -1 && route.back() != tempEndIdx) {
auto it = std::find(route.begin(), route.end(), tempEndIdx);
if (it != route.end()) {
route.erase(it);
}
route.push_back(tempEndIdx);
}
// ========================================================================
// STEP 3: Iterative improvement using 2-Opt and Relocation
// ========================================================================
// Repeatedly apply local optimizations until no improvement is possible.
// This typically converges quickly (a few iterations) to a near-optimal solution.
//
// Two optimization techniques:
// 1. 2-Opt: Reverse segments of the route to eliminate crossing paths
// 2. Relocation: Move individual points to better positions in the route
bool improvementFound = true;
while (improvementFound) {
improvementFound = false;
// --- 2-Opt Optimization ---
// Try reversing every possible segment of the route.
// If reversing segment [i+1...j-1] reduces total distance, keep it.
//
// Example: Route A-B-C-D-E becomes A-D-C-B-E if reversing B-C-D is better
bool reorderFound = true;
while (reorderFound) {
reorderFound = false;
for (size_t i = 0; i + 3 < route.size(); ++i) {
for (size_t j = i + 3; j < route.size(); ++j) {
// Current edges: i→(i+1) and (j-1)→j
double curLen = dist(pts[route[i]], pts[route[i + 1]])
+ dist(pts[route[j - 1]], pts[route[j]]);
// New edges after reversal: (i+1)→j and i→(j-1)
// Add epsilon to prevent cycles from floating point errors
double newLen = dist(pts[route[i + 1]], pts[route[j]])
+ dist(pts[route[i]], pts[route[j - 1]]) + 1e-5;
if (newLen < curLen) {
// Reverse the segment between i+1 and j (exclusive)
std::reverse(route.begin() + i + 1, route.begin() + j);
reorderFound = true;
improvementFound = true;
}
}
}
}
// --- Relocation Optimization ---
// Try moving each point to a different position in the route.
// If moving point i to position j improves the route, do it.
bool relocateFound = true;
while (relocateFound) {
relocateFound = false;
for (size_t i = 1; i + 1 < route.size(); ++i) {
// Try moving point i backward (to positions before i)
for (size_t j = 1; j + 2 < i; ++j) {
// Current cost: edges around point i and edge j→(j+1)
double curLen = dist(pts[route[i - 1]], pts[route[i]])
+ dist(pts[route[i]], pts[route[i + 1]])
+ dist(pts[route[j]], pts[route[j + 1]]);
// New cost: bypass i, insert i after j
double newLen = dist(pts[route[i - 1]], pts[route[i + 1]])
+ dist(pts[route[j]], pts[route[i]])
+ dist(pts[route[i]], pts[route[j + 1]]) + 1e-5;
if (newLen < curLen) {
// Move point i to position after j
int node = route[i];
route.erase(route.begin() + i);
route.insert(route.begin() + j + 1, node);
relocateFound = true;
improvementFound = true;
}
}
// Try moving point i forward (to positions after i)
for (size_t j = i + 1; j + 1 < route.size(); ++j) {
double curLen = dist(pts[route[i - 1]], pts[route[i]])
+ dist(pts[route[i]], pts[route[i + 1]])
+ dist(pts[route[j]], pts[route[j + 1]]);
double newLen = dist(pts[route[i - 1]], pts[route[i + 1]])
+ dist(pts[route[j]], pts[route[i]])
+ dist(pts[route[i]], pts[route[j + 1]]) + 1e-5;
if (newLen < curLen) {
int node = route[i];
route.erase(route.begin() + i);
route.insert(route.begin() + j, node);
relocateFound = true;
improvementFound = true;
}
}
}
}
}
// ========================================================================
// STEP 4: Remove temporary start/end points
// ========================================================================
// The temporary markers served their purpose during optimization.
// Now remove them so they don't appear in the final result.
if (tempEndIdx != -1 && !route.empty() && route.back() == tempEndIdx) {
route.pop_back();
}
if (tempStartIdx != -1 && !route.empty() && route.front() == tempStartIdx) {
route.erase(route.begin());
}
// ========================================================================
// STEP 5: Map route indices back to original point array
// ========================================================================
// Since we inserted a temp start point at index 0, all subsequent indices
// are offset by 1. Adjust them back to match the original points array.
std::vector<int> result;
for (int idx : route) {
// Adjust for temp start offset
if (tempStartIdx != -1) {
--idx;
}
// Only include valid indices from the original points array
if (idx >= 0 && idx < static_cast<int>(points.size())) {
result.push_back(idx);
}
}
return result;
}
} // namespace
@@ -64,194 +301,11 @@ void twoOptSwap(std::vector<int>& path, size_t i, size_t k)
* - If both are provided, the path will respect both constraints while optimizing the middle path
* - The algorithm ensures all points are visited exactly once
*/
std::vector<int> TSPSolver::solve(const std::vector<TSPPoint>& points,
const TSPPoint* startPoint,
const TSPPoint* endPoint)
{
size_t n = points.size();
if (n == 0) {
return {};
}
// Start with a simple nearest neighbor path
std::vector<bool> visited(n, false);
std::vector<int> path;
// If startPoint provided, find the closest point to it
size_t current = 0;
if (startPoint) {
double minDist = std::numeric_limits<double>::max();
for (size_t i = 0; i < n; ++i) {
double d = dist(points[i], *startPoint);
if (d < minDist) {
minDist = d;
current = i;
}
}
}
path.push_back(static_cast<int>(current));
visited[current] = true;
for (size_t step = 1; step < n; ++step) {
double min_dist = std::numeric_limits<double>::max();
size_t next = n; // Use n as an invalid index
for (size_t i = 0; i < n; ++i) {
if (!visited[i]) {
double d = dist(points[current], points[i]);
if (d < min_dist) {
min_dist = d;
next = i;
}
}
}
current = next;
path.push_back(static_cast<int>(current));
visited[current] = true;
}
// 2-Opt optimization
bool improved = true;
while (improved) {
improved = false;
for (size_t i = 1; i < n - 1; ++i) {
for (size_t k = i + 1; k < n; ++k) {
double delta = dist(points[path[i - 1]], points[path[k]])
+ dist(points[path[i]], points[path[(k + 1) % n]])
- dist(points[path[i - 1]], points[path[i]])
- dist(points[path[k]], points[path[(k + 1) % n]]);
if (delta < -Base::Precision::Confusion()) {
twoOptSwap(path, i, k);
improved = true;
}
}
}
}
// Handle end point constraint if specified
if (endPoint) {
// If both start and end points are specified, we need to handle them differently
if (startPoint) {
// Find the closest points to start and end
size_t startIdx = 0;
size_t endIdx = 0;
double minStartDist = std::numeric_limits<double>::max();
double minEndDist = std::numeric_limits<double>::max();
// Find the indices of the closest points to both start and end points
for (size_t i = 0; i < n; ++i) {
// Find closest to start
double dStart = dist(points[i], *startPoint);
if (dStart < minStartDist) {
minStartDist = dStart;
startIdx = i;
}
// Find closest to end
double dEnd = dist(points[i], *endPoint);
if (dEnd < minEndDist) {
minEndDist = dEnd;
endIdx = i;
}
}
// If start and end are different points, create a new path
if (startIdx != endIdx) {
// Create a new path starting with the start point and ending with the end point
// This ensures both constraints are met
std::vector<bool> visited(n, false);
std::vector<int> newPath;
// Add start point
newPath.push_back(static_cast<int>(startIdx));
visited[startIdx] = true;
// Add all other points except end point using nearest neighbor algorithm
// This builds a path that starts at startIdx and visits all intermediate points
size_t current = startIdx;
for (size_t step = 1; step < n - 1; ++step) {
double minDist = std::numeric_limits<double>::max();
size_t next = n; // Invalid index (n is out of bounds)
for (size_t i = 0; i < n; ++i) {
if (!visited[i] && i != endIdx) {
double d = dist(points[current], points[i]);
if (d < minDist) {
minDist = d;
next = i;
}
}
}
if (next == n) {
break; // No more points to add
}
current = next;
newPath.push_back(static_cast<int>(current));
visited[current] = true;
}
// Add end point as the final stop in the path
newPath.push_back(static_cast<int>(endIdx));
// Apply 2-opt optimization while preserving the start and end points
// The algorithm only swaps edges between interior points
bool improved = true;
while (improved) {
improved = false;
// Start from 1 and end before the last point to preserve start/end constraints
for (size_t i = 1; i < newPath.size() - 1; ++i) {
for (size_t k = i + 1; k < newPath.size() - 1; ++k) {
// Calculate improvement in distance if we swap these edges
double delta = dist(points[newPath[i - 1]], points[newPath[k]])
+ dist(points[newPath[i]], points[newPath[k + 1]])
- dist(points[newPath[i - 1]], points[newPath[i]])
- dist(points[newPath[k]], points[newPath[k + 1]]);
// If the swap reduces the total distance, make the swap
if (delta < -Base::Precision::Confusion()) {
std::reverse(newPath.begin() + static_cast<long>(i),
newPath.begin() + static_cast<long>(k) + 1);
improved = true;
}
}
}
}
path = newPath;
}
// If start and end are the same point, keep path as is
}
else {
// Only end point specified (no start point constraint)
// Find the point in the current path that's closest to the desired end point
double minDist = std::numeric_limits<double>::max();
size_t endIdx = 0;
for (size_t i = 0; i < n; ++i) {
double d = dist(points[path[i]], *endPoint);
if (d < minDist) {
minDist = d;
endIdx = i;
}
}
// Rotate the path so that endIdx is at the end
// This preserves the relative order of points while ensuring the path ends
// at the point closest to the specified end coordinates
if (endIdx != n - 1) {
std::vector<int> newPath;
// Start with points after endIdx
for (size_t i = endIdx + 1; i < n; ++i) {
newPath.push_back(path[i]);
}
// Then add points from beginning up to and including endIdx
for (size_t i = 0; i <= endIdx; ++i) {
newPath.push_back(path[i]);
}
path = newPath;
}
}
}
return path;
return solve_impl(points, startPoint, endPoint);
}

View File

@@ -626,7 +626,8 @@ def sort_locations_tsp(locations, keys, attractors=None, startPoint=None, endPoi
- endPoint: Optional ending point [x, y]
Returns the sorted list of locations in TSP order.
If startPoint is None, path starts from the first point in the original order.
If startPoint is None, the path is optimized to start near the first point in the original list,
but may not start exactly at that point.
"""
# Extract points from locations
points = [(loc[keys[0]], loc[keys[1]]) for loc in locations]