Genetic Algorithms

Michael BrenndoerferOctober 29, 202659 min read

Genetic algorithms from scratch: encoding, tournament selection, order crossover, mutation, elitism, diversity, and budget-matched routing comparisons.

Machine Learning from ScratchAll chapters
Part 15 of 15Heuristic and Evolutionary Optimization
Chapter 91Genetic Algorithms
Reading the full chapter · 10 sections · 59 min10 sections · 59 min

All 10 sections · 59 min read

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

Notes & comments

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 8!=403208! = 40320 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 38.266538.2665 and 39.123339.1233 produce a child of cost 37.356037.3560, 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 n=8n = 8, let C={1,…,8}\mathcal{C} = \{1,\dots,8\} index the customers, and let node 00 be the depot. A genotype is a permutation

p=(p1,p2,…,p8),{p1,…,p8}=C,p = (p_1, p_2, \dots, p_8), \qquad \{p_1,\dots,p_8\} = \mathcal{C},

where:

  • pp: the permutation (genotype), a length-8 ordering of the customers
  • pjp_j: the customer visited at position jj of the permutation
  • C\mathcal{C}: the set of customer indices {1,…,8}\{1,\dots,8\}
  • nn: the number of customers (n=8n=8), hence the length of every permutation

so the search space is X=S8X = S_8 with ∣X∣=8!=40320|X| = 8! = 40320 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:

D(p)=([0,p1,p2,p3,p4,0],  [0,p5,p6,p7,p8,0]).D(p) = \big([0, p_1, p_2, p_3, p_4, 0],\; [0, p_5, p_6, p_7, p_8, 0]\big).

where:

  • D(p)D(p): the phenotype of permutation pp, a pair of closed depot tours
  • p1,…,p4p_1,\dots,p_4: the customers assigned to the first vehicle, visited in that order
  • p5,…,p8p_5,\dots,p_8: the customers assigned to the second vehicle, visited in that order
  • 00: 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 qi=1q_i = 1 and Q=4Q = 4. That is not an accident of this instance: every customer demand is qi=1q_i = 1, every vehicle capacity is Q=4Q = 4, and the total demand ∑i∈Cqi=8\sum_{i \in \mathcal{C}} q_i = 8 equals the total capacity 2Q=82Q = 8. The decoder enforces the only capacity split that can work.

Writing d(a,b)d(a,b) for the Euclidean distance between nodes aa and bb, 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.

f(p)  =  ∑r=12[ d ⁣(0, p4r−3)  +  ∑j=4r−34r−1d ⁣(pj, pj+1)  +  d ⁣(p4r, 0)].f(p) \;=\; \sum_{r=1}^{2}\Bigg[\, d\!\left(0,\,p_{4r-3}\right) \;+\; \sum_{j=4r-3}^{4r-1} d\!\left(p_j,\,p_{j+1}\right) \;+\; d\!\left(p_{4r},\,0\right) \Bigg].

where:

  • pp: the permutation (genotype) being scored, a length-8 ordering of the customers
  • pjp_j: the customer visited at position jj of the permutation
  • rr: the vehicle index, running over the two vehicles r=1r=1 and r=2r=2
  • 4r−34r-3: the first position of vehicle rr's block (11 for vehicle 1, 55 for vehicle 2)
  • 4r4r: the last position of vehicle rr's block (44 for vehicle 1, 88 for vehicle 2)
  • d(a,b)d(a,b): the Euclidean distance between nodes aa and bb
  • 00: the depot node
  • f(p)f(p): the summed length of both closed tours, the quantity minimised

Read the outer sum first: it runs over the two vehicles, r=1r=1 and r=2r=2. 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 4r−34r-3 is just bookkeeping that starts vehicle 1 at position 11 and vehicle 2 at position 55: for r=1r=1 we get 4r−3=14r-3=1 and the inner sum runs over j=1,2,3j=1,2,3; for r=2r=2 we get 4r−3=54r-3=5 and the inner sum runs over j=5,6,7j=5,6,7. 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 sis_i the service start at node ii, aia_i the arrival, eie_i and ℓi\ell_i the window bounds, and τi\tau_i the service time, the recursion is

si=max⁡(ai, ei),anext=si+τi+d(i,next),\begin{aligned} s_i &= \max(a_i,\, e_i), \\ a_{\text{next}} &= s_i + \tau_i + d(i,\text{next}), \end{aligned}

