Cairn
data structures · spatial indexing · avg O(log n) query, O(n) worst case

back to Spatial

KD-Tree

Every search tree on this site so far orders nodes by a single key: a number, a string, one comparable value. That works because a 1D key has a natural total order — "less than" always means something. A 2D point doesn't: is (3, 9) less than (7, 2)? Less in x, greater in y — there's no single answer, so there's no obvious way to drop points into a plain binary search tree and keep its ordering guarantees. A KD-tree (k-dimensional tree) sidesteps the problem by not picking one order at all: it alternates. The root splits the point set on x, the root's children split on y, their children split on x again, and so on — each level cycling through the dimensions, so no single axis ever has to carry the whole ordering by itself.

This opens a new Spatial category — the site's Geometry algorithms (Closest Pair of Points, Delaunay Triangulation, Fortune's Algorithm, and six others) all operate on a fixed point set once; a KD-tree is what you build when the same point set needs to answer many nearest-neighbor or region queries after that, without re-scanning every point from scratch each time.

Try it

Eleven points, split alternately on x then y by median at each level. Press Step or Run to watch the tree get built one split at a time — each split draws a line clipped to the region it's dividing, the classic KD-tree "carved-up plane" picture. Once the tree is built, the demo automatically switches to a nearest-neighbor query for the square point Q: watch the search descend toward Q first, track the best point found so far (dashed circle = current search radius), and backtrack into a sibling subtree only when that subtree's splitting plane is still closer than the best distance found — everything else gets skipped and fades out.

nodes visited: 0 · best so far:
Press Step or Run.

Why it works

Building the tree is recursive median partitioning: sort the current point set by whichever axis this level splits on, put the median point at this node, and recurse on the points before it (one child) and after it (the other child), flipping to the next axis each time. Because the split is always at the median, each recursive call gets roughly half the points its parent had — the same halving that keeps a balanced BST at O(log n) depth, just alternating which coordinate does the halving.

Searching for the nearest neighbor of a query point starts the same way a BST search would: at each node, compare the query against that node's splitting axis and descend toward whichever side the query falls on — this reaches a small region actually containing the query point in O(log n), and every point visited along the way is a legitimate candidate for "best so far." The part that makes this more than a BST lookup is the trip back up: at each node on the way back, the *other* child — the one not descended into — might still hold something closer, because being on the wrong side of one split doesn't mean being far away in actual distance. The fix is the same "distance to a boundary, not to any specific point" argument Closest Pair of Points uses for its strip check: if the query's perpendicular distance to this node's splitting plane already exceeds the best distance found so far, then every point on the far side is provably farther than that plane, which is farther than the current best — the entire subtree can be skipped without looking at a single point in it. Only when the plane is still closer than the best-so-far does the far side get a visit.

Reference implementation

function buildKDTree(points, depth = 0) {
  if (points.length === 0) return null;
  const axis = depth % 2; // 0 = x, 1 = y — alternate every level

  const sorted = [...points].sort((a, b) => a[axis] - b[axis]);
  const mid = Math.floor(sorted.length / 2);

  return {
    point: sorted[mid],
    axis,
    left: buildKDTree(sorted.slice(0, mid), depth + 1),
    right: buildKDTree(sorted.slice(mid + 1), depth + 1),
  };
}

function dist2(a, b) {
  const dx = a[0] - b[0], dy = a[1] - b[1];
  return dx * dx + dy * dy; // skip the sqrt — only relative order matters
}

function nearestNeighbor(root, target) {
  let best = null;
  let bestD2 = Infinity;

  function visit(node) {
    if (!node) return;

    const d2 = dist2(node.point, target);
    if (d2 < bestD2) { bestD2 = d2; best = node.point; }

    const axis = node.axis;
    const diff = target[axis] - node.point[axis];
    const nearSide = diff <= 0 ? node.left : node.right;
    const farSide = diff <= 0 ? node.right : node.left;

    visit(nearSide); // the side the query actually falls on, always worth checking

    // The far side only matters if the splitting plane itself is closer than
    // the best distance found so far -- see "Why it works" above.
    if (diff * diff < bestD2) {
      visit(farSide);
    }
  }

  visit(root);
  return best;
}

