Closest Pair of Points — Why Your Strip Takes O(n²)
- Sort by x, divide at median, recurse on each half to get δ_L and δ_R. δ = min(δ_L, δ_R). Check strip of width 2δ around the dividing line.
- The 7-point bound: in a 2δ×δ rectangle, at most 8 points can be placed with mutual distance ≥ δ (2×2 arrangement per half). So each point checks ≤ 7 neighbours in the strip.
- Strip must be sorted by y-coordinate for the 7-point bound to apply. Pre-sorting by y (merging during the divide step) achieves T(n) = 2T(n/2) + O(n) → O(n log n).
- Divide and conquer splits points by median x, finds closest pair in each half recursively, then checks a narrow strip around the dividing line.
- The strip’s width is δ (minimum distance from left/right halves). Only points within δ of the midline are considered.
- Each point in the strip checks at most 7 neighbours, thanks to a geometric packing argument in a 2δ×δ rectangle.
- Performance insight: the combine step runs in O(n) when the strip is sorted by y, giving overall O(n log n).
- Production insight: forgetting to sort the strip by y can silently balloon the combine step to O(n²) on dense datasets.
- Biggest mistake: assuming the 7-neighbour bound holds without sorting the strip by y-coordinate.
Production Incident
strip.sort(key=lambda p: p[1]) and enforced the 7-point check with break condition on y-difference. Profiling confirmed O(n log n) after the fix.Production Debug GuideSymptom-driven actions for production and implementation issues
The closest pair problem is a beautiful example of computational geometry enabling an otherwise-quadratic combine step to run in linear time. The naive O(n^2) solution checks every pair. The D&C solution achieves O(n log n) by proving a remarkable geometric fact: in the critical 'strip' around the dividing line, each point has at most 7 other points it needs to check.
This seven-point bound — derived from packing arguments in a 2δ×δ rectangle — is the kind of geometric reasoning that separates algorithm designers from algorithm users. It is also a template for many computational geometry optimisations: use the structure of space to bound the number of interactions.
Algorithm Overview
- Sort points by x-coordinate: O(n log n)
- Recursively find closest pair in left half (δ_L) and right half (δ_R)
- δ = min(δ_L, δ_R)
- Find all points within δ of the dividing line (the strip)
- Check pairs in the strip sorted by y-coordinate
- The key: each point in the strip has at most 7 other points to check
# Package: io.thecodeforge.closest_pair from math import dist from itertools import combinations def closest_pair(points: list[tuple]) -> tuple[float, tuple, tuple]: points = sorted(points, key=lambda p: p[0]) return _closest(points) def _closest(pts): n = len(pts) if n <= 3: # Brute force for small input best = float('inf'), pts[0], pts[1] for a, b in combinations(pts, 2): d = dist(a, b) if d < best[0]: best = d, a, b return best mid = n // 2 mid_x = pts[mid][0] d_left = _closest(pts[:mid]) d_right = _closest(pts[mid:]) best = d_left if d_left[0] < d_right[0] else d_right delta = best[0] # Build strip: points within delta of dividing line strip = [p for p in pts if abs(p[0] - mid_x) < delta] strip.sort(key=lambda p: p[1]) # sort by y # Check strip pairs (at most 7 comparisons per point) for i in range(len(strip)): for j in range(i+1, min(i+8, len(strip))): if strip[j][1] - strip[i][1] >= delta: break d = dist(strip[i], strip[j]) if d < delta: best = d, strip[i], strip[j] delta = d return best points = [(2,3),(12,30),(40,50),(5,1),(12,10),(3,4)] d, p1, p2 = closest_pair(points) print(f'Closest: {p1} and {p2}, distance: {d:.4f}')
The 7-Neighbour Proof
Why does each point in the strip have at most 7 other points to check? Consider a δ×2δ rectangle straddling the midline. In each δ×δ half, the closest pair distance is ≥ δ (we already found the best in each half). So in each δ×δ quarter, we can pack at most 4 points at distance ≥ δ from each other (a 2×2 grid). Total: 8 points maximum in the 2δ×2δ strip rectangle including the reference point itself — so at most 7 others.
This bounds the inner loop to constant work per point → O(n) combine step.
Optimisation — Pre-Sort by Y
Re-sorting the strip each time adds a log factor. The optimisation: maintain two sorted arrays throughout — one sorted by x (for dividing) and one sorted by y (for strip checking). This gives T(n) = 2T(n/2) + O(n) → O(n log n) with smaller constants.
Implementation detail: during recursion, merge the y-sorted lists of the left and right halves (like merge sort) to produce the parent's y-sorted list. The strip can then be filtered in O(|strip|) from the y-sorted list without a full sort.
Complexity Analysis and Recurrence
The recurrence for the naive version (no pre-sort): T(n) = 2T(n/2) + O(n log n). Solving via Master Theorem: a=2, b=2, f(n)=O(n log n) = O(n^(log₂2) log n) = O(n log n). This falls under Case 2 of the Master Theorem (actually a special case). The solution is O(n log² n). Because the combine step sorts the strip, it's O(n log n), leading to T(n) = 2T(n/2) + O(n log n) = O(n log² n). Wait — careful: if we sort the strip each time, the combine is O(k log k) where k ≤ n. That sums to O(n log² n) in the worst case. Pre-sorting brings combine to O(n) and recurrence to O(n log n).
- Without pre-sort: T(n) = 2T(n/2) + O(n log n) → by Master Theorem Case 2, T(n) = Θ(n log² n).
- With pre-sort: T(n) = 2T(n/2) + O(n) → by Master Theorem Case 2, T(n) = Θ(n log n).
So pre-sorting doesn't just reduce constants — it changes the asymptotic complexity.
Implementation Details and Edge Cases
Base Case: n ≤ 3. Brute force is efficient and avoids recursion overhead. Use combinations for clarity, but a triple-nested loop is fine for n=3.
Median Selection: Sort by x once, then the median is simply p[mid]. No need for linear-time selection — the sort dominates anyway.
Duplicate Points: If two points have identical coordinates, the distance is 0. The algorithm should handle this correctly because δ becomes 0 and the strip width becomes 0 — only points exactly on the midline are considered, but duplicates on the same side will be caught in the recursive step. However, if duplicates exist across halves, they may be missed because the strip is empty (δ=0). Fix: before divide, deduplicate or handle zero-distance case explicitly.
Vertical and Horizontal Lines: Sorting by x works fine. If many points share the same x, the median may be ambiguous. Use a stable sort or choose the middle index after sorting.
Floating-Point Precision: Distance calculations can suffer from floating-point errors. Use math.dist which is stable, but for comparison, prefer squared distances to avoid sqrt overhead.
Integer Coordinates: If all coordinates are integers, use squared distances to avoid sqrt entirely, only taking sqrt at the end.
# Package: io.thecodeforge.closest_pair_optimized from math import dist def closest_pair_with_pre_sort(points: list[tuple]) -> tuple[float, tuple, tuple]: # Pre-sort by x for divide step px = sorted(points, key=lambda p: p[0]) py = sorted(points, key=lambda p: p[1]) return _closest(px, py) def _closest(px: list, py: list) -> tuple[float, tuple, tuple]: n = len(px) if n <= 3: # Brute force best = float('inf'), px[0], px[1] for i in range(n): for j in range(i+1, n): d = dist(px[i], px[j]) if d < best[0]: best = d, px[i], px[j] return best mid = n // 2 mid_x = px[mid][0] # Split y-sorted points into left and right based on x py_left = [] py_right = [] for p in py: if p[0] <= mid_x and len(py_left) < mid: py_left.append(p) else: py_right.append(p) # Recurse d_left = _closest(px[:mid], py_left) d_right = _closest(px[mid:], py_right) delta = min(d_left[0], d_right[0]) # Build strip from py (already sorted by y) strip = [p for p in py if abs(p[0] - mid_x) < delta] # Check up to 7 neighbours best = d_left if d_left[0] < d_right[0] else d_right for i in range(len(strip)): for j in range(i+1, min(i+8, len(strip))): if strip[j][1] - strip[i][1] >= delta: break d = dist(strip[i], strip[j]) if d < best[0]: best = d, strip[i], strip[j] delta = d return best
Extensions and Higher Dimensions
The D&C approach generalises to k dimensions. The strip becomes a slab of width 2δ, and the neighbour bound becomes O(3^k), which grows exponentially with dimension. For 3D, each point checks at most 31 neighbours (3^3 - 1 = 26? Actually the bound is more complex). But the combinatorial explosion makes D&C impractical for dimensions > 3. For high-dimensional data, use space-filling curves or KD-trees.
Related Problems: The packing argument also applies to finding the minimal distance between two convex polygons, or computing the diameter of a point set using rotating calipers.
Practical Note: In production, for 2D and 3D, the D&C algorithm is the gold standard. For higher dimensions, approximate methods (like LSH) are used.
| Algorithm | Time Complexity | Space Complexity | When to Use |
|---|---|---|---|
| Naive (Brute Force) | O(n²) | O(1) | n ≤ 1000, prototyping |
| Divide and Conquer (Pre-sorted) | O(n log n) | O(n) | General purpose, n up to 10⁷ |
| Shamos-Hoey (Sweep Line) | O(n log n) | O(n) | When points are streaming or you need incremental updates |
🎯 Key Takeaways
- Sort by x, divide at median, recurse on each half to get δ_L and δ_R. δ = min(δ_L, δ_R). Check strip of width 2δ around the dividing line.
- The 7-point bound: in a 2δ×δ rectangle, at most 8 points can be placed with mutual distance ≥ δ (2×2 arrangement per half). So each point checks ≤ 7 neighbours in the strip.
- Strip must be sorted by y-coordinate for the 7-point bound to apply. Pre-sorting by y (merging during the divide step) achieves T(n) = 2T(n/2) + O(n) → O(n log n).
- O(n log n) is optimal for comparison-based closest pair — same lower bound as sorting.
- The packing argument generalises: closest pair in higher dimensions also bounds strip neighbours by a constant, though the constant grows with dimension.
- Production failure: missing y-sort turns O(n log n) into O(n²). Always sort your strip.
⚠ Common Mistakes to Avoid
Interview Questions on This Topic
- QWhy does each point in the strip need at most 7 comparisons?SeniorReveal
- QWhat is the recurrence and complexity of the closest pair algorithm with and without pre-sorting by y?Mid-levelReveal
- QWhat happens at the base case and why is brute force acceptable there?JuniorReveal
- QHow would you extend this to 3D?SeniorReveal
- QHow can you optimise the closest pair algorithm for floating-point coordinates?Mid-levelReveal
Frequently Asked Questions
Is O(n log n) optimal for closest pair?
Yes — any comparison-based algorithm requires Ω(n log n) by reduction from element distinctness. The D&C algorithm is therefore asymptotically optimal.
Why 7 and not 8?
The reference point itself is one of the up-to-8 points in the rectangle, so it checks at most 7 others.
What if points have the same x-coordinate?
The median is chosen after sorting by x. If many points share the median x, the split may be uneven, but the algorithm still works. The strip includes points on both sides of the dividing line, so vertical lines are handled.
Can I use this algorithm for large-scale geospatial queries?
Yes, but pre-process to handle duplicates and watch out for floating-point precision. For millions of points, pre-sorting by y is essential. Also consider using a sweep-line variant if the points are indexed in a spatial database.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.