""" Use a genetic algorithm to solve the travelling salesman problem (TSP) which asks the following question: "Given a list of cities and the distances between each pair of cities, what is the shortest possible route that visits each city exactly once and returns to the origin city?" https://en.wikipedia.org/wiki/Genetic_algorithm https://en.wikipedia.org/wiki/Travelling_salesman_problem Author: Clark """ import copy import random cities = { 0: [0, 0], 1: [0, 5], 2: [3, 8], 3: [8, 10], 4: [12, 8], 5: [12, 4], 6: [8, 0], 7: [6, 2], } def main( cities: dict[int, list[int]], population_size: int, iterations_num: int, crossover_probability: float, mutation_probability: float, ) -> tuple[list[int], float]: """ Genetic algorithm main function The algorithm is stochastic, so seed ``random`` and assert invariants of the returned tour rather than one exact ordering (not reproducible across platforms / Python versions). >>> import random >>> random.seed(0) >>> path, best = main(cities=cities, population_size=100, iterations_num=100, ... crossover_probability=0.6, mutation_probability=0.2) >>> path[0] == 0 and path[-1] == 0 # starts and ends at the origin city True >>> sorted(path[:-1]) == sorted(cities) # every city visited exactly once True >>> 37 <= best < 45 # converges close to the optimal round-trip (~37.9) True >>> main(cities={0: [0, 0], 1: [2, 2]}, population_size=10, iterations_num=10, ... crossover_probability=0.6, mutation_probability=0.2) ([0, 1, 0], 5.656854249492381) >>> main(cities={},population_size=10,iterations_num=10, ... crossover_probability=0.6,mutation_probability=0.2) Traceback (most recent call last): ... IndexError: list assignment index out of range """ best_path: list[int] = [] best_distance = float("inf") chromosomes, cities_list = init(cities, population_size) fitness_matrix, best_path, best_distance = fitness( cities, chromosomes, best_path, best_distance ) for _ in range(iterations_num): """ Uncomment to choose another selection operator Only one of the two selection operators can be uncommented at the same time. """ # chromosomes = chose_ts(fitness_matrix, chromosomes, population_size) chromosomes = chose_rws(fitness_matrix, chromosomes, population_size) for x in range(int(population_size / 2)): # Population crossover chromosomes[x], chromosomes[x + int(population_size / 2)] = crossing( chromosomes[x], chromosomes[x + int(population_size / 2)], crossover_probability, cities_list, ) for x in range(population_size): # Population variation chromosomes[x] = mutate(chromosomes[x], mutation_probability) fitness_matrix, best_path, best_distance = fitness( cities, chromosomes, best_path, best_distance ) return best_path, best_distance def distance(city1: list[int], city2: list[int]) -> float: """ Calculate the distance between two coordinate points >>> distance([0, 0], [3, 4] ) 5.0 >>> distance([0, 0], [-3, 4] ) 5.0 >>> distance([0, 0], [-3, -4] ) 5.0 """ return (((city1[0] - city2[0]) ** 2) + ((city1[1] - city2[1]) ** 2)) ** 0.5 def init( cities: dict[int, list[int]], population_size: int ) -> tuple[list[list[int]], list[int]]: """ Initialization generates initial population >>> init(cities={0: [0, 0], 1: [2, 2]}, population_size=2) ([[0, 1, 0], [0, 1, 0]], [1]) >>> init(cities={0: [0, 0], 1: [2, 2]}, population_size=0) ([], [1]) >>> init(cities={},population_size=2) Traceback (most recent call last): ... IndexError: list assignment index out of range """ chromosomes = [] cities_list = list(cities.keys()) del cities_list[0] for _ in range(population_size): chromosome = [] chromosome.append(0) # Add starting point chromosome.extend(random.sample(cities_list, len(cities_list))) chromosome.append(0) # Add end point chromosomes.append(chromosome) return chromosomes, cities_list def fitness( cities: dict[int, list[int]], chromosomes: list[list[int]], best_path: list[int], best_distance: float, ) -> tuple[list[float], list[int], float]: """ Calculate population fitness Generate a fitness matrix and obtain the optimal value in the current population >>> fitness(cities={0: [0, 0], 1: [2, 2]},chromosomes=[[0,1,0]], ... best_path=[], best_distance=float("inf")) ([0.17677669529663687], [0, 1, 0], 5.656854249492381) >>> fitness(cities={0: [0, 0], 1: [2, 2]},chromosomes=[[0,1,0],[0,1,0]], ... best_path=[], best_distance=float("inf")) ([0.17677669529663687, 0.17677669529663687], [0, 1, 0], 5.656854249492381) >>> fitness(cities={}, chromosomes=[[0,1,0]], ... best_path=[], best_distance=float("inf")) Traceback (most recent call last): ... KeyError: 0 >>> fitness(cities={0: [0, 0], 1: [2, 2]},chromosomes=[], ... best_path=[], best_distance=float("inf")) ([], [], inf) """ fitness_matrix = [] new_best_path = best_path new_best_distance = best_distance for chromosome in chromosomes: total_distance = 0.0 for i in range(len(chromosome) - 1): # Calculate total distance total_distance += distance(cities[chromosome[i]], cities[chromosome[i + 1]]) fitness_matrix.append(1 / total_distance) if total_distance < new_best_distance: new_best_path = chromosome new_best_distance = total_distance return fitness_matrix, new_best_path, new_best_distance def chose_ts( fitness_matrix: list[float], chromosomes: list[list[int]], population_size: int ) -> list[list[int]]: """ A type of selection operator Tournament Selection >>> chose_ts(fitness_matrix=[1], chromosomes=[[0,1,0]], population_size=1) [[0, 1, 0]] >>> chose_ts(fitness_matrix=[1], chromosomes=[0,1,0], population_size=0) [] >>> chose_ts(fitness_matrix=[], chromosomes=[[0,1,0]], population_size=1) Traceback (most recent call last): ... IndexError: list index out of range >>> chose_ts(fitness_matrix=[1], chromosomes=[], population_size=1) Traceback (most recent call last): ... IndexError: list index out of range >>> import random >>> random.seed(0) >>> chose_ts(fitness_matrix=[1], chromosomes=[0, 1, 0], population_size=2) Traceback (most recent call last): ... IndexError: list index out of range """ chromosomes_new = [] for _ in range(population_size): x1 = random.randint(0, population_size - 1) x2 = random.randint(0, population_size - 1) if fitness_matrix[x1] >= fitness_matrix[x2]: chromosomes_new.append(chromosomes[x1]) else: chromosomes_new.append(chromosomes[x2]) return chromosomes_new def chose_rws( fitness_matrix: list[float], chromosomes: list[list[int]], population_size: int ) -> list[list[int]]: """ A type of selection operator Roulette Wheel Selection >>> chose_rws(fitness_matrix=[1], chromosomes=[[0,1,0]], population_size=1) [[0, 1, 0]] >>> chose_rws(fitness_matrix=[1], chromosomes=[0,1,0], population_size=0) [0, 1, 0] >>> chose_rws(fitness_matrix=[], chromosomes=[[0,1,0]], population_size=1) Traceback (most recent call last): ... IndexError: list index out of range >>> chose_rws(fitness_matrix=[1], chromosomes=[], population_size=1) Traceback (most recent call last): ... IndexError: list index out of range >>> chose_rws(fitness_matrix=[1], chromosomes=[0,1,0], population_size=2) [0, 0, 0] """ probabilitys = [0.0] * len(fitness_matrix) total_probability = 0.0 for i in fitness_matrix: total_probability += i for i in range(len(fitness_matrix)): probabilitys[i] = fitness_matrix[i] / total_probability chromosomes_new = copy.deepcopy(chromosomes) for i in range(population_size): k = 0.0 r = random.uniform(0, 1) for j in range(population_size): k = k + probabilitys[j] if r <= k: chromosomes_new[i] = chromosomes[j] break return chromosomes_new def crossing( chromosome_a: list[int], chromosome_b: list[int], crossover_probability: float, cities_list: list[int], ) -> tuple[list[int], list[int]]: """ Population crossover >>> crossing(chromosome_a=[0,1,0], chromosome_b=[0,1,0], ... crossover_probability=0,cities_list=[1]) ([0, 1, 0], [0, 1, 0]) >>> crossing(chromosome_a=[0,1,0], chromosome_b=[0,1,0], ... crossover_probability=1,cities_list=[1]) ([0, 1, 0], [0, 1, 0]) >>> crossing(chromosome_a=[0,1,0], chromosome_b=[], ... crossover_probability=1,cities_list=[1]) Traceback (most recent call last): ... IndexError: list index out of range >>> crossing(chromosome_a=[0,1,0], chromosome_b=[0,1,0], ... crossover_probability=1,cities_list=[]) ([0, 1, 0], [0, 1, 0]) """ new_chromosome_a = copy.deepcopy(chromosome_a) new_chromosome_b = copy.deepcopy(chromosome_b) if random.random() <= crossover_probability: crossover_segment = sorted( [ random.randint(0, len(new_chromosome_a) - 1), random.randint(0, len(new_chromosome_a) - 1), ] ) for k in range((crossover_segment[1] - crossover_segment[0]) + 1): ( new_chromosome_a[crossover_segment[0] + k], new_chromosome_b[crossover_segment[0] + k], ) = ( new_chromosome_b[crossover_segment[0] + k], new_chromosome_a[crossover_segment[0] + k], ) for chromosome in [new_chromosome_a, new_chromosome_b]: unique_elements_set = set(chromosome) if len(unique_elements_set) != len( chromosome ): # Determine whether the chromosome segment has duplication for segment_index in range( (crossover_segment[1] - crossover_segment[0]) + 1 ): target_index = 0 for chrom_index in range( 1, len(chromosome) - 1 ): # Exclude start and end points 0 when searching if chrom_index == (crossover_segment[0] + segment_index): continue if ( chromosome[chrom_index] == chromosome[crossover_segment[0] + segment_index] ): target_index = chrom_index break if target_index != 0: cities_list_copy = copy.deepcopy(cities_list) for t in chromosome: try: cities_list_copy.remove(t) except ValueError: continue chromosome[target_index] = random.sample(cities_list_copy, 1)[0] return new_chromosome_a, new_chromosome_b def mutate(chromosome: list[int], mutation_probability: float) -> list[int]: """ Population variation: swap two interior cities (endpoints stay at 0). >>> mutate([0, 1, 0], mutation_probability=0) # no mutation -> unchanged [0, 1, 0] >>> import random >>> random.seed(1) >>> mutate([0, 1, 2, 3, 0], mutation_probability=1) # swaps two interior cities [0, 2, 1, 3, 0] An empty chromosome has no interior cities to swap; match only the exception type since the exact stdlib message changes across Python versions. >>> mutate([], mutation_probability=1) # doctest: +IGNORE_EXCEPTION_DETAIL Traceback (most recent call last): ... ValueError """ new_chromosome = copy.deepcopy(chromosome) if random.random() <= mutation_probability: mutate_location = [ random.randint(1, len(new_chromosome) - 2), random.randint(1, len(new_chromosome) - 2), ] # Exclude start and end points 0 new_chromosome[mutate_location[0]], new_chromosome[mutate_location[1]] = ( new_chromosome[mutate_location[1]], new_chromosome[mutate_location[0]], ) return new_chromosome if __name__ == "__main__": best_path, best_distance = main( cities=cities, population_size=100, iterations_num=100, crossover_probability=0.6, mutation_probability=0.2, ) print(f"{best_path = }") print(f"{best_distance = }")