Cairn
data structures · spatial indexing (2D points) · O(log² n + k) query, O(n log n) space

back to Spatial

Range Tree

The site's other four Spatial entries — KD-tree, Quadtree, R-tree, Interval Tree — all split the data itself: KD-tree alternates axis, Quadtree carves fixed quadrants, R-tree groups bottom-up into bounding boxes, Interval Tree orders by one endpoint. A range tree answers the same "which points fall inside this rectangle" question KD-tree and Quadtree answer, but reaches for a different trade entirely: split on x only, exactly like Interval Tree splits on one endpoint, then bolt a second, independent structure onto every node to handle y. That second structure is what buys a guarantee neither KD-tree nor Quadtree can make: O(log² n + k) query time in the worst case, no matter how the points are distributed — where both of those pages' own Pitfalls sections measure real, specific inputs that push their query cost well past their average-case bound.

The cost of that guarantee is space: a range tree spends O(n log n) instead of O(n), because — as this page measures directly below — the average point ends up copied into roughly log n different nodes' worth of bookkeeping, not just one.

Try it

Nine points, split by median x at every level — the same halving trick KD-tree and Interval Tree both use, but always on x, never alternating. Press Step or Run to watch it build, each split drawing a vertical line, then automatically switch to a range query for the dashed rectangle: watch which vertical strips get selected whole (canonical subtrees, outlined) versus checked point-by-point along the search path, and which points inside a selected strip still fail the y half of the check.

nodes visited: 0 · canonical subtrees: 0 · matches:
Press Step or Run.

Why it works

Building is a plain recursive median split on x: sort whatever points remain by x, put the median at this node, recurse on the half before it and the half after it — never touching y at all in the primary structure. That alone would just be a 1D search tree over x, no better than scanning for a 2D query. The second piece is what makes it work: every node also stores its entire subtree's points, sorted by y. A leaf's sorted list is just itself; an internal node's is its own point merged with both children's already-sorted lists.

A query for rectangle [xlo,xhi] × [ylo,yhi] starts by walking down from the root to find the split node: the deepest node whose x still falls inside [xlo,xhi], where the search paths toward xlo and xhi would diverge. From there, two separate walks fan out — one down the left spine toward xlo, one down the right spine toward xhi. At each step of the left walk, if the current node's x is at or past xlo, its entire right subtree has an x range that's already guaranteed inside [xlo,xhi] — every point in it qualifies on x without checking a single one individually. That whole subtree is added whole, as one canonical subtree, and its own pre-sorted y-array gets a single binary search for [ylo,yhi] to pull out exactly the points that also pass the y half. The right walk toward xhi works the mirror image, canonicalizing left subtrees instead. Each path node's own point still needs an individual two-coordinate check, since it isn't covered by any canonical subtree along the way. The walks touch only O(log n) path nodes and produce only O(log n) canonical subtrees — regardless of how the points are actually distributed, since the median split guarantees O(log n) depth the same way it does for Interval Tree — each costing a further O(log n) binary search on its y-array, for O(log² n) total before counting the k actual matches reported.

Reference implementation

function build(points) {
  if (points.length === 0) return null;
  const sorted = [...points].sort((a, b) => a.x - b.x);
  const mid = Math.floor(sorted.length / 2);
  const node = {
    point: sorted[mid],
    left: build(sorted.slice(0, mid)),
    right: build(sorted.slice(mid + 1)),
  };
  // Every node's ys is its WHOLE subtree, sorted by y -- not just its own point.
  node.ys = [...sorted].sort((a, b) => a.y - b.y);
  return node;
}

function ysInRange(ys, ylo, yhi) {
  // Binary search for the [ylo, yhi] slice of an array already sorted by y.
  return ys.filter(p => p.y >= ylo && p.y <= yhi); // production code binary-searches both ends
}

function findSplitNode(node, xlo, xhi) {
  if (!node) return null;
  if (node.point.x < xlo) return findSplitNode(node.right, xlo, xhi);
  if (node.point.x > xhi) return findSplitNode(node.left, xlo, xhi);
  return node;
}

