Genetic Algorithms
Genetic algorithms from scratch: encoding, tournament selection, order crossover, mutation, elitism, diversity, and budget-matched routing comparisons.
Machine Learning from ScratchAll chapters Genetic AlgorithmsFull chapter · Sections in this chapter1 / 10 ↓
Reading tools Definitions, links & notes
Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.
Article links
Make inline references clickable
Genetic Algorithms
In the preceding chapter we compared nearest-neighbour construction, best-improvement hill climbing with restarts, and simulated annealing on a small vehicle-routing instance. Those searches maintain one current candidate at a time. The hill climber can stop improving in its current neighbourhood, while annealing can accept a worse move under its temperature rule; neither finite-budget run guarantees that it visits every promising region.
A genetic algorithm attacks the same problem from a different angle. Instead of one candidate solution, it maintains a population of them. Instead of perturbing and accepting, it selects promising parents, builds new candidates by combining parts of two parents (crossover) and by perturbing the result (mutation), and then lets the better candidates survive (selection with elitism). The hope is that recombination assembles partial solutions that neither parent contains, so the population can improve faster than a single trajectory.
For that hope to pay off, the encoding has to cooperate. Our decision variable is the visiting order of eight customers, so a candidate is a permutation and every child of crossover or mutation must remain a permutation: no duplicated customers, no missing ones. The order-preserving crossover we build below guarantees this by construction, and the decoder turns any permutation into two feasible depot tours, so no repair step is needed for this instance.
Our aim in this chapter is to build the mechanism from primitives, count every objective evaluation, and compare it with NN-seeded random search, best-improvement segment-reversal hill climbing, and simulated annealing under the same 300-evaluation budget on exactly the same routing instance. Nearest-neighbour construction supplies the common starting permutation and is also reported separately as a one-evaluation baseline. Because the instance has only eight customers, we can enumerate all permutations and report gaps to that reference. The worked parents are chosen to illustrate crossover; the repeated-run comparison will tell us how the actual search performs under its budget.
Advantages
The evolutionary search here needs no derivatives. It scores complete candidates, making the framework applicable to a black-box objective when an encoding, valid variation operators, and a meaningful comparison rule are available. Candidates may be permutations as here, binary subsets, assignments, or real and integer vectors. Gradient Descent and Numerical Optimization used derivatives to construct local updates; our loop compares candidate costs directly. Noise still requires care because a low observed cost need not mean a low expected cost.
A population can retain candidates in several regions and offers several parents from which to build children. Recombination can combine structures from two candidates. In the deliberately selected worked example, parents of cost and produce a child of cost , shorter than either parent and equal to the enumerated minimum to the reported precision. This demonstrates one possible update; it does not estimate the probability of finding that child in a run or establish an advantage over local search.
The framework admits different encodings and operators. Constraints may be enforced by a feasible decoder, checked after variation, repaired, or included in a penalized comparison. Each approach needs validation. For a fixed deterministic objective, the elitist survival rule used here preserves the best retained cost, and an archive can return the best scored candidate when the allowed budget is exhausted.
Disadvantages
Population initialization spends evaluations before any offspring are produced. With a population of 20 and a budget of 300, initialization costs 20 and leaves 280 offspring evaluations, or 14 complete generations. Full enumeration uses 40320 evaluations and is feasible for this teaching instance, but it has a different budget. We compare the heuristics at 300 evaluations and account separately for the exhaustive reference and other diagnostic work.
A finite run does not certify a global optimum. Outcomes can depend on the starting population, tournament size, variation probabilities, survivor rule, and stopping budget. Testing settings also performs evaluations: report that tuning separately or include it in the application budget. A successful hand-picked crossover is not evidence that a particular parameter setting will be effective across instances.
Selection can reduce diversity. When parents are identical permutations, the order crossover below returns that same permutation, so mutation is needed for a different child. A mutated child can still be rejected by elitist survival. Different permutations can also encode the same unordered pair of tours; this distinction matters when interpreting diversity. Finally, crossover can break useful route adjacencies or change vehicle assignments, even when it always produces a valid permutation.
Formula
The intuition behind a genetic algorithm is easy to state: keep several candidates alive, let good ones reproduce, and trust variation plus selection to improve the population. The formulas below make that intuition precise. They answer four questions in order: how a candidate is encoded and scored, how parents are chosen, how children are built without breaking the encoding, and how the next generation is chosen. Each piece exists for a reason, and the reasons are what make the algorithm more than a random search with extra steps.
Representation, fitness, and feasible solutions
We begin with the object being searched over. Because the decision is the order in which eight customers are visited, the natural genotype is a permutation. Let , let index the customers, and let node be the depot. A genotype is a permutation
where:
- : the permutation (genotype), a length-8 ordering of the customers
- : the customer visited at position of the permutation
- : the set of customer indices
- : the number of customers (), hence the length of every permutation
so the search space is with elements. This is already a strong constraint: every point in the search space is a valid ordering, and every ordering contains each customer exactly once. The algorithm never has to reason about missing or duplicated customers.
The permutation alone is not yet a pair of routes. The phenotype is produced by a decoder that cuts the permutation into two blocks of four and closes each block into a depot tour:
where:
- : the phenotype of permutation , a pair of closed depot tours
- : the customers assigned to the first vehicle, visited in that order
- : the customers assigned to the second vehicle, visited in that order
- : the depot that opens and closes each tour
The decoder is doing two jobs at once. It assigns the first four customers to one vehicle and the last four to the other, and it makes every permutation feasible with respect to capacity, because each vehicle receives exactly four customers with and . That is not an accident of this instance: every customer demand is , every vehicle capacity is , and the total demand equals the total capacity . The decoder enforces the only capacity split that can work.
Writing for the Euclidean distance between nodes and , the objective is the total length of both tours: the sum of the two closed depot tours produced by the decoder, in which each route begins at the depot, visits its four assigned customers in permutation order, and returns to the depot. The sum has ten terms in total: two depot-to-first-customer arcs, six customer-to-customer arcs, and two last-customer-to-depot arcs.
where:
- : the permutation (genotype) being scored, a length-8 ordering of the customers
- : the customer visited at position of the permutation
- : the vehicle index, running over the two vehicles and
- : the first position of vehicle 's block ( for vehicle 1, for vehicle 2)
- : the last position of vehicle 's block ( for vehicle 1, for vehicle 2)
- : the Euclidean distance between nodes and
- : the depot node
- : the summed length of both closed tours, the quantity minimised
Read the outer sum first: it runs over the two vehicles, and . Inside each term there are three pieces:
- the arc from the depot to the first customer of the route,
- the three arcs that connect the four customers of the route in order,
- the arc back to the depot.
The index arithmetic is just bookkeeping that starts vehicle 1 at position and vehicle 2 at position : for we get and the inner sum runs over ; for we get and the inner sum runs over . The inner sum connects consecutive customers within a block, and the two outer distance terms close the block to the depot at both ends, so each route has five arcs: three customer-to-customer arcs connecting the four customers, plus the depot-to-first-customer arc and the last-customer-to-depot arc. Across both routes that is ten arcs, which matches the ten distance terms in the formula. The objective is a pure routing length: no vehicle cost, no lateness term, no service-time term. Every one of the ten arcs that the decoder creates is charged exactly once, and no arc is charged twice.
We use unit travel speed, so travel time equals Euclidean distance. Feasibility is checked by simulating each route. With the service start at node , the arrival, and the window bounds, and the service time, the recursion is
where:
- : the service start time at node , i.e. arrival held until the window opens
- : the arrival time at node (the clock after traveling from the previous node)
- : the opening of the time window at node
- : the closing of the time window at node
- : the service time at node
- : the Euclidean travel distance from node to the next node
The recursion is subject to for every node including the return to the depot, and to , where is the demand at node and is the vehicle capacity. Here every customer demand is and every vehicle capacity is , and the total demand equals the total capacity , so each vehicle must carry exactly four customers: the decoder enforces that automatically. All windows are , and since every coordinate lies in no arc exceeds . A route has five arcs and serves four customers, each with service time . Starting from the depot, the service start at the fourth customer is at most , and the return to the depot takes one more arc of at most and one more service time of , giving . Every permutation is therefore feasible here. That is deliberate: the wide windows isolate search behaviour, and this instance does not test restrictive time-window handling.
Because we minimise, lower cost is better, and tournaments can compare costs without converting them into a separate fitness scale. For illustration, the positive transform maps costs 38 and 40 to and . The latter is percent smaller than the former. That transform compresses this particular cost difference; how concentrated proportional selection becomes depends on the full population. Our implementation instead selects the lowest raw cost in each sampled tournament.
On this symmetric-distance instance with deliberately wide windows, swapping the vehicles or reversing either tour preserves cost and feasibility. The decoded route sequences themselves change. We define a canonical signature by choosing the lexicographically smaller direction for each route and sorting the two resulting sequences. It identifies unordered pairs of undirected tours. Counting these signatures measures phenotype diversity; raw permutations and Hamming distance measure a different, genotype-level form of diversity that can also affect crossover.
Selection, crossover, mutation, and elitism
Selection is the pressure that turns variation into progress. Tournament selection draws distinct individuals uniformly at random from the population and returns the one with the lowest cost. It is simple, it needs no fitness scaling, and its selection pressure has a clean closed form.
For this rank calculation, assume distinct costs, or a fixed total tie order applied consistently when selecting a winner. In a population of size , rank is best and rank is worst. An individual of rank wins if it is drawn and no better individual (one of the individuals with smaller rank) is drawn with it. The number of -subsets that contain it and otherwise contain only the worse individuals is , out of equally likely subsets, so
where:
- : the population size
- : the tournament size (the number of individuals drawn without replacement)
- : the rank of the individual, with the best and the worst
- : the number of -subsets containing the individual and only worse individuals
- : the total number of equally likely -subsets
Under that strict ranking, for and the unique best individual is selected with probability , three times the uniform rate of , while the worst individual with rank , hence , has probability . Raising sharpens the pressure; is mild and always picks the leader. Summing the expression over returns by the hockey-stick identity, , so it is a proper distribution. The formula describes selection under the stated ranking. Our code resolves a cost tie by the order in which contenders were sampled, so it does not assign a fixed strict rank to tied individuals. The rank formula alone does not predict a diversity-loss rate.
The selection pressure implied by this formula can be read directly. The following computation evaluates the rank probabilities for several tournament sizes.
PythonIn [2]Expand
import math
import numpy as np
selection_n = 20
selection_ranks = np.arange(1, selection_n + 1)
selection_k_values = (2, 3, 5)
selection_probabilities = {
k: np.array(
[
math.comb(selection_n - r, k - 1) / math.comb(selection_n, k)
for r in selection_ranks
],
dtype=float,
)
for k in selection_k_values
}VisualizationOut [3]Expand

