Back to Graph Theory Series

TSP Heuristics (Nearest Neighbor, 2-Opt & 3-Opt)

September 27, 2026 Wasil Zafar 28 min read

A practical TSP tour is often built in layers: make a complete route quickly, repair its obvious defects, then search a richer neighborhood only when the extra computation is worthwhile.

Contents

  1. History of Edge-Exchange Search
  2. The Construct–Improve Pipeline
  3. Nearest Neighbor Constructive Heuristic
  4. 2-Opt Local Search
  5. 3-Opt Local Search
  6. Worked Example
  7. Complexity & Performance
  8. Implementation
  9. Real-World Applications
  10. Exercises
  11. Limitations

History of Edge-Exchange Search

The Traveling Salesman Problem, introduced in Part 14, is NP-hard. The methods here do not prove optimality; they search for progressively better tours by changing a small number of edges.

1958
Georges A. Croes published a tour-improvement method now associated with the 2-opt exchange: remove two edges and reconnect the resulting paths more cheaply.
1965
Shen Lin reported computer experiments with 3-opt-style exchanges, broadening the neighborhood beyond pairs of removed edges.
1973
Lin and Brian Kernighan introduced a variable-depth exchange heuristic that chooses the effective value of $k$ while building a promising move.
2000
Keld Helsgaun published an influential implementation of Lin–Kernighan, known as LKH, combining candidate sets, strong search rules, and careful engineering.

The enduring idea is not a particular benchmark percentage. It is the neighborhood: define which nearby tours are reachable by one move, then keep moving to a shorter neighbor until the chosen neighborhood contains no improvement.

The Construct–Improve Pipeline

Construct

Nearest Neighbor supplies a complete feasible tour quickly. Its job is to give local search somewhere sensible to begin.

Improve

2-opt or 3-opt changes a few edges while preserving one cycle. Every accepted move reduces tour length.

Stop

Stop at local optimality, a time budget, a pass limit, or a sufficiently small improvement threshold.

Diversify

Try multiple start cities, random restarts, or perturbations because one local optimum need not be globally best.

feasible firstimprove monotonicallymeasure the gap when possible

Nearest Neighbor Constructive Heuristic

Choose a start city

Mark it visited. Different starts can produce different tours, so a single start is a design choice rather than an invariant.

Take the cheapest immediate step

Move from the current city to its nearest unvisited city; use a deterministic tie-breaker for repeatable output.

Close the cycle

After the last unvisited city, return to the start—even if that closing edge is expensive.

Nearest Neighbor builds a tour one local decision at a time The algorithm follows several short teal edges but leaves a long crimson closing edge because it cannot reconsider earlier choices. Greedy construction from A ABCDEF short local choicesforced closing edge F → A
Nearest Neighbor has no look-ahead. Early short edges can leave the final city far from the start—the characteristic cost of greedy myopia.

Simple upgrades

Run Nearest Neighbor from several starts and keep the shortest result, or use spatial indexes and candidate lists when scanning every unvisited city is too expensive.

2-Opt Local Search

Choose two nonadjacent tour edges $(A,B)$ and $(C,D)$. Removing them creates two paths. There is exactly one nontrivial way to reconnect those paths into a different undirected cycle: add $(A,C)$ and $(B,D)$, which reverses the segment from $B$ through $C$.

Only four distances change, so the move can be evaluated in constant time:

$$\Delta = d(A,C) + d(B,D) - d(A,B) - d(C,D)$$

Accept when $\Delta < 0$. First improvement applies the first profitable move and restarts scanning; best improvement evaluates the whole neighborhood and applies the most profitable move. Both finish at a 2-opt local optimum, but may reach different tours.

Array operation behind the geometry

For a tour array, replacing $(A,B)$ and $(C,D)$ with $(A,C)$ and $(B,D)$ is exactly a reversal of the contiguous segment $B \ldots C$.

3-Opt Local Search

Remove three mutually nonadjacent edges. The tour becomes three path segments that can be reordered and reversed. For a symmetric tour there are up to seven non-original reconnections: three are effectively 2-opt moves and four are genuine 3-opt moves.

Three-opt cuts a tour into three path segments and reconnects them Three removed crimson edges create navy, teal, and blue path segments. A different ordering and orientation reconnects them into a new cycle. Cut three edges cut 1cut 2cut 3 Reconnect:segment 2 ↔segment 1 →segment 3 ↔new cycle
3-opt can reorder and reverse three path segments, reaching tours that no single 2-opt reversal can reach. Its richer neighborhood costs more to search.

