Case Study: The Traveling Salesperson — When Good Enough Is Good Enough
The Scenario
A regional delivery company, QuickRoute Logistics, manages daily deliveries across 15 cities in the Midwest. Every morning, their dispatcher needs to plan a route that visits all 15 cities and returns to the warehouse — minimizing total driving distance to save fuel and time.
This is a version of the Traveling Salesperson Problem (TSP), one of the most famous problems in computer science. It sounds simple: find the shortest route through n cities. But the simplicity is deceptive.
The Brute-Force Approach
The obvious strategy is brute force: try every possible route, calculate the total distance of each, and pick the shortest one.
How many routes are there? For n cities, the number of possible orderings is (n-1)! (factorial) — we fix the starting city and permute the rest.
import math
import time
from itertools import permutations
import random
def total_distance(route: list[tuple[float, float]]) -> float:
"""Calculate total distance of a route (returns to start)."""
dist = 0.0
for i in range(len(route)):
x1, y1 = route[i]
x2, y2 = route[(i + 1) % len(route)]
dist += math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)
return dist
def brute_force_tsp(cities: list[tuple[float, float]]) -> tuple[list, float]:
"""Find shortest route by trying all permutations. O(n!)."""
best_route = None
best_distance = float("inf")
# Fix first city, permute the rest
first = cities[0]
rest = cities[1:]
for perm in permutations(rest):
route = [first] + list(perm)
dist = total_distance(route)
if dist < best_distance:
best_distance = dist
best_route = route
return best_route, best_distance
# Test with small numbers of cities
for n in [5, 8, 10]:
cities = [(random.uniform(0, 100), random.uniform(0, 100)) for _ in range(n)]
start = time.time()
if n <= 10:
_, best_dist = brute_force_tsp(cities)
elapsed = time.time() - start
print(f"{n} cities: {math.factorial(n-1):>10,} routes, "
f"best = {best_dist:.1f}, time = {elapsed:.3f}s")
else:
print(f"{n} cities: {math.factorial(n-1):>10,} routes — too slow!")
# Show the scale of the problem
print("\n--- Route counts by number of cities ---")
for n in [5, 10, 15, 20, 25]:
routes = math.factorial(n - 1)
print(f" {n:2d} cities: {routes:>25,} routes")
Output (approximate):
5 cities: 24 routes, best = 198.3, time = 0.000s
8 cities: 5,040 routes, best = 247.1, time = 0.015s
10 cities: 362,880 routes, best = 231.5, time = 1.127s
--- Route counts by number of cities ---
5 cities: 24 routes
10 cities: 362,880 routes
15 cities: 87,178,291,200 routes
20 cities: 121,645,100,408,832,000 routes
25 cities: 620,448,401,733,239,439,360,000 routes
For 15 cities — QuickRoute's actual problem — brute force would need to evaluate 87 billion routes. Even at a million routes per second, that's over 24 hours. For 20 cities, it's millions of years. For 25, longer than the age of the universe.
This is what computer scientists call a combinatorial explosion: the problem size grows factorially, making exact solutions impractical beyond tiny inputs.
The Greedy Approach: Nearest Neighbor
Since brute force is hopeless for 15+ cities, QuickRoute's developer tries a greedy algorithm: at each city, go to the nearest unvisited city.
def nearest_neighbor_tsp(cities: list[tuple[float, float]]) -> tuple[list, float]:
"""Greedy TSP: always go to the nearest unvisited city. O(n²)."""
unvisited = list(cities)
route = [unvisited.pop(0)] # start at first city
while unvisited:
current = route[-1]
# Find nearest unvisited city
nearest = min(unvisited, key=lambda c: math.sqrt(
(c[0] - current[0]) ** 2 + (c[1] - current[1]) ** 2
))
route.append(nearest)
unvisited.remove(nearest)
return route, total_distance(route)
# Compare on 10 cities (where we can still run brute force)
random.seed(42)
cities_10 = [(random.uniform(0, 100), random.uniform(0, 100)) for _ in range(10)]
_, optimal_dist = brute_force_tsp(cities_10)
_, greedy_dist = nearest_neighbor_tsp(cities_10)
print(f"10 cities:")
print(f" Optimal (brute force): {optimal_dist:.1f}")
print(f" Greedy (nearest neighbor): {greedy_dist:.1f}")
print(f" Greedy is {(greedy_dist / optimal_dist - 1) * 100:.1f}% longer than optimal")
# Now solve 15 cities — only greedy is feasible
print()
cities_15 = [(random.uniform(0, 100), random.uniform(0, 100)) for _ in range(15)]
start = time.time()
_, greedy_15 = nearest_neighbor_tsp(cities_15)
elapsed = time.time() - start
print(f"15 cities (greedy): distance = {greedy_15:.1f}, time = {elapsed:.4f}s")
print(" Brute force would take hours — greedy took a fraction of a second.")
Output (approximate):
10 cities:
Optimal (brute force): 296.4
Greedy (nearest neighbor): 331.7
Greedy is 11.9% longer than optimal
15 cities (greedy): distance = 398.2, time = 0.0003s
Brute force would take hours — greedy took a fraction of a second.
The greedy route is about 10-25% longer than optimal (this varies with the specific city layout). For QuickRoute, that means maybe 30 extra miles on a 250-mile route. Is that acceptable? It depends on the business context.
When "Good Enough" Is Good Enough
QuickRoute's dispatcher considers the trade-off:
| Approach | Distance | Time to Compute | Feasible for 15 Cities? |
|---|---|---|---|
| Brute force (optimal) | Shortest possible | O(n!) — hours to years | No |
| Greedy (nearest neighbor) | ~10-25% longer | O(n²) — milliseconds | Yes |
The greedy solution saves the company less fuel per trip but can be computed instantly. Since routes change daily (different deliveries, traffic, new customers), spending hours computing the perfect route for yesterday's deliveries is pointless.
The Deeper Lesson
The Traveling Salesperson Problem is NP-hard — a class of problems for which no known algorithm can find the exact solution in polynomial time (like O(n²) or O(n³)). For NP-hard problems, the practical approach is usually:
- Use brute force if n is small enough (up to ~15-20 for TSP)
- Use a heuristic (like greedy nearest neighbor) that gives a good-but-not-perfect answer quickly
- Use a more sophisticated approximation (like 2-opt, simulated annealing, or genetic algorithms — topics for an algorithms course)
The most important skill isn't knowing the perfect algorithm. It's knowing when perfect is necessary and when good enough will do.
Discussion Questions
-
QuickRoute's greedy route is ~15% longer than optimal. Under what business conditions would this be unacceptable? When would it be perfectly fine?
-
The brute-force approach is O(n!) — even worse than O(2ⁿ). At what value of n does this become impractical on a modern computer that can evaluate 1 billion routes per second?
-
The greedy nearest-neighbor heuristic sometimes produces very bad routes (e.g., it might cross the same area twice). Can you think of a way to improve the greedy solution without going all the way to brute force? (Hint: what if you tried the greedy approach starting from each city and picked the best result?)
-
Many real-world optimization problems (scheduling, routing, resource allocation) are NP-hard. How should this influence software development decisions? Should developers always aim for optimal solutions?
Mini-Project
Implement a "random restart" improvement: run the nearest-neighbor heuristic starting from each city (n starts), and keep the best route. This is O(n³) — much slower than single greedy but much faster than brute force. Compare its solution quality to single-start greedy on 15-20 cities. How much improvement do you see?