Three Correct TSP Solvers. Three Incompatible Promises.

go dev.to

The cold-chain fixture returns a 114-minute tour. The validated thermal window is 120 minutes.

Six minutes of margin.

The dispatcher asks a perfectly reasonable question:

Is 114 the best possible route?

For this run, the answer is not proven.

The tour came from Christofides with exact minimum-weight perfect matching. lvlath/tsp publishes ApproximationRatio=1.5 and Optimal=false.

The difference matters because the public call looks identical.

TL;DR

Tour and Cost are not enough for a production TSP result. In a 30-matrix experiment, 2-opt matched the exact optimum cost 24/30 times and 3-opt 27/30 times, yet neither solver can honestly publish Optimal=true. Christofides + Blossom matched the optimum only 8/30 times, but it is the heuristic regime with a formal 1.5 worst-case guarantee. Runtime, observed solution quality, and proof strength are different axes.

Textbooks explain permutations, NP-hardness, Branch-and-Bound, and 2-opt well enough. The awkward part appears later, when all of those algorithms are hidden behind one friendly function:

result, err := tsp.SolveMatrix(dist, ids, opts)
Enter fullscreen mode Exit fullscreen mode

The call looks uniform. The result is not.

One result type, several very different claims

This is the public Result in github.com/lvlath/go@v0.1.0/tsp:

type Result struct {
    Tour  []int
    Cost  float64
    IDs   []string

    Algorithm Algorithm
    Exact     bool
    Optimal   bool
    TimedOut  bool

    MetricClosureApplied bool
    Symmetric             bool
    ApproximationRatio   float64

    Iterations    int
    NodesExpanded int
}
Enter fullscreen mode Exit fullscreen mode

Those fields are intentionally redundant with the solver choice. A caller should not have to reconstruct what happened from old options, log text, or a function name.

A few valid states:

Run Exact Optimal TimedOut ApproximationRatio
Held-Karp completed true true false 0
Branch-and-Bound completed true true false 0
Branch-and-Bound stopped by time limit true false true 0
Christofides + Blossom false false false 1.5
Christofides + Greedy false false false 0
2-opt / 3-opt false false false 0

Route one: six minutes of cold-chain margin

The first runnable example is a six-stop symmetric metric travel-time matrix for vaccine distribution.

opts := tsp.DefaultOptions()
opts.Algo = tsp.Christofides
opts.Symmetric = true
opts.StartVertex = 0
opts.MatchingAlgo = tsp.BlossomMatch
opts.EnableLocalSearch = false

result, err := tsp.SolveMatrix(dist, nil, opts)
Enter fullscreen mode Exit fullscreen mode

The relevant output is short:

route-minutes=114.0
formal-ratio=1.5
thermal-margin=6.0 minutes
Enter fullscreen mode Exit fullscreen mode

The 114 is empirical: it belongs to this matrix and this returned tour.

The 1.5 comes from a theorem, and it survives only if the whole Christofides contract survives.

Same outer pipeline, different matching policy. The proof obligation changes even when both paths return a valid tour.

One caveat matters here: v0.1.0 documents metric input as a precondition; it does not prove the triangle inequality for every arbitrary matrix before solving. Symmetry and completeness can be validated mechanically. Metricity may still be a domain fact the caller has to defend. If that assumption is not trustworthy, the downstream system should not present 1.5 as an unconditional theorem.

The load-bearing stage here is minimum-weight perfect matching on the odd-degree vertices of the MST.

lvlath/tsp exposes that choice directly:

const (
    GreedyMatch MatchingAlgo = iota
    BlossomMatch
)
Enter fullscreen mode Exit fullscreen mode

BlossomMatch is exact MWPM. GreedyMatch is deterministic and cheaper, but heuristic.

So this is a valid explicit configuration:

opts := tsp.DefaultOptions()
opts.Algo = tsp.Christofides
opts.Symmetric = true
opts.MatchingAlgo = tsp.GreedyMatch
opts.EnableLocalSearch = false

result, err := tsp.SolveMatrix(dist, ids, opts)
if err != nil {
    return err
}

fmt.Println(result.ApproximationRatio) // 0: no formal ratio claimed
Enter fullscreen mode Exit fullscreen mode

There is no hidden "Blossom failed, quietly use Greedy" fallback. A failed stronger policy stays visible instead of returning weaker mathematics with stronger-looking metadata.

Route two: asymmetric cost is domain information

The second example models armored cash-in-transit routing. Cost combines time, fuel, guard exposure, and interception risk, so A -> B can legitimately differ from B -> A.

The solver is directed 2-opt*:

opts := tsp.DefaultOptions()
opts.Algo = tsp.TwoOptOnly
opts.Symmetric = false
opts.StartVertex = 0
opts.EnableLocalSearch = true

result, err := tsp.SolveMatrix(dist, nil, opts)
Enter fullscreen mode Exit fullscreen mode

It returns:

