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

  1. What an optimisation problem is: variables, objective function, constraints
  2. Exact versus approximate optimisation; local search
  3. Preparing NovaMarket's travelling salesman problem: the distance matrix with Dijkstra
  4. Brute force as the reference (7 deliveries)
  5. Hill climbing by swapping stops: local optima and random restarts
  6. A bigger problem: 15 delivery addresses
  7. Simulated annealing: the intuition of temperature
  8. Genetic algorithms: population, tournament, crossover, mutation and elitism
  9. Second example: assigning orders to two warehouses with capacity (constraints as penalties)
  10. Comparison table of methods and when to use each one
  11. Link to module 4: learning is optimising

  1. 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.

  1. 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.

  1. 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 total

MATRIX[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.

  1. 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))
39.0 Leganes -> Carabanchel -> Arganzuela -> Retiro -> Vallecas -> Usera -> Villaverde

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.

  1. 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))
39.0 Leganes -> Carabanchel -> Arganzuela -> Retiro -> Vallecas -> Usera -> Villaverde

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.

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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 == 0 in 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, not current.
  • 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

Module 3: Algorithms in AI

Module 4: Machine Learning

Module 5: Neural Networks and Deep Learning

Module 6: Logic and Expert Systems

Module 7: Tools and Programming Languages in AI

Module 8: Projects and Case Studies

Module 9: Exercises and Practice

Module 10: Additional Resources

© Copyright 2026. All rights reserved