Multidimensional Indexes
A ride-sharing platform must answer a deceptively simple question every second. Which drivers are within two kilometers of this rider? A geographic information system must find every parcel of land. It must locate parcels that overlap a proposed highway route. A recommendation engine must locate the nearest neighbors of a user's embedding vector. It searches among millions of points. Each of these is a multidimensional query. This is a search over two or more attributes simultaneously. Each defeats the humble B+-tree. A B+-tree imposes a single total order on keys. This works beautifully when queries ask "find all rows with `salary` between X and Y." But latitude and longitude have no natural combined order. No such order preserves proximity in both dimensions at once. Concatenating them into one key performs badly. Building two separate single-attribute indexes and intersecting the results also performs badly. The first approach destroys locality. The second forces the system to fetch and merge two large candidate sets. That is worse than handling one small set. Real spatial and multi-attribute workloads need index structures purpose-built for regions, ranges, and nearest-neighbor search in dimensions. This article covers the three classical answers to this problem. The grid file partitions space into a coarse grid with a directory. The k-d tree recursively bisects space along alternating dimensions. The R-tree groups nearby objects into a hierarchy of bounding boxes. The R-tree is the structure most production spatial databases actually use. Each embodies a different tradeoff between build cost and query cost. Each also differs in how gracefully it degrades as dimensionality grows. By the end you will understand how each structure organizes disk pages. You will learn how to trace a range query through each one. You will also learn how to trace a nearest-neighbor query through each one. You will see why R-trees dominate in practice for disk-resident spatial data. Finally, you will understand why all multidimensional indexes suffer from the curse of dimensionality. This limitation motivates the index-selection tradeoffs discussed when choosing indexes for a real workload.
- Multidimensional indexes support region queries (range/window search) and nearest-neighbor queries over two or more attributes — tasks a B+-tree cannot do well.
- Grid files overlay a coarse grid on the attribute space and use a directory to map grid cells to disk buckets; good for uniform data, poor under skew.
- k-d trees recursively split space along one dimension at a time, alternating dimensions by depth; simple and elegant but page-unfriendly and hard to keep balanced under updates.
- R-trees organize data into a height-balanced tree of (possibly overlapping) minimum bounding rectangles, generalizing the B+-tree to spatial extents; they are the dominant disk-based spatial index in real systems (PostGIS, Oracle Spatial, SQLite R-Tree module).
- All three structures answer range and nearest-neighbor queries by pruning search using bounding geometry, but their guarantees degrade as dimensionality grows — the curse of dimensionality.
- Choosing among grid files, k-d trees, and R-trees (or abandoning tree indexes for a linear scan) is itself an instance of the broader problem of Index Selection.
Multidimensional indexes replace the B+-tree's single sort order with spatial partitioning schemes — grids, recursive splits, or bounding-box hierarchies — that let a query prune large regions of space that cannot possibly contain an answer.
The idea
A multidimensional index organizes records described by two or more attributes so that a query engine can efficiently answer two families of queries beyond simple equality lookup. A region query (also called a window query or range query) asks for all records whose attribute vector lies inside a given box, circle, or polygon. A nearest-neighbor (NN) query asks for the record(s) closest to a given point under some distance metric, typically Euclidean distance. Both queries are ubiquitous in geographic information systems, computer-aided design, computer vision (nearest neighbors in feature space), and any relational query with a `WHERE` clause that constrains several correlated columns at once. The fundamental obstacle is that a total order, the mechanism a B+-tree relies on, does not exist in more than one dimension in any way that preserves proximity. You can linearize a plane by scanning row by row, but two points that are close in 2-D (say, diagonally adjacent) can be arbitrarily far apart in the linearization. Multidimensional structures instead partition the data space directly, using geometric objects — grid cells, half-space splits, or minimum bounding rectangles (MBRs) — as the unit of indexing. A search then walks the structure, discarding any partition whose geometry cannot intersect the query region; this is called spatial pruning, and it is the multidimensional analogue of a B+-tree's ability to skip subtrees whose key range excludes the search key. Three structures dominate the classical curriculum. The grid file is a direct generalization of extendible hashing to multiple dimensions: a coarse grid divides space into cells, and a directory maps each cell to a disk bucket, allowing the grid lines to be spaced non-uniformly to absorb data skew. The k-d tree is a binary tree that recursively bisects the data space, alternating the splitting dimension at each depth; it is simple, elegant, and excellent as a main-memory structure but less naturally suited to disk pages. The R-tree generalizes the B+-tree by making each internal entry a bounding rectangle enclosing all the geometry beneath it in the tree; unlike a B+-tree's key ranges, sibling MBRs in an R-tree are allowed to overlap, which is the source of both its flexibility and its central algorithmic challenge.
How it works
Grid files
A grid file partitions the domain of each indexed dimension into intervals, forming a grid whose cells are grouped into disk buckets via a directory. Unlike a fixed uniform grid, the dividers themselves adapt: when a bucket overflows, the system can split a grid line, which affects an entire row or column of cells rather than a single cell, keeping the directory structured as a full grid rather than an unbalanced tree. ``` GRID-FILE-SEARCH(query_point q): // Locate the cell containing q using the linear scales for each dimension for each dimension d: i_d = LINEAR-SCALE-LOOKUP(scale[d], q[d]) // O(log n_d) via binary search bucket = directory[i_1][i_2]...[i_k] return SCAN(bucket) // one disk I/O (usually) GRID-FILE-INSERT(record r): bucket = GRID-FILE-SEARCH(r.key) if bucket has space: insert r into bucket else: // split the bucket: choose a dimension, insert a new grid line pick dimension d to split (e.g., round-robin or by variance) insert new boundary value into scale[d] redistribute directory pointers (may double directory along d) redistribute records between old and new bucket ``` A region query (a bounding box) is answered by computing the range of grid indices the box overlaps in each dimension, then visiting every bucket in that sub-grid — an operation that costs one I/O per bucket touched, with buckets outside the query box never visited.
k-d trees
A k-d tree is a binary search tree over points in -dimensional space. Each internal node stores a splitting dimension and a splitting value; it partitions its subspace into two half-spaces, sending points with a smaller coordinate to the left child and the rest to the right. The splitting dimension cycles with depth (dimension 0 at the root, dimension 1 at depth 1, dimension 0 again at depth 2, and so on for 2-D; more generally `depth mod k`). ``` KD-INSERT(node, point p, depth): if node is null: return new leaf storing p d = depth mod k if p[d] < node.splitValue: node.left = KD-INSERT(node.left, p, depth+1) else: node.right = KD-INSERT(node.right, p, depth+1) return node KD-RANGE-SEARCH(node, box, depth): if node is null: return {} result = {} if node.point is inside box: result.add(node.point) d = depth mod k if box.min[d] <= node.splitValue: result += KD-RANGE-SEARCH(node.left, box, depth+1) if box.max[d] >= node.splitValue: result += KD-RANGE-SEARCH(node.right, box, depth+1) return result KD-NEAREST-NEIGHBOR(node, target, depth, best): if node is null: return best if distance(node.point, target) < distance(best, target): best = node.point d = depth mod k (near, far) = (node.left, node.right) if target[d] < node.splitValue else (node.right, node.left) best = KD-NEAREST-NEIGHBOR(near, target, depth+1, best) // only descend into the far side if its region could still // contain a closer point than the current best if |target[d] - node.splitValue| < distance(best, target): best = KD-NEAREST-NEIGHBOR(far, target, depth+1, best) return best ``` The nearest-neighbor pruning rule — comparing the distance to the current best against the distance to the splitting hyperplane — is the crux of why k-d trees can beat a linear scan: an entire subtree is skipped whenever its bounding hyperplane is already farther away than the best answer found so far.
R-trees
An R-tree is a height-balanced tree, structurally similar to a B+-tree, in which every entry (internal or leaf) is paired with a minimum bounding rectangle (MBR). A leaf entry pairs an MBR with a pointer to (or the coordinates of) an actual data object; an internal entry pairs an MBR with a pointer to a child page, and that MBR tightly encloses the MBRs of everything in the subtree below it. Unlike B+-tree key ranges, MBRs of sibling entries may overlap, because two disjoint rectangles at one level can both legitimately need to enclose overlapping geometry beneath them. Every node holds between and entries (parameters analogous to a B+-tree's fanout bounds, with ), except the root, which may hold fewer than . ``` RTREE-SEARCH(node, queryBox): result = {} if node is a leaf: for entry in node.entries: if entry.MBR intersects queryBox: result.add(entry.object) else: for entry in node.entries: if entry.MBR intersects queryBox: result += RTREE-SEARCH(entry.childPage, queryBox) return result RTREE-INSERT(root, object o): leaf = CHOOSE-LEAF(root, o.MBR) if leaf has room: add (o.MBR, o) to leaf else: (leaf1, leaf2) = SPLIT-NODE(leaf, o) propagate split upward, adjusting/creating parent entries if root itself split: create new root with two children // update MBRs of all nodes on the insertion path so they // tightly enclose their (possibly enlarged) children ADJUST-TREE(leaf-to-root path) CHOOSE-LEAF(node, mbr): if node is a leaf: return node // pick the child entry requiring least MBR enlargement // to include mbr; break ties by smaller resulting area best = argmin over entries e of ENLARGEMENT(e.MBR, mbr) return CHOOSE-LEAF(best.childPage, mbr) ``` Insertion mirrors a B+-tree: descend to a leaf, insert, split on overflow, and propagate splits upward, possibly growing the tree by one level at the root. The R-tree-specific step is CHOOSE-LEAF, which must decide which subtree to descend into using a geometric heuristic (least MBR enlargement) rather than a simple key comparison, and SPLIT-NODE, which must partition entries into two groups whose bounding boxes are small and preferably non-overlapping — a combinatorial optimization problem, usually solved heuristically (the classic quadratic-cost split picks the two "most different" seed entries and grows two groups greedily).
Complexity & correctness
Grid files. Locating the bucket for a point takes time per dimension via binary search on that dimension's scale (or with an array offset if scales are stored densely), so a point query costs CPU work plus one disk I/O to fetch the bucket — matching a B+-tree's single-page point lookup when data is uniformly distributed. A region query touches every bucket overlapping the query box; in the worst case this is proportional to the volume of the query box relative to cell size, which is efficient for small, localized queries but can degrade for large or thin diagonal query regions that clip many cells. The directory itself, however, can grow superlinearly in the number of records under skewed data: splitting one grid line to fix one crowded bucket affects an entire hyperplane of cells, potentially creating many nearly-empty buckets elsewhere. This is the grid file's chief weakness — directory size is not guaranteed to stay proportional to under adversarial or highly skewed distributions.
k-d trees. For points inserted in reasonably random order, expected tree height is , giving expected point lookup and expected time for orthogonal range queries in 2-D (more generally in dimensions), where is the output size — the classical result for range trees restricted to axis-aligned k-d partitioning. The intuition: at each level, roughly half the subtree is pruned by the query box unless the box straddles the splitting hyperplane, and the number of "boundary" nodes that must be visited on both sides grows sublinearly. Nearest-neighbor search has expected cost for uniformly distributed data in low dimensions, using the pruning test `|target[d] - splitValue| < distance-to-best` to skip subtrees whose entire region lies farther than the current best candidate. The correctness of this pruning rule follows directly from the triangle inequality: if every point in a subtree's region is provably farther from the target than the current best distance, no point in that subtree can improve the answer. The major caveat is that a plain k-d tree built by naive sequential insertion is only balanced in expectation; adversarial insertion orders (e.g., sorted input) can produce height, degrading every operation to linear time, and there is no simple local rebalancing analogous to a B+-tree's split/merge — rebuilding the whole tree is the usual remedy. k-d trees also do not natively map to fixed-size disk pages the way B+-trees and R-trees do, which is why disk-resident spatial systems favor R-trees.
R-trees. Because an R-tree is height-balanced with fanout bounded between and , tree height is , exactly like a B+-tree, giving disk I/Os in the best case for a point/small-region query. The catch is that R-tree sibling entries can overlap, so a query may need to descend into multiple children at each level whose MBRs intersect the query box — worst-case search cost is therefore if overlap is severe (imagine every leaf's MBR covering the whole space), though well-tuned insertion heuristics (minimizing enlargement and overlap at split time) keep real-world performance close to , where is the result size and is the page capacity. Insertion and deletion cost node visits for the base descent, plus work per level to run the split heuristic on overflow, matching B+-tree insertion asymptotically while adding a constant-factor overhead for the geometric split computation. Correctness invariant: at every level, the MBR stored with an entry must tightly contain the union of all geometry in the subtree below it; `ADJUST-TREE` maintains this invariant bottom-up after every insertion, and it is precisely this invariant that makes `RTREE-SEARCH`'s pruning step sound — if a query box does not intersect a node's MBR, it provably cannot intersect anything stored beneath that node.
The curse of dimensionality. All three structures share a fundamental limitation as the number of indexed dimensions grows. Query regions that are small relative to the whole space in each dimension can still occupy a large fraction of the volume once is large — geometrically, a hypercube covering half the range in every one of dimensions covers only of the total volume, but bounding boxes built from real, correlated data tend to become nearly as "wide" as the whole domain in high dimensions because there is so much empty space between points. Practically, this means pruning becomes ineffective, sibling MBRs overlap heavily, and query cost degrades toward a full scan once exceeds roughly 10–20 for R-trees and k-d trees alike. This is not a bug in any particular implementation; it is an intrinsic geometric phenomenon, which is why very high-dimensional similarity search (e.g., embedding vectors with hundreds of dimensions) instead relies on approximate methods such as locality-sensitive hashing rather than exact multidimensional trees.
Worked example
Consider inserting five 2-D points representing sensor stations, indexed by `(x, y)` coordinates, into an R-tree with maximum fanout and minimum fanout :
| step | action | resulting leaf state | MBR |
|---|---|---|---|
| 1 | insert | root leaf: | – |
| 2 | insert | root leaf: | – |
| 3 | insert | root leaf: | – |
| 4 | insert : leaf full (), must split | split into (chosen as "most distant" seed pair together with a greedy third), ; new root created with two children | : ; : |
| 5 | insert : `CHOOSE-LEAF` compares enlargement cost | enlarging 's MBR to include costs area growth from to (Δ=10); enlarging 's MBR from to (Δ=15) | goes to (smaller enlargement); , MBR |
After step 5 the tree has a root with two children, (MBR , now full at 3 entries) and (MBR ). Now issue a range query: find all stations inside the box . 1. At the root, test the query box against both children's MBRs: intersects (they overlap in ), and also intersects. Both subtrees must be visited — this is the overlap cost inherent to R-trees. 2. Descend into : check each point individually. fails (); fails (); passes (inside the box). Emit . 3. Descend into : fails (); passes. Emit . 4. Result: , computed after two page fetches (one per leaf) rather than scanning all five records — the pruning was only partial here because the query box straddled both leaves' MBRs, illustrating the realistic middle ground between a lucky single-subtree hit and a full scan. Compare this to a k-d tree built on the same five points (splitting on at depth 0, on at depth 1): a query for the nearest neighbor to would descend toward or first depending on the split values chosen, compute the true distance to that candidate, and then use the hyperplane-distance test to decide whether the sibling subtree (containing ) could possibly hold something closer — in this small example it can prune that entire branch once it confirms is closer than the distance from to the splitting line, avoiding three additional distance computations.
Common pitfalls & traps
| pitfall | why it happens | consequence |
|---|---|---|
| Concatenating attributes into one B+-tree key | Assuming lexicographic order preserves 2-D proximity | Range queries on the second attribute degrade to near-full scans |
| Ignoring MBR overlap when choosing R-tree split heuristic | Using a naive split (e.g., always splitting by insertion order) | Overlapping MBRs balloon, and search visits many extra subtrees |
| Treating k-d tree height as always | Forgetting that height depends on insertion order | Sorted or adversarial insertions cause -height, linear-time queries |
| Building a grid file with a fixed, uniform grid | Not adapting cell boundaries to data skew | A few buckets overflow massively while most stay nearly empty |
| Applying exact multidimensional trees to very high-dimensional vectors | Assuming spatial pruning scales to any | Curse of dimensionality forces near-linear scans; approximate methods needed instead |
| Forgetting to update ancestor MBRs after R-tree insertion | Treating `ADJUST-TREE` as optional bookkeeping | Broken pruning invariant: search can silently miss matching records |
Pioneers & history
Origins · 1 minJon Louis Bentley and how this was discovered
Bentley's 1975 paper "Multidimensional Binary Search Trees Used for Associative Searching" formalized the k-d tree, showing how alternating the splitting dimension by depth generalizes ordinary binary search trees to point data in arbitrary dimensions, with direct applications to nearest-neighbor and range search that underpin geometric and database algorithms to this day.
Where this matters
Spatial and multidimensional indexes are load-bearing infrastructure wherever geometry meets scale. PostGIS (PostgreSQL's spatial extension), Oracle Spatial, and SQLite's R-Tree module all implement R-trees or R-tree variants as the primary access method for `ST_Intersects`, `ST_Contains`, and nearest-neighbor spatial queries; commercial GIS platforms such as Esri's ArcGIS rely on closely related bounding-volume hierarchies. Ride-sharing and delivery platforms use spatial indexes to answer "nearest available driver" queries in real time, and computer-aided design (CAD) and computer graphics systems use bounding-volume hierarchies — a close cousin of the R-tree — to accelerate collision detection and ray tracing. k-d trees remain the workhorse in-memory structure inside libraries like scikit-learn and OpenCV's FLANN for nearest-neighbor classification and feature matching, precisely because those workloads are small enough to fit in memory and low-dimensional enough to avoid the worst of the curse of dimensionality. Grid files, though less common in modern commercial systems, illustrate a design pattern — directory-based partitioning with adaptive cell splitting — that reappears in adaptive spatial partitioning schemes used in distributed spatial data processing frameworks. Choosing which of these structures (or none at all, favoring a scan) best fits a given workload's dimensionality, skew, and query mix is exactly the kind of tradeoff analysis formalized in Index Selection, and understanding these structures also deepens the general contrast between Clustered vs Unclustered Indexes when spatial data must be physically laid out on disk.
Ask the tutor: How do R*-tree split and insertion heuristics reduce MBR overlap compared to Guttman's original R-tree?
Why not just build a separate B+-tree on each attribute and intersect the results?
This works but is often inefficient. Each single-attribute B+-tree returns a candidate set filtered on only one predicate, so for a two-attribute range query the system may fetch two large, mostly-irrelevant row-ID lists and then compute their intersection, incurring I/O proportional to the size of each partial result rather than the (usually much smaller) final answer. A true multidimensional index prunes on both attributes simultaneously during the same traversal, so it typically visits far fewer pages. The B+-tree-intersection approach is still used pragmatically when a database lacks spatial indexing support, but it scales poorly as selectivity per attribute weakens.
Is an R-tree always better than a k-d tree for spatial data?
Not universally — it depends on where the data lives. R-trees are height-balanced and page-oriented by design, making them the natural choice for disk-resident data because their fanout is tuned to disk block size and their structure guarantees logarithmic height regardless of insertion order. k-d trees are simpler and often faster for main-memory workloads with static or batch-loaded data, especially when built via a balanced construction algorithm (e.g., median-of-medians splitting) rather than naive sequential insertion. For dynamic, disk-backed, or extent-based (non-point) spatial data, R-trees are the standard production choice.
What happens when R-tree sibling MBRs overlap heavily — does the structure become incorrect?
No, overlap does not break correctness, only efficiency. The search algorithm is still guaranteed to find every matching record because it descends into every child whose MBR intersects the query, regardless of how much sibling MBRs overlap each other. What overlap costs is performance: a query box that falls inside the overlapping region of multiple MBRs forces the search to visit multiple subtrees that a tighter, non-overlapping partition would have pruned. This is exactly the motivation behind refined variants like the R*-tree, which use smarter insertion and split heuristics specifically to minimize overlap.
How does a grid file handle highly skewed data, like cities clustered on a coastline?
Poorly, without care. A grid file adapts by splitting individual grid lines when a bucket overflows, but because a single split affects an entire row or column of cells, a skewed region (many points near the coast, almost none inland) can force many splits concentrated in one area, producing a directory with a huge number of nearly empty buckets far from the skewed cluster. This directory bloat is the grid file's principal weakness compared to R-trees, whose bounding-rectangle hierarchy adapts locally to data density rather than globally across an entire hyperplane.
What does "curse of dimensionality" mean precisely for indexing, as opposed to for machine learning?
For indexing, it means that spatial pruning — the mechanism that makes these structures faster than a scan — loses effectiveness as dimensionality grows, because bounding regions computed from finite samples tend to span most of the range in most dimensions once is large, so a query box or MBR overlaps almost everything. Concretely, the fraction of a hypercube's volume within distance of its boundary approaches 1 as grows, meaning "most" of high-dimensional space is near the edge of any bounding region, making tight, well-separated bounding boxes essentially impossible to construct. This is the same root geometric phenomenon that plagues distance-based machine learning methods, just applied to index pruning instead of classification.
Can these structures support nearest-neighbor queries for more than one neighbor, i.e., k-NN?
Yes, both k-d trees and R-trees generalize naturally to k-nearest-neighbor search by maintaining a bounded priority queue (often called a "best-so-far" list) of the closest candidates found instead of a single best point, and pruning any subtree whose minimum possible distance to the target exceeds the current worst distance in that queue. The correctness argument is identical to the single-nearest-neighbor case: it still relies on the fact that every point inside a pruned subtree is provably farther from the target than every candidate currently retained, so discarding that subtree cannot invalidate any of the top- answers already found.
Does adding a multidimensional index always speed up a spatial query?
No — it depends on selectivity, data distribution, and query shape, which is exactly why choosing indexes is a distinct design problem. A very large query box that covers most of the indexed space gains little from an R-tree, since nearly every leaf must be visited anyway, and the traversal overhead can even lose to a straightforward sequential scan. Similarly, heavily skewed data can degrade a grid file's directory or unbalance a k-d tree. Evaluating these tradeoffs against the actual query workload, rather than assuming an index always helps, is the central concern of Index Selection.