This repository contains a high-performance Traveling Salesperson Problem (TSP) solver based on cascading k-opt heuristic, optimized with Numba, and parallelized for large-scale datasets.
uv sync# Create and activate a virtual environment
python -m venv .venv
# Windows:
.venv\Scripts\activate
# Unix/macOS:
source .venv/bin/activate
# Install production dependencies
pip install .
# Install development tools
pip install -e ".[dev]"Note: Core dependencies include Numba, NumPy, and SciPy. Development tools (Mypy, Ruff, Pytest, Pyright) are in the dev group.
The main production script orchestrates the full pipeline: loading cities, computing lower bounds, and running parallel K-opt optimization.
# Run on a subset of 500 cities for a quick test
uv run python -m src.scripts.main --n 500 --kicks 100 --iters 1 --seeds 8--n: Number of cities to sample from the dataset (0 for all).--kicks: Number of double-bridge kicks per seed.--seeds: Number of parallel optimization starts.--iters: Number of full re-optimization passes (0 for infinite).--max-opt: Maximum K-opt level (default 5, range [2, 5]).--hk-iter: Iterations for Held-Karp lower bound computation (default 100).--no-cache: Disable using cached Held-Karp results.--no-alpha: Skip Alpha-value refinement and use raw KD-tree distances.--start-tour: Path to a tour CSV/text file to initialize optimization.--seed-strategy: Initial seeding strategy: 'greedy' (default) or 'random'.--lkh: Use LKH engine instead of K-Opt.--backbone-weight: Soft backbone strength (0.0 to 1.0). Biases candidate sorting by edge frequency in population.
Use the run_sample script to evaluate performance and log results to notes.md.
uv run python -m src.scripts.run_sample --n 1000 --kicks 500 --iters 3The project maintains strict standards for type safety and linting:
# Run unit tests
uv run pytest
# Run strict type checking
uv run mypy src tests
uv run pyright src tests
# Run linter
uv run ruff check .data/solutions.csv: Contains the results of the current run.data/best_tour.csv: Stores the global best tour found (only updated during full-scale runs).
The Traveling Salesperson Problem (TSP) on a graph
Objective function:
Subject to degree constraints:
Subject to subtour elimination constraints (SECs):
A 1-tree is defined as a spanning tree on the vertex set
The degree constraints
The modified edge costs,
The Lagrangian function
The Dual Problem maximizes this formulation:
The dual problem is non-differentiable. Subgradient ascent is utilized to iteratively update the
The degree of vertex
The multipliers are updated using the formula:
The step size
where
This specific relaxation is evaluated algorithmically because the problem of finding a minimum weight 1-tree is solvable in
The algorithm applies the Greedy Nearest Neighbor setup for initialization. The data in doc/archive_notes.md records an 800-trial experiment with parameters N=5000 and max_opt=5. The metrics from this experiment are:
- 100% Greedy Nearest Neighbor yielded a 5.71% Gap.
- 100% Hilbert yielded a 6.70% Gap.
- The ANOVA test resulted in p < 2.07 x 10^-85.
The Greedy Nearest Neighbor algorithm was chosen based on these gap metrics. Algorithmically, Greedy Nearest Neighbor provides a different search basin than space-filling curves.
A trial testing re-seeding via 100% Exploitation yielded a 5.52% Gap, with an ANOVA p < 2.74 x 10^-19.
Based on this 5.52% Gap result, the algorithm enforces 100% resets to the global minimum-distance tour for all cores. The algorithm enforces this reset rule so that every processing unit starts its search sequence from the exact vertex coordinates that produced the global minimum distance, eliminating iterations on coordinates with higher distance values.
The algorithm maps seed index i to starting vertices using Uniform Index Rotation math. The equation is:
shift = (i * n) // num_seedsThe variables are defined as:
shift: The integer offset applied to the vertex array.i: The seed index.n: The vertex count.num_seeds: The seed count.
This integer division spaces the starting vertices at intervals of n // num_seeds across the vertices to spread search origins.
The local search phase executes a sequence of edge-exchange operators in a strictly escalating order: 2-opt, Or-opt, 3-opt, proceeding up to the integer limit MAX_OPT.
The cascade functions as a nested feedback loop. The engine iterates over all
The 2-opt operation removes two non-adjacent edges
-
Array Manipulation: The tour is stored as a 1D integer array. The operation reverses the sequence of vertices from index
$v$ to index$x$ . The algorithm performs this using a two-pointer loop, swapping array elements at the boundary indices and iterating inward.
The Or-opt operation relocates a sequence of contiguous vertices (length 1, 2, or 3) to a different target index within the tour array. Mathematically, it evaluates a subset of the 3-opt neighborhood to test block translations.
- Array Manipulation: The algorithm copies the source segment into a temporary buffer. Array elements between the source and target indices are shifted left or right by the segment length. The buffer contents are written to the target indices.
The 3-opt operation removes three edges, generating three disconnected path segments. The algorithm evaluates the four strictly 3-opt reconnection permutations. The sequence scales to MAX_OPT by enumerating combinations of
- Array Manipulation: Reconnection executes multiple segment reversals and block shifts. The loop evaluates the sum of the removed edge weights minus the sum of the added edge weights. Permutations yielding a positive scalar (indicating a distance reduction) trigger the array index updates.
The solver implements a variable-depth Lin-Kernighan-Helsgaun search algorithm, replacing the fixed k-opt bounds when executed with the --lkh flag. The implementation limits the recursive variable-depth descent to a MAX_BACKTRACKING_DEPTH integer of 5 to bound computational time complexity per search node.
The LKH operator evaluates sequential edge exchanges. It deletes an edge
The engine utilizes a 1D boolean array, dlb, with a length equal to True to dlb[i]. The main iteration loop skips vertex indices where dlb[i] == True. A successful edge exchange resets the DLB array values to False for all vertices involved in the mutation.
The engine implements the Double Bridge operator to perturb the tour state between local search phases.
The engine dynamically scales the integer count of sequential Double Bridge operators applied per kick phase based on a stagnation metric. A stagnation counter increments by 1 when the 1D array permutation phase fails to reduce the objective sum by GAIN_EPSILON. The engine applies 1 Double Bridge operator when the counter is less than or equal to 50% of the reset limit. It applies 2 sequential Double Bridge operators when the counter exceeds 50% of the limit. It applies 3 sequential Double Bridge operators when the counter exceeds 75% of the limit. The reset limit is calculated as max(STAGNATION_LIMIT_MIN, N // STAGNATION_LIMIT_DIVISOR).
The Double Bridge is a 4-opt move that removes four edges and reconnects the resulting four segments (labeled A, B, C, D) into the sequence A, D, C, B. This operator generates a coordinate topology that cannot be reverted by sequential 2-opt operations. It alters the state coordinates beyond the boundary of the 2-opt search neighborhood.
The algorithm selects four array indices
The orchestration sequence executes the Held-Karp subgradient ascent algorithm strictly once during initialization. It computes the 64-bit float lower bound and the (N,) base_candidate_set graph across all iterations, the optimal dual function
The application imports the multiprocessing module for task distribution. The process limit executes the formula:
min(mp.cpu_count(), args.seeds)
mp.cpu_count(): The operating system function returns the hardware thread integer.args.seeds: The variable stores the integer passed via the CLI argument.
The application instantiates a multiprocessing.Pool object. The processes parameter of the pool receives the integer output from the formula. The pool partitions the seed array into chunks and maps the chunks to the processes.
The engine reads integers from the src/config.py file to bound array sizes and loops. The file contains 7 assignments:
KD_TREE_QUERY_SIZE=64: The KD-Tree index returns an array containing 64 points per query.K_NEIGHBORS=64: The matrix allocates rows of length 64 to store distances per vertex.K_3OPT=32: The 3-opt function restricts the array length for permutation loops to 32 vertices.K_4OPT=16: The 4-opt function restricts the array length for permutation loops to 16 vertices.K_5OPT=8: The 5-opt function restricts the array length for permutation loops to 8 vertices.OR_OPT_MAX_LEN=8: The Or-opt function executes block relocations on an array slice with a length limit of 8 elements.MAX_OPT=5: The engine restricts the k-opt steps to a k-value limit of 5.
The engine reads the args.iters integer to set the loop termination bound.
- The process receives a coordinate matrix and a seed integer.
- The process instantiates a counter with a value of 0.
- The process executes array mutations.
- The process increments the counter by 1.
- The process evaluates the condition:
counter < args.iters. - When the condition equates to
True, execution jumps to step 3. - When the condition equates to
False, the loop terminates. - The process returns the array matrix to the
multiprocessingpool.
- Coordinate Matrices: Instantiated with dimensions (N, 2). The dtype
np.float64allocates 8 bytes per scalar, resulting in N * 16 bytes per matrix. - Index Arrays: Instantiated with dimensions (N,). The dtype
np.int32allocates 4 bytes per scalar, resulting in N * 4 bytes per array. - 64-byte Memory Alignment: Memory is allocated via
np.empty(nbytes + 64, dtype=np.uint8). The memory address modulo 64 is computed. The value of 64 minus the remainder is added to the memory pointer to reach a multiple of 64 bytes. An array view is cast to the dtypenp.float64ornp.int32starting from this offset.
- Distance Metric: Time complexity is O(1). Space complexity is O(1).
- KD-tree: Time complexity is O(N log N) for tree construction. Space complexity is O(N).
- 1-Tree Generation: Time complexity is O(N * K log N), where K represents the count of neighbors evaluated per vertex. Space complexity is O(N).
- Lowest Common Ancestor (LCA) via Binary Lifting: Preprocessing time complexity is O(N log N). Query time complexity is O(1) per vertex pair. Space complexity is O(N log N) for the table of ancestors stored at intervals of 2^i.
- Input Deserialization:
np.loadtxtreads coordinate data from text files and assigns the parsed data to the (N, 2)np.float64memory block. - Output Serialization: CSV serialization writes the (N,)
np.int32vertex sequence and thenp.float64objective sum to a text file on disk.