Order crossover (OX) builds the child from two cut positions. Fix cut positions and parents and , and index positions from to as in code:
- copy the block: for every ;
- let be the values already used, and list the values of in cyclic order starting just after the block, with indices taken modulo , discarding any value in ;
- fill the remaining positions in the same cyclic order, modulo , with that filtered list.
where:
- : the number of customers (), hence the length of each permutation
- : the inclusive cut positions bounding the copied block of the first parent
- : the first parent permutation, which supplies the copied block
- : the second parent permutation, which supplies the remaining values in cyclic order
- : the child permutation built by the rule
- : the set of values already placed in the child from the copied block
The child is a permutation. The block contributes distinct values; the filtered list contributes exactly the values of that are not in , in some order; the two position sets are disjoint and cover all positions. Nothing is duplicated and nothing is lost, so with no repair step. OX preserves the copied block and the cyclic relative order of the remaining values from . Relative order does not preserve every adjacency: filtering out a value creates new neighbours, and the decoder may assign a value to a different vehicle. Interior adjacencies of the copied block survive, but even that block can cross the vehicle split. Feasible crossover is not an improvement guarantee.
Mutation in permutation space is a swap. Choosing two distinct positions and exchanging their values always yields a valid permutation, and there are possible swaps. Within a single route a swap touches the arcs incident to both positions, at most four arcs (three if the positions are adjacent). Swapping one position in the first block with one in the second block instead re-assigns two customers between vehicles, each touching its own incident arcs, so the change in objective can be much larger: several long arcs can change at once. Mutation is therefore a source of novelty, not a guarantee of improvement.
Elitism fixes the survival rule. With parents and offspring per generation, selection sets
Here and are indexed collections, and the union denotes concatenation with multiplicity: duplicate individuals still occupy separate slots. The lowest-cost slots survive. Assume the objective is fixed and deterministic. In detail:
- : the population of parents at generation
- : the offspring produced at generation
- : the number of parents retained each generation (the population size)
- : the operator that keeps the lowest-cost individuals of the union. Because the old population is included among the candidates, the lowest retained cost is no larger than the old minimum, so the incumbent satisfies : with elitism the best-so-far curve is non-increasing, and we record the incumbent after each evaluation in a separate archive. A running-minimum archive can provide such a trace under other survival rules too. The population may churn, but the best solution found so far never gets worse.
Finally, the budget identity. With a population of , the initial population costs evaluations (the shared nearest-neighbour score plus random members), and each completed generation costs evaluations. The total is:
where:
- : the total evaluation budget (here )
- : the single complete-route evaluation of the nearest-neighbour construction
- : the random permutations that fill the rest of the initial population
- : the population size, also the number of children charged per generation
- : the number of generations
The displayed identity is for complete generations. In general with , and the implementation can spend the remainder on a partial final generation. With and , and this gives generations. This identity is the accounting contract for the whole chapter: initialization, every child, and every duplicate are charged, so a comparison with the other methods is a comparison of equal evaluation budgets rather than equal wall-clock time.
The budget identity also makes the population-size trade-off explicit. The following computation tabulates the number of complete generations available for a range of population sizes at the same budget.
PythonIn [4]Expand
import numpy as np
budget_for_generations = 300
population_sizes = np.arange(5, 51, 5)
generations = np.floor(
(budget_for_generations - population_sizes) / population_sizes
).astype(int)VisualizationOut [5]Expand