A clear reference implementation may build and score candidate tours explicitly, costing $O(n^4)$ per exhaustive pass. Optimized implementations compare only changed edges in $O(1)$ per reconnection, reducing a full three-cut scan to $O(n^3)$ before candidate-set pruning.

Worked Example: Removing a Crossing

Consider square corners $A(0,0)$, $B(0,1)$, $C(1,0)$, and $D(1,1)$ with the crossing tour $A \to D \to B \to C \to A$.

A two-opt move removes crossing diagonals from a square tour The original tour uses crossed edges A D and B C and has length about 4.828. Replacing them with A B and D C produces the perimeter tour of length 4. Before: crossing tourAfter: 2-opt reversal ABCD ×length ≈ 4.828 ABCD length = 4.000; improvement = 0.828
Replace diagonals AD and BC with sides AB and DC. The changed-edge calculation gives $\Delta = 2 - 2\sqrt{2} \approx -0.828$, so the reversal is accepted.

Euclidean crossing fact

For four points in general position, uncrossing two tour edges strictly shortens the route by the triangle inequality. Therefore, a 2-opt local optimum in a Euclidean instance has no geometric crossings.

Complexity & Performance

MethodRoleNeighborhood or passWhat stopping means
Nearest NeighborConstructive$O(n^2)$ with a full scan at every stepAll cities have been inserted
2-optLocal improvement$O(n^2)$ candidate pairs per exhaustive passNo improving 2-edge exchange exists
3-optRicher local improvement$O(n^3)$ triples with constant-time deltasNo improving tested 3-edge exchange exists
Lin–KernighanVariable-depth searchAdaptive; practical cost depends on candidate sets and rulesNo accepted variable-depth move, or budget reached

Total runtime is the cost per pass multiplied by the number of accepted moves and restarts. Tour quality is instance-dependent, so report the achieved length, runtime, start strategy, and—when an exact optimum or lower bound is available—the relative gap:

$$\text{gap} = \frac{L_{\text{heuristic}} - L_{\text{reference}}}{L_{\text{reference}}} \times 100\%$$

Implementation

The Python tab includes a deliberately clear 3-opt reference implementation that rebuilds candidate tours. The C++ and Java tabs focus on the most common production baseline—deterministic Nearest Neighbor followed by first-improvement 2-opt.

import math


def validate_points(points):
    if len(points) < 3:
        raise ValueError("at least three points are required")
    if any(len(p) != 2 or not all(math.isfinite(x) for x in p) for p in points):
        raise ValueError("each point must contain two finite coordinates")


def distance(a, b):
    return math.hypot(a[0] - b[0], a[1] - b[1])


def tour_length(tour, points):
    return sum(distance(points[tour[i]], points[tour[(i + 1) % len(tour)]])
               for i in range(len(tour)))


def validate_tour(tour, points):
    validate_points(points)
    if sorted(tour) != list(range(len(points))):
        raise ValueError("tour must be a permutation of every city")


def nearest_neighbor_tsp(points, start=0):
    validate_points(points)
    n = len(points)
    if not 0 <= start < n:
        raise ValueError("start index is outside the point array")
    unvisited = set(range(n))
    unvisited.remove(start)
    tour = [start]

    while unvisited:
        current = tour[-1]
        nxt = min(unvisited, key=lambda city: (distance(points[current], points[city]), city))
        unvisited.remove(nxt)
        tour.append(nxt)
    return tour


def two_opt(tour, points, epsilon=1e-12):
    """First-improvement 2-opt; returns a new list and leaves input untouched."""
    validate_tour(tour, points)
    result, n = list(tour), len(tour)
    improved = True

    while improved:
        improved = False
        for i in range(n - 1):
            for j in range(i + 2, n):
                if i == 0 and j == n - 1:
                    continue  # the first and last tour edges are adjacent
                a, b = result[i], result[(i + 1) % n]
                c, d = result[j], result[(j + 1) % n]
                delta = (distance(points[a], points[c]) + distance(points[b], points[d])
                         - distance(points[a], points[b]) - distance(points[c], points[d]))
                if delta < -epsilon:
                    result[i + 1:j + 1] = reversed(result[i + 1:j + 1])
                    improved = True
                    break
            if improved:
                break
    return result


