Point quadtree in Python
Quadtree in which each node stores a data point and its split point is that point, not a fixed grid line.
- File
point_quadtree.py, 178 lines- Level
- I0 not checked yet
- Toolchain
- Python 3.14, pytest 9
- Test framework
- pytest
- Run the tests
python3 -m pytest
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/point-quadtree/. The authors are listed in prov/C2HX1P.jsonl.
from typing import Optional, Sequence
Point = tuple[float, float]
def quadrant(p: Point, q: Point) -> int:
"""The quadrant of q relative to p: 0 SW, 1 SE, 2 NW, 3 NE. O(1).
East means q.x >= p.x and north means q.y >= p.y.
"""
return (1 if q[0] >= p[0] else 0) | (2 if q[1] >= p[1] else 0)
class Node:
__slots__ = ("point", "child")
def __init__(self, point: Point) -> None:
self.point = point
self.child: list[Optional[Node]] = [None, None, None, None]
class PointQuadtree:
"""Finkel and Bentley's point quadtree for a set of points in the plane.
Each node holds one point, and its four children hold the points of the
four quadrants around it. Invariant: every point in child[i] of a node
with point p has quadrant(p, q) == i. Points are stored as a set.
"""
def __init__(self, points: Sequence[Sequence[float]] = ()) -> None:
"""Build by median splits on x: with distinct x coordinates the height
is at most floor(lg n) + 1. O(n log n) time with sorting."""
pts = sorted({(float(p[0]), float(p[1])) for p in points})
self.size = len(pts)
self.root: Optional[Node] = None
# Each task: (points sorted by (x, y), parent, quadrant index).
stack: list[tuple[list[Point], Optional[Node], int]] = [(pts, None, 0)]
while stack:
group, parent, qi = stack.pop()
if not group:
continue
p = group[len(group) // 2] # the median by x
t = Node(p)
if parent is None:
self.root = t
else:
parent.child[qi] = t
parts: list[list[Point]] = [[], [], [], []]
for q in group:
if q != p:
parts[quadrant(p, q)].append(q) # each part stays sorted
for i in range(4):
stack.append((parts[i], t, i))
def __len__(self) -> int:
return self.size
def contains(self, q: Sequence[float]) -> bool:
"""Whether q is stored. O(h)."""
p = (float(q[0]), float(q[1]))
t = self.root
while t is not None:
if t.point == p:
return True
t = t.child[quadrant(t.point, p)]
return False
def insert(self, q: Sequence[float]) -> bool:
"""Add q as a new leaf; return False if it is already stored. O(h)."""
p = (float(q[0]), float(q[1]))
if self.root is None: # @line L1
self.root = Node(p)
self.size = 1
return True
t = self.root
while True:
if t.point == p: # @line L2
return False
i = quadrant(t.point, p) # @line L3
c = t.child[i]
if c is None: # @line L4
t.child[i] = Node(p)
self.size += 1
return True
t = c # @line L5
def range_search(self, lo: Sequence[float], hi: Sequence[float]) -> list[Point]:
"""The points p with lo[0] <= p.x <= hi[0] and lo[1] <= p.y <= hi[1]."""
out: list[Point] = []
stack = [self.root]
while stack:
t = stack.pop()
if t is None: # @line L6
continue
x, y = t.point
if lo[0] <= x <= hi[0] and lo[1] <= y <= hi[1]: # @line L7
out.append(t.point)
west, east = lo[0] < x, hi[0] >= x # @line L8
south, north = lo[1] < y, hi[1] >= y
if south and west: # @line L9
stack.append(t.child[0])
if south and east:
stack.append(t.child[1])
if north and west:
stack.append(t.child[2])
if north and east:
stack.append(t.child[3])
return out
def delete(self, q: Sequence[float]) -> bool:
"""Remove q: cut out its node and reinsert the points below it.
O(s h) time, where s is the size of the subtree of the node.
"""
p = (float(q[0]), float(q[1]))
parent: Optional[Node] = None
pi = 0
t = self.root
while t is not None and t.point != p: # @line L10
parent, pi = t, quadrant(t.point, p)
t = t.child[pi]
if t is None:
return False
rest: list[Point] = [] # @line L11
stack = [c for c in t.child if c is not None]
while stack:
u = stack.pop()
rest.append(u.point)
stack.extend(c for c in u.child if c is not None)
if parent is None: # @line L12
self.root = None
else:
parent.child[pi] = None
self.size -= 1 + len(rest)
for r in rest: # @line L13
self.insert(r)
return True
def points(self) -> list[Point]:
out: list[Point] = []
stack = [self.root]
while stack:
t = stack.pop()
if t is not None:
out.append(t.point)
stack.extend(t.child)
return out
def height(self) -> int:
best = 0
stack = [(self.root, 1)]
while stack:
t, d = stack.pop()
if t is not None:
best = max(best, d)
stack.extend((c, d + 1) for c in t.child)
return best
def check_invariant(self) -> None:
"""Each point lies in the quadrant of each ancestor that its path takes."""
count = 0
seen: set[Point] = set()
stack: list[tuple[Optional[Node], list[tuple[Point, int]]]] = [(self.root, [])]
while stack:
t, path = stack.pop()
if t is None:
continue
count += 1
assert t.point not in seen
seen.add(t.point)
for a, i in path:
assert quadrant(a, t.point) == i and a != t.point
for i in range(4):
stack.append((t.child[i], path + [(t.point, i)]))
assert count == self.size
test_point_quadtree.py 91 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/point-quadtree/. The authors are listed in prov/C2HX1P.jsonl.
import math
import random
from typing import Optional
from point_quadtree import Node, PointQuadtree
P = [(35, 40), (50, 10), (60, 75), (80, 65), (85, 15), (5, 45), (25, 35), (90, 5)]
def shape(t: Optional[Node]) -> object:
if t is None:
return None
if all(c is None for c in t.child):
return t.point
return (t.point, [shape(c) for c in t.child])
def test_page_example() -> None:
t = PointQuadtree()
for p in P:
assert t.insert(p)
t.check_invariant()
assert shape(t.root) == (
(35, 40),
[(25, 35), ((50, 10), [None, (90, 5), None, (85, 15)]), (5, 45), ((60, 75), [None, (80, 65), None, None])],
)
assert t.height() == 3
assert sorted(t.range_search((20, 30), (70, 80))) == [(25, 35), (35, 40), (60, 75)]
assert t.delete((50, 10))
t.check_invariant()
assert shape(t.root) == (
(35, 40),
[(25, 35), ((85, 15), [None, (90, 5), None, None]), (5, 45), ((60, 75), [None, (80, 65), None, None])],
)
b = PointQuadtree(P)
b.check_invariant()
assert b.root is not None and b.root.point == (60, 75) and b.height() == 3
def test_edges() -> None:
t = PointQuadtree()
assert not t.contains((0, 0)) and not t.delete((0, 0))
assert t.range_search((0, 0), (1, 1)) == []
assert t.insert((1, 1)) and not t.insert((1, 1))
assert t.insert((1, 2)) and t.insert((2, 1)) and t.insert((1, 0))
t.check_invariant()
assert sorted(t.range_search((1, 1), (1, 1))) == [(1, 1)]
assert t.delete((1, 1)) and len(t) == 3
t.check_invariant()
assert sorted(t.points()) == [(1, 0), (1, 2), (2, 1)]
def test_balanced_build_and_sorted_chain() -> None:
n = 1000
pts = [(i, (i * 7919) % n) for i in range(n)]
t = PointQuadtree(pts)
t.check_invariant()
assert t.height() <= math.floor(math.log2(n)) + 1
c = PointQuadtree()
for i in range(n):
c.insert((i, i))
assert c.height() == n
assert len(c.range_search((10, 10), (20, 20))) == 11
assert c.delete((0, 0)) and len(c) == n - 1
def test_random_against_set() -> None:
rng = random.Random(1974)
for _ in range(600):
t = PointQuadtree([(rng.randrange(12), rng.randrange(12)) for _ in range(rng.randrange(0, 15))])
model = set(t.points())
for _ in range(rng.randrange(1, 30)):
p = (rng.randrange(12), rng.randrange(12))
r = rng.random()
if r < 0.4:
assert t.insert(p) == (p not in model)
model.add(p)
elif r < 0.7:
assert t.delete(p) == (p in model)
model.discard(p)
else:
assert t.contains(p) == (p in model)
lo = (rng.randrange(-1, 12), rng.randrange(-1, 12))
hi = (lo[0] + rng.randrange(6), lo[1] + rng.randrange(6))
want = sorted(q for q in model if lo[0] <= q[0] <= hi[0] and lo[1] <= q[1] <= hi[1])
assert sorted(t.range_search(lo, hi)) == want
t.check_invariant()
assert len(t) == len(model)
What the next level needs
- the tests did not run in this build