Population diversity, repair, and premature convergence
Define the diversity of a generation as the number of distinct canonical signatures in the population, , with , where:
- : the number of distinct canonical signatures in generation
- : the canonical signature of permutation (vehicle-symmetric, reversal-invariant)
- : the population of parents at generation
- : the number of parents (population size)
For populations with , genotype-level diversity can also be measured with the mean pairwise Hamming distance , where:
- : the -th individual permutation in the population
- : a position index,
- : the number of parents (population size)
The sum runs over ordered pairs , so each unordered pair is counted twice, matching the denominator. This measures genotype difference: swapping the two halves changes Hamming distance while preserving the unordered tour pair. The signature count instead measures distinct phenotypes under the stated symmetry; neither measure fully characterizes what crossover can produce.
Diversity is what makes crossover useful, and this can be shown rather than asserted. Order crossover applied to identical parents returns the parent: the block matches, and the filtered list from (the values of not copied into the block, in the cyclic order in which they appear in ) is placed into the empty positions in cyclic order; the two cyclic orderings agree, so each value is placed back where it started. So if the population collapses to copies of one permutation, for every selected pair, and the only source of novelty is mutation. A population in that state is a single point with random kicks, which is precisely the trajectory we were trying to escape.
This loss of useful variation is one form of premature convergence. Selection can remove variants; mutation proposes new variants but does not guarantee their survival. A low mutation rate may leave identical parents reproducing copies. A high rate may perturb useful structures. The appropriate balance depends on the encoding, objective, survivor rule, and evaluation budget.
The balance is problem specific, and with a budget of only generations there is little room to recover from a bad setting mid-run. We illustrate the trade-off with actual runs in the visualisation section rather than claiming a rule of thumb.
Repair is one alternative when feasibility is not enforced by construction. A repair operator maps an infeasible genotype back into the feasible set, and a penalty method folds violations into the objective:
where:
- : the repair operator, which takes any genotype and returns a feasible one
- : the routing length of permutation , as defined above
- : a nonnegative violation measure, larger when capacity or windows are exceeded
- : the penalty weight that trades violation against routing length
The penalty objective compares violation and route cost on a chosen scale. A finite penalty need not make its minimizer feasible: a low route cost can outweigh the violation penalty. A validated repair may require computation or additional scoring, and many-to-one repair can reduce variation. Increasing amplifies violation differences rather than generally flattening the objective. Our decoder needs no repair on this wide-window instance. With restrictive windows, feasibility would need a suitable decoder, feasible moves, rejection, repair, penalties with explicit validation, or another constraint-handling rule.
Example
The formulas are only convincing if they reproduce on a single generation. We therefore work through one generation by hand on the shared instance, with a population of four so the arithmetic stays visible. The costs below are computed from the distance matrix; the implementation re-derives them from code, which lets us check that the hand calculation and the program agree.
Step 1: decode and score four individuals
Take the population
| Individual | Permutation | Vehicle 1 | Vehicle 2 | Cost |
|---|---|---|---|---|
| (1, 7, 8, 3, 6, 5, 4, 2) | 0→1→7→8→3→0 | 0→6→5→4→2→0 | 40.8563 | |
| (1, 2, 3, 8, 6, 5, 4, 7) | 0→1→2→3→8→0 | 0→6→5→4→7→0 | 38.2665 | |
| (1, 2, 3, 7, 4, 5, 6, 8) | 0→1→2→3→7→0 | 0→4→5→6→8→0 | 39.1233 | |
| (2, 6, 1, 5, 3, 7, 4, 8) | 0→2→6→1→5→0 | 0→3→7→4→8→0 | 60.0227 |
Distances and totals below are rounded independently from full-precision values, so summing the displayed arc lengths can differ in the final decimal place. The first individual is the nearest-neighbour construction, which is where every method in this chapter starts. Its cost is the sum of the arcs , , , , for the first vehicle, giving , and , , , , for the second, giving . In total . The other rows are decoded the same way: split the permutation after position four, close each half with the depot, and sum the five arcs in each route.
Step 2: select parents by tournament
For this illustrative update, two size- tournaments select the intended parents. The first pits against and returns , since . The second pits against and returns , since . Thus and .
Each tournament returns its cheaper contender. The two sampled pairs are supplied for the hand calculation, rather than a claim about which individuals a random run will select. The example uses four scored parents and one illustrated offspring before survival.
Step 3: order crossover with cuts ,
The block is copied first. Note that positions , , of hold the values , so .
| Position | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 8 | 6 | 5 | 4 | 7 | |
| 1 | 2 | 3 | 7 | 4 | 5 | 6 | 8 | |
| child after copy | – | – | – | – | 6 | 5 | 4 | – |
Next, list cyclically starting at position : the values are , then , , , , and then , , which are all in and are skipped. The filtered list is therefore . The empty positions in cyclic order from are , so we assign
giving the child .
The important point is that the child is a permutation without any repair. The copied block supplies ; the filtered list supplies the remaining values in the order had them, after removing the copied ones. Nothing is duplicated and nothing is lost.
Step 4: score the child
The child decodes to the tours and . Vehicle 1 costs , and vehicle 2 costs . The total is , which is shorter than either parent ( and ).
That is recombination doing real work. The child keeps the left block in 's order and appends the value that had right after it, and it keeps 's descending block and closes it with from . Neither parent contained both blocks in that arrangement. The example is small, but it shows the mechanism the population is supposed to exploit: crossover can assemble a combination that neither parent had.
Step 5: mutation, and elitist replacement
The loop applies swap mutation to a child with probability (the per-child mutation rate, here 0.5) before scoring it, so only one of the two versions, the mutated one or the unmutated one, is charged to the budget. For illustration, swapping positions and of the child gives with cost , which is worse than the unmutated child; mutation is a perturbation, not an improvement operator. This one swap is worse; it does not establish how often all swaps lose.
Survivor selection is with . The pool is the four parents plus the child, and the four cheapest are kept: the child at , then at , at , and at . at is discarded. The population mean falls from
Whether this child is optimal is a question for enumeration, which we run in the implementation; on this instance enumeration confirms that the child is optimal, with minimum cost approximately (rounded to four decimal places). The enumeration is an independent computation excluded from every search budget; the optimization figures and the summary table report that measured result. Scoring the four parents and both displayed child variants costs six illustrative evaluations, separate from every search budget. In the actual loop only the final, possibly mutated version of each child is scored. The hand example has shown the mechanics; the next section turns those mechanics into code and holds the result to the same budget as the other methods.
Implementation in NumPy
We use NumPy arrays to expose the population, variation, and selection operations directly. The implementation has four parts: the instance and its independent validator, the evaluation account, the operators, and the comparison harness that runs every method under the same 300-evaluation budget.
PythonIn [6]Expand
import time
from itertools import permutations
import numpy as np
import pandas as pd
from book_plot_style import seed_book_plots
seed_book_plots()
BUDGET = 300
SEEDS = list(range(20))
CUSTOMERS = tuple(range(1, 9))The routing instance
Coordinates, demands, capacities, windows, and service times are the shared instance from the preceding chapter. The distance matrix is the same matrix used for both the objective and the schedule, so there is no second notion of "cost". Keeping one matrix for both purposes is important: if the objective and the feasibility check used different distance conventions, a route could be scored with one length and validated with another.
PythonIn [7]Expand
COORDS = np.array(
[
[0.0, 0.0], # depot
[1.0, 3.0], # 1
[2.0, 6.0], # 2
[4.0, 5.0], # 3
[7.0, 6.0], # 4
[8.0, 3.0], # 5
[6.0, 1.0], # 6
[3.0, 1.0], # 7
[5.0, 3.0], # 8
]
)
N_NODES = len(COORDS)
DEPOT = 0
DEMAND = np.array([0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0])
CAPACITY = 4.0
WINDOW_OPEN = np.zeros(N_NODES)
WINDOW_CLOSE = np.full(N_NODES, 80.0)
SERVICE = np.array([0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0])
_pairs = COORDS[:, None, :] - COORDS[None, :, :]
DIST = np.sqrt((_pairs**2).sum(axis=2))| node 0 | node 1 | node 2 | node 3 | node 4 | node 5 | node 6 | node 7 | node 8 | |
|---|---|---|---|---|---|---|---|---|---|
| node 0 | 0.000 | 3.162 | 6.325 | 6.403 | 9.220 | 8.544 | 6.083 | 3.162 | 5.831 |
| node 1 | 3.162 | 0.000 | 3.162 | 3.606 | 6.708 | 7.000 | 5.385 | 2.828 | 4.000 |
| node 2 | 6.325 | 3.162 | 0.000 | 2.236 | 5.000 | 6.708 | 6.403 | 5.099 | 4.243 |
| node 3 | 6.403 | 3.606 | 2.236 | 0.000 | 3.162 | 4.472 | 4.472 | 4.123 | 2.236 |
| node 4 | 9.220 | 6.708 | 5.000 | 3.162 | 0.000 | 3.162 | 5.099 | 6.403 | 3.606 |
| node 5 | 8.544 | 7.000 | 6.708 | 4.472 | 3.162 | 0.000 | 2.828 | 5.385 | 3.000 |
| node 6 | 6.083 | 5.385 | 6.403 | 4.472 | 5.099 | 2.828 | 0.000 | 3.000 | 2.236 |
| node 7 | 3.162 | 2.828 | 5.099 | 4.123 | 6.403 | 5.385 | 3.000 | 0.000 | 2.828 |
| node 8 | 5.831 | 4.000 | 4.243 | 2.236 | 3.606 | 3.000 | 2.236 | 2.828 | 0.000 |
Decoder, objective, and an independent validator
The decoder is a slice; the objective is a simulation of two tours. The validator is deliberately written a second time from raw coordinates with np.hypot, so that a reported route is checked by code that does not share the objective's distance matrix. That redundancy is intentional: it catches mistakes in the objective rather than merely repeating them.
PythonIn [9]Expand
def decode(perm):
"""Split an 8-customer permutation into two four-customer depot tours."""
values = np.asarray(perm)
if (
values.shape != (8,)
or values.dtype.kind not in "iu"
or sorted(values.tolist()) != list(CUSTOMERS)
):
raise ValueError("Expected one integer permutation of customers1..8")
perm = values.tolist()
return [[DEPOT, *perm[0:4], DEPOT], [DEPOT, *perm[4:8], DEPOT]]
def schedule(route):
"""Simulate one depot tour: arc lengths, load, service starts, window checks."""
load = 0.0
clock = 0.0
length = 0.0
feasible = True
for a, b in zip(route[:-1], route[1:]):
load += float(DEMAND[a])
travel = float(DIST[a, b])
length += travel
arrival = clock + float(SERVICE[a]) + travel
start = max(arrival, float(WINDOW_OPEN[b]))
if start > float(WINDOW_CLOSE[b]) + 1e-9:
feasible = False
clock = start
if load > CAPACITY + 1e-9:
feasible = False
return length, load, clock, feasible
def objective(perm):
"""Complete-route objective: summed Euclidean length of both closed tours."""
total = 0.0
for route in decode(perm):
length, _, _, feasible = schedule(route)
if not feasible:
return float("inf")
# Match the preceding chapter's sequential10-arc accumulation.
for a, b in zip(route[:-1], route[1:]):
total += float(DIST[a, b])
return total
def validate_solution(perm):
"""Independent check that recomputes distances from coordinates, not DIST."""
values = np.asarray(perm)
if (
values.shape != (8,)
or values.dtype.kind not in "iu"
or sorted(values.tolist()) != list(CUSTOMERS)
):
return {
"routes": [],
"total": float("inf"),
"feasible": False,
"permutation": values.tolist(),
}
perm = values.tolist()
# Reconstruct the two routes independently of decode and DIST.
routes = [[0, *perm[:4], 0], [0, *perm[4:], 0]]
rows = []
total = 0.0
feasible = sorted(perm) == list(CUSTOMERS)
for route in routes:
load = 0.0
clock = 0.0
length = 0.0
windows_ok = True
for a, b in zip(route[:-1], route[1:]):
load += float(DEMAND[a])
travel = float(
np.hypot(
COORDS[a, 0] - COORDS[b, 0], COORDS[a, 1] - COORDS[b, 1]
)
)
length += travel
arrival = clock + float(SERVICE[a]) + travel
start = max(arrival, float(WINDOW_OPEN[b]))
if start > float(WINDOW_CLOSE[b]) + 1e-9:
windows_ok = False
clock = start
capacity_ok = load <= CAPACITY + 1e-9
rows.append(
{
"route": route,
"load": load,
"length": length,
"return_to_depot": clock,
"capacity_ok": capacity_ok,
"windows_ok": windows_ok,
}
)
total += length
feasible = feasible and capacity_ok and windows_ok
return {
"routes": rows,
"total": total,
"feasible": feasible,
"permutation": perm,
}Counting every evaluation
Every scoring call for a complete permutation passes through one counter. The counter also stores the running best, which is what lets us plot evaluation-indexed incumbent traces instead of wall-clock curves. There is no memoisation anywhere: if a child duplicates an existing individual it is scored again and charged again. This is the implementation of the budget identity from the formula section.
PythonIn [10]Expand
class BudgetedObjective:
"""Counts every complete-route evaluation and records the incumbent trace."""
def __init__(self, budget):
if (
isinstance(budget, (bool, np.bool_))
or not isinstance(budget, (int, np.integer))
or budget < 1
):
raise ValueError("budget must be a positive integer")
self.budget = int(budget)
self.used = 0
self.best = float("inf")
self.trace = []
def __call__(self, perm):
if self.used >= self.budget:
raise RuntimeError("Objective evaluation budget exhausted")
self.used += 1
value = objective(perm)
if value < self.best:
self.best = value
self.trace.append(self.best)
return valueThe shared starting point
Nearest-neighbour construction scans 36 candidate arcs to build the permutation: it considers 8+7+6+5+4+3+2+1 remaining customers, resetting to the depot after the first four. These arc lookups are extra construction work, not complete-route evaluations. The diagnostic NN cost below is one separate score. Each search run scores the NN permutation again through its own counter as evaluation 1, then spends its remaining budget. No cached NN objective value is inserted into a run.
PythonIn [11]Expand
def nearest_neighbor_perm():
"""Greedy construction: nearest remaining customer within each vehicle block."""
perm = []
remaining = list(CUSTOMERS)
current = DEPOT
while remaining:
if len(perm) % 4 == 0:
current = DEPOT
nxt = min(remaining, key=lambda c: (float(DIST[current, c]), c))
perm.append(nxt)
remaining.remove(nxt)
current = nxt
return np.array(perm, dtype=int)
NN_PERM = nearest_neighbor_perm()
NN_COST = float(objective(NN_PERM))ConsoleOut [12]Expand
nearest-neighbour permutation: (1, 7, 8, 3, 6, 5, 4, 2) nearest-neighbour cost: 40.85635
Baselines: NN-seeded random search, hill climbing, annealing
The three baselines follow the shared comparison contract. Each performs one actual nearest-neighbour scoring call per run and then spends the remaining 299 evaluations. The SA schedule is before proposal , for . It skips an acceptance draw for a strict improvement; ties consume a draw and are accepted. The objective also accumulates all ten arcs in the same sequential order as the preceding chapter. Even mathematically equivalent summation can change a floating-point tie and therefore the later random-call order; matching these details makes the baseline reproducible across the two chapters. The random baseline is explicitly NN-seeded random search, not 300 uniform draws.
Before scoring, the implementation rejects a represented t_end / t_start ratio below the smallest positive normal value of its floating-point dtype. This catches zero and subnormal quotients, which can erase or distort the cooling schedule. A constant positive subnormal temperature remains allowed because its ratio is one. The temperatures still approximate the geometric schedule in floating-point arithmetic; this guard does not guarantee exact endpoints or a bound on relative error.
PythonIn [13]Expand
def nn_seeded_random_search(rng, budget, nn_perm):
"""Nearest-neighbour score, then uniform draws to the remaining budget."""
ev = BudgetedObjective(budget)
initial_cost = ev(nn_perm)
best_perm = np.asarray(nn_perm, dtype=int).copy()
best_cost = float(initial_cost)
while ev.used < budget:
perm = rng.permutation(CUSTOMERS)
value = ev(perm)
if value < best_cost:
best_perm, best_cost = perm.copy(), value
return best_perm, best_cost, ev
def hill_climbing(rng, budget, nn_perm):
"""Tolerance-aware scans of all 28 segment reversals, with restarts."""
ev = BudgetedObjective(budget)
initial_cost = ev(nn_perm)
current = np.asarray(nn_perm, dtype=int).copy()
current_cost = float(initial_cost)
best_perm, best_cost = current.copy(), current_cost
while ev.used < budget:
neighbor_cost = float("inf")
neighbor = None
completed = True
for i in range(8):
for j in range(i + 1, 8):
if ev.used >= budget:
completed = False
break
trial = current.copy()
trial[i : j + 1] = trial[i : j + 1][::-1]
value = ev(trial)
if (
value < neighbor_cost - 1e-12
): # retain first on numerical ties
neighbor_cost = value
neighbor = trial
if value < best_cost:
best_perm, best_cost = trial.copy(), value
if not completed:
break
if not completed:
break # partial sweep at exhaustion
if neighbor_cost < current_cost - 1e-12:
current, current_cost = neighbor, neighbor_cost
else:
if ev.used >= budget:
break
current = rng.permutation(CUSTOMERS) # restart, one counted call
current_cost = ev(current)
if current_cost < best_cost:
best_perm, best_cost = current.copy(), current_cost
return best_perm, best_cost, ev
def simulated_annealing(rng, budget, nn_perm, t_start=2.0, t_end=0.01):
"""Symmetric single-reversal proposal, geometric cooling, best-so-far reported."""
if not (
np.isfinite(t_start) and np.isfinite(t_end) and 0 < t_end <= t_start
):
raise ValueError("Require finite temperatures with0<t_end<=t_start")
temperature_ratio = t_end / t_start
if temperature_ratio < np.finfo(np.asarray(temperature_ratio).dtype).tiny:
raise ValueError(
"temperature ratio must be normal in its floating-point dtype"
)
ev = BudgetedObjective(budget)
initial_cost = ev(nn_perm)
current = np.asarray(nn_perm, dtype=int).copy()
current_cost = float(initial_cost)
best_perm, best_cost = current.copy(), current_cost
while ev.used < budget:
fraction = ev.used / max(budget - 1, 1)
temperature = t_start * (t_end / t_start) ** fraction
i, j = sorted(int(v) for v in rng.choice(8, size=2, replace=False))
trial = current.copy()
trial[i : j + 1] = trial[i : j + 1][::-1]
value = ev(trial)
delta = value - current_cost
if delta < 0.0 or rng.random() < np.exp(-delta / temperature):
current, current_cost = trial, value
if value < best_cost:
best_perm, best_cost = trial.copy(), value
return best_perm, best_cost, evGenetic operators
The operators are short enough to read line by line. order_crossover implements the rule derived in the formula section, using cyclic position indices so that a block ending at the last position wraps around correctly. swap_mutation is the permutation-space mutation, and tournament and elitist_survivors implement the selection and survival rules.
PythonIn [14]Expand
def order_crossover(parent_one, parent_two, a, b):
"""Order crossover: copy one block, fill the rest in cyclic order from parent two."""
n = len(parent_one)
child = np.full(n, -1, dtype=int)
child[a : b + 1] = parent_one[a : b + 1]
used = set(int(v) for v in child[a : b + 1])
empty_positions = [(b + 1 + k) % n for k in range(n - (b - a + 1))]
scan_order = [(b + 1 + k) % n for k in range(n)]
values = [
int(parent_two[t]) for t in scan_order if int(parent_two[t]) not in used
]
for position, value in zip(empty_positions, values):
child[position] = value
return child
def swap_mutation(perm, rng):
"""Exchange the values at two distinct positions; validity is automatic."""
child = perm.copy()
i, j = (int(v) for v in rng.choice(len(child), size=2, replace=False))
child[i], child[j] = child[j], child[i]
return child
def tournament(rng, population, size):
"""Sample distinct individuals and return the permutation with lowest cost."""
picked = rng.choice(len(population), size=size, replace=False)
contenders = [population[int(k)] for k in picked]
return min(contenders, key=lambda individual: individual[1])[0]
def elitist_survivors(parents, children, population_size):
"""Mu-plus-lambda survival: keep the cheapest `size` of parents plus children."""
pool = parents + children
pool = sorted(
pool, key=lambda individual: individual[1]
) # stable: parents win ties
return [(perm.copy(), float(cost)) for perm, cost in pool[:population_size]]
def canonical_signature(perm):
"""Vehicle-symmetric signature: tours are unordered and reversals are equal."""
signature = []
for route in decode(perm):
sequence = tuple(int(v) for v in route[1:-1])
signature.append(min(sequence, sequence[::-1]))
return tuple(sorted(signature))
def population_stats(population):
"""Diversity and cost summary of one generation."""
costs = np.array([cost for _, cost in population], dtype=float)
matrix = np.array([perm for perm, _ in population], dtype=int)
hamming = (matrix[:, None, :] != matrix[None, :, :]).sum(axis=2)
upper = np.triu_indices(len(matrix), k=1)
return {
"best": float(costs.min()),
"median": float(np.median(costs)),
"worst": float(costs.max()),
"distinct": int(
len({canonical_signature(perm) for perm, _ in population})
),
"mean_hamming": float(hamming[upper].mean()),
}Key Parameters
The following settings define this comparison; their effects need to be measured under the chosen budget.
pop_size(, here 20): the number of retained candidates. Complete generations number after initialization. A larger population leaves no more complete generations at a fixed budget, and often fewer, but does not guarantee greater useful diversity.tournament_size(, here 3): the tournament width. A unique best individual is sampled with probability under distinct costs, compared with for uniform parent choice. Ties and downstream diversity need separate interpretation.crossover_rate(here 0.9): the chance of applying OX to two selected parents rather than copying the first. The implementation draws uniformly from 0 through 6, then uniformly from through 7; these cut pairs are not uniformly distributed.mutation_rate(here 0.5): the probability that a child receives one swap. This is the only operator that can create novelty once the population copies itself.- Budget handling: the NN permutation is scored once inside each run, then 19 random permutations complete the initial population. Every child is scored, including duplicates. A budget below the requested initial population is rejected before scoring.
The population budget and number of generations are coupled. Parent pressure and variation also interact through the candidates that survive. The explicit settings make those choices reproducible; they do not establish that more mutation, more population members, or larger tournaments improve performance.
Key Methods
The functions map onto the components of the algorithm. decode and schedule define the genotype-to-phenotype map and the feasibility check; objective is the complete-route cost that the counter charges for; validate_solution re-derives the same quantities independently from the raw coordinates and is used only on reported answers. On the search side, tournament implements selection, order_crossover and swap_mutation implement variation, elitist_survivors implements survival with elitism, and population_stats records the diagnostics (number of distinct canonical solutions and mean pairwise Hamming distance), while canonical_signature computes the vehicle-symmetric tour signature used for those diagnostics, that we use to discuss diversity.
PythonIn [15]Expand
def genetic_algorithm(
rng,
budget,
nn_perm,
pop_size=20,
tournament_size=3,
crossover_rate=0.9,
mutation_rate=0.5,
):
"""Elitist generational GA over permutations. Returns (best_perm, best_cost, counter, history)."""
n = len(CUSTOMERS)
ev = BudgetedObjective(budget)
if (
isinstance(pop_size, (bool, np.bool_))
or not isinstance(pop_size, (int, np.integer))
or pop_size < 2
or budget < pop_size
):
raise ValueError(
"Require population size>=2 and budget>=population size"
)
if (
isinstance(tournament_size, (bool, np.bool_))
or not isinstance(tournament_size, (int, np.integer))
or not 1 <= tournament_size <= pop_size
):
raise ValueError("Tournament size must be within the population")
if not 0 <= crossover_rate <= 1 or not 0 <= mutation_rate <= 1:
raise ValueError("Variation probabilities must lie in[0,1]")
initial_cost = ev(nn_perm)
population = [(np.asarray(nn_perm, dtype=int).copy(), initial_cost)]
while len(population) < pop_size and ev.used < budget:
perm = rng.permutation(CUSTOMERS)
population.append((perm, ev(perm)))
history = []
while ev.used < budget:
children = []
for _ in range(pop_size):
if ev.used >= budget:
break
parent_one = tournament(rng, population, tournament_size)
parent_two = tournament(rng, population, tournament_size)
if rng.random() < crossover_rate:
a = int(rng.integers(0, n - 1))
b = int(rng.integers(a + 1, n))
child = order_crossover(parent_one, parent_two, a, b)
else:
child = parent_one.copy()
if rng.random() < mutation_rate:
child = swap_mutation(child, rng)
children.append((child, ev(child)))
population = elitist_survivors(population, children, pop_size)
history.append(population_stats(population))
best_perm, best_cost = min(population, key=lambda individual: individual[1])
return best_perm.copy(), float(best_cost), ev, historyChecking the worked example in code
Before trusting the loop, we verify the hand arithmetic from the example. This cell also shows the operators producing exactly the child we computed by hand. If the code and the hand calculation disagree, the formula section and the implementation are not yet the same algorithm.
PythonIn [16]Expand
example_perms = {
"P1 nearest neighbour": [1, 7, 8, 3, 6, 5, 4, 2],
"P2": [1, 2, 3, 8, 6, 5, 4, 7],
"P3": [1, 2, 3, 7, 4, 5, 6, 8],
"P4": [2, 6, 1, 5, 3, 7, 4, 8],
}
child = order_crossover(
np.array(example_perms["P2"]), np.array(example_perms["P3"]), a=4, b=6
)
mutated = np.array([1, 2, 3, 7, 6, 4, 5, 8])
example_table = pd.DataFrame(
[
{
"individual": name,
"permutation": tuple(perm),
"cost": objective(perm),
}
for name, perm in example_perms.items()
]
+ [
{
"individual": "OX child",
"permutation": tuple(int(v) for v in child),
"cost": objective(child),
},
{
"individual": "OX child + swap",
"permutation": tuple(int(v) for v in mutated),
"cost": objective(mutated),
},
]
)ConsoleOut [17]Expand
individual permutation cost 0 P1 nearest neighbour (1, 7, 8, 3, 6, 5, 4, 2) 40.8563 1 P2 (1, 2, 3, 8, 6, 5, 4, 7) 38.2665 2 P3 (1, 2, 3, 7, 4, 5, 6, 8) 39.1233 3 P4 (2, 6, 1, 5, 3, 7, 4, 8) 60.0227 4 OX child (1, 2, 3, 7, 6, 5, 4, 8) 37.3560 5 OX child + swap (1, 2, 3, 7, 6, 4, 5, 8) 39.0210
The table reproduces the hand calculation: the parents cost and , the crossover child costs , and the swapped version costs . Every value came from objective, so the arithmetic in the example section and the code agree.
Running the budget-matched comparison
Each method gets a separate generator initialized with the same integer for a given run index. Those labels organize the results; different algorithms consume the stream differently, so this alone does not establish statistical variance reduction. Every run is expected to finish with exactly 300 evaluations; the assertion enforces the accounting contract rather than trusting it.
PythonIn [18]Expand
def run_ga(rng, budget, nn_perm):
best_perm, best_cost, ev, _ = genetic_algorithm(rng, budget, nn_perm)
return best_perm, best_cost, ev
RUNNERS = {
"NN-seeded random search": nn_seeded_random_search,
"2-opt hill climbing": hill_climbing,
"Simulated annealing": simulated_annealing,
"Genetic algorithm": run_ga,
}
records = []
traces = {name: [] for name in RUNNERS}
final_perms = {name: {} for name in RUNNERS}
ga_histories = {}
for seed in SEEDS:
for name, runner in RUNNERS.items():
rng = np.random.default_rng(seed)
started = time.perf_counter()
if name == "Genetic algorithm":
best_perm, best_cost, ev, history = genetic_algorithm(
rng, BUDGET, NN_PERM
)
ga_histories[seed] = history
else:
best_perm, best_cost, ev = runner(rng, BUDGET, NN_PERM)
elapsed = time.perf_counter() - started
records.append(
{
"method": name,
"seed": seed,
"best_cost": float(best_cost),
"evaluations": int(ev.used),
"seconds": elapsed,
}
)
traces[name].append(np.asarray(ev.trace, dtype=float))
final_perms[name][seed] = np.asarray(best_perm, dtype=int)
results = pd.DataFrame(records)
assert (results["evaluations"] == BUDGET).all(), "budget accounting violated"ConsoleOut [19]Expand
min max count method 2-opt hill climbing 300 300 20 Genetic algorithm 300 300 20 NN-seeded random search 300 300 20 Simulated annealing 300 300 20
Exact reference by enumeration
The reference is a separate computation, excluded from every search budget. With 40320 permutations the enumeration is cheap and it lets us report gaps instead of opinions. It also gives the number of distinct canonical solutions under the vehicle-swap and tour-reversal symmetries. Computations use double-precision distances; reference hits use an absolute tolerance of 1e-10 and zero relative tolerance, not symbolic equality.
PythonIn [20]Expand
all_perms = np.array(list(permutations(CUSTOMERS)), dtype=int)
exact_costs = np.array([objective(perm) for perm in all_perms])
EXACT_BEST = float(exact_costs.min())
exact_best_perms = all_perms[
np.isclose(exact_costs, EXACT_BEST, rtol=0.0, atol=1e-10)
]
distinct_signatures = len({canonical_signature(perm) for perm in all_perms})ConsoleOut [21]Expand
permutations enumerated: 40320 distinct canonical solutions: 5040 exact optimum total length: 37.35598 permutations attaining it: 8 nearest-neighbour gap: 3.50037
Summary table with exact gaps
The table reports the distribution of final costs across the 20 seeds, how often each method hit the exact optimum, and the median wall-clock time. The timing column is machine dependent and is included only to make the point that equal evaluation counts do not imply equal runtime.
PythonIn [22]Expand
grouped = results.groupby("method")
summary = pd.DataFrame(
{
"mean": grouped["best_cost"].mean(),
"std": grouped["best_cost"].std(ddof=1),
"median": grouped["best_cost"].median(),
"min": grouped["best_cost"].min(),
"max": grouped["best_cost"].max(),
"median_seconds": grouped["seconds"].median(),
}
).reindex(list(RUNNERS))
summary["hits_exact_optimum"] = (
results.assign(
hit=np.isclose(results["best_cost"], EXACT_BEST, rtol=0.0, atol=1e-10)
)
.groupby("method")["hit"]
.sum()
.reindex(list(RUNNERS))
.astype(int)
)
summary["mean_gap_pct"] = 100.0 * (summary["mean"] - EXACT_BEST) / EXACT_BESTConsoleOut [23]Expand
mean std median min max \
method
NN-seeded random search 39.2541 0.9189 39.0487 38.0002 40.7200
2-opt hill climbing 37.8378 0.4685 38.0002 37.3560 38.3926
Simulated annealing 37.8173 0.6421 37.3560 37.3560 38.9898
Genetic algorithm 37.6574 0.5158 37.3560 37.3560 38.9898
median_seconds hits_exact_optimum mean_gap_pct
method
NN-seeded random search 0.0023 0 5.081
2-opt hill climbing 0.0021 9 1.290
Simulated annealing 0.0037 12 1.235
Genetic algorithm 0.0081 14 0.807Independently validated final routes
Every reported final solution is re-checked by validate_solution, which recomputes distances from coordinates, sums loads, and re-simulates the schedule. All 80 final method/seed outcomes are checked, and the assertions require feasibility and agreement within 1e-10. The displayed table selects the best run per method. These 80 validation re-simulations are separate diagnostic work and do not provide candidates or scores to the searches.
PythonIn [24]Expand
best_rows = results.loc[results.groupby("method")["best_cost"].idxmin()]
validation_rows = []
for _, row in results.iterrows():
perm = final_perms[row["method"]][int(row["seed"])]
report = validate_solution(perm)
assert report["feasible"]
assert np.isclose(report["total"], row["best_cost"], rtol=0.0, atol=1e-10)
validation_rows.append(
{
"method": row["method"],
"seed": int(row["seed"]),
"tour 1": " - ".join(str(v) for v in report["routes"][0]["route"]),
"tour 2": " - ".join(str(v) for v in report["routes"][1]["route"]),
"length": report["total"],
"feasible": report["feasible"],
"matches report": bool(
np.isclose(
report["total"], row["best_cost"], rtol=0.0, atol=1e-10
)
),
}
)
validation_all = pd.DataFrame(validation_rows)
validation = validation_all.loc[best_rows.index].reset_index(drop=True)ConsoleOut [25]Expand
method seed tour 1 \
0 2-opt hill climbing 0 0 - 7 - 3 - 2 - 1 - 0
1 Genetic algorithm 4 0 - 7 - 3 - 2 - 1 - 0
2 NN-seeded random search 1 0 - 4 - 5 - 6 - 7 - 0
3 Simulated annealing 1 0 - 7 - 3 - 2 - 1 - 0
tour 2 length feasible matches report
0 0 - 8 - 4 - 5 - 6 - 0 37.3560 True True
1 0 - 6 - 5 - 4 - 8 - 0 37.3560 True True
2 0 - 1 - 2 - 3 - 8 - 0 38.0002 True True
3 0 - 8 - 4 - 5 - 6 - 0 37.3560 True TrueVisualizing Genetic Algorithms
The figures below use runs that were already computed. The first pair shows what happens inside one population; the second pair compares methods at the same evaluation budget; the last shows the tours themselves.
What one population does across its generations
We reuse the genetic algorithm history with seed 0 and record, for each generation, the best, median, and worst cost in the population plus two diversity diagnostics. This is a diagnostic run on the same budget, not an extra budget for the comparison.
PythonIn [26]Expand
demo_history = ga_histories[0]
demo_frame = pd.DataFrame(demo_history)
demo_frame.insert(0, "generation", np.arange(1, len(demo_frame) + 1))ConsoleOut [27]Expand
generation best median worst distinct mean_hamming 0 1 40.508 47.226 49.915 17 6.647 1 2 40.508 44.398 46.649 16 6.474 2 3 38.731 41.239 44.398 13 6.221 3 4 38.393 40.912 43.411 10 5.747 4 5 38.267 40.449 40.856 8 4.247 5 6 38.267 38.393 40.508 6 2.868 6 7 38.267 38.393 38.393 2 1.074 7 8 38.197 38.393 38.393 3 2.079 8 9 38.000 38.267 38.393 4 2.358 9 10 37.356 38.267 38.393 5 2.168 10 11 37.356 38.267 38.267 4 2.542 11 12 37.356 38.197 38.267 4 3.016 12 13 37.356 38.000 38.267 4 3.332 13 14 37.356 37.356 38.000 2 1.768
VisualizationOut [28]Expand