where:

  • sis_i: the service start time at node ii, i.e. arrival held until the window opens
  • aia_i: the arrival time at node ii (the clock after traveling from the previous node)
  • eie_i: the opening of the time window at node ii
  • ℓi\ell_i: the closing of the time window at node ii
  • τi\tau_i: the service time at node ii
  • d(i,next)d(i,\text{next}): the Euclidean travel distance from node ii to the next node

The recursion is subject to si≤ℓis_i \le \ell_i for every node including the return to the depot, and to ∑i∈routeqi≤Q\sum_{i \in \text{route}} q_i \le Q, where qiq_i is the demand at node ii and QQ is the vehicle capacity. Here every customer demand is qi=1q_i = 1 and every vehicle capacity is Q=4Q = 4, and the total demand ∑i∈Cqi=8\sum_{i \in \mathcal{C}} q_i = 8 equals the total capacity 2Q=82Q = 8, so each vehicle must carry exactly four customers: the decoder enforces that automatically. All windows are [0,80][0, 80], and since every coordinate lies in [0,8]×[0,6][0,8]\times[0,6] no arc exceeds 82+62=10\sqrt{8^2+6^2}=10. A route has five arcs and serves four customers, each with service time τi=1\tau_i = 1. Starting from the depot, the service start at the fourth customer is at most 4⋅10+3⋅1=43<804\cdot 10 + 3\cdot 1 = 43 < 80, and the return to the depot takes one more arc of at most 1010 and one more service time of 11, giving 4⋅10+4⋅1+10=54<804\cdot 10 + 4\cdot 1 + 10 = 54 < 80. 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 F(p)=1/(1+f(p))F(p)=1/(1+f(p)) maps costs 38 and 40 to 1/391/39 and 1/411/41. The latter is 2/41≈4.882/41\approx4.88 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 σ(p)\sigma(p) 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 kk 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 NN, rank r=1r=1 is best and rank r=Nr=N is worst. An individual of rank rr wins if it is drawn and no better individual (one of the r−1r-1 individuals with smaller rank) is drawn with it. The number of kk-subsets that contain it and otherwise contain only the N−rN-r worse individuals is (N−rk−1)\binom{N-r}{k-1}, out of (Nk)\binom{N}{k} equally likely subsets, so

P(select individual of rank r)  =  (N−r k−1 )(N k ).P(\text{select individual of rank } r) \;=\; \frac{\binom{N-r}{\,k-1\,}}{\binom{N}{\,k\,}}.

where:

  • NN: the population size
  • kk: the tournament size (the number of individuals drawn without replacement)
  • rr: the rank of the individual, with r=1r=1 the best and r=Nr=N the worst
  • (N−rk−1)\binom{N-r}{k-1}: the number of kk-subsets containing the individual and only worse individuals
  • (Nk)\binom{N}{k}: the total number of equally likely kk-subsets

Under that strict ranking, for N=20N=20 and k=3k=3 the unique best individual is selected with probability (192)/(203)=171/1140=0.15\binom{19}{2}/\binom{20}{3} = 171/1140 = 0.15, three times the uniform rate of 1/20=0.051/20 = 0.05, while the worst individual with rank r=20r=20, hence N−r=0N-r=0, has probability (02)/(203)=0\binom{0}{2}/\binom{20}{3} = 0. Raising kk sharpens the pressure; k=2k=2 is mild and k=Nk=N always picks the leader. Summing the expression over r=1,…,Nr = 1,\dots,N returns 11 by the hockey-stick identity, ∑r=1N(N−rk−1)=∑m=0N−1(mk−1)=(Nk)\sum_{r=1}^{N}\binom{N-r}{k-1} = \sum_{m=0}^{N-1}\binom{m}{k-1} = \binom{N}{k}, 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]
CollapseExpand
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]
CollapseExpand
Line chart of tournament selection probability by rank for three tournament sizes.
Tournament selection probability under a strict cost ranking for tournament sizes of 2, 3, and 5 in a population of 20. Larger tournaments concentrate probability on the best ranks and assign almost none to the worst ranks, which is the pressure that can remove diversity.

Order crossover (OX) builds the child from two cut positions. Fix cut positions 0≤a<b≤n−10 \le a < b \le n-1 and parents pp and qq, and index positions from 00 to n−1n-1 as in code:

  1. copy the block: ct=ptc_t = p_t for every t∈{a,…,b}t \in \{a,\dots,b\};
  2. let U={pa,…,pb}U = \{p_a,\dots,p_b\} be the values already used, and list the values of qq in cyclic order starting just after the block, qb+1,qb+2,…,qb+nq_{b+1}, q_{b+2}, \dots, q_{b+n} with indices taken modulo nn, discarding any value in UU;
  3. fill the remaining positions in the same cyclic order, b+1,b+2,…b+1, b+2, \dots modulo nn, with that filtered list.

