Algorithm Search⌘K

Uniform grid bucketing in Python

Partition space into equal cells and store each point in its cell; the basis of fixed-radius and grid-based closest pair.

File
uniform_grid.py, 121 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
uniform_grid.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/uniform-grid/. The authors are listed in prov/F5M3BP.jsonl.

import math

Point = tuple[float, float]


class UniformGrid:
    """A uniform grid of nx by ny square cells of side h over the box
    [x0, x0 + nx*h) x [y0, y0 + ny*h).

    Cell (i, j) is the half-open square [x0 + i*h, x0 + (i+1)*h) x
    [y0 + j*h, y0 + (j+1)*h); its bucket is the list cells[j*nx + i].
    Invariant: each stored point is in the bucket of the cell that holds it,
    once per copy (the grid stores a multiset of points).
    Space: O(nx*ny + n).
    """

    def __init__(self, x0: float, y0: float, h: float, nx: int, ny: int) -> None:
        if h <= 0 or nx < 1 or ny < 1:
            raise ValueError("need h > 0, nx >= 1 and ny >= 1")
        self.x0, self.y0, self.h, self.nx, self.ny = x0, y0, h, nx, ny
        self.cells: list[list[Point]] = [[] for _ in range(nx * ny)]
        self.n = 0

    def __len__(self) -> int:
        return self.n

    def cell_of(self, p: Point) -> tuple[int, int]:
        """The cell (i, j) that holds p. O(1). Raises ValueError outside the box."""
        i = math.floor((p[0] - self.x0) / self.h)
        j = math.floor((p[1] - self.y0) / self.h)
        if not (0 <= i < self.nx and 0 <= j < self.ny):
            raise ValueError("the point is outside the grid")
        return i, j

    def insert(self, p: Point) -> None:
        """Add one copy of p. O(1)."""
        i, j = self.cell_of(p)  # @line L9
        self.cells[j * self.nx + i].append(p)  # @line L10
        self.n += 1

    def delete(self, p: Point) -> bool:
        """Remove one copy of p; return False if p is not stored. O(1 + size of its bucket)."""
        i, j = self.cell_of(p)  # @line L11
        bucket = self.cells[j * self.nx + i]
        if p not in bucket:  # @line L12
            return False
        bucket.remove(p)  # @line L13
        self.n -= 1
        return True

    def radius_query(self, q: Point, r: float) -> list[Point]:
        """All stored points p with |p - q| <= r (Euclidean), q anywhere.

        Scans the cells that meet the square [q - r, q + r]^2, so the cost is
        O((2*ceil(r/h) + 1)^2 + the number of points in those cells).
        """
        if r < 0:
            return []
        h = self.h
        i0 = max(0, math.floor((q[0] - r - self.x0) / h))  # @line L1
        i1 = min(self.nx - 1, math.floor((q[0] + r - self.x0) / h))
        j0 = max(0, math.floor((q[1] - r - self.y0) / h))
        j1 = min(self.ny - 1, math.floor((q[1] + r - self.y0) / h))
        out: list[Point] = []  # @line L2
        rr = r * r
        for j in range(j0, j1 + 1):  # @line L3
            for i in range(i0, i1 + 1):
                for p in self.cells[j * self.nx + i]:  # @line L4
                    dx, dy = p[0] - q[0], p[1] - q[1]
                    if dx * dx + dy * dy <= rr:  # @line L5
                        out.append(p)
        return out  # @line L6

    def close_pairs(self) -> list[tuple[Point, Point]]:
        """All unordered pairs of stored copies at distance at most h.

        Each cell is paired with itself and with four of its neighbors
        (east, north-east, north, north-west), so each pair of cells is
        examined once. O(nx*ny + n + K) for K pairs at distance at most h.
        """
        out: list[tuple[Point, Point]] = []
        hh = self.h * self.h
        nx, ny = self.nx, self.ny
        for j in range(ny):  # @line L14
            for i in range(nx):
                a = self.cells[j * nx + i]
                for s in range(len(a)):  # @line L15: pairs inside the cell
                    for t in range(s + 1, len(a)):
                        if _d2(a[s], a[t]) <= hh:
                            out.append((a[s], a[t]))
                for di, dj in ((1, 0), (1, 1), (0, 1), (-1, 1)):  # @line L16
                    ii, jj = i + di, j + dj
                    if 0 <= ii < nx and jj < ny:
                        b = self.cells[jj * nx + ii]
                        for p in a:  # @line L17
                            for p2 in b:
                                if _d2(p, p2) <= hh:
                                    out.append((p, p2))
        return out

    def points(self) -> list[Point]:
        """All stored copies. O(nx*ny + n)."""
        return [p for c in self.cells for p in c]

    def check_invariant(self) -> None:
        """Raise AssertionError if a point is in the wrong bucket or the count is wrong."""
        total = 0
        for k, c in enumerate(self.cells):
            for p in c:
                i, j = self.cell_of(p)
                assert j * self.nx + i == k
            total += len(c)
        assert total == self.n