Pitfalls

Skipping the backtrack check still terminates and still looks plausible — it's just wrong. A version of nearestNeighbor that only ever calls visit(nearSide) and never checks the far side at all is shorter, always finishes, and returns a nearby point — just not reliably the nearest one. Checked directly on this page's own 11 points: querying for the point nearest Q (this page's demo target), the real algorithm correctly backtracks into a sibling subtree and returns K at distance 53.85; the no-backtrack version never looks at K's subtree at all and confidently returns C at distance 80.62 instead — 50% farther than the real answer, not a rounding difference. This isn't a rare edge case either: a 20,000-trial stress test against random point sets (1 to 30 points each) found the no-backtrack version disagreeing with a brute-force linear scan in 3,193 trials — 16% of the time. The full version with the backtrack check matched brute force in all 20,000.

A depth-balanced tree does not mean a query stays cheap. Median-by-index splitting keeps the tree's depth balanced regardless of where the values actually fall — even if every point shares the same y coordinate, sorting by y and taking the middle index still produces a perfectly balanced split, because "median" here means median position in the sorted order, not median of a spread-out range. But a split on an axis with zero (or near-zero) variance prunes nothing: the query's perpendicular distance to that plane is always ~0, so the "skip the far side" check in nearestNeighbor almost never fires, and the search ends up visiting nearly everything under that node anyway. Measured directly: building trees over both a random 2D point set and a collinear point set (all points on one line) of the same size, both come out with identical depth at every size tested (14 levels at n=12,800) — but the average nodes visited per nearest-neighbor query is 20.1 for the random set versus 222.5 for the collinear one, over 200 queries each. Same tree shape, roughly 11× more work, because the data itself — not the tree — stopped giving the pruning check anything to work with.

An empty point set has no nearest neighbor to find. buildKDTree([]) returns null, and nearestNeighbor(null, target) correctly returns null right back rather than throwing — visit's first line is exactly this guard. Callers that assume the result is never null are the ones that need to handle it, the same shape of bug Closest Pair of Points' own Pitfalls section flags for a zero- or one-point input.

Where KD-trees show up

Anywhere the same point set gets queried repeatedly instead of processed once: nearest-neighbor classification in machine learning (find the k closest labeled examples to a new point), collision and proximity checks in games and physics engines, GIS "find the nearest gas station" queries, and as an acceleration structure for ray tracing (which object does this ray hit first). The common thread is amortization — building the tree costs O(n log n) once, and every query after that is cheap, which only pays off if there are enough queries to amortize the build over. For a single one-off "which two points are closest" question over a fixed set, Closest Pair of Points' direct divide-and-conquer is the right tool instead — no tree to maintain.

Not to be confused with the randomized incremental trapezoidal map named as still-unbuilt future work on this site's Point in Polygon and Slab Decomposition pages: that structure answers "which region of a planar subdivision contains this point" (point-location among possibly-overlapping polygons/edges), a genuinely different question from "which of these individual points is nearest" — this page doesn't close that reference.

Complexity

Build: each level halves the point count and sorts the current subset to find the median, so — like Closest Pair of Points' own strip re-sort — this reference implementation costs O(n log n) per level across O(log n) levels, i.e. O(n log² n) total, traded for clarity. A production implementation finds the median with a linear-time selection algorithm (quickselect or nth_element) instead of a full sort, which brings the real bound down to O(n log n).

Query: commonly stated as O(log n) on average, for data spread reasonably across both axes — confirmed directly across the same 20,000-trial stress test above: the real algorithm visited an average of 5.70 nodes against an average set size of 15.52, roughly 2.7× fewer than a full linear scan would need. This page's own 11-point demo visits 6 nodes to answer its query. But average-case is not worst-case: the second Pitfall above measures the same query degrading to visiting the large majority of nodes when the data doesn't actually vary along one of the two axes, even though the tree's depth stays exactly as balanced as the random case's tree. Space: O(n) — one tree node per input point.

This site's guide, Choosing a Spatial Structure, compares this entry against the other seven Spatial entries that share the same "index points, query later" question side by side.