directed-risk-cost=34.0
exact=false
optimal=false
approximation-ratio=0
Enter fullscreen mode Exit fullscreen mode

The result is a deterministic locally improved ATSP tour under the selected policy. ApproximationRatio=0 states the missing guarantee explicitly instead of leaving the caller to infer it from Algorithm.

Route three: exact search can still return a non-optimal result

The semiconductor example has five drilling sites and an offline planning stage, so Branch-and-Bound with a 1-tree lower bound is a sensible exact choice:

opts := tsp.DefaultOptions()
opts.Algo = tsp.BranchAndBound
opts.Symmetric = true
opts.StartVertex = 0
opts.BoundAlgo = tsp.OneTreeBound
opts.EnableLocalSearch = false

result, err := tsp.SolveMatrix(dist, nil, opts)
Enter fullscreen mode Exit fullscreen mode

The example completes with:

single-board-latency=16.8 ms
exact=true
optimal=true
Enter fullscreen mode Exit fullscreen mode

Now add a wall-clock limit.

Branch-and-Bound is still an exact algorithm, but an interrupted search has not completed the proof. v0.1.0 may return a non-nil incumbent together with ErrTimeLimit:

result, err := tsp.SolveMatrix(dist, ids, opts)

if errors.Is(err, tsp.ErrTimeLimit) {
    if result == nil {
        return errors.New("time limit without incumbent")
    }

    fmt.Printf("cost=%.3f exact=%v optimal=%v timedOut=%v\n",
        result.Cost,
        result.Exact,    // true
        result.Optimal,  // false
        result.TimedOut, // true
    )
    return nil
}
if err != nil {
    return err
}
Enter fullscreen mode Exit fullscreen mode

Exact describes the algorithm family. Optimal describes what this invocation actually proved.

That is a distinction a single Success bool cannot encode.

180 runs: observed quality vs. formal proof

For the article I generated 30 deterministic Euclidean metric matrices:

n = 8, 10, 12
10 fixed seeds per size
Enter fullscreen mode Exit fullscreen mode

Each matrix was solved by six policies, for 180 solver runs. Held-Karp supplied the exact-cost baseline. Before writing a CSV row, an independent harness checked that the returned witness was a closed Hamiltonian cycle, visited every matrix vertex once, used finite non-negative edges, and recomputed the published cost from the source matrix.

I kept solution-quality measurements separate from testing.B: duration_ns in that CSV is diagnostic single-run timing, not benchmark evidence.

Across all 30 matrices:

Solver Median gap to exact Worst observed gap Matched exact cost What that proves
Held-Karp 0% 0% 30 / 30 exact baseline
Branch-and-Bound 0% 0% 30 / 30 completed exact search
Christofides + Blossom 3.059% 12.234% 8/30 empirical quality + formal 1.5 bound
Christofides + Greedy 8.261% 34.568% 5/30 empirical quality only
2-opt 0% 1.634% 24/30 empirical quality only
3-opt 0% 1.222% 27/30 empirical quality only
Local search won the empirical hit-rate on these small fixtures; Christofides + Blossom retained the theorem.

This is where the experiment gets uncomfortable.

2-opt hit the exact optimum cost 24 times. Christofides + Blossom did it 8 times. Yet Christofides is the one allowed to publish a worst-case mathematical guarantee.

The apparent contradiction disappears once the two questions are separated:

  • How did this solver behave on these 30 fixtures?
  • What can the algorithm prove outside this sample?

The first is empirical evidence. The second is a theorem.


Observed quality and proof strength are different axes.

Treating "the route was close to the optimum in my benchmark" as a formal guarantee is benchmark-driven overfitting.

One matrix makes the distinction even sharper

The equal-size benchmark used the same deterministic n=10, seed-47 matrix for every solver. testing.B measured only SolveMatrix; fixture construction stayed outside the timed region.

Solver Median time B/op allocs/op Gap on the same seed-47 matrix Published claim
Held-Karp 313.6 µs 172.1 KiB 68 0% exact + proven optimal
Branch-and-Bound 388.9 µs 14.39 KiB 197 0% exact + proven optimal
Christofides + Blossom 25.24 µs 9.887 KiB 158 1.409% formal 1.5 approximation
Christofides + Greedy 16.26 µs 5.809 KiB 96 1.409% no formal ratio
2-opt 7.008 µs 1.562 KiB 10 0% heuristic; no proof of optimality
3-opt 37.60 µs 3.844 KiB 21 0% heuristic; no proof of optimality

The CSV for that exact fixture records:

Branch-and-Bound      cost=26037.596462281  nodes=3663  exact=true  optimal=true
2-opt                 cost=26037.596462281  iters=9     exact=false optimal=false
3-opt                 cost=26037.596462281  iters=0     exact=false optimal=false
Christofides/Blossom  cost=26404.536340990  ratio=1.5
Christofides/Greedy   cost=26404.536340990  ratio=0
Enter fullscreen mode Exit fullscreen mode

