DSA DSA In Depth
0%
lesson divide-and-conquercomputational-geometryclosest-pair

Geometric Divide-and-Conquer: Planar Closest Pair of Points in O(N log N)

7 min read Module 2.3: Binary Search on Monotonic Spaces & Divide-and-Conquer Updated Oct 23, 2026
📄 Cheat Sheet
Planar Closest Pair of Points in O(N log N) via Divide and Conquer (7-Neighbor Strip Proof)
Algorithms by Sharma Thankachan Planar Closest Pair of Points in O(N log N) via Divide and Conquer (7-Neighbor Strip Proof) Watch on YouTube ↗

Erik Demaine derives the O(N log N) planar closest-pair divide-and-conquer algorithm and proves the geometric packing lemma bounding strip comparisons to at most 7 neighbors.

0:00 / --:--

The Naive Bottleneck vs. Shamos-Hoey (1975) Divide-and-Conquer

Given $N$ points $P = \{p_0, p_1, \dots, p_{N-1}\}$ in the Euclidean plane $\mathbb{R}^2$, the Closest Pair of Points problem asks for the minimum Euclidean distance $\min_{i \neq j} \|p_i - p_j\|_2$. Exhaustive pairwise inspection requires $\binom{N}{2} = \Theta(N^2)$ distance calculations. Can we apply spatial Divide-and-Conquer like Merge Sort to reach $O(N \log N)$?

  1. Divide: Presort all points by $X$-coordinate ($P_X$) and by $Y$-coordinate ($P_Y$) once in $O(N \log N)$ time. At each recursive step, split $P_X$ at the median index $m = \lfloor N / 2 \rfloor$ with vertical dividing line $x = x_m$ into $P_L$ and $P_R$. Partition $P_Y$ in $O(N)$ time into $Y$-sorted subsets $P_{YL}$ and $P_{YR}$.
  2. Conquer: Recursively find the minimum distance $\delta_L$ in $P_L$ and $\delta_R$ in $P_R$. Let $\delta = \min(\delta_L, \delta_R)$.
  3. Combine (The $2\delta$ Strip): Any pair $(p_l, p_r)$ with $p_l \in P_L, p_r \in P_R$ that beats $\delta$ must have both points within horizontal distance $\delta$ of the dividing line $x = x_m$. Filter $P_Y$ in $O(N)$ time (preserving $Y$-sorted order) into a strip array $S = \{p \in P_Y : |p.x - x_m| < \delta\}$.
Planar Closest Pair Divide-and-Conquer & 7-Neighbor 2δ × δ Strip
O(N log N) · ≤ 7 Strip Checks

See why the y-sorted strip [x_mid - δ, x_mid + δ] requires checking at most 7 subsequent points per point: a 2δ × δ box splits into eight (δ/2) × (δ/2) squares of diameter δ/√2 < δ.

Step 1 / 7
Custom Input:
Timeline

Invariant & State

Pigeonhole Packing Lemma: Each (δ/2) × (δ/2) cell lies entirely within L or R and has diagonal δ/√2 < δ. Since min distance within L and R is δ, each of the 8 cells holds ≤ 1 point!
p_i p_j (d < δ!) 8 Sub-Squares of size (δ/2)×(δ/2): Diagonal = δ/√2 < δ ⇒ At most 1 point per square (≤ 7 others)
Strip Width / Height
2δ × δ (8 cells of δ/2 × δ/2)
Cell Diagonal
δ / √2 ≈ 0.707δ < δ
Inner Loop Bound
≤ 7 comparisons per point ⇒ O(N)
[Step 1] Filtered y-sorted points within |x - x_mid| < δ and checked at most 7 neighbors per point in O(N) total time.
Algorithm / Variant Time Complexity Auxiliary Space Combine Step Cost per Level
Naive All-Pairs Search $\Theta(N^2)$ $O(1)$ N/A
D&C Re-sorting $Y$ at Every Level $\Theta(N \log^2 N)$ $O(N)$ $O(N \log N)$ sort of strip $S$
Shamos-Hoey D&C (Merge-Sort on $Y$) $\Theta(N \log N)$ $O(N)$ $O(N)$ strip filter + $\le 7N$ comparisons
Rabin (1976) Randomized Grid Hashing $\Theta(N)$ expected $O(N)$ hash table $O(1)$ expected ($3 \times 3$ grid cells of side $\delta$)
The 7-Neighbor Geometric Packing Lemma