where:

  • nn: the number of customers (n=8n=8), hence the length of each permutation
  • a,ba, b: the inclusive cut positions bounding the copied block of the first parent
  • pp: the first parent permutation, which supplies the copied block
  • qq: the second parent permutation, which supplies the remaining values in cyclic order
  • cc: the child permutation built by the rule
  • UU: the set of values already placed in the child from the copied block

The child is a permutation. The block contributes b−a+1b-a+1 distinct values; the filtered list contributes exactly the n−(b−a+1)n-(b-a+1) values of qq that are not in UU, in some order; the two position sets are disjoint and cover all nn positions. Nothing is duplicated and nothing is lost, so c∈Xc \in X with no repair step. OX preserves the copied block and the cyclic relative order of the remaining values from qq. 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 (82)=28\binom{8}{2} = 28 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 μ\mu parents and λ\lambda offspring per generation, (μ+λ)(\mu+\lambda) selection sets

Pt+1  =  top-⁡μ(Pt∪Ot),P_{t+1} \;=\; \operatorname*{top-}\mu\big(P_t \cup O_t\big),

Here PtP_t and OtO_t are indexed collections, and the union denotes concatenation with multiplicity: duplicate individuals still occupy separate slots. The μ\mu lowest-cost slots survive. Assume the objective is fixed and deterministic. In detail:

  • PtP_t: the population of parents at generation tt
  • OtO_t: the offspring produced at generation tt
  • μ\mu: the number of parents retained each generation (the population size)
  • top-⁡μ\operatorname*{top-}\mu: the operator that keeps the μ\mu 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 min⁡x∈Pt+1f(x)≤min⁡x∈Ptf(x)\min_{x\in P_{t+1}} f(x) \le \min_{x \in P_t} f(x): 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 NN, the initial population costs 1+(N−1)=N1 + (N-1) = N evaluations (the shared nearest-neighbour score plus N−1N-1 random members), and each completed generation costs NN evaluations. The total is:

B  =  1⏟NN  +  (N−1)⏟random members  +  G⋅N.B \;=\; \underbrace{1}_{\text{NN}} \;+\; \underbrace{(N-1)}_{\text{random members}} \;+\; G \cdot N .

where:

  • BB: the total evaluation budget (here 300300)
  • 11: the single complete-route evaluation of the nearest-neighbour construction
  • N−1N-1: the random permutations that fill the rest of the initial population
  • NN: the population size, also the number of children charged per generation
  • GG: the number of generations

The displayed identity is for complete generations. In general B=N+GN+RB=N+GN+R with 0≤R<N0\le R<N, and the implementation can spend the remainder on a partial final generation. With B=300B=300 and N=20N=20, R=0R=0 and this gives G=⌊(300−N)/N⌋=⌊(300−20)/20⌋=⌊280/20⌋=14G = \lfloor (300 - N)/N \rfloor = \lfloor (300-20)/20 \rfloor = \lfloor 280/20 \rfloor = 14 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]
CollapseExpand
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]
CollapseExpand
Generations versus population size under a fixed 300-evaluation budget.
Completed generations as a function of population size for population sizes from 5 to 50 under the same 300-evaluation budget. Every additional population member costs one evaluation per full generation. Increasing population size leaves no more complete generations after initialization; the floor calculation produces plateaus as well as decreases.

Population diversity, repair, and premature convergence

Define the diversity of a generation as the number of distinct canonical signatures in the population, Dt=∣{σ(p):p∈Pt}∣D_t = |\{\sigma(p) : p \in P_t\}|, with Dt≤μD_t \le \mu, where:

  • DtD_t: the number of distinct canonical signatures in generation tt
  • σ(p)\sigma(p): the canonical signature of permutation pp (vehicle-symmetric, reversal-invariant)
  • PtP_t: the population of parents at generation tt
  • μ\mu: the number of parents (population size)

