We close the module with the problem that opened it. In 03-01 we worked out that the order of a van's deliveries has n! possibilities and that brute force dies beyond a dozen stops; in 03-02 we learnt to find the best path between two points, and in 03-03 to decide against an opponent. But none of those techniques solves Diego's full problem: given the map and the day's deliveries, in what order should the van visit them to drive the fewest possible kilometres? It is the famous travelling salesman problem, and it is the perfect example of an optimisation problem: we are not looking for a path in a graph, but for the best among an astronomical number of complete solutions. In this lesson we will learn to formulate it (variables, objective function, constraints), distinguish exact from approximate methods, and build three local search and metaheuristic algorithms that solve NovaMarket's travelling salesman in seconds: hill climbing, simulated annealing and genetic algorithms, validating them against brute force at small size. Then we will apply the same idea to the assignment of orders to the Zaragoza and Getafe warehouses with a capacity constraint, and see how a constraint becomes a penalty. We will finish with the connection that opens module 4: training a machine learning model is, on the inside, also an optimisation problem.
Contents
- What an optimisation problem is: variables, objective function, constraints
- Exact versus approximate optimisation; local search
- Preparing NovaMarket's travelling salesman problem: the distance matrix with Dijkstra
- Brute force as the reference (7 deliveries)
- Hill climbing by swapping stops: local optima and random restarts
- A bigger problem: 15 delivery addresses
- Simulated annealing: the intuition of temperature
- Genetic algorithms: population, tournament, crossover, mutation and elitism
- Second example: assigning orders to two warehouses with capacity (constraints as penalties)
- Comparison table of methods and when to use each one
- Link to module 4: learning is optimising
- What an optimisation problem is
An optimisation problem has three components:
| Component | What it is | In the van's travelling salesman | In the warehouse assignment |
|---|---|---|---|
| Decision variables | What we can choose | The order of the stops (a permutation of the deliveries) | For each order, whether it ships from Getafe or from Zaragoza |
| Objective function | The number we want to minimise (or maximise) | Total kilometres of the closed route from the warehouse | Total shipping cost |
| Constraints | Conditions every solution must satisfy | Visit each delivery exactly once, leave from and return to the warehouse | Do not exceed the picking capacity of each warehouse |
Each specific assignment of values to the variables is a candidate solution; those satisfying the constraints are feasible; the feasible one with the best objective is the optimum. The difference from the search of 03-02 is one of perspective: there the object was a path built step by step from the initial state, and the cost accumulated leg by leg; here we work with complete solutions that we evaluate at once with the objective function, and the "path" we care about is not on the map but in the solution space. In 02-01 we said that a utility-based agent chooses the action that maximises a function; the algorithms of this lesson are the machinery for making that choice when there are too many options to list.
- Exact versus approximate optimisation; local search
| Approach | What it guarantees | Examples | Cost | When |
|---|---|---|---|---|
| Exact | Finds the provable optimum | Brute force, branch and bound, dynamic programming, linear programming (for problems with linear structure) | Exponential in the worst case for the travelling salesman and its relatives | Small size, or problems with special structure |
| Constructive heuristic | A reasonable solution, fast | "Always go to the nearest stop" (nearest neighbour) | Very low | Starting point; when time is critical |
| Local search / metaheuristic | A good solution, no guarantee of optimality, with the possibility of improving it given more time | Hill climbing, simulated annealing, genetic algorithms, tabu search | Adjustable | The default option in large real-world problems |
Local search starts from a complete solution (good or bad) and improves it step by step by applying small changes, called moves; the set of solutions reachable with one move from the current one is its neighbourhood. The usual metaphor is a landscape: every solution is a point, its objective is the altitude, and the algorithm is a hiker who wants to reach the highest point (or the lowest, if minimising) moving only to neighbouring points. With that image the three algorithms of the lesson are immediately understood: hill climbing always goes up and gets stuck on the first summit; simulated annealing allows itself to go down sometimes in order to escape; the genetic algorithm sends many hikers at once and crosses their routes.
A note on vocabulary: although in the travelling salesman we minimise kilometres, the literature speaks of hill "climbing" out of habit; minimising f is maximising −f, so the idea is the same.
- Preparing the problem: the distance matrix with Dijkstra
In 03-01, brute force only accepted legs between districts connected by a direct road. Now we can do it properly: the distance between any two stops is that of the shortest path between them, which we compute with the uniform-cost search (Dijkstra) of 03-02, run from every node. The result is a distance matrix with which the travelling salesman no longer depends on the graph's topology. This separation into two layers (Dijkstra/A* for "how to get from A to B", optimisation for "in what order to visit A, B, C…") is exactly how real delivery planners work.
import heapq
import math
import random
import itertools
CITY_GRAPH = {
"Warehouse_Getafe": [("Leganes", 4.5), ("Villaverde", 5.0)],
"Leganes": [("Warehouse_Getafe", 4.5), ("Carabanchel", 4.5), ("Villaverde", 6.5)],
"Villaverde": [("Warehouse_Getafe", 5.0), ("Leganes", 6.5), ("Usera", 4.5), ("Vallecas", 7.5)],
"Carabanchel": [("Leganes", 4.5), ("Usera", 4.5), ("Arganzuela", 5.0)],
"Usera": [("Villaverde", 4.5), ("Carabanchel", 4.5), ("Arganzuela", 3.5), ("Vallecas", 5.5)],
"Vallecas": [("Villaverde", 7.5), ("Usera", 5.5), ("Retiro", 6.0)],
"Arganzuela": [("Carabanchel", 5.0), ("Usera", 3.5), ("Retiro", 4.0)],
"Retiro": [("Arganzuela", 4.0), ("Vallecas", 6.0)],
}
def distances_from(graph, origin):
"""Compact Dijkstra: minimum distance from origin to ALL nodes (03-02)."""
dist = {origin: 0.0}
frontier = [(0.0, origin)]
while frontier:
g, node = heapq.heappop(frontier)
if g > dist[node]: # stale heap entry
continue
for neighbour, d in graph[node]:
new = g + d
if neighbour not in dist or new < dist[neighbour]:
dist[neighbour] = new
heapq.heappush(frontier, (new, neighbour))
return dist
MATRIX = {n: distances_from(CITY_GRAPH, n) for n in CITY_GRAPH}
NODES = list(CITY_GRAPH)
DELIVERIES = [n for n in NODES if n != "Warehouse_Getafe"] # today's 7 deliveries
def route_length(route, dist, origin="Warehouse_Getafe"):
"""Km of the closed route: origin -> route[0] -> ... -> route[-1] -> origin."""
total = dist[origin][route[0]] + dist[route[-1]][origin]
for a, b in zip(route, route[1:]):
total += dist[a][b]
return totalMATRIX[a][b] is the minimum road distance from a to b; for example, MATRIX["Warehouse_Getafe"]["Retiro"] is 17.0 (the A* result from 03-02) and MATRIX["Leganes"]["Vallecas"] is 14.0 even though there is no direct road. The full matrix, in km:
| Getafe | Leganes | Villaverde | Carabanchel | Usera | Vallecas | Arganzuela | Retiro | |
|---|---|---|---|---|---|---|---|---|
| Warehouse_Getafe | 0 | 4.5 | 5.0 | 9.0 | 9.5 | 12.5 | 13.0 | 17.0 |
| Leganes | 4.5 | 0 | 6.5 | 4.5 | 9.0 | 14.0 | 9.5 | 13.5 |
| Villaverde | 5.0 | 6.5 | 0 | 9.0 | 4.5 | 7.5 | 8.0 | 12.0 |
| Carabanchel | 9.0 | 4.5 | 9.0 | 0 | 4.5 | 10.0 | 5.0 | 9.0 |
| Usera | 9.5 | 9.0 | 4.5 | 4.5 | 0 | 5.5 | 3.5 | 7.5 |
| Vallecas | 12.5 | 14.0 | 7.5 | 10.0 | 5.5 | 0 | 9.0 | 6.0 |
| Arganzuela | 13.0 | 9.5 | 8.0 | 5.0 | 3.5 | 9.0 | 0 | 4.0 |
| Retiro | 17.0 | 13.5 | 12.0 | 9.0 | 7.5 | 6.0 | 4.0 | 0 |
The function route_length is our objective function: it takes a permutation of the deliveries (the decision variable) and returns the kilometres of the closed route. The constraint "each delivery exactly once" is guaranteed by construction, because we will only ever handle permutations.
- Brute force as the reference (7 deliveries)
With 7 deliveries there are 7! = 5,040 permutations: the brute force of 03-01, now over the matrix, gives us the exact optimum that will serve as the yardstick for the approximate methods.
def brute_force(deliveries, dist):
best, best_km = None, math.inf
for order in itertools.permutations(deliveries):
km = route_length(order, dist)
if km < best_km:
best, best_km = list(order), km
return best, best_km
optimum, optimum_km = brute_force(DELIVERIES, MATRIX)
print(optimum_km, " -> ".join(optimum))The optimum is 39.0 km (curiously the same one found by the restricted version in 03-01: on this map the best route already used only direct roads). Remember that this number is out of reach for brute force beyond about 12-13 deliveries.
- Hill climbing by swapping stops
Move: swap two stops of the route. With 7 stops there are 21 possible swaps: that is the neighbourhood. Algorithm: look at every neighbour, move to the best one if it improves the current route, and stop when no neighbour improves ("best improvement" version; the "first improvement" alternative moves to the first neighbour that improves).
def swap_neighbours(route):
"""Generate every route that results from swapping two stops."""
for i in range(len(route)):
for j in range(i + 1, len(route)):
v = route[:]
v[i], v[j] = v[j], v[i]
yield v
def hill_climbing(initial_route, dist, verbose=False):
current = initial_route[:]
current_km = route_length(current, dist)
steps = 0
while True:
best_neighbour, best_km = None, current_km
for v in swap_neighbours(current):
km = route_length(v, dist)
if km < best_km:
best_neighbour, best_km = v, km
if best_neighbour is None: # no neighbour improves: local optimum
return current, current_km, steps
current, current_km = best_neighbour, best_km
steps += 1
if verbose:
print(f" step {steps}: {current_km:.1f} km {' -> '.join(current)}")
random.seed(7)
initial = DELIVERIES[:]
random.shuffle(initial)
print("Initial:", route_length(initial, MATRIX), "km", " -> ".join(initial))
route, km, steps = hill_climbing(initial, MATRIX, verbose=True)
print("Final:", km, "km in", steps, "steps")Initial: 68.5 km Arganzuela -> Retiro -> Vallecas -> Leganes -> Usera -> Villaverde -> Carabanchel step 1: 53.0 km Arganzuela -> Retiro -> Vallecas -> Carabanchel -> Usera -> Villaverde -> Leganes step 2: 47.5 km Vallecas -> Retiro -> Arganzuela -> Carabanchel -> Usera -> Villaverde -> Leganes Final: 47.5 km in 2 steps
In two steps it has gone from 68.5 to 47.5 km, and there it stopped: none of the 21 swaps improves that route, yet the optimum is 39.0. We have reached a local optimum: a summit from which everything you can see around is downhill, even though there are higher mountains further away. It is the structural flaw of hill climbing, and it has three classic remedies:
- Richer neighbourhoods (more possible moves: for example, reversing a segment of the route, the famous 2-opt, which we will use in section 7).
- Random restarts: repeat the climb from many different initial solutions and keep the best. It is simple, parallelises effortlessly and works surprisingly well.
- Sometimes accepting moves that make things worse, which is the idea of simulated annealing.
def hill_climbing_restarts(deliveries, dist, n_restarts, seed=0):
rng = random.Random(seed)
best, best_km = None, math.inf
for _ in range(n_restarts):
initial = deliveries[:]
rng.shuffle(initial)
route, km, _ = hill_climbing(initial, dist)
if km < best_km:
best, best_km = route, km
return best, best_km
route, km = hill_climbing_restarts(DELIVERIES, MATRIX, n_restarts=10)
print(km, " -> ".join(route))With 10 restarts it reaches the brute-force optimum. If you repeat the experiment with 20 different starts you will see that about 8 out of 20 end at 39.0, about as many at 39.5 and the rest at worse local optima (46-47.5 km): the probability of hitting it with a single attempt is around 40 %, but with 10 independent attempts it is over 99 %. And the total cost has been 10 × (a few steps × 21 evaluations): about 500 evaluations versus the 5,040 of brute force. With 7 stops the difference is anecdotal; with 30 it is the difference between seconds and centuries.
- A bigger problem: 15 delivery addresses
To give the metaheuristics something to chew on, we generate a larger instance: 15 specific customer addresses scattered around the districts of COORDINATES (03-02), with straight-line distance as an approximation of the real distance (in production we would use the Dijkstra matrix over the street network, exactly as in section 3; the optimisation algorithm does not notice the difference because it only ever looks up dist[a][b]).
COORDINATES = {
"Warehouse_Getafe": (0, 0), "Leganes": (-3, 3), "Villaverde": (3, 3), "Carabanchel": (-2, 7),
"Usera": (2, 7), "Vallecas": (7, 8), "Arganzuela": (1, 10), "Retiro": (4, 12),
}
def generate_addresses(n, seed=42):
"""n fictional delivery addresses: each one near a randomly chosen district."""
rng = random.Random(seed)
districts = [d for d in COORDINATES if d != "Warehouse_Getafe"]
addresses = {}
for i in range(1, n + 1):
district = rng.choice(districts)
x, y = COORDINATES[district]
addresses[f"D{i:02d}_{district[:4]}"] = (x + rng.uniform(-1.5, 1.5), y + rng.uniform(-1.5, 1.5))
return addresses
ADDRESSES = generate_addresses(15)
POINTS = {"Warehouse_Getafe": (0, 0), **ADDRESSES}
def euclidean(a, b):
(x1, y1), (x2, y2) = POINTS[a], POINTS[b]
return math.hypot(x2 - x1, y2 - y1)
DIST15 = {a: {b: euclidean(a, b) for b in POINTS} for a in POINTS}
DELIVERIES15 = list(ADDRESSES)
rng = random.Random(1)
initial15 = DELIVERIES15[:]
rng.shuffle(initial15)
print("Initial:", round(route_length(initial15, DIST15), 1), "km")
route, km, steps = hill_climbing(initial15, DIST15)
print("Hill climbing:", round(km, 1), "km in", steps, "steps")
for n in [1, 10, 50]:
route, km = hill_climbing_restarts(DELIVERIES15, DIST15, n_restarts=n)
print(f"With {n:2d} restarts: {km:.2f} km")Initial: 97.6 km Hill climbing: 51.8 km in 7 steps With 1 restarts: 43.79 km With 10 restarts: 36.45 km With 50 restarts: 36.45 km
The address names carry the district prefix (D04_Vall is the fourth delivery, near Vallecas). With 15 stops brute force would need 15! ≈ 1.3 trillion evaluations; simple hill climbing gets stuck at 51.8 km, and with restarts it reaches 36.45 km, which is (as the other two methods will confirm) the best known value for this instance. Keep that figure as the reference.
- Simulated annealing: the intuition of temperature
Simulated annealing takes its name from metallurgy: a metal heated and cooled slowly crystallises into a minimum-energy structure, whereas cooled abruptly it ends up full of defects. Translated into local search:
- At each iteration one random neighbour is generated (not all of them).
- If it improves, it is always accepted.
- If it worsens by an amount Δ, it is accepted with probability e^(−Δ/T), where T is the temperature. With high T almost everything is accepted (free exploration, jumping between valleys); with low T almost nothing that worsens is accepted (the algorithm behaves like hill climbing and refines the solution).
- T starts high and is reduced little by little (cooling), for example by multiplying it by 0.995 at each iteration.
A few numbers to fix the intuition (probability of accepting a worsening of Δ km at temperature T):
| Δ (km worse) | T = 10 | T = 1 | T = 0.1 |
|---|---|---|---|
| 0.5 | 0.95 | 0.61 | 0.007 |
| 2 | 0.82 | 0.14 | ≈ 0 |
| 5 | 0.61 | 0.007 | ≈ 0 |
At the start it accepts almost anything; at the end, practically only improvements. As the move we will use 2-opt: pick two positions and reverse the segment between them, which on routes is more effective than the simple swap because it undoes crossings.
def simulated_annealing(initial_route, dist, T0=10.0, cooling=0.995, T_min=0.01, seed=0):
rng = random.Random(seed)
current = initial_route[:]
current_km = route_length(current, dist)
best, best_km = current[:], current_km
T = T0
iterations = accepted_worse = 0
while T > T_min:
i, j = sorted(rng.sample(range(len(current)), 2))
neighbour = current[:]
neighbour[i:j + 1] = reversed(neighbour[i:j + 1]) # 2-opt move: reverse a segment
neighbour_km = route_length(neighbour, dist)
delta = neighbour_km - current_km
if delta < 0 or rng.random() < math.exp(-delta / T): # improves: always; worsens: sometimes
if delta > 0:
accepted_worse += 1
current, current_km = neighbour, neighbour_km
if current_km < best_km: # remember the best one seen
best, best_km = current[:], current_km
T *= cooling
iterations += 1
return best, best_km, iterations, accepted_worse
route, km, iterations, worse = simulated_annealing(initial15, DIST15)
print(f"Annealing: {km:.2f} km in {iterations} iterations ({worse} worsening moves accepted)")
for seed in range(5):
route, km, _, worse = simulated_annealing(initial15, DIST15, seed=seed)
print(f" seed {seed}: {km:.2f} km, {worse} worsenings accepted")Annealing: 36.45 km in 1379 iterations (117 worsening moves accepted) seed 0: 36.45 km, 117 worsenings accepted seed 1: 36.84 km, 101 worsenings accepted seed 2: 36.45 km, 101 worsenings accepted seed 3: 36.84 km, 105 worsenings accepted seed 4: 36.45 km, 88 worsenings accepted
From the same 97.6 km initial route on which hill climbing stalled at 51.8, annealing reaches 36.45 km (or 36.84, 1 % worse) in about 1,400 evaluations, accepting along the way a hundred or so moves that made things worse: those "steps backwards" are what allow it to get out of the valleys. Three details of the code deserve attention: the best solution seen is stored separately (the current one can get worse at the end of the hot phase); the temperature is multiplied at every iteration (geometric cooling, the most common); and the number of iterations is set by the cooling rate (from 10 to 0.01 at a rate of 0.995 is about 1,380 iterations). Cooling more slowly (0.999) improves quality at the cost of more time; it is the dial Marta will adjust according to the seconds available before 8 in the morning.
- Genetic algorithms
Genetic algorithms are inspired by evolution: instead of one solution that moves, they maintain a population of solutions that reproduces; the best have more offspring, children combine pieces of their parents and now and then undergo mutations. Vocabulary and cycle:
flowchart TD
A[Random initial population<br/>N routes] --> B[Evaluation: km of each route<br/>fitness = -km]
B --> C{Done?<br/>generations exhausted}
C -- No --> D[Tournament selection:<br/>pick k at random, the best wins]
D --> E[OX crossover:<br/>copy a segment from parent 1<br/>and fill in parent 2's order]
E --> F[Mutation:<br/>with prob. p, swap two stops]
F --> G[Elitism:<br/>the e best pass through intact]
G --> B
C -- Yes --> H[Return the best route]
- Individual / chromosome: a solution (a route); its genes are the stops.
- Fitness: the objective (fewer km, fitter).
- Tournament selection: k individuals are drawn at random and the best wins; good ones reproduce more, but bad ones still have some chance, which preserves diversity.
- Crossover: combining two parents. With permutations you cannot copy halves (stops would repeat); order crossover (OX) copies a segment from parent 1 and fills the remaining positions with the missing stops in the order they appear in parent 2.
- Mutation: a small random change (swapping two stops), which introduces novelty and prevents the population from becoming identical.
- Elitism: the best individuals pass unchanged to the next generation, so that the best solution found is never lost.
def tournament(population, dist, rng, k=3):
candidates = rng.sample(population, k)
return min(candidates, key=lambda r: route_length(r, dist))
def ox_crossover(parent1, parent2, rng):
n = len(parent1)
i, j = sorted(rng.sample(range(n), 2))
child = [None] * n
child[i:j + 1] = parent1[i:j + 1] # segment inherited from parent 1
remaining = [g for g in parent2 if g not in child] # what is missing, in parent 2's order
gaps = [k for k in range(n) if child[k] is None]
for k, g in zip(gaps, remaining):
child[k] = g
return child
def swap_mutation(route, rng, prob):
if rng.random() < prob:
i, j = rng.sample(range(len(route)), 2)
route[i], route[j] = route[j], route[i]
return route
def genetic_algorithm(deliveries, dist, population_size=60, generations=200,
elitism=2, mutation_prob=0.2, seed=0, verbose=False):
rng = random.Random(seed)
population = []
for _ in range(population_size):
r = deliveries[:]
rng.shuffle(r)
population.append(r)
history = []
for gen in range(generations):
population.sort(key=lambda r: route_length(r, dist)) # best first
best_km = route_length(population[0], dist)
history.append(best_km)
if verbose and gen % 25 == 0:
mean = sum(route_length(r, dist) for r in population) / len(population)
print(f" gen {gen:3d}: best {best_km:.2f} km, mean {mean:.2f} km")
new = [r[:] for r in population[:elitism]] # elitism
while len(new) < population_size:
p1, p2 = tournament(population, dist, rng), tournament(population, dist, rng)
child = ox_crossover(p1, p2, rng)
child = swap_mutation(child, rng, mutation_prob)
new.append(child)
population = new
population.sort(key=lambda r: route_length(r, dist))
return population[0], route_length(population[0], dist), history
route, km, history = genetic_algorithm(DELIVERIES15, DIST15, verbose=True)
print(f"Genetic: {km:.2f} km (best reached in generation {history.index(min(history))})")
print(" -> ".join(route))gen 0: best 79.70 km, mean 99.49 km gen 25: best 36.45 km, mean 39.24 km gen 50: best 36.45 km, mean 39.69 km gen 75: best 36.45 km, mean 40.86 km gen 100: best 36.45 km, mean 39.18 km gen 125: best 36.45 km, mean 41.31 km gen 150: best 36.45 km, mean 39.64 km gen 175: best 36.45 km, mean 37.70 km Genetic: 36.45 km (best reached in generation 18) D05_Vill -> D02_Vill -> D04_Vall -> D07_Vall -> D15_Vall -> D06_Vall -> D09_Reti -> D13_Reti -> D03_Arga -> D01_Arga -> D10_Cara -> D11_Cara -> D12_Cara -> D08_Cara -> D14_Lega
The population starts with a mean of almost 100 km and in 18 generations (about 1,000 evaluations) its best individual is already at 36.45 km. The final route makes complete geographical sense: it heads out towards Villaverde, covers Vallecas, goes up to Retiro and Arganzuela, comes down through Carabanchel and returns via Leganes. An example of OX crossover so you can see the mechanics: with parents A B C D E F G and G F E D C B A, if the chosen segment is positions 1-2, the child inherits _ B C _ _ _ _ from the first and fills in with G F E D A (the order of the second, skipping B and C): G B C F E D A. No stop is repeated or lost.
Two honest warnings: genetic algorithms are sensitive to their parameters and stochastic. With other seeds, the same configuration ends at 41.3, 43.4, 43.8 or 46.1 km: the population converges prematurely (all individuals look alike and crossover no longer contributes anything). Raising the mutation probability to 0.5 and the population to 150 makes 4 out of 5 runs reach 36.45. Compared with annealing, here the genetic algorithm needs more evaluations for the same result; its advantage shows in problems where there is no natural local move, where evaluation is parallelisable, or where several good and different solutions are of interest.
- Second example: assigning orders to two warehouses with capacity
Let us change problem but not method. Every morning Diego has to decide which orders are picked in Getafe and which in Zaragoza (use case 6). Each order has a different shipping cost depending on the warehouse (because of the destination zone) and a volume in boxes; each warehouse has a daily picking capacity in boxes. Formulation:
- Variables: for each order,
"Getafe"or"Zaragoza"(a list of 20 labels; 2²⁰ ≈ 1 million solutions). - Objective: minimise the total shipping cost.
- Constraint: the sum of boxes assigned to each warehouse cannot exceed its capacity.
The most common way to handle a constraint in local search is to turn it into a penalty: add to the objective an artificial cost proportional to the violation (here, €20 for every excess box). That way every solution is "evaluable", the infeasible ones simply come out very expensive, and the algorithm learns to avoid them.
def generate_orders(n, seed=11):
rng = random.Random(seed)
orders = []
rate = {"centre": (4, 7), "south": (3, 9), "northeast": (9, 4), "east": (8, 5)} # €/box (Getafe, Zaragoza)
for i in range(1, n + 1):
zone = rng.choice(list(rate))
boxes = rng.choice([1, 1, 2, 3, 5])
orders.append({"id": f"P{i:03d}", "zone": zone, "boxes": boxes,
"getafe_cost": rate[zone][0] * boxes,
"zaragoza_cost": rate[zone][1] * boxes})
return orders
ORDERS = generate_orders(20) # 60 boxes in total
CAPACITY = {"Getafe": 32, "Zaragoza": 30}
PENALTY = 20 # € per excess box
def assignment_cost(assignment, orders, capacity, penalty=PENALTY):
"""Return (penalised cost, real cost, load per warehouse, excess boxes)."""
cost = 0
load = {"Getafe": 0, "Zaragoza": 0}
for o, warehouse in zip(orders, assignment):
cost += o["getafe_cost"] if warehouse == "Getafe" else o["zaragoza_cost"]
load[warehouse] += o["boxes"]
excess = sum(max(0, load[w] - capacity[w]) for w in load)
return cost + penalty * excess, cost, load, excess
# Naive solution: every order to the cheapest warehouse, ignoring capacity
naive = ["Getafe" if o["getafe_cost"] <= o["zaragoza_cost"] else "Zaragoza" for o in ORDERS]
print("Naive:", assignment_cost(naive, ORDERS, CAPACITY))
def assignment_neighbours(assignment):
"""Neighbours: move ONE order to the other warehouse."""
for i in range(len(assignment)):
v = assignment[:]
v[i] = "Zaragoza" if v[i] == "Getafe" else "Getafe"
yield v
def assignment_hill_climbing(assignment, orders, capacity):
current = assignment[:]
f_current = assignment_cost(current, orders, capacity)[0]
while True:
best, f_best = None, f_current
for v in assignment_neighbours(current):
f = assignment_cost(v, orders, capacity)[0]
if f < f_best:
best, f_best = v, f
if best is None:
return current, f_current
changed = [i for i in range(len(current)) if current[i] != best[i]][0]
current, f_current = best, f_best
print(f" move {orders[changed]['id']} ({orders[changed]['zone']}, "
f"{orders[changed]['boxes']} boxes) to {current[changed]} -> "
f"{assignment_cost(current, orders, capacity)}")
final, f = assignment_hill_climbing(naive, ORDERS, CAPACITY)
print("Final:", assignment_cost(final, ORDERS, CAPACITY))Naive: (359, 239, {'Getafe': 38, 'Zaragoza': 22}, 6)
move P008 (centre, 5 boxes) to Zaragoza -> (274, 254, {'Getafe': 33, 'Zaragoza': 27}, 1)
move P012 (centre, 1 boxes) to Zaragoza -> (257, 257, {'Getafe': 32, 'Zaragoza': 28}, 0)
Final: (257, 257, {'Getafe': 32, 'Zaragoza': 28}, 0)The naive assignment is the cheapest on paper (€239), but it loads 38 boxes onto Getafe, 6 above its capacity: penalised, it costs 359. Hill climbing moves two centre-zone orders to Zaragoza (the ones that lose least by changing warehouse: €15 and €3 more) and reaches a feasible assignment of €257. Since here the space has "only" 2²⁰ solutions, we can check by full enumeration that €257 is the exact optimum; with 200 orders (2²⁰⁰) that check would be impossible and local search would be the only option.
Two lessons from this example: the penalty must be high enough that no infeasible solution pays off (with €20/box it works; with €2/box the algorithm would rather pay the fine), but not so high that it flattens the differences in real cost and turns the landscape into a plateau; and the same local search skeleton serves problems of a different nature by changing only the representation, the neighbourhood and the objective function.
- Comparison table of methods
| Method | Type | Optimality guarantee | Computational cost | Parameters | When to use it |
|---|---|---|---|---|---|
| Brute force | Exact | Yes | n! or 2ⁿ: only tiny sizes | None | Validating other methods; toy problems |
| Branch and bound, dynamic programming, linear programming | Exact | Yes (if the problem has the right structure) | Exponential in the worst case; very effective in many real cases | Few | When a suitable solver exists (assignment, flow, linear planning) |
| Nearest neighbour and other constructive methods | Heuristic | No; typically 20-25 % worse than the optimum on the travelling salesman | Very low (n²) | None | Starting solution; instant answer |
| Hill climbing (+ restarts) | Local search | No; local optimum | Low per restart, scalable | Neighbourhood, number of restarts | First choice: simple and often sufficient |
| Simulated annealing | Metaheuristic | No, but converges to the optimum with infinitely slow cooling | Medium, controllable | T₀, cooling rate | Spaces with many local optima; a single good solution |
| Genetic algorithm | Metaheuristic | No | Medium-high (population × generations), parallelisable | Population, tournament, crossover, mutation, elitism | No natural local move; several diverse solutions; parallel evaluation |
Marta's decision rule: if the problem is small or has known structure, exact (and there are excellent linear programming solvers, though they fall outside this course); otherwise, hill climbing with restarts as the baseline, simulated annealing when it gets stuck, and genetic when the representation calls for it. And always validate at small size against brute force, as we have done.
- Link to module 4: learning is optimising
We close with the most important connection of the lesson. Remember learn_threshold from 01-02: it looked for the threshold that best separated two classes by trying values. That was already optimisation: variable (the threshold), objective (hits), method (brute force over a grid). Every training of a machine learning model is an optimisation problem: the variables are the model's parameters (a handful in a regression, billions in a large language model), the objective function is a measure of error on the training data (the "loss function"), and the constraints or penalties are the regularisation techniques of 04-06. The difference from this lesson is that, when the parameters are continuous numbers and the loss function is smooth, there is no need to try random neighbours: you can compute in which direction the loss decreases (its gradient) and take a step in it, over and over. That method, gradient descent, is the "hill climbing with a compass" that makes neural networks work, and we will study it in 05-03. Everything you have learnt today about local optima, step size (here, temperature and neighbourhood) and validation will carry over there as is.
Common Mistakes and Tips
- Confusing the local optimum with the global one: a hill climb that "does not improve any further" has not finished, it has got stuck. Restart, change the neighbourhood or use annealing before accepting the solution as good.
- Not validating at small size: before trusting a metaheuristic for 40 stops, check that with 7 or 8 it finds the same optimum as brute force. A bug in the objective function (for example, forgetting the leg back to the warehouse) goes unnoticed without that check.
- Seeds and reproducibility: the methods of this lesson are stochastic. Fix the seed (
random.Random(seed)) so you can reproduce results and debug, and always evaluate several seeds before drawing conclusions about a parameter. - Cooling too fast in annealing turns the algorithm into an expensive hill climb; cooling too slowly wastes time. Start with T₀ of the order of a typical "large" worsening and adjust by watching how many worsening moves are accepted.
- Crossover that breaks the representation: with permutations, a naive crossover produces routes with repeated or missing stops. Use OX or another permutation-preserving operator and check after every crossover that
sorted(child) == sorted(parent1). - Badly calibrated penalty: too low and the "optimal" solution violates the constraint; too high and the algorithm cannot tell feasible solutions apart. Always check
excess == 0in the final solution. - Forgetting to store the best solution seen in annealing: the current solution can get worse when accepting worsening moves; always return
best, notcurrent. - Optimising the wrong objective: as 02-01 warned, if the cost only measures kilometres, the optimal route may break every time slot. Add to the objective (or as a penalty) everything that matters.
Exercises
Exercise 1: nearest neighbour as a starting point
Implement the nearest neighbour constructive heuristic: starting from the warehouse, always go to the nearest pending delivery. Apply it to DELIVERIES with MATRIX and to DELIVERIES15 with DIST15, compare with the known optima (39.0 and 36.45 km) and use it as the initial route for hill climbing. Does the result improve compared with a random start?
Exercise 2: 2-opt in hill climbing
Write two_opt_neighbours(route) that generates every route obtained by reversing a segment route[i:j+1] and use it instead of swap_neighbours inside hill_climbing (parameterise the neighbourhood function). Compare, for 20 random starts on DELIVERIES15, how many times each neighbourhood reaches 36.45 km.
Exercise 3: tighter capacity in the assignment
Change CAPACITY to {"Getafe": 30, "Zaragoza": 32} and run assignment_hill_climbing again from the naive solution. Which orders move now, what is the final cost and is it feasible? Then lower PENALTY to 2 and watch what happens to the excess of the final solution. Explain the result.
Solutions
Solution 1.
def nearest_neighbour(deliveries, dist, origin="Warehouse_Getafe"):
pending = set(deliveries)
route, current = [], origin
while pending:
nxt = min(pending, key=lambda e: dist[current][e])
route.append(nxt)
pending.remove(nxt)
current = nxt
return route
for deliveries, dist, name in [(DELIVERIES, MATRIX, "7 districts"), (DELIVERIES15, DIST15, "15 addresses")]:
r = nearest_neighbour(deliveries, dist)
km = route_length(r, dist)
r2, km2, steps = hill_climbing(r, dist)
print(f"{name}: nearest neighbour {km:.2f} km -> after climbing {km2:.2f} km ({steps} steps)")With the 7 districts nearest neighbour gives 39.5 km (Leganes, Carabanchel, Usera, Arganzuela, Retiro, Vallecas, Villaverde: it goes round the map clockwise and comes back via Villaverde), just 0.5 km from the optimum, and swap-based hill climbing cannot improve it (0 steps): it is a local optimum very close to the global one. With the 15 addresses it gives 37.82 km and a single climbing step takes it to 36.45. A good initial solution saves a lot of work (compare with the 51.8 km of the random start), but on its own it does not guarantee the optimum: local search is still needed, and in general restarts or annealing.
Solution 2.
def two_opt_neighbours(route):
for i in range(len(route)):
for j in range(i + 1, len(route)):
v = route[:]
v[i:j + 1] = reversed(v[i:j + 1])
yield v
def generic_hill_climbing(initial_route, dist, neighbourhood):
current, current_km = initial_route[:], route_length(initial_route, dist)
while True:
best, best_km = None, current_km
for v in neighbourhood(current):
km = route_length(v, dist)
if km < best_km:
best, best_km = v, km
if best is None:
return current, current_km
current, current_km = best, best_km
for name, neighbourhood in [("swap", swap_neighbours), ("2-opt", two_opt_neighbours)]:
rng = random.Random(0)
hits = 0
for _ in range(20):
ini = DELIVERIES15[:]
rng.shuffle(ini)
_, km = generic_hill_climbing(ini, DIST15, neighbourhood)
hits += round(km, 2) == 36.45
print(f"{name}: {hits} of 20 starts reach 36.45 km")Output: swap: 1 of 20 starts reach 36.45 km versus 2-opt: 15 of 20 starts reach 36.45 km. Reversing segments undoes crossings in the route, which is the typical source of bad local optima in the travelling salesman, and that is why 2-opt is the standard neighbourhood for this problem.
Solution 3. With {"Getafe": 30, "Zaragoza": 32} the naive solution has 8 excess boxes (399 penalised). The climb moves P008 (centre, 5 boxes) and then P015 (centre, 3 boxes) to Zaragoza, leaving 30 and 30 boxes: €263, feasible, and again it is the exact optimum (check it with itertools.product). With PENALTY = 2, moving P008 to Zaragoza costs €15 more in shipping but only saves €10 in fines (5 boxes × €2), so the algorithm moves nothing and returns the naive assignment (€255 penalised, €239 real) with 8 excess boxes: the penalty is too cheap to represent a constraint that in reality is hard (the warehouse simply cannot pick any more). Moral: the penalty must exceed the largest saving that can be obtained by violating the constraint.
Conclusion
With this lesson we have completed the module's algorithmic toolbox. We have formulated optimisation problems with their three components (variables, objective, constraints), distinguished exact from approximate methods, and built three local search and metaheuristic algorithms: hill climbing, which climbs to the first local optimum and is rescued by random restarts; simulated annealing, which accepts worsenings with a probability that decreases with temperature in order to escape valleys; and the genetic algorithm, which evolves a population through tournament, OX crossover, mutation and elitism. All three have solved the NovaMarket van's travelling salesman problem (39.0 km on 7 districts, validated against brute force; 36.45 km on 15 addresses, where brute force is impossible) over the distance matrix that Dijkstra gave us in 03-02, and the same skeleton has solved the assignment of orders to Getafe and Zaragoza by turning capacity into a penalty. Finally we have seen that training a model is optimising, the idea that links this module with the next two.
This closes module 3. You have travelled the road from the definition of an algorithm and the combinatorial explosion (03-01) to path search with BFS, DFS, uniform-cost and A* on CITY_GRAPH (03-02), decision-making against an opponent with minimax and alpha-beta pruning (03-03) and the optimisation of complete solutions with local search and metaheuristics (03-04). NovaMarket's route planner and order assignment, which in 02-01 were only a formulation, now have working code; in 09-01 you will return to them with more exercises. In module 4, Machine Learning, we will change paradigm: instead of programming the algorithm that solves the problem ourselves, we will give the system historical data (orders.csv, reviews.csv, incidents.csv) so that it learns the rule by itself, and we will see that, on the inside, that learning is a search for the best model in an enormous space, with the same ideas of objective, local optimum and validation you have just mastered. Marta and Diego will move from optimising routes to forecasting demand and detecting fraud.
Fundamentals of Artificial Intelligence (AI)
Module 1: Introduction to Artificial Intelligence
Module 2: Basic Principles of AI
- Fundamental Concepts: Agents, Environments and Rationality
- Types of Artificial Intelligence
- Data as the Raw Material of AI
- Ethics and Considerations in AI
Module 3: Algorithms in AI
- Introduction to Algorithms
- Search Algorithms
- Adversarial Search: Games and Minimax
- Optimization Algorithms
Module 4: Machine Learning
- Basic Concepts of Machine Learning
- Types of Machine Learning
- Data Preparation and Feature Engineering
- Machine Learning Algorithms
- Model Evaluation and Validation
- Overfitting, Regularization and Hyperparameter Tuning
Module 5: Neural Networks and Deep Learning
- Introduction to Neural Networks
- Neural Network Architecture
- How a Network Learns: Gradient Descent and Backpropagation
- Deep Learning and Its Applications
- Transformers, Large Language Models and Generative AI
Module 6: Logic and Expert Systems
- Logic in AI
- Expert Systems
- Reasoning under Uncertainty: Probability and Bayesian Networks
- Applications of Expert Systems
Module 7: Tools and Programming Languages in AI
- Programming Languages for AI
- Scientific Python: NumPy, pandas and Matplotlib
- Popular Tools and Libraries
- Development Environments
Module 8: Projects and Case Studies
Module 9: Exercises and Practice
- Algorithm Exercises
- Machine Learning Practice
- Neural Network Projects
- Capstone Project: from Idea to Prototype
