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
# 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