For populations with μ>1\mu>1, genotype-level diversity can also be measured with the mean pairwise Hamming distance 1μ(μ−1)∑i≠j∣{t:pt(i)≠pt(j)}∣\frac{1}{\mu(\mu-1)}\sum_{i \ne j} |\{t : p^{(i)}_t \ne p^{(j)}_t\}|, where:

  • p(i)p^{(i)}: the ii-th individual permutation in the population
  • tt: a position index, t=1,…,nt = 1,\dots,n
  • μ\mu: the number of parents (population size)

The sum runs over ordered pairs i≠ji \ne j, so each unordered pair is counted twice, matching the μ(μ−1)\mu(\mu-1) 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 q=pq=p (the values of pp not copied into the block, in the cyclic order in which they appear in pp) 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, OX(p,p)=p\text{OX}(p,p) = p 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 G=⌊(300−20)/20⌋=14G = \lfloor (300-20)/20 \rfloor = 14 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 RR maps an infeasible genotype back into the feasible set, and a penalty method folds violations into the objective:

fpen(p)=f(p)+λ⋅v(p)f_{\text{pen}}(p) = f(p) + \lambda \cdot v(p)

where:

  • RR: the repair operator, which takes any genotype and returns a feasible one
  • f(p)f(p): the routing length of permutation pp, as defined above
  • v(p)v(p): a nonnegative violation measure, larger when capacity or windows are exceeded
  • λ≥0\lambda \ge 0: 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 λ\lambda 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

Decoded tours and costs for the four worked-example individuals.
IndividualPermutationVehicle 1Vehicle 2Cost
P1P_1(1, 7, 8, 3, 6, 5, 4, 2)0→1→7→8→3→00→6→5→4→2→040.8563
P2P_2(1, 2, 3, 8, 6, 5, 4, 7)0→1→2→3→8→00→6→5→4→7→038.2665
P3P_3(1, 2, 3, 7, 4, 5, 6, 8)0→1→2→3→7→00→4→5→6→8→039.1233
P4P_4(2, 6, 1, 5, 3, 7, 4, 8)0→2→6→1→5→00→3→7→4→8→060.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 d(0,1)=3.1623d(0,1)=3.1623, d(1,7)=2.8284d(1,7)=2.8284, d(7,8)=2.8284d(7,8)=2.8284, d(8,3)=2.2361d(8,3)=2.2361, d(3,0)=6.4031d(3,0)=6.4031 for the first vehicle, giving 17.458317.4583, and d(0,6)=6.0828d(0,6)=6.0828, d(6,5)=2.8284d(6,5)=2.8284, d(5,4)=3.1623d(5,4)=3.1623, d(4,2)=5.0000d(4,2)=5.0000, d(2,0)=6.3246d(2,0)=6.3246 for the second, giving 23.398023.3980. In total 40.856340.8563. 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-k=2k=2 tournaments select the intended parents. The first pits P2P_2 against P4P_4 and returns P2P_2, since 38.2665<60.022738.2665<60.0227. The second pits P3P_3 against P1P_1 and returns P3P_3, since 39.1233<40.856339.1233<40.8563. Thus p=P2=(1,2,3,8,6,5,4,7)p=P_2=(1,2,3,8,6,5,4,7) and q=P3=(1,2,3,7,4,5,6,8)q=P_3=(1,2,3,7,4,5,6,8).

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 a=4a=4, b=6b=6

The block is copied first. Note that positions 44, 55, 66 of pp hold the values 6,5,46, 5, 4, so U={4,5,6}U = \{4,5,6\}.

Order-crossover copy step for the worked cut positions.
Position01234567
pp12386547
qq12374568
child after copy––––654–

Next, list qq cyclically starting at position b+1=7b+1 = 7: the values are q7=8q_7=8, then q0=1q_0=1, q1=2q_1=2, q2=3q_2=3, q3=7q_3=7, and then q4=4q_4=4, q5=5q_5=5, q6=6q_6=6 which are all in UU and are skipped. The filtered list is therefore 8,1,2,3,78, 1, 2, 3, 7. The empty positions in cyclic order from 77 are 7,0,1,2,37, 0, 1, 2, 3, so we assign

c7←8,c0←1,c1←2,c2←3,c3←7,c_7 \leftarrow 8,\quad c_0 \leftarrow 1,\quad c_1 \leftarrow 2,\quad c_2 \leftarrow 3,\quad c_3 \leftarrow 7,

giving the child c=(1,2,3,7,6,5,4,8)c = (1,2,3,7,6,5,4,8).