For this fixed deterministic objective, elitist survival makes the retained best non-increasing. The best-to-worst range describes cost spread; it is not the selection differential, which would compare selected parents with the original population. Similar costs do not imply identical permutations or a single canonical tour pair. Read the cost curves together with both diversity measures.
Diversity and the mutation rate
The following diagnostic compares the same seed at mutation rates 0.1 and 0.5. The 0.5 history is reused from the budgeted comparison; the 0.1 history is one additional 300-evaluation run, reported separately from the four-method benchmark.
PythonIn [29]Expand
diversity_rows = []
for rate in (0.1, 0.5):
if rate == 0.5:
history = ga_histories[0]
else:
rng = np.random.default_rng(0)
_, _, _, history = genetic_algorithm(
rng, BUDGET, NN_PERM, mutation_rate=rate
)
for step, row in enumerate(history, start=1):
diversity_rows.append(
{"generation": step, "mutation_rate": rate, **row}
)
diversity_frame = pd.DataFrame(diversity_rows)VisualizationOut [30]Expand

For this seed, the 0.1 run reaches one canonical signature at generation 8 and stays there; the 0.5 run finishes with two. The counts can increase as new offspring survive. A single signature also permits several different permutations, and crossing such permutations can produce a different phenotype. Identical parents always produce a copy; a matching canonical signature alone supplies no such guarantee. These two runs do not establish a general mutation-rate ranking.
Incumbent traces at equal budget
The following traces compare complete-route evaluation effort. Runtime is a separate useful comparison; neither axis alone controls all computational costs. All four methods start from the same nearest-neighbour value, so all four curves begin at the same height.
PythonIn [31]Expand
trace_length = min(min(len(t) for t in traces[name]) for name in RUNNERS)
trace_matrix = {
name: np.vstack([t[:trace_length] for t in traces[name]])
for name in RUNNERS
}VisualizationOut [32]Expand