def _d2(a: Point, b: Point) -> float:
    dx, dy = a[0] - b[0], a[1] - b[1]
    return dx * dx + dy * dy
test_uniform_grid.py 89 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/uniform-grid/. The authors are listed in prov/F5M3BP.jsonl.

import math
import random

import pytest

from uniform_grid import UniformGrid

PAGE = [(1.0, 1.0), (2.5, 1.5), (3.5, 3.2), (6.2, 0.5), (6.8, 1.6), (4.1, 5.0), (0.4, 4.4), (5.6, 5.9)]


def test_page_example() -> None:
    g = UniformGrid(0.0, 0.0, 2.0, 4, 3)
    for p in PAGE:
        g.insert(p)
        g.check_invariant()
    assert g.cell_of((3.5, 3.2)) == (1, 1)
    assert sorted(g.radius_query((3.0, 2.0), 2.0)) == [(2.5, 1.5), (3.5, 3.2)]
    pairs = sorted(tuple(sorted(pq)) for pq in g.close_pairs())
    assert pairs == [
        ((1.0, 1.0), (2.5, 1.5)),
        ((2.5, 1.5), (3.5, 3.2)),
        ((3.5, 3.2), (4.1, 5.0)),
        ((4.1, 5.0), (5.6, 5.9)),
        ((6.2, 0.5), (6.8, 1.6)),
    ]
    assert g.delete((2.5, 1.5))
    assert not g.delete((2.5, 1.5))
    g.check_invariant()
    assert g.radius_query((3.0, 2.0), 2.0) == [(3.5, 3.2)]
    assert len(g) == 7


def test_edges() -> None:
    g = UniformGrid(0.0, 0.0, 1.0, 1, 1)
    assert g.radius_query((0.5, 0.5), 10.0) == []
    assert g.close_pairs() == []
    with pytest.raises(ValueError):
        g.insert((1.0, 0.5))  # the cells are half-open
    with pytest.raises(ValueError):
        UniformGrid(0.0, 0.0, 0.0, 1, 1)
    g.insert((0.0, 0.0))
    g.insert((0.0, 0.0))
    assert len(g.radius_query((0.0, 0.0), 0.0)) == 2
    assert len(g.close_pairs()) == 1
    assert g.radius_query((0.0, 0.0), -1.0) == []
    # a query far outside the grid still works
    assert g.radius_query((100.0, 100.0), 1.0) == []


def test_all_in_one_cell() -> None:
    g = UniformGrid(0.0, 0.0, 10.0, 3, 3)
    for k in range(20):
        g.insert((1.0 + k * 0.01, 1.0))
    assert len(g.close_pairs()) == 190


def test_random_against_brute_force() -> None:
    rng = random.Random(17)
    for _ in range(600):
        h = rng.choice([0.5, 1.0, 2.5])
        nx, ny = rng.randint(1, 6), rng.randint(1, 6)
        g = UniformGrid(-1.0, 2.0, h, nx, ny)
        model: list[tuple[float, float]] = []
        for _ in range(rng.randint(0, 25)):
            if model and rng.random() < 0.3:
                p = rng.choice(model)
                model.remove(p)
                assert g.delete(p)
            else:
                p = (round(rng.uniform(-1.0, -1.0 + nx * h - 1e-9), 2), round(rng.uniform(2.0, 2.0 + ny * h - 1e-9), 2))
                if p[0] >= -1.0 + nx * h or p[1] >= 2.0 + ny * h:
                    continue
                g.insert(p)
                model.append(p)
        g.check_invariant()
        q = (rng.uniform(-3, 2 + nx * h), rng.uniform(0, 4 + ny * h))
        r = rng.uniform(0, 3)
        want = sorted(p for p in model if math.dist(p, q) <= r)
        assert sorted(g.radius_query(q, r)) == want
        want_pairs = sorted(
            tuple(sorted((model[a], model[b])))
            for a in range(len(model))
            for b in range(a + 1, len(model))
            if (model[a][0] - model[b][0]) ** 2 + (model[a][1] - model[b][1]) ** 2 <= h * h
        )
        assert sorted(tuple(sorted(pq)) for pq in g.close_pairs()) == want_pairs

What the next level needs

  • the tests did not run in this build

Read the full page about Uniform grid bucketing