The important point is that the child is a permutation without any repair. The copied block supplies 6,5,46,5,4; the filtered list supplies the remaining values in the order qq 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 0→1→2→3→7→00 \to 1 \to 2 \to 3 \to 7 \to 0 and 0→6→5→4→8→00 \to 6 \to 5 \to 4 \to 8 \to 0. Vehicle 1 costs d(0,1)+d(1,2)+d(2,3)+d(3,7)+d(7,0)≈3.1623+3.1623+2.2361+4.1231+3.1623≈15.8460d(0,1) + d(1,2) + d(2,3) + d(3,7) + d(7,0) \approx 3.1623 + 3.1623 + 2.2361 + 4.1231 + 3.1623 \approx 15.8460, and vehicle 2 costs d(0,6)+d(6,5)+d(5,4)+d(4,8)+d(8,0)≈6.0828+2.8284+3.1623+3.6056+5.8310≈21.5100d(0,6) + d(6,5) + d(5,4) + d(4,8) + d(8,0) \approx 6.0828 + 2.8284 + 3.1623 + 3.6056 + 5.8310 \approx 21.5100. The total is 15.8460+21.5100=37.356015.8460 + 21.5100 = 37.3560, which is shorter than either parent (38.266538.2665 and 39.123339.1233).

That is recombination doing real work. The child keeps the left block 1,2,31,2,3 in qq's order and appends the value 77 that qq had right after it, and it keeps pp's descending block 6,5,46,5,4 and closes it with 88 from qq. 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 pmp_m (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 55 and 66 of the child gives (1,2,3,7,6,4,5,8)(1,2,3,7,6,4,5,8) with cost 39.021039.0210, 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 (μ+λ)(\mu+\lambda) with μ=4\mu=4. The pool is the four parents plus the child, and the four cheapest are kept: the child at 37.356037.3560, then P2P_2 at 38.266538.2665, P3P_3 at 39.123339.1233, and P1P_1 at 40.856340.8563. P4P_4 at 60.022760.0227 is discarded. The population mean falls from

40.8563+38.2665+39.1233+60.02274=44.5672to37.3560+38.2665+39.1233+40.85634=38.9005.\frac{40.8563+38.2665+39.1233+60.0227}{4} = 44.5672 \quad\text{to}\quad \frac{37.3560+38.2665+39.1233+40.8563}{4} = 38.9005 .

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 37.356037.3560 (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]
CollapseExpand
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]
CollapseExpand
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))
Out[8]:
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]
CollapseExpand
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]
CollapseExpand
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 value

The 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]
CollapseExpand
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]
CollapseExpand
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 Tm=2(0.01/2)m/299T_m=2(0.01/2)^{m/299} before proposal mm, for m=1,…,299m=1,\ldots,299. 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]
CollapseExpand
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, ev

Genetic 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]
CollapseExpand
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 (μ\mu, here 20): the number of retained candidates. Complete generations number ⌊(B−N)/N⌋\lfloor(B-N)/N\rfloor 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 (kk, here 3): the tournament width. A unique best individual is sampled with probability 3/20=0.153/20=0.15 under distinct costs, compared with 1/201/20 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 aa uniformly from 0 through 6, then bb uniformly from a+1a+1 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 (μ+λ)(\mu+\lambda) 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]
CollapseExpand
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, history

Checking 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]
CollapseExpand
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]
CollapseExpand
             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 38.266538.2665 and 39.123339.1233, the crossover child costs 37.356037.3560, and the swapped version costs 39.021039.0210. 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]
CollapseExpand
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]
CollapseExpand
                         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]
CollapseExpand
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]
CollapseExpand
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]
CollapseExpand
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_BEST
ConsoleOut [23]
CollapseExpand
                            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.807

Independently 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]
CollapseExpand
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]
CollapseExpand
                    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            True

Visualizing 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]
CollapseExpand
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]
CollapseExpand
    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]