The costs are identical, while the metadata differs because only the exact runs completed a proof of global optimality.

The equal-n table is a performance comparison for one fixed workload, not a universal solver ranking. That caveat matters: the permanent package benchmarks deliberately exercise very different regimes (n=12 Held-Karp, n=14 Branch-and-Bound, n=200 Christofides, n=500 2-opt), so putting those permanent numbers into one "fastest TSP algorithm" chart would be nonsense.

What does the 1.5 guarantee cost here?

The cleanest apples-to-apples regression pair in the package changes only the matching policy inside Christofides. Both use the same deterministic metric n=200 fixture. Local search is disabled.

Matching policy sec/op B/op allocs/op Formal ratio
Blossom exact MWPM 70.13 ms 2.714 MiB 8,925 1.5
Greedy matching 2.655 ms 450.4 KiB 1,401 none
Same n=200 fixture and outer Christofides pipeline; only the matching policy changes.

On this machine and fixture, the exact-matching pipeline is about 26.4× slower, uses 6.2× the bytes per operation, and performs 6.4× the allocations.

This comparison is narrow enough to be useful: the outer algorithm, matrix family, size, and local-search setting stay fixed. The changed subroutine is also the one that carries the proof obligation.

The isolated dense Blossom benchmark makes the growth visible:

32 odd vertices   2.350 ms
64               12.36  ms
128              57.72  ms
Enter fullscreen mode Exit fullscreen mode

From 32 to 128 odd vertices, measured solve time increased about 24.6× on this workload.

That does not make Greedy "better" or Blossom "slow". It tells me what the stronger matching policy costs in this implementation, on this machine, for these fixtures.

A claim-first way to choose the solver

A size-only rule is too vague for exact TSP. v0.1.0 ships Held-Karp with a soft MaxExactN guard whose default is 16. Callers may raise that option, but the underlying cost remains O(n²·2ⁿ) time and O(n·2ⁿ) memory. Branch-and-Bound has no comparable fixed vertex cap; its worst case is exponential and its practical limit depends on the matrix, lower bound, incumbent quality, and wall-clock budget.

Requirement Start here Do not say
Predictable exact DP and n fits the configured MaxExactN plus memory/time budget (default guard: 16) Held-Karp Raising MaxExactN makes the exponential state space cheap.
Global optimum required but Held-Karp's DP budget is unattractive; pruning may help and runtime may be instance-dependent Branch-and-Bound + explicit TimeLimit; use an admissible bound A timeout incumbent is optimal.
Symmetric complete metric input; formal approximation bound required Christofides + Blossom Greedy matching keeps the 1.5 proof.
Locally optimal route required; global proof is unnecessary or computationally infeasible 2-opt / 3-opt with explicit move limits Local optimum is global optimum.
Directed/asymmetric cost Held-Karp / Branch-and-Bound when exact search is feasible, otherwise directed local search Symmetrization is harmless cleanup.
Missing edges encoded as +Inf Metric closure only when that transformation is valid for the domain Derived closure distances are original edges.
Metric assumptions are unknown Validate them or publish no ratio Symmetry alone proves triangle inequality.

Reproduction details

The benchmark output recorded:

goos: darwin
goarch: amd64
cpu: Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz
Enter fullscreen mode Exit fullscreen mode

The repository's permanent regression benchmarks live in tsp/bench_test.go. The equal-n benchmark and the CSV quality harness were article-only local files; I ran them, kept the raw results, and did not add them to the library.

Exact regression:

GOMAXPROCS=1 go test ./tsp \
  -run '^$' \
  -bench 'Benchmark(HeldKarp|BranchBound)' \
  -benchmem -cpu=1 -count=10 -benchtime=3x
Enter fullscreen mode Exit fullscreen mode

Practical regimes:

GOMAXPROCS=1 go test ./tsp \
  -run '^$' \
  -bench 'Benchmark(Christofides|TwoOpt|ThreeOpt)' \
  -benchmem -cpu=1 -count=10 -benchtime=1s
Enter fullscreen mode Exit fullscreen mode

Blossom regression:

GOMAXPROCS=1 go test ./tsp \
  -run '^$' \
  -bench 'BenchmarkBlossom' \
  -benchmem -cpu=1 -count=10 -benchtime=1s
Enter fullscreen mode Exit fullscreen mode

For strict reproduction, publish the exact commit SHA alongside the raw benchmark files. The Go version was not present in the benchmark outputs I kept, so I would not invent it after the fact.


There was no single winner in these fixtures. Local search often matched the exact optimum cost; Christofides carried the stronger worst-case statement; exact methods were the only ones allowed to set Optimal=true. The benchmark and the theorem ranked the same solvers differently.

If a caller cannot distinguish proven, bounded, timed out, and merely good on this run, the solver is hiding state it already knows.

Code and contracts:

Source: dev.to

arrow_back Back to Tutorials