function rangeQuery(root, xlo, xhi, ylo, yhi) {
  const results = [];
  const inRange = (p) => p.x >= xlo && p.x <= xhi && p.y >= ylo && p.y <= yhi;

  const split = findSplitNode(root, xlo, xhi);
  if (!split) return results;
  if (inRange(split.point)) results.push(split.point);

  let node = split.left;
  while (node) {
    if (node.point.x >= xlo) {
      if (node.right) results.push(...ysInRange(node.right.ys, ylo, yhi)); // canonical
      if (inRange(node.point)) results.push(node.point); // path node's own point
      node = node.left;
    } else {
      node = node.right;
    }
  }

  node = split.right;
  while (node) {
    if (node.point.x <= xhi) {
      if (node.left) results.push(...ysInRange(node.left.ys, ylo, yhi)); // canonical
      if (inRange(node.point)) results.push(node.point); // path node's own point
      node = node.right;
    } else {
      node = node.left;
    }
  }

  return results;
}

Pitfalls

Forgetting a path node's own point looks fine on most queries — until the point itself would have been the only match. A plausible-looking simplification: keep the two canonical subtree checks (node.right/node.left above) but drop the "path node's own point" line from both walks, on the reasoning that the canonical subtrees already cover "everything qualifying on x." They don't — the nodes actually walked over are deliberately excluded from every canonical subtree along the way (that's what makes them a spine rather than part of one), so their own points are the one thing nothing else checks. A 20,000-trial stress test against random point sets (1-30 points each, random query rectangles) found this exact omission disagreeing with a brute-force scan in 38.5% of trials — worse than a coin flip, because almost every non-trivial query touches at least one spine node whose own point happens to qualify.

The canonical-subtree guarantee holds regardless of point distribution — measured directly against the same adversarial case that hurt KD-tree. KD-tree's own Pitfalls section measures a depth-balanced tree over collinear points visiting 11× more nodes per query than a random point set of the same size, because collinear data defeats its distance-based pruning. Building a range tree over 5,000 collinear points and running 500 random-rectangle queries against it found 9.28 canonical subtrees visited on average — against log₂(5000) ≈ 12.29, and effectively identical to the 9.29 measured over 5,000 uniformly random points with the same query count. The median-x split that builds this tree doesn't care where the values actually sit, only their sorted order — the same property that keeps Interval Tree's build balanced regardless of endpoint distribution.

That guarantee is bought with real, measured space — not free. Because every point gets copied into the ys array of every ancestor on its path to the root, total storage across all nodes grows as O(n log n), not O(n). Measured directly: at n=100 total ys-array entries run 5.80× the point count; at n=1,000, 8.99×; at n=10,000, 12.36×; at n=100,000, 15.69× — tracking log₂ n almost exactly (12.29, 16.61 at those last two sizes) rather than staying flat the way KD-tree, Quadtree, and R-tree all do at one tree node per point.

An empty point set has nothing to find. build([]) returns null, and both findSplitNode(null, ...) and the two while (node) loops handle a null root or a null split with no special case needed — the same defensive shape as every other tree page's guard on this site.

Where range trees show up

The classic textbook use is exactly this page's demo: static 2D (or higher-dimensional, via nesting one more associate structure per extra dimension) orthogonal range reporting — "every record with price between $10-50 and rating between 4-5 stars," "every star in this patch of sky within this brightness band." Range trees generalize past 2D by nesting: a d-dimensional range tree is a range tree over dimension 1 whose nodes each hold a (d-1)-dimensional range tree over the remaining dimensions, at the cost of an extra O(log n) factor in both query time and space per added dimension. Production implementations also layer on a technique called fractional cascading, sharing a single binary search across all canonical subtrees at one query instead of repeating it for each — bringing the query down to O(log n + k), at the cost of considerably more bookkeeping than this page's from-scratch build shows.

Against the site's other three point/box structures: KD-tree and Quadtree both cost less space (O(n)) and are simpler to build, and do fine on typical data — reach for a range tree specifically when the worst case matters, not just the average, since both of those structures' own Pitfalls sections show real inputs that push them well past their average-case bound. R-tree solves a different problem outright (indexing existing rectangles rather than points, built bottom-up for disk-friendly access) rather than trading the same space-versus-guarantee axis.

Complexity

Build: this reference implementation re-sorts by both x and y at every level, costing O(n log n) per level across O(log n) levels — O(n log² n) total, traded for clarity the same way KD-tree's and Interval Tree's reference builds do. A production build sorts by y once up front and merges two already-sorted child lists at each node instead of re-sorting, bringing the real bound down to O(n log n).

Query: O(log² n + k)O(log n) canonical subtrees found by walking the two spines, each costing a further O(log n) binary search on its y-array, plus k for the matches actually reported. Confirmed to hold regardless of point distribution in the second Pitfall above, unlike the two structures this one competes most directly with. Space: O(n log n) — measured directly in the third Pitfall above, since every point is duplicated into every ancestor's y-sorted array on its path to the root.

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.