CollapseExpand
Line chart with three series across fourteen generations: population best, median, and worst tour length. The best line is flat or decreasing, the worst line starts far higher and steps down.
Population best, median, and worst tour length by generation for one seed-0 run. For this fixed deterministic objective, elitist replacement keeps the retained best, median, and worst costs non-increasing. The gap between the three lines is the spread that selection is acting on.

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]
CollapseExpand
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]
CollapseExpand
Line chart of the number of distinct canonical solutions in the population across fourteen generations, drawn for mutation rate 0.1 and 0.5.
Distinct canonical solutions per generation for one seed at mutation rates 0.1 and 0.5. In this recorded seed the 0.1 run reaches one signature while the 0.5 run retains several for longer; this is a measured trajectory, not a general rate law. The signature treats vehicles as interchangeable and tours as reversible under this instance's symmetric distances and wide windows; genotype diversity is a separate diagnostic.

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]
CollapseExpand
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]
CollapseExpand
Line chart of mean best-so-far tour length versus evaluation index for four search methods, each with a shaded percentile band, all starting at the same value and declining at different rates.
Best-so-far objective against the number of complete-route evaluations for four search methods, averaged over 20 seeds. Lines are means and bands span the 10th to 90th percentile. All methods start from the shared nearest-neighbour score at evaluation 1, so differences are due to how each spends its remaining 299 evaluations.

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]
CollapseExpand
Strip plot of final tour length per seed for four methods, with points spread vertically in a narrow range and a dashed horizontal line marking the exact optimum.
Final best objective for each method across the 20 seeds, with a small deterministic horizontal offset so overlapping points stay visible. The vertical spread shows seed-to-seed outcomes; horizontal offsets are deterministic display positions, and the dashed line is the exact optimum from enumerating all 40,320 permutations. A single seed would be a poor summary of any of these methods.

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; s/Rs/\sqrt{R} 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]
CollapseExpand
Scatter plot of eight customer coordinates plus a depot at the origin, with two closed loops departing from and returning to the depot.
The two depot tours of the exact optimum found by enumeration, with squares marking the depot, circles the customers, and two colours distinguishing the vehicles. The two colored sequences show the enumerated reference routes; every customer is visited exactly once and both tours return to the depot.

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 [0,80][0,80], 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, k=3k=3 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 NN 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 2N2N individuals, and pairwise Hamming diagnostics form an N×N×nN\times N\times n comparison. Equal candidate counts do not make these costs equal across methods.

The population stores NN permutations of length nn plus costs. The current Hamming diagnostic also creates an N×N×nN\times N\times n boolean array, and retained traces/history consume additional memory. Exact enumeration stores n!n! permutations and their costs, feasible at n=8n=8 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 rr is (N−rk−1)/(Nk)\binom{N-r}{k-1}/\binom{N}{k}; 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 (μ+λ)(\mu+\lambda) 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 38.266538.2665 and 39.123339.1233 produced a child of cost approximately 37.356037.3560, 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.

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!

A different angle
All writing
Machine Learning

Local Search and Simulated Annealing

Build local search and simulated annealing from scratch on an eight-customer routing instance, then compare them across 20 seeds against the enumerated optimum.

42 min read
Machine Learning

Differential Evolution

Build Differential Evolution from scratch: differential mutation, binomial crossover, greedy selection, bounds, and budget-matched benchmarks on Rastrigin.

39 min read
World Models Handbook · Vol. 4

Frontier Questions and Research Roadmaps

Frontier questions and research roadmaps for world models: generality contracts, causal structure, persistent memory, and decision-relevant evaluation.

98 min read

About the author

Reference

Citation details

Cite or share this article.

BIBTEXAcademic
@misc{brenndoerfer2026geneticalgorithms, author = {Michael Brenndoerfer}, title = {Genetic Algorithms}, year = {2026}, url = {https://mbrenndoerfer.com/writing/genetic-algorithms-from-scratch}, organization = {mbrenndoerfer.com}, note = {Accessed: 2026-10-30} }
APAAcademic
Michael Brenndoerfer (2026). Genetic Algorithms. Retrieved from https://mbrenndoerfer.com/writing/genetic-algorithms-from-scratch
MLAAcademic
Michael Brenndoerfer. "Genetic Algorithms." 2026. Web. October 30, 2026. <https://mbrenndoerfer.com/writing/genetic-algorithms-from-scratch>.
CHICAGOAcademic
Michael Brenndoerfer. "Genetic Algorithms." Accessed October 30, 2026. https://mbrenndoerfer.com/writing/genetic-algorithms-from-scratch.
HARVARDAcademic
Michael Brenndoerfer (2026) 'Genetic Algorithms'. Available at: https://mbrenndoerfer.com/writing/genetic-algorithms-from-scratch (Accessed: October 30, 2026).
SimpleBasic
Michael Brenndoerfer (2026). Genetic Algorithms. https://mbrenndoerfer.com/writing/genetic-algorithms-from-scratch
Newsletter

Stay up to date

Get new articles, book updates, and notes on AI and technology.

Unsubscribe at any time. Privacy policy