def three_opt_reference(tour, points, epsilon=1e-12):
    """Best-improvement 3-opt; explicit candidates favor clarity over speed."""
    validate_tour(tour, points)
    result, n = list(tour), len(tour)
    if n < 6:  # three mutually nonadjacent tour edges do not exist
        return result

    while True:
        best, best_length = result, tour_length(result, points)
        for a in range(n - 4):
            for b in range(a + 2, n - 2):
                for c in range(b + 2, n):
                    if a == 0 and c == n - 1:
                        continue
                    p1 = result[a + 1:b + 1]
                    p2 = result[b + 1:c + 1]
                    p3 = result[c + 1:] + result[:a + 1]
                    candidates = (
                        p3 + p1 + p2, p3 + p1[::-1] + p2,
                        p3 + p1 + p2[::-1], p3 + p1[::-1] + p2[::-1],
                        p3 + p2 + p1, p3 + p2[::-1] + p1,
                        p3 + p2 + p1[::-1], p3 + p2[::-1] + p1[::-1],
                    )
                    for candidate in candidates:
                        zero = candidate.index(0)
                        candidate = candidate[zero:] + candidate[:zero]
                        length = tour_length(candidate, points)
                        if length < best_length - epsilon:
                            best, best_length = candidate, length
        if best is result:
            return result
        result = best


coords = [(0, 0), (0, 4), (3, 0), (3, 4), (1, 1), (4, 2)]
nn_tour = nearest_neighbor_tsp(coords)
two_tour = two_opt(nn_tour, coords)
three_tour = three_opt_reference(two_tour, coords)
print("NN:   ", nn_tour, tour_length(nn_tour, coords))
print("2-opt:", two_tour, tour_length(two_tour, coords))
print("3-opt:", three_tour, tour_length(three_tour, coords))
#include <iostream>
#include <vector>
#include <cmath>
#include <algorithm>
#include <stdexcept>

using namespace std;

struct Point { double x, y; };

double dist(Point p1, Point p2) {
    return hypot(p1.x - p2.x, p1.y - p2.y);
}

double getTourLength(const vector<int>& tour, const vector<Point>& pts) {
    double len = 0;
    int n = tour.size();
    for (int i = 0; i < n; ++i) {
        len += dist(pts[tour[i]], pts[tour[(i + 1) % n]]);
    }
    return len;
}

vector<int> nearestNeighbor(const vector<Point>& pts) {
    int n = pts.size();
    if (n < 3) throw invalid_argument("at least three points are required");
    vector<bool> visited(n, false);
    vector<int> tour = {0};
    visited[0] = true;

    for (int step = 1; step < n; ++step) {
        int curr = tour.back();
        int best_next = -1;
        double min_d = 1e18;
        for (int i = 0; i < n; ++i) {
            double candidate = dist(pts[curr], pts[i]);
            if (!visited[i] && (candidate < min_d ||
                (candidate == min_d && i < best_next))) {
                min_d = candidate;
                best_next = i;
            }
        }
        visited[best_next] = true;
        tour.push_back(best_next);
    }
    return tour;
}

void twoOpt(vector<int>& tour, const vector<Point>& pts) {
    int n = tour.size();
    bool improved = true;

    while (improved) {
        improved = false;
        for (int i = 1; i < n - 1; ++i) {
            for (int j = i + 1; j < n; ++j) {
                if (j - i == 1) continue;

                Point A = pts[tour[i - 1]], B = pts[tour[i]];
                Point C = pts[tour[j]],     D = pts[tour[(j + 1) % n]];

                double d_old = dist(A, B) + dist(C, D);
                double d_new = dist(A, C) + dist(B, D);

                if (d_new < d_old - 1e-12) {
                    reverse(tour.begin() + i, tour.begin() + j + 1);
                    improved = true;
                    break;
                }
            }
            if (improved) break;
        }
    }
}

int main() {
    vector<Point> pts = {{0,0}, {0,4}, {3,0}, {3,4}, {1,1}};
    vector<int> tour = nearestNeighbor(pts);
    cout << "NN Length: " << getTourLength(tour, pts) << endl;

    twoOpt(tour, pts);
    cout << "2-Opt Length: " << getTourLength(tour, pts) << endl;
    return 0;
}
import java.util.*;

public class TSPHeuristics {
    static class Point {
        double x, y;
        Point(double x, double y) { this.x = x; this.y = y; }
    }