Suppose we iterate through the strip $S$ in ascending order of $Y$-coordinate. For any point $p = S[i]$ with vertical coordinate $y_0$, any subsequent point $q = S[j]$ ($j > i$) with $\|p - q\|_2 < \delta$ must lie inside the bounding rectangle $R = [x_m - \delta, x_m + \delta] \times [y_0, y_0 + \delta]$ of width $2\delta$ and height $\delta$.

Partition $R$ into eight squares of side $\frac{\delta}{2} \times \frac{\delta}{2}$ (four in the left half $x \le x_m$, four in the right half $x \ge x_m$). The maximum distance between any two points inside the same $\frac{\delta}{2} \times \frac{\delta}{2}$ square is its diagonal:

$$\text{diam}\left(\frac{\delta}{2} \times \frac{\delta}{2}\right) = \sqrt{\left(\frac{\delta}{2}\right)^2 + \left(\frac{\delta}{2}\right)^2} = \frac{\delta}{\sqrt{2}} \approx 0.7071\delta < \delta$$

Because every pair of points inside $P_L$ has distance $\ge \delta_L \ge \delta$ and every pair inside $P_R$ has distance $\ge \delta_R \ge \delta$, at most one point can occupy each of the 8 squares! Counting $S[i]$ itself as occupying one square, at most 7 subsequent points $S[i+1], \dots, S[i+7]$ can lie inside $R$.

The 2δ × δ Bounding Box Partitioned into Eight (δ/2 × δ/2) Pigeonhole Squares x = x_m δ / √2 < δ S[i] (y_0) ← δ (Left Half P_L) → ← δ (Right Half P_R) → δ/2 δ/2
flowchart TD A["Presort Points by X -> P_X Count N; Allocate Buffer B"] --> B["Recurse on Left [lo, mid) -> delta_L Recurse on Right [mid, hi) -> delta_R"] B --> C["delta = min(delta_L, delta_R) std::merge Left & Right by Y-Coordinate"] C --> D["Filter Y-Sorted Range into Strip S where |p.x - x_mid| < delta"] D --> E["For each S[i], check S[j] (j = i+1..i+7) Stop early as soon as S[j].y - S[i].y >= delta"]
Hardware Reality: Combining Bottom-Up Y-Merge vs. Top-Down Partitioning

Instead of passing down a pre-sorted $P_Y$ vector and partitioning it on the way down (which requires scattered writes depending on whether a point belongs to $P_L$ or $P_R$), production implementations perform the $Y$-sort bottom-up during the unwind phase—identical to standard Merge Sort! After recursive calls on $[lo, mid)$ and $[mid, hi)$ return $\delta_L$ and $\delta_R$ AND leave both subarrays sorted by $Y$, a single streaming std::merge by $Y$ takes $O(N)$ sequential L1-cached reads, followed immediately by the strip filter and 7-neighbor scan. Furthermore, comparing squared Euclidean distances $\Delta x^2 + \Delta y^2 < \delta^2$ inside the inner loop avoids hardware sqrtpd latency (~12–15 cycles) until the final return.

Production C++20 Implementation: $O(N \log N)$ Closest Pair of Points

#include <algorithm>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <limits>
#include <vector>

struct Point2D {
    double x;
    double y;
    std::uint32_t id;
};

struct ClosestPairResult {
    double min_dist;
    std::uint32_t id1;
    std::uint32_t id2;
};

inline double dist_sq(const Point2D& a, const Point2D& b) noexcept {
    const double dx = a.x - b.x;
    const double dy = a.y - b.y;
    return dx * dx + dy * dy;
}

