Algorithm Search⌘K

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
point_quadtree.pyRawGitHub
# 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

Read the full page about Point quadtree