The traces record the incumbent after every scored candidate, including rejected moves. A hill-climbing sweep scores 28 neighbours before selecting its next current state, but its incumbent can improve during that sweep. SA's current state may worsen while its best-so-far trace never rises. The GA also records improvements during initialization, before its first generation. These curves are means across 20 runs; the bands are seed quantiles, not confidence intervals for a mean.
Final costs across seeds
VisualizationOut [33]Expand

The vertical spread displays variation over these 20 seeds. Overlap does not establish equivalence or statistical indistinguishability of the methods. The mean marker is descriptive; estimates the standard error of a sample mean under independent, identically distributed runs with finite variance, but no confidence interval or hypothesis test is shown here.
The tours themselves
VisualizationOut [34]Expand

The route picture makes the decoded reference visible. It does not prove optimality by its appearance; the exhaustive computation supplies the reference. OX copies positions, so a block crossing the vehicle split can change the assignment of several customers. Whether such a child is better requires scoring it.
Practical Implications
A genetic algorithm is a candidate when an encoding and objective are available and the budget can support a population. Independent offspring can be evaluated concurrently, and recombination may combine useful structures. Those mechanisms do not establish superiority for routing, scheduling, feature selection, or another application. On this eight-customer instance, enumeration is computationally feasible; the 300-evaluation comparison serves to inspect the heuristics under a deliberately limited budget.
If evaluations are expensive and serial, initialization can consume a substantial part of the budget. Our population of 20 leaves 14 full generations after initialization at budget 300. A single-current-state method allocates its budget differently. Select a baseline and compare under the application's constraints rather than inferring a preferred optimizer from that accounting alone.
A practical decision should also account for constraint structure. If a decoder can map every genotype to a feasible solution, as the two-tour split does here, the search never wastes evaluations on infeasible candidates. If feasibility is difficult to encode, a constraint-handling rule is needed; feasible moves, rejection, repair and penalties have different costs and guarantees. Any scoring or tuning they perform must be disclosed. That choice affects validity and the work available for exploring new candidates.
Equal-Budget Baselines and Repeated-Run Evaluation
Comparing heuristics requires a common budget and an explicit accounting convention. Here the unit is one complete-route objective evaluation: both tours are decoded and scored. Initialisation, duplicate children, rejected proposals, restart candidates, and any complete scoring calls performed during repair are charged; memoisation is disabled so that repeated candidates cost full price. Nearest-neighbour construction also scans arcs that are not complete-route evaluations, and those scans are disclosed separately rather than folded into the count. Equal evaluation counts therefore do not imply equal runtime, and the median_seconds column should be read as machine-dependent.
Repeated seeds expose run-to-run variation on this fixed instance. The same seed integer initializes a separate default_rng for each method, but the algorithms consume random numbers differently and use them for different operations. Matching seed labels does not remove random advantage or automatically reduce comparison variance. We report the 20 observed outcomes, reference hits within the stated numerical tolerance, and gaps; uncertainty about other seeds or other instances remains.
The reference must be independent of the search. Calling the best observed heuristic value "the optimum" does not establish a lower bound; gaps to that value can understate the unknown gap to the true minimum. Here all 40320 permutations are scored by the stated objective, and a separate coordinate-based reproduction checks the reference and decoder, so each heuristic result becomes a measured gap. When full enumeration becomes impractical, a result needs another independent optimality certificate to be called optimal. Without one, report "best found under budget".
Data Requirements and Preprocessing
A genetic algorithm does not require a training set or labels; it needs a candidate representation and a comparable objective or comparison rule, with decoding and constraint handling chosen for the problem. For this chapter the genotype is a permutation of eight customers, the decoder splits it into two four-customer depot tours, and the objective sums Euclidean arc lengths. The distance matrix used by the objective should be the same matrix used by the feasibility check; if two distance conventions are mixed, a route can be scored with one length and validated with another.
With noisy evaluations, a low observed cost can be lucky. Elitist survival then compares stored observations rather than expected costs. Re-evaluation and additional simulation replicates need a stated budget and comparison rule; increasing population size alone does not validate the incumbent. Record both candidate-evaluation effort and runtime when either matters to the application.
Preprocessing is mostly about encoding and feasibility rather than scaling. Features must be mapped into a representation that variation operators can preserve: permutations for ordering problems, binary vectors for subsets, integer or real vectors for mixed parameters. With restrictive constraints, validate the decoder and choose an explicit constraint-handling rule. Count any objective evaluations performed during repair, rejection or comparison. In this instance every customer has demand 1, each vehicle has capacity 4, and all windows are , so every permutation is feasible; the wide windows isolate search behaviour and do not test restrictive window handling.
Best Practices
At budget 300, populations 10,20 and 50 permit 29,14 and 5 complete generations after initialization, respectively. These are budget calculations, not recommendations for every combinatorial problem. Tournament width likewise defines a selection rule: with distinct costs, samples a unique best member with probability 0.15 in a population of 20. Choose a small, explicitly budgeted setting comparison if the application needs tuning, and monitor both cost and diversity.
This comparison uses crossover probability 0.9 and per-child swap probability 0.5; the diagnostic also tries 0.1. These choices illustrate the operators and are not validated defaults. Elitist survival preserves the best retained deterministic cost, while an explicit archive could preserve a best observation under another survival rule. Count duplicate children and inspect genotype and phenotype diversity alongside cost.
Use explicit seed indices, report the outcome distribution over a stated number of runs, and independently validate reported routes. The 20 seeds here are a disclosed experimental choice, not a universal minimum. Parallel evaluations can change runtime without changing the candidate count. Population size, variation and selection settings still require assessment on the actual encoding and objective.
Common Pitfalls
The characteristic failure is premature convergence. Selection favours the current leaders, so the population can become a set of near-copies; once two parents are identical, order crossover returns the same permutation, and only mutation creates novelty. If the whole population collapses to copies of one permutation, crossover adds no variation; mutation supplies the random kicks. Monitoring the number of distinct canonical signatures and the mean pairwise Hamming distance can reveal the collapse early. Increasing mutation, reducing tournament size, or injecting immigrants are standard responses, but each consumes budget or perturbs good structure.
A second pitfall is misusing a generic crossover. Permutation search needs variation that preserves validity or an explicit correction rule. OX is one validity-preserving crossover; swapping values position by position or using one-point crossover on a permutation can create duplicates and omissions, which then require repair. Repair is not free, and a repair operator that maps many children to the same feasible point can bias the search. A validated feasibility-preserving encoding can avoid repair work when its construction cost and restrictions on the search are acceptable. Penalty methods have another failure mode: a finite weight may still prefer an infeasible point, and a large weight can make violation differences dominate route-length differences. Validate feasibility rather than inferring it from a penalized score.
A third pitfall is treating the genotype as the solution. A permutation is not a pair of routes until it is decoded, checked against capacity and time windows, and scored by the same objective used in the search. Reporting a permutation without that validation can hide infeasible offspring, a mismatched decoder, or a distance convention that differs from the one used for scoring. The final answer should be the validated phenotype and its objective, not the raw genotype alone.
Computational and Deployment Considerations
Each complete generation scores children in addition to initialization. For expensive objectives, scoring may dominate and independent evaluations may be parallelized. For the cheap objective here, operator and diagnostic overhead can matter. OX uses lists and membership checks; survivor sorting processes up to individuals, and pairwise Hamming diagnostics form an comparison. Equal candidate counts do not make these costs equal across methods.
The population stores permutations of length plus costs. The current Hamming diagnostic also creates an boolean array, and retained traces/history consume additional memory. Exact enumeration stores permutations and their costs, feasible at but growing factorially. For larger instances, distinguish a lower bound, a relaxation, or a best-known feasible reference from a proven minimum.
In deployment, the genetic algorithm is naturally an anytime method: for this deterministic objective, elitism preserves the best retained cost. After valid initialization, the function can spend a partial final generation and return its incumbent at the stated budget. Monitor the best cost, the population diversity, and the evaluation count; if diversity falls or the incumbent stalls, investigate with a separately budgeted experiment rather than treating either diagnostic as a convergence certificate. Revalidate the decoded routes before use, version the objective, decoder and search implementation together. Record the instance, runtime, operator settings and initial seed to reproduce a run; store the final candidate and its validation report to check the reported result. On small instances where a local search is cheaper and more reliable, the genetic algorithm should be treated as one option among several rather than the default.
Summary
A genetic algorithm is population-based black-box search. A candidate solution is an encoding that must survive variation: here a permutation, cut into two blocks of four by a decoder that turns it into two feasible depot tours. Selection applies pressure through tournaments, whose selection probability for an individual of rank is ; order crossover recombines two permutations without duplicating a customer within the child, because the copied block and the filtered cyclic listing of the second parent partition the value set; swap mutation can propose a different permutation; and survival with elitism makes the best-so-far curve non-increasing at the price of a population that can collapse onto near-copies.
The mechanism is built from primitives, and the worked example illustrates recombination of two supplied parents: two mediocre parents of cost and produced a child of cost approximately , which enumeration confirms is optimal for this instance, because one contributed a good left block and the other a good right block. The one illustrated swap is worse, and its score still costs an evaluation. Neither that example nor the selected optimal crossover estimates how often arbitrary children improve.
Held to the same 300-evaluation budget as NN-seeded random search, 2-opt hill climbing, and simulated annealing, and repeated over 20 seeds against an exact enumeration of all 40320 permutations, the genetic algorithm's standing on this instance is a measured outcome, not a foregone conclusion: the multi-method figures and the summary table report the observed distributions, exact hits, and gaps without presupposing which method wins. That is the honest conclusion for a problem this small, and it is the right frame for using the method in practice: count every evaluation, repeat the runs, report distributions, validate the final solutions independently, and never call the best result found under a stopping budget optimal without an independent optimality certificate.
In the next chapter we look at differential evolution, a related population method whose variation operator is designed for continuous vectors rather than permutations, and which constructs a mutant from one donor plus a scaled difference of two others, then chooses mutant or target coordinates with a crossover mask.
Quiz
Ready to test your understanding? Take this quick quiz to reinforce what you've learned about genetic algorithms, permutation encodings, variation operators, and budget-matched comparison.
Join the discussionAsk a question or share what you learned.
Keep exploring
Reference
Citation details
Cite or share this article.


Comments
Add to the conversation
A question, a correction, or an example from your own work.
No comments yet. Be the first to share your thoughts!