ClosestPairResult solve_closest_pair(std::vector<Point2D>& pts) {
    const std::size_t n = pts.size();
    if (n < 2) return {std::numeric_limits<double>::infinity(), 0, 0};

    // 1. Sort initially by X-coordinate (tie-break by Y)
    std::sort(pts.begin(), pts.end(), [](const Point2D& a, const Point2D& b) {
        return (a.x != b.x) ? (a.x < b.x) : (a.y < b.y);
    });

    std::vector<Point2D> scratch(n);
    std::vector<Point2D> strip(n);
    double best_d2 = std::numeric_limits<double>::infinity();
    std::uint32_t best_u = 0, best_v = 0;

    auto update_best = [&](const Point2D& a, const Point2D& b) noexcept {
        const double d2 = dist_sq(a, b);
        if (d2 < best_d2) {
            best_d2 = d2;
            best_u = a.id;
            best_v = b.id;
        }
    };

    auto rec = [&](auto& self, std::size_t lo, std::size_t hi) -> void {
        if (hi - lo <= 8) {
            // Small base case: O(1) brute force + insertion sort by Y
            for (std::size_t i = lo; i < hi; ++i) {
                for (std::size_t j = i + 1; j < hi; ++j) {
                    update_best(pts[i], pts[j]);
                }
            }
            std::sort(pts.begin() + lo, pts.begin() + hi,
                      [](const Point2D& a, const Point2D& b) { return a.y < b.y; });
            return;
        }

        const std::size_t mid = lo + (hi - lo) / 2;
        const double mid_x = pts[mid].x;

        self(self, lo, mid);
        self(self, mid, hi);

        // Merge [lo, mid) and [mid, hi) by Y-coordinate in O(N) time
        std::merge(pts.begin() + lo, pts.begin() + mid,
                   pts.begin() + mid, pts.begin() + hi,
                   scratch.begin() + lo,
                   [](const Point2D& a, const Point2D& b) { return a.y < b.y; });
        std::copy(scratch.begin() + lo, scratch.begin() + hi, pts.begin() + lo);

        // Collect points within horizontal distance sqrt(best_d2) of mid_x
        std::size_t s_len = 0;
        for (std::size_t i = lo; i < hi; ++i) {
            const double dx = pts[i].x - mid_x;
            if (dx * dx < best_d2) {
                strip[s_len++] = pts[i];
            }
        }

        // Scan Y-sorted strip: inner loop executes at most 7 times per point!
        for (std::size_t i = 0; i < s_len; ++i) {
            for (std::size_t j = i + 1; j < s_len; ++j) {
                const double dy = strip[j].y - strip[i].y;
                if (dy * dy >= best_d2) break; // Early exit on vertical distance
                update_best(strip[i], strip[j]);
            }
        }
    };

    rec(rec, 0, n);
    return {std::sqrt(best_d2), best_u, best_v};
}
Pitfall: Splitting by Coordinate Value Instead of Array Index

Suppose $\frac{N}{2}$ points lie on the exact same vertical line $x = x_m$. If you partition $P_Y$ using the predicate p.x < mid_x vs. p.x >= mid_x, all collinear points fall into the right partition, causing an unbalanced $O(N^2)$ recursion tree! Either split strictly by original array index / identity when top-down partitioning $P_Y$, or use the bottom-up std::merge by $Y$ shown above, which always splits indices at mid = lo + (hi - lo) / 2 regardless of duplicate $X$-coordinates.

Check yourself Question 1 of 4
Concept Check The 2-Delta Vertical Strip Filter recall

In the Shamos-Hoey planar closest-pair divide-and-conquer algorithm, the left and right recursive calls return minimum distances delta_L and delta_R around dividing line x = x_m, and we set delta = min(delta_L, delta_R).

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

How can an adversarial point cloud cause this top-down partitioning to crash or degrade to Theta(N^2) time, and how is it prevented?

Key Takeaways

Sorting by X initially and merging by Y bottom-up during recursion satisfies T(N) = 2T(N/2) + O(N) = O(N log N).
Only points with |p.x - x_mid| < delta can form a cross-boundary pair closer than delta = min(delta_L, delta_R).
Partitioning the 2*delta x delta bounding box into eight (delta/2 x delta/2) squares of diagonal delta/sqrt(2) < delta proves that each point in the Y-sorted strip needs to check at most 7 subsequent points.
Tracking squared distance best_d2 eliminates slow hardware square-root instructions from the recursive inner loop.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue