Heuristic — constructive

▶ Open interactive explainer →

Fourier-basis Constructive Solver

Alias fourier
Type Heuristic — constructive
Complexity O(K_max · epochs · (n + M) log M) per run

Description

Encodes a TSP tour as a closed curve in the complex plane and optimises the Fourier coefficients of that curve with gradient descent. Decoding is a pure argsort: no penalty, no repair step, and no possibility of producing an invalid tour.

Curve representation

The tour is the closed curve

γ(s) = Σ_{k=-K}^{K} c_k · exp(2πi · k · s),   s ∈ [0, 1)

sampled at M evenly-spaced points s_j = j/M. The 2K+1 complex coefficients c_k are the only free variables; gradient descent moves them so the curve passes near each city.

Energy

E(c) = Σ_i  min_j |city_i − γ(s_j)|²   +   λ Σ_k (2πk)² |c_k|²
         attraction                              tension

Coarse-to-fine optimisation loop

initialise c[0] = centroid, c[1] = radius/2, all others = 0
λ ← opts.lambda

for k_active = 1 … K_max:
    basis[k][j] = exp(2πi · ks[k] · j/M)   // pre-computed; constant within stage

    repeat opts.epochs times:
        γ = eval_curve(c, ks, M)
        grad = attraction_gradient + tension_gradient
        for k where |ks[k]| ≤ k_active:
            c[k] -= (lr / n) * grad[k]

    λ *= lambda_decay

Unlocking modes one stage at a time lets the optimiser set the overall loop shape (low modes) before refining local detail (high modes), avoiding the saddle points that occur when all modes compete simultaneously.

Decode (always valid)

s_i = argmin_j |city_i − γ(s_j)|     // nearest curve sample per city
tour = argsort(s_i)                    // sort cities by their curve position

The argsort gives array positions in cities[], which are then mapped to their .id fields — the same pattern used by Christofides. This guarantees a valid Hamiltonian tour regardless of convergence quality.

procedure FourierSolver(cities):
    coeffs ← fit_fourier_basis_to_cities(cities)
    curve ← reconstruct_closed_curve(coeffs)
    tour ← argsort_cities_by_curve_parameter(cities, curve)
    tour ← two_opt_polish(tour)
    return tour

Options

Field CLI flag Default Range Description
k_max --k-max 4 ≥ 1 Maximum Fourier mode (number of frequency stages)
m --m 200 ≥ 2 Curve sampling resolution (points on γ)
lambda 0.05 > 0 Initial tension weight
lambda_decay 0.5 (0, 1) Tension multiplier applied at each stage
lr 0.05 > 0 Gradient descent learning rate
epochs --epochs 400 ≥ 1 Gradient steps per k_active stage

epochs follows the same vocabulary as all other solvers in this codebase, with one exception: unlike HeuristicOptions.epochs elsewhere, epochs=0 is not a “run forever” sentinel here — it’s rejected by validation, since it counts gradient steps per k_active stage, not outer solver iterations.

Only k_max, m, and epochs have CLI flags; lambda, lambda_decay, and lr are reachable only via the REST API’s configs.fourier or a [fourier]/[stage.fourier] TOML table (field names match the table above).

Tuning for larger instances

k_max (coefficient count) is the dominant quality lever, not m (curve resolution). On a280 (280 cities), scaling k_max alone took the standalone gap from +103.4% at the shipped default (k_max=4) down to +24.6% at k_max=32 (further down to +7.6% after piping into a 2-opt polish pass, which is the more relevant number since Fourier is typically used as a warm-start). Scaling m alone, without more coefficients, made quality worse.

Config Standalone gap +2-opt gap Wall time
k_max=4, m=200 (default) +103.4% 0.19s
k_max=32, m=200 +24.6% +7.6% 4.25s
k_max=32, m=1120 (4n) +15.9% +15.8% 19.2s

(a280, 280 cities; wall times measured after the KD-tree nearest-sample optimization that shipped in #370.)

Cost scales as k_max × epochs gradient steps total, with no upper bound enforced by validate() — there’s no instance-size-aware guard, so an oversized --k-max on a large instance will simply run for a long time (or, at extreme values, effectively hang) rather than error out. Start by scaling k_max alone before touching m; m scaling is comparatively expensive and, per the table above, doesn’t reliably help quality on its own.

Usage

# standalone
teeline solve fourier -i ./data/tsplib/berlin52.tsp

# as warm-start for 2-opt (recommended)
teeline pipeline --steps=fourier,2opt -i ./data/tsplib/berlin52.tsp

# as warm-start for LK
teeline pipeline --steps=fourier,lk -i ./data/tsplib/berlin52.tsp

# tuning k_max for a larger instance
teeline solve fourier -i ./data/tsplib/a280.tsp --k-max 32

Per-stage TOML config (via pipeline --config):

[[stage]]
solver = "fourier"

[stage.fourier]
k_max = 32
m     = 200

Notes

Relationship to the Elastic Net

This algorithm is a Fourier-parameterised variant of the Elastic Net (Durbin & Willshaw 1987). Both share the same two-term energy (city attraction + curve smoothness/tension) and optimise via gradient descent. The key differences:

Elastic Net This implementation
Curve representation M explicit node positions 2K+1 Fourier coefficients
Tension term Sum of squared edge lengths λ(2πk)² |c_k|² (diagonal in coefficient space)
Mode schedule Simultaneous + temperature annealing Coarse-to-fine frequency unlocking
Tour decode Explicit node ordering Argsort of nearest-sample parameter s_i

References