    static double dist(Point p1, Point p2) {
        return Math.hypot(p1.x - p2.x, p1.y - p2.y);
    }

    static double getTourLength(int[] tour, Point[] pts) {
        double len = 0;
        int n = tour.length;
        for (int i = 0; i < n; i++) {
            len += dist(pts[tour[i]], pts[tour[(i + 1) % n]]);
        }
        return len;
    }

    static int[] nearestNeighbor(Point[] pts) {
        int n = pts.length;
        if (n < 3) throw new IllegalArgumentException("at least three points are required");
        boolean[] visited = new boolean[n];
        int[] tour = new int[n];
        tour[0] = 0; visited[0] = true;

        for (int step = 1; step < n; step++) {
            int curr = tour[step - 1];
            int bestNext = -1;
            double minD = Double.MAX_VALUE;
            for (int i = 0; i < n; i++) {
                double candidate = dist(pts[curr], pts[i]);
                if (!visited[i] && (candidate < minD ||
                    (candidate == minD && i < bestNext))) {
                    minD = candidate;
                    bestNext = i;
                }
            }
            visited[bestNext] = true;
            tour[step] = bestNext;
        }
        return tour;
    }

    static void twoOpt(int[] tour, Point[] pts) {
        int n = tour.length;
        boolean improved = true;

        while (improved) {
            improved = false;
            for (int i = 1; i < n - 1; i++) {
                for (int j = i + 1; j < n; j++) {
                    if (j - i == 1) continue;

                    Point A = pts[tour[i - 1]], B = pts[tour[i]];
                    Point C = pts[tour[j]],     D = pts[tour[(j + 1) % n]];

                    double dOld = dist(A, B) + dist(C, D);
                    double dNew = dist(A, C) + dist(B, D);

                    if (dNew < dOld - 1e-12) {
                        reverse(tour, i, j);
                        improved = true;
                        break;
                    }
                }
                if (improved) break;
            }
        }
    }

    private static void reverse(int[] arr, int i, int j) {
        while (i < j) {
            int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp;
            i++; j--;
        }
    }

    public static void main(String[] args) {
        Point[] pts = {
            new Point(0,0), new Point(0,4), new Point(3,0), new Point(3,4), new Point(1,1)
        };
        int[] tour = nearestNeighbor(pts);
        System.out.println("NN Length: " + getTourLength(tour, pts));

        twoOpt(tour, pts);
        System.out.println("2-Opt Length: " + getTourLength(tour, pts));
    }
}

Real-World Applications

Route construction

Build an initial stop order quickly, then improve it before applying time-window, capacity, or driver constraints in a richer vehicle-routing model.

Tool-path planning

Reduce nonproductive movement among drilling, cutting, inspection, or pick-and-place locations.

Ordering problems

Whenever items have pairwise transition costs, a tour heuristic can provide a fast ordering—even when “distance” is not geographic.

Anytime optimization

Return the current tour at any deadline. More passes or restarts can improve it without sacrificing feasibility.

Exercises

1. Start-city sensitivity

Run Nearest Neighbor from every city of the same instance. Plot the resulting tour lengths and explain why rotation symmetry does not make these runs equivalent.

2. Prove the crossing claim

Use the triangle inequality to show that replacing two crossing Euclidean edges by the appropriate uncrossed pair cannot increase length.

3. Map the 3-opt neighborhood

List the seven non-original reconnections after cutting three nonadjacent edges. Identify the three that reduce to 2-opt and the four genuine 3-opt moves.

4. Escape a local minimum

Add double-bridge perturbations or simulated annealing over 2-opt moves. Compare the best-of-ten result with one deterministic run under the same time budget.

Limitations

  • Local is neighborhood-relative: a 2-optimal tour may still have a profitable 3-opt or variable-depth move.
  • Starting tours matter: deterministic construction plus deterministic first improvement explores only one basin of attraction.
  • No optimality certificate: a locally optimal tour does not reveal its distance from the global optimum unless a separate exact solver or lower bound is available.
  • Naive scans scale poorly: full $O(n^2)$ and $O(n^3)$ neighborhoods require candidate lists, geometric filtering, or time budgets on large instances.
  • Model assumptions matter: asymmetric costs, forbidden edges, capacities, and time windows require modified move-feasibility checks.

Heuristic versus approximation algorithm

These local-search methods aim for good empirical tours but do not supply Christofides' proven $3/2$ bound for metric TSP. Use Christofides' algorithm when that worst-case certificate is required.