Quadtree and Octree — Junior Level¶
Read time: ~40 minutes · Audience: Students comfortable with binary trees and 2D coordinates, ready to extend tree thinking into space.
A quadtree is a tree where every internal node has exactly four children, and each node owns a rectangular region of 2D space that is split into four equal quadrants among its children. An octree is the 3D analog: every internal node has eight children, and each owns a cube subdivided into eight octants. These two structures are the workhorse spatial indexes of computer graphics, GIS, robotics, physics simulation, and game collision detection. If you have ever wondered how Google Maps loads only the tiles you can see, how Minecraft stores billions of blocks, or how a game can detect collisions among ten thousand moving objects in a single millisecond — the answer almost always involves a quadtree or an octree.
This document teaches the point-region quadtree (the most common variant) from scratch, then shows the octree as a near-identical 3D generalization. We cover bounding-box arithmetic, the recursive insert with subdivision, range queries, the pitfalls of coincident points and infinite subdivision, and the practical bucket-size and depth-limit choices that separate a textbook implementation from a production one. By the end, you will be able to write a quadtree from memory and reason about its performance on real workloads.
Table of Contents¶
- Introduction — Pruning Empty Space
- Prerequisites
- Glossary
- Core Concepts — Subdivision and Recursion
- Big-O Summary
- Real-World Analogies
- Pros and Cons vs k-d Tree
- Step-by-Step Walkthrough
- Code Examples — Go, Java, Python
- Coding Patterns — The Recursive Bbox Prune
- Error Handling — Boundary Conventions
- Performance Tips
- Best Practices
- Edge Cases
- Common Mistakes
- Cheat Sheet
- Visual Animation Reference
- Summary
- Further Reading
1. Introduction — Pruning Empty Space¶
You are writing a 2D game with ten thousand asteroids floating around a screen. Every frame you must answer: for each asteroid, which other asteroids might it collide with? The naive approach compares every pair — 10,000 × 10,000 = 100 million comparisons per frame. At 60 FPS that is 6 billion comparisons per second. Your laptop's CPU caves in.
The fix is to stop comparing things that are obviously far apart. Divide the screen into four quadrants. An asteroid in the top-left quadrant cannot collide with one in the bottom-right; you do not even need to test that pair. Subdivide further: each quadrant becomes four sub-quadrants. Now an asteroid sitting in the top-left sub-quadrant of the top-left quadrant only ever needs to test against its handful of immediate neighbors. Most pairs are eliminated without any per-pair comparison — they are pruned by a single bbox-vs-bbox test at a high level of the tree.
This is the quadtree idea. A node represents an axis-aligned bounding box of 2D space; its four children represent the NE, NW, SE, SW quadrants of that box. Recurse until each leaf contains only a small number of items (typically 4 to 16). Spatial queries — find all points inside this rectangle, find the nearest point to this location, find all objects whose bbox overlaps mine — visit only the nodes whose box intersects the query, and the rest of the tree is skipped entirely.
The octree is the same idea in 3D. A cubic region is split into eight equal octants. Used for voxel worlds, ray tracing, 3D collision, point-cloud processing from LIDAR, and astrophysical N-body simulation. Everything you learn about quadtrees transfers directly.
The asymptotic complexity of quadtree queries is O(log n) on average for uniformly distributed data, but the headline win is the enormous constant-factor reduction from pruning empty space. On real game and GIS workloads you routinely see 100x or 1000x speedups over the naive O(n²) baseline. Worst case is still O(n) — adversarial clustered inputs can build a degenerate tree — but with a depth cap and a sensible bucket size this is rare in practice.
The structure was introduced by Finkel and Bentley in 1974 ("Quad Trees: A Data Structure for Retrieval on Composite Keys") for indexing multi-key records; Meagher's 1980 octree paper generalized it to 3D for solid-modeling and rendering. The definitive survey is Hanan Samet's 1984 ACM Computing Surveys paper "The Quadtree and Related Hierarchical Data Structures", and his 2006 book Foundations of Multidimensional and Metric Data Structures is still the reference.
2. Prerequisites¶
To follow this document you need:
- 2D Cartesian coordinates. A point is
(x, y); a rectangle is usually(x, y, w, h)(top-left + size) or(cx, cy, halfW, halfH)(center + half-extents). We use the center+half-extents form because it makes the subdivision arithmetic symmetric and cleaner. - Recursion. A quadtree node is naturally defined in terms of four children of the same type. If you have written a binary tree traversal, you can write a quadtree traversal.
- Axis-aligned bounding boxes. Two ops:
bbox.contains(point)(is the point inside?) andbboxA.intersects(bboxB)(do they overlap?). These are four-comparison operations. - Basic Big-O. You should be comfortable with the idea that a tree of branching factor 4 and depth d has up to 4^d leaves, so depth ≈ log_4(n) on balanced inputs.
That is all. No prior knowledge of spatial data structures is assumed.
3. Glossary¶
| Term | Definition |
|---|---|
| Quadtree | Tree where each internal node has 4 children, each representing one quadrant (NE, NW, SE, SW) of the parent's bbox. |
| Octree | 3D analog: each internal node has 8 children, one per octant of the parent's cube. |
| Point quadtree | Original Finkel-Bentley variant. Each node is a point; the splitting lines pass through that point, so children are unequal in size. |
| Region quadtree | Each node represents a region of space split into 4 equal sub-regions, regardless of where points fall. Used for raster images. |
| PR-quadtree (Point-Region) | Region quadtree that stores points; a leaf holds at most one point (or, with bucketing, a small bucket). The most popular variant; this document focuses on it. |
| Bbox | Axis-aligned bounding box. {cx, cy, hw, hh} or {xmin, ymin, xmax, ymax}. |
| Bucket | A small array of points held at a leaf. When the bucket overflows, the leaf subdivides. |
| Bucket capacity (threshold, k) | Maximum points a leaf may hold before subdividing. Typical values 4-16. |
| Subdivide | Convert a leaf into an internal node by creating four (or eight) empty children and distributing the bucket's points into them. |
| Quadrant | One of the four children of a quadtree node: NE (top-right), NW (top-left), SE (bottom-right), SW (bottom-left). |
| Octant | One of eight children in 3D, indexed by the sign of (dx, dy, dz) relative to the cube's center. |
| Depth limit / MAX_DEPTH | Cap on recursion depth to prevent infinite subdivision when many points coincide. |
| Leaf | A node with no children; holds the bucket of points. |
| Internal node | A node that has been subdivided; in the most common variant it holds no points itself, only children. |
| Range query | Find all points inside a query rectangle (or axis-aligned box in 3D). |
| k-NN query | Find the k nearest neighbors of a query point. |
4. Core Concepts — Subdivision and Recursion¶
4.1 The node¶
A PR-quadtree node carries:
struct Node {
Bbox boundary // the square region this node owns
int capacity // max points before subdividing
Point[] points // bucket — empty after subdivision
bool divided // true once we have children
Node* NE, NW, SE, SW // the four quadrant children, nil while leaf
}
A node starts as a leaf with an empty bucket. Insertions push into points until the bucket overflows (len(points) == capacity); then we subdivide.
4.2 Subdivision¶
Subdivision creates four children whose boundaries are the four quadrants of the parent's boundary:
parent.boundary = {cx, cy, hw, hh}
NE.boundary = {cx + hw/2, cy + hh/2, hw/2, hh/2} // top-right
NW.boundary = {cx - hw/2, cy + hh/2, hw/2, hh/2} // top-left
SE.boundary = {cx + hw/2, cy - hh/2, hw/2, hh/2} // bottom-right
SW.boundary = {cx - hw/2, cy - hh/2, hw/2, hh/2} // bottom-left
(In screen coordinates where y grows downward, swap NE/SE and NW/SW signs — pick a convention and document it.)
After subdivision, in the standard variant the parent's bucket is emptied: each existing point is re-inserted into the appropriate child. From now on the parent stores no points directly; queries always recurse to leaves.
4.3 Insert¶
insert(node, p):
if not node.boundary.contains(p):
return false // point is outside this region
if not node.divided:
if len(node.points) < node.capacity:
node.points.append(p)
return true
if node.depth >= MAX_DEPTH:
node.points.append(p) // last resort: oversized bucket
return true
node.subdivide()
return node.NE.insert(p) or node.NW.insert(p) or node.SE.insert(p) or node.SW.insert(p)
The or short-circuits: only one child contains the point, so only one insert succeeds. Average cost: O(log n) on uniform data. Worst case O(MAX_DEPTH).
4.4 Range query¶
queryRange(node, rect, found):
if not node.boundary.intersects(rect):
return # PRUNE — this subtree is irrelevant
for p in node.points: # leaves; internal nodes have empty .points
if rect.contains(p):
found.append(p)
if node.divided:
queryRange(node.NE, rect, found)
queryRange(node.NW, rect, found)
queryRange(node.SE, rect, found)
queryRange(node.SW, rect, found)
The first line is the magic. If the query rectangle does not touch the node's boundary, we return immediately — an entire subtree (possibly thousands of points) is skipped with one comparison. This is what gives the quadtree its real-world speed.
4.5 The bbox helpers¶
contains(bbox, p):
return abs(p.x - bbox.cx) <= bbox.hw && abs(p.y - bbox.cy) <= bbox.hh
intersects(a, b):
return abs(a.cx - b.cx) <= a.hw + b.hw && abs(a.cy - b.cy) <= a.hh + b.hh
Both are four comparisons. Branchless on modern CPUs.
5. Big-O Summary¶
| Operation | Average (uniform) | Worst (clustered) |
|---|---|---|
| Insert | O(log n) | O(MAX_DEPTH) ≈ O(1) |
| Range query | O(log n + k) | O(n) |
| Nearest neighbor | O(log n) | O(n) |
| Build from n points | O(n log n) | O(n × MAX_DEPTH) |
| Space | O(n) | O(n + 4^MAX_DEPTH) |
k is the size of the output set. The query cost has a log n term to find the right leaves plus a linear term to enumerate the matches — you cannot return k things in less than k time.
Constant factors matter more than asymptotics here. On a million uniformly distributed points with a query rectangle covering 1% of the area, a quadtree typically returns ~10,000 points in under a millisecond — the asymptote is O(log n + k) but the practical speedup over linear scan can be 100-1000×.
For n = 10^6 uniform points and bucket size 8, tree depth is ~7-10. Insert touches about that many nodes.
6. Real-World Analogies¶
6.1 City map zoom levels¶
Open Google Maps. The whole world is one tile at zoom 0. Zoom in once and the world is split into 4 tiles (a 2×2 grid). Zoom again: 16 tiles. Zoom level 20 has 4^20 ≈ 10^12 tiles — enough for every house. When you pan the map, only the tiles intersecting your viewport are downloaded. That tile pyramid is a quadtree, and the address of a tile encodes the path from the root through the quadrants.
6.2 Hierarchical address: "borough → district → block"¶
To find a coffee shop in Brooklyn you do not search Tokyo first. You navigate New York → Brooklyn → Park Slope → 7th Avenue. Each step prunes a vast region. The quadtree is exactly this hierarchy applied to arbitrary rectangles.
6.3 Picture compression by quadrants¶
A black-and-white image. If a 256×256 block is entirely white, you store one "white" symbol. If it is entirely black, one "black" symbol. If it is mixed, you split into four 128×128 blocks and recurse. Final tree has one leaf per uniform region. This is the region quadtree for image compression — covered in senior.md.
6.4 Astronomy: Barnes-Hut¶
A galaxy with N stars. Computing pairwise gravity is O(N²) — infeasible for N = 10^9. Build an octree of star positions; for any star, treat far-away clusters as a single mass at their center of mass. Per-star cost drops from O(N) to O(log N). Total: O(N log N). This algorithm has been used in every major cosmological simulation since 1986.
6.5 Game collision broad-phase¶
Every frame, insert each game object into a fresh quadtree (or rebuild incrementally). To find candidates for object A, query the quadtree for A's bbox; only nearby objects are returned. The remaining narrow-phase test (exact geometry) runs on a tiny set instead of all N objects.
7. Pros and Cons vs k-d Tree¶
| Aspect | Quadtree / Octree | k-d Tree |
|---|---|---|
| Subdivision rule | Space: split fixed midpoint regardless of where points lie | Data: split along the median point so each half has equal count |
| Branching factor | 4 (2D) or 8 (3D) | 2 |
| Depth | O(log n) on uniform; deeper on clustered | O(log n) on any input (median-balanced) |
| Empty regions | Many empty children; wasted nodes | None — every leaf has data |
| Build cost | O(n log n) average | O(n log n) guaranteed |
| Update cost (insert/delete) | Cheap — local subdivision/coalesce | Expensive — may unbalance, requires rebuild |
| Bbox tests | Simple, axis-aligned, symmetric | Per-axis comparisons; more complex k-NN math |
| Best fit | Dynamic scenes (games, sims), rasters, multi-scale data | Static k-NN over fixed point sets |
The k-d tree is covered in a separate chapter. Use a quadtree when objects move (cheap rebuild, cheap incremental updates) and when query rectangles are axis-aligned. Use a k-d tree when the point set is static and you need k-NN with rigorous balance guarantees.
Quadtree pros¶
- Simple to code; the recursive structure mirrors the recursive geometry.
- Insertions/deletions are local — no global rebalance.
- Multi-scale: zoom in or out by descending or ascending the tree. Natural fit for tiled rendering and LOD.
- Easy to parallelize: independent subtrees can be queried by separate threads.
- Encodable as a flat array via Morton order — see
middle.md.
Quadtree cons¶
- Pathological depth on clustered data (galaxies of points at one location). Requires MAX_DEPTH.
- Worst-case query is O(n) on adversarial inputs.
- Empty children waste memory. Sparse quadtrees mitigate this.
- Points exactly on a subdivision line require a tiebreak convention.
8. Step-by-Step Walkthrough¶
Build a PR-quadtree over the box [0, 16] × [0, 16] (center (8, 8), half-extents (8, 8)) with bucket capacity = 2. Insert 8 points:
p1 = (2, 2)
p2 = (3, 3)
p3 = (14, 2)
p4 = (15, 5)
p5 = (4, 12)
p6 = (5, 14)
p7 = (12, 13)
p8 = (13, 11)
Insert p1 (2,2). Root is leaf with empty bucket. Bucket: [p1]. Insert p2 (3,3). Bucket: [p1, p2]. Still under capacity. Insert p3 (14,2). Bucket would overflow → subdivide root. Root's four children: - NE: [8,16] × [8,16] - NW: [0,8] × [8,16] - SE: [8,16] × [0,8] - SW: [0,8] × [0,8]
Re-distribute existing bucket: p1 (2,2) → SW, p2 (3,3) → SW. SW.bucket = [p1, p2]. Now insert p3 (14,2) → SE. SE.bucket = [p3].
Insert p4 (15,5). Falls into SE. SE.bucket = [p3, p4]. Insert p5 (4,12). Falls into NW. NW.bucket = [p5]. Insert p6 (5,14). Falls into NW. NW.bucket = [p5, p6]. Insert p7 (12,13). Falls into NE. NE.bucket = [p7]. Insert p8 (13,11). Falls into NE. NE.bucket = [p7, p8].
Tree:
Root [0,16]×[0,16] (internal)
├── NE [8,16]×[8,16] → bucket [p7(12,13), p8(13,11)]
├── NW [0,8]×[8,16] → bucket [p5(4,12), p6(5,14)]
├── SE [8,16]×[0,8] → bucket [p3(14,2), p4(15,5)]
└── SW [0,8]×[0,8] → bucket [p1(2,2), p2(3,3)]
Depth 1, 4 leaves, 8 points. Balanced.
Range query: rectangle [10, 16] × [10, 16]¶
- Root intersects query. Recurse to all four children.
- NE
[8,16]×[8,16]intersects query. Bucket: p7(12,13) inside ✔, p8(13,11) inside ✔. Add both. - NW
[0,8]×[8,16]does not intersect query (xmax=8 < 10). Prune. - SE
[8,16]×[0,8]does not intersect query (ymax=8 < 10). Prune. - SW
[0,8]×[0,8]does not intersect query. Prune.
Output: [p7, p8]. Visited 1 root + 4 children = 5 node tests, looked at 2 points. Linear scan would have looked at 8 points. With 1 million points, the win is 5 orders of magnitude.
9. Code Examples — Go, Java, Python¶
9.1 Quadtree (PR variant)¶
Go:
package quadtree
type Point struct{ X, Y float64 }
type Bbox struct{ Cx, Cy, Hw, Hh float64 }
func (b Bbox) Contains(p Point) bool {
return p.X >= b.Cx-b.Hw && p.X <= b.Cx+b.Hw &&
p.Y >= b.Cy-b.Hh && p.Y <= b.Cy+b.Hh
}
func (a Bbox) Intersects(c Bbox) bool {
return !(a.Cx+a.Hw < c.Cx-c.Hw || a.Cx-a.Hw > c.Cx+c.Hw ||
a.Cy+a.Hh < c.Cy-c.Hh || a.Cy-a.Hh > c.Cy+c.Hh)
}
const MaxDepth = 16
type Quadtree struct {
Boundary Bbox
Capacity int
Depth int
Points []Point
Divided bool
NE, NW, SE, SW *Quadtree
}
func New(b Bbox, cap int) *Quadtree {
return &Quadtree{Boundary: b, Capacity: cap}
}
func (q *Quadtree) Insert(p Point) bool {
if !q.Boundary.Contains(p) {
return false
}
if !q.Divided {
if len(q.Points) < q.Capacity || q.Depth >= MaxDepth {
q.Points = append(q.Points, p)
return true
}
q.subdivide()
}
return q.NE.Insert(p) || q.NW.Insert(p) || q.SE.Insert(p) || q.SW.Insert(p)
}
func (q *Quadtree) subdivide() {
h := q.Boundary.Hw / 2
v := q.Boundary.Hh / 2
d := q.Depth + 1
q.NE = &Quadtree{Boundary: Bbox{q.Boundary.Cx + h, q.Boundary.Cy + v, h, v}, Capacity: q.Capacity, Depth: d}
q.NW = &Quadtree{Boundary: Bbox{q.Boundary.Cx - h, q.Boundary.Cy + v, h, v}, Capacity: q.Capacity, Depth: d}
q.SE = &Quadtree{Boundary: Bbox{q.Boundary.Cx + h, q.Boundary.Cy - v, h, v}, Capacity: q.Capacity, Depth: d}
q.SW = &Quadtree{Boundary: Bbox{q.Boundary.Cx - h, q.Boundary.Cy - v, h, v}, Capacity: q.Capacity, Depth: d}
q.Divided = true
old := q.Points
q.Points = nil
for _, p := range old {
_ = q.NE.Insert(p) || q.NW.Insert(p) || q.SE.Insert(p) || q.SW.Insert(p)
}
}
func (q *Quadtree) QueryRange(r Bbox, found *[]Point) {
if !q.Boundary.Intersects(r) {
return
}
for _, p := range q.Points {
if r.Contains(p) {
*found = append(*found, p)
}
}
if q.Divided {
q.NE.QueryRange(r, found)
q.NW.QueryRange(r, found)
q.SE.QueryRange(r, found)
q.SW.QueryRange(r, found)
}
}
Java:
public final class Quadtree {
static final int MAX_DEPTH = 16;
public record Point(double x, double y) {}
public record Bbox(double cx, double cy, double hw, double hh) {
public boolean contains(Point p) {
return p.x() >= cx - hw && p.x() <= cx + hw &&
p.y() >= cy - hh && p.y() <= cy + hh;
}
public boolean intersects(Bbox b) {
return !(cx + hw < b.cx - b.hw || cx - hw > b.cx + b.hw ||
cy + hh < b.cy - b.hh || cy - hh > b.cy + b.hh);
}
}
private final Bbox boundary;
private final int capacity;
private final int depth;
private final java.util.List<Point> points = new java.util.ArrayList<>();
private boolean divided = false;
private Quadtree NE, NW, SE, SW;
public Quadtree(Bbox boundary, int capacity) { this(boundary, capacity, 0); }
private Quadtree(Bbox b, int c, int d) { this.boundary = b; this.capacity = c; this.depth = d; }
public boolean insert(Point p) {
if (!boundary.contains(p)) return false;
if (!divided) {
if (points.size() < capacity || depth >= MAX_DEPTH) {
points.add(p);
return true;
}
subdivide();
}
return NE.insert(p) || NW.insert(p) || SE.insert(p) || SW.insert(p);
}
private void subdivide() {
double h = boundary.hw() / 2, v = boundary.hh() / 2;
double cx = boundary.cx(), cy = boundary.cy();
int d = depth + 1;
NE = new Quadtree(new Bbox(cx + h, cy + v, h, v), capacity, d);
NW = new Quadtree(new Bbox(cx - h, cy + v, h, v), capacity, d);
SE = new Quadtree(new Bbox(cx + h, cy - v, h, v), capacity, d);
SW = new Quadtree(new Bbox(cx - h, cy - v, h, v), capacity, d);
divided = true;
java.util.List<Point> old = new java.util.ArrayList<>(points);
points.clear();
for (Point p : old) {
boolean ok = NE.insert(p) || NW.insert(p) || SE.insert(p) || SW.insert(p);
assert ok;
}
}
public void queryRange(Bbox r, java.util.List<Point> out) {
if (!boundary.intersects(r)) return;
for (Point p : points) if (r.contains(p)) out.add(p);
if (divided) {
NE.queryRange(r, out); NW.queryRange(r, out);
SE.queryRange(r, out); SW.queryRange(r, out);
}
}
}
Python:
from dataclasses import dataclass, field
from typing import List, Optional
MAX_DEPTH = 16
@dataclass(frozen=True)
class Point:
x: float
y: float
@dataclass(frozen=True)
class Bbox:
cx: float
cy: float
hw: float
hh: float
def contains(self, p: Point) -> bool:
return (self.cx - self.hw <= p.x <= self.cx + self.hw and
self.cy - self.hh <= p.y <= self.cy + self.hh)
def intersects(self, b: 'Bbox') -> bool:
return not (self.cx + self.hw < b.cx - b.hw or self.cx - self.hw > b.cx + b.hw or
self.cy + self.hh < b.cy - b.hh or self.cy - self.hh > b.cy + b.hh)
@dataclass
class Quadtree:
boundary: Bbox
capacity: int = 4
depth: int = 0
points: List[Point] = field(default_factory=list)
divided: bool = False
NE: Optional['Quadtree'] = None
NW: Optional['Quadtree'] = None
SE: Optional['Quadtree'] = None
SW: Optional['Quadtree'] = None
def insert(self, p: Point) -> bool:
if not self.boundary.contains(p):
return False
if not self.divided:
if len(self.points) < self.capacity or self.depth >= MAX_DEPTH:
self.points.append(p)
return True
self._subdivide()
return (self.NE.insert(p) or self.NW.insert(p) or
self.SE.insert(p) or self.SW.insert(p))
def _subdivide(self) -> None:
b = self.boundary
h, v = b.hw / 2, b.hh / 2
d = self.depth + 1
self.NE = Quadtree(Bbox(b.cx + h, b.cy + v, h, v), self.capacity, d)
self.NW = Quadtree(Bbox(b.cx - h, b.cy + v, h, v), self.capacity, d)
self.SE = Quadtree(Bbox(b.cx + h, b.cy - v, h, v), self.capacity, d)
self.SW = Quadtree(Bbox(b.cx - h, b.cy - v, h, v), self.capacity, d)
self.divided = True
old, self.points = self.points, []
for p in old:
self.NE.insert(p) or self.NW.insert(p) or self.SE.insert(p) or self.SW.insert(p)
def query_range(self, r: Bbox, out: List[Point]) -> None:
if not self.boundary.intersects(r):
return
for p in self.points:
if r.contains(p):
out.append(p)
if self.divided:
self.NE.query_range(r, out)
self.NW.query_range(r, out)
self.SE.query_range(r, out)
self.SW.query_range(r, out)
9.2 Octree (3D analog)¶
Python sketch:
@dataclass(frozen=True)
class Point3:
x: float; y: float; z: float
@dataclass(frozen=True)
class Box3:
cx: float; cy: float; cz: float
hx: float; hy: float; hz: float
def contains(self, p: Point3) -> bool:
return (abs(p.x - self.cx) <= self.hx and
abs(p.y - self.cy) <= self.hy and
abs(p.z - self.cz) <= self.hz)
class Octree:
"""Same shape as Quadtree but with 8 children indexed by (xSign, ySign, zSign)."""
def __init__(self, boundary: Box3, capacity: int = 8, depth: int = 0):
self.boundary = boundary
self.capacity = capacity
self.depth = depth
self.points: List[Point3] = []
self.children: List[Optional['Octree']] = [None] * 8
self.divided = False
def _octant_index(self, p: Point3) -> int:
return (1 if p.x >= self.boundary.cx else 0) | \
(2 if p.y >= self.boundary.cy else 0) | \
(4 if p.z >= self.boundary.cz else 0)
def insert(self, p: Point3) -> bool:
if not self.boundary.contains(p): return False
if not self.divided:
if len(self.points) < self.capacity or self.depth >= MAX_DEPTH:
self.points.append(p); return True
self._subdivide()
return self.children[self._octant_index(p)].insert(p)
def _subdivide(self) -> None:
b = self.boundary
hx, hy, hz = b.hx / 2, b.hy / 2, b.hz / 2
for i in range(8):
dx = hx if (i & 1) else -hx
dy = hy if (i & 2) else -hy
dz = hz if (i & 4) else -hz
child_box = Box3(b.cx + dx, b.cy + dy, b.cz + dz, hx, hy, hz)
self.children[i] = Octree(child_box, self.capacity, self.depth + 1)
self.divided = True
for p in self.points:
self.children[self._octant_index(p)].insert(p)
self.points = []
The octree has 8 children indexed by bitmask of (x-sign, y-sign, z-sign). Otherwise identical.
10. Coding Patterns — The Recursive Bbox Prune¶
Almost every quadtree algorithm is the same shape:
recurse(node, query):
if not node.boundary intersects/touches query:
return # PRUNE — entire subtree skipped
process leaf data at this node
if internal:
recurse(each child, query)
That single template covers:
- Range query (the query is a rectangle; "intersects" = bbox-bbox overlap).
- Point query (the query is a point; "intersects" = bbox contains point).
- k-NN (the query is a point + current k-th-best distance; "intersects" = bbox is within current radius — branch and bound).
- Ray cast (the query is a ray; "intersects" = ray-bbox intersection test).
- Collision broad-phase (the query is a moving object's swept bbox).
- Image rendering (the query is the viewport; "intersects" = visible tile test).
If you internalize this pattern, the quadtree is one mental model that solves an entire family of problems.
11. Error Handling — Boundary Conventions¶
11.1 Points on the edge between quadrants¶
When p.x == node.cx, which child gets the point? The answer must be consistent. Two common conventions:
- Inclusive-lower: NE gets
[cx, cx+hw] × [cy, cy+hh], NW gets[cx-hw, cx) × [cy, cy+hh]. Points on the splitting line go right or up. - Inclusive-upper: opposite. Points on the line go left or down.
Pick one and document it. The pattern in our Go code (>= and <= everywhere) is "inclusive on both sides" — which means a point exactly on the dividing line will be claimed by both of two children. The || short-circuit in Insert ensures only the first one keeps it, but the order (NE → NW → SE → SW) becomes the tiebreak. That is fine as long as you do not silently change the order later.
11.2 Points exactly on the boundary of the root¶
boundary.contains(p) uses <= on both edges so the closed rectangle includes its boundary. A point exactly at (0, 0) on a [0,16]×[0,16] root is inserted. A point at (-0.0001, 0) is rejected; the caller must handle this (typically by enlarging the root or filtering input first).
11.3 Floating-point boundaries¶
After many subdivisions, the boundary numbers carry accumulated rounding error. For typical floats and 16-deep trees the error is below 10^-12 — invisible. For double-precision GIS at planetary scale you may need to use the original root bbox in the contains test (instead of the local node's drifted bbox), or switch to fixed-point integer coordinates.
11.4 NaN coordinates¶
NaN >= x is false for any x, so a point with NaN coordinates is rejected by contains. The caller should filter NaN before inserting.
12. Performance Tips¶
- Bucket size 4-16. Too small (1) means many subdivisions and lots of pointer-chasing on insert; too large (>32) means leaves degrade to linear scans. Benchmark for your workload.
- MAX_DEPTH around 16-20. Caps memory in pathological cases. At depth 20 a 2D world is partitioned into ~10^12 cells — far finer than any realistic resolution.
- Pre-allocate the points slice. The recursive resize during subdivide hurts. In hot loops, set
pointscapacity = bucket capacity from the start. - Reuse the tree across frames. Building a fresh tree every frame is fine for ≤10^5 dynamic objects. Beyond that, consider an incremental tree with cell coalescing (rebalance on deletion).
- Avoid recursion for queries on shallow trees. An iterative stack-based traversal is 30-50% faster on hot paths because it skips the function-call overhead.
- Order children by query relevance in k-NN. Descend the closest child first — the pruning radius shrinks and other children get cheaply pruned.
- Loose quadtrees (covered in
professional.md) reduce edge-case re-bucketing for moving objects.
13. Best Practices¶
- Document the y-axis convention. Screen-space y grows downward; math-space y grows upward. Pick one for the whole codebase.
- Use center+half-extents (not corner+size) for boundaries. The subdivide arithmetic is symmetric, and
containsis one comparison per axis. - Always set MAX_DEPTH. Coincident points (same x,y inserted many times) will otherwise infinite-loop.
- Keep buckets at leaves only. When you subdivide, drain the parent's bucket into children. This makes range queries simpler — internal nodes contribute zero points.
- Provide a
Clear()method. For per-frame rebuilds you want to reset without GC churn. - Wrap the public API. Hide subdivide and child accessors; expose
Insert,QueryRange,Nearest,Clear.
14. Edge Cases¶
| Case | Behavior |
|---|---|
| Empty tree | QueryRange returns nothing. No crash. |
| Insert outside root | Insert returns false. Decide: expand the root or reject. |
| All points at the same location | Without MAX_DEPTH, infinite subdivision. With MAX_DEPTH, leaf bucket grows beyond capacity and queries become O(k). |
| Single point | Stored in root's bucket. No subdivision. |
| Bucket capacity = 0 | Subdivide on every insert. Each leaf can hold at most MAX_DEPTH-deep singletons. Avoid. |
| Query rectangle equal to root | Returns all points. Same as full traversal. |
| Query rectangle outside root | First intersects test fails. Returns instantly. |
| Two points exactly on a splitting line | Both end up in the same child (the first one your || order tries). |
| Deeply clustered points | Tree depth → MAX_DEPTH; leaf overflows; query degrades to O(n) within the cluster. |
15. Common Mistakes¶
- Forgetting the bbox-intersect prune. Without it, every query walks every node — O(n) regardless of the tree.
- Off-by-one on the bbox edges. Using
<vs<=inconsistently acrosscontainsandintersectscauses silent point-loss at the boundary. - Mixing center-vs-corner coordinates. A subtle bug: you define boundaries as
(cx, cy, hw, hh)but pass(x, y, w, h)to the constructor. Half the tree maps to nowhere. - No MAX_DEPTH. Stack overflow when many coincident points arrive.
- Subdividing but leaving points in the parent. Now range queries either miss them (if leaves are the only data source) or double-count them.
- Re-using the same
foundslice across queries without resetting. Results accumulate; the caller sees stale data. - Inserting moving objects without removing-and-reinserting. When an asteroid moves out of its current quadrant, the tree no longer reflects reality. For dynamic scenes, either rebuild each frame or implement removal + reinsertion.
- Using
==to compare floating-point coordinates. Points at "the same location" may differ by 10^-15 due to floating-point math; use a small epsilon if you need to detect duplicates.
16. Cheat Sheet¶
QUADTREE NODE: boundary, capacity, depth, points[], divided, NE, NW, SE, SW
INSERT(p):
if !boundary.contains(p): return false
if !divided:
if len(points) < capacity || depth >= MAX_DEPTH:
points.append(p); return true
subdivide()
return NE.insert(p) || NW.insert(p) || SE.insert(p) || SW.insert(p)
SUBDIVIDE:
create 4 children with half-size boundaries (NE, NW, SE, SW)
re-insert current points into them
points = []
QUERY_RANGE(rect):
if !boundary.intersects(rect): return # PRUNE
for p in points: if rect.contains(p): emit p
if divided: recurse on all 4 children
PARAMETERS
capacity: 4-16
MAX_DEPTH: 16-20
COMPLEXITY (n uniform points)
Insert O(log n) avg
Range O(log n + k)
Build O(n log n)
Space O(n)
17. Visual Animation Reference¶
See animation.html in this folder. Click on the dark canvas to add points; the quadtree subdivides whenever a bucket overflows, and the splitting lines animate in. Drag a rectangle to issue a range query: matching points light up green, visited (intersected) nodes glow yellow, pruned subtrees grey out. Stats display point count, depth, leaf count, and the number of points returned by the last query.
18. Summary¶
- A quadtree partitions 2D space into four quadrants recursively; an octree does the same with eight octants in 3D.
- The point-region (PR) variant stores points in leaf buckets and subdivides when a bucket overflows.
- Insert is recursive descent + subdivide on overflow; range query is recursive descent with a bbox-intersect prune at every node.
- Average operations are O(log n); the headline win is empty-space pruning, often 100-1000× faster than naive O(n²) on real spatial workloads.
- Pick bucket capacity around 4-16 and MAX_DEPTH around 16-20 to balance memory and depth against pathological clustering.
- Master the recursive bbox-prune pattern: it underlies range queries, k-NN, ray casting, collision detection, and image rendering.
19. Further Reading¶
- Finkel, Bentley, "Quad Trees: A Data Structure for Retrieval on Composite Keys", Acta Informatica 4(1), 1974. The paper that introduced the structure.
- Meagher, "Octree Encoding: A New Technique for the Representation, Manipulation and Display of Arbitrary 3-D Objects by Computer", 1980. The octree's first appearance.
- Samet, "The Quadtree and Related Hierarchical Data Structures", ACM Computing Surveys 16(2), 1984. The canonical survey.
- Samet, Foundations of Multidimensional and Metric Data Structures, Morgan Kaufmann, 2006. The reference text — comprehensive coverage of every variant.
- de Berg et al., Computational Geometry: Algorithms and Applications, 3rd ed., Springer, 2008. Chapter 14 covers quadtrees with rigorous analysis.
- Continue with
middle.mdfor region quadtrees, linear quadtrees with Morton order, k-NN via branch-and-bound, and octree ray traversal.