beam: add interval_tree with point-stabbing queries + tests
This commit is contained in:
1 parent
926f9a1c59
commit
0dce52d3bb
2 files changed
+211
No files matched your search
@@ -0,0 +1,128 @@
|
||||
"""Interval tree (center-point partitioning) with point-stabbing queries.
|
||||
|
||||
An interval tree stores closed intervals [low, high] and answers:
|
||||
which intervals contain point x?
|
||||
in O(log n + k) where k is the number of reported intervals.
|
||||
|
||||
Design
|
||||
------
|
||||
Each node holds:
|
||||
- center: a point (median of midpoints of the node's intervals)
|
||||
- left: intervals entirely to the left of center (high < center)
|
||||
- right: intervals entirely to the right of center (low > center)
|
||||
- cross: intervals crossing center (low <= center <= high), stored twice:
|
||||
cross_by_low sorted by low (ascending)
|
||||
cross_by_high sorted by high (descending)
|
||||
- left_child, right_child
|
||||
|
||||
Query at point x:
|
||||
- If x == center: every interval in cross contains x.
|
||||
- If x < center: intervals in cross with low <= x contain x
|
||||
(since high >= center > x automatically); recurse left.
|
||||
- If x > center: intervals in cross with high >= x contain x
|
||||
(since low <= center < x automatically); recurse right.
|
||||
|
||||
Insert/delete rebuild the affected subtree from its interval list, keeping
|
||||
the structure simple and obviously correct. Complexity is O(n log n) per
|
||||
mutating op in the worst case, acceptable for the intended use (correctness
|
||||
over micro-performance).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Iterable, List, Tuple
|
||||
|
||||
Interval = Tuple[int, int]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Node:
|
||||
center: int
|
||||
cross_by_low: List[Interval] = field(default_factory=list)
|
||||
cross_by_high: List[Interval] = field(default_factory=list)
|
||||
left_child: "_Node | None" = None
|
||||
right_child: "_Node | None" = None
|
||||
left_intervals: List[Interval] = field(default_factory=list)
|
||||
right_intervals: List[Interval] = field(default_factory=list)
|
||||
|
||||
|
||||
def _build(intervals: List[Interval]) -> "_Node | None":
|
||||
if not intervals:
|
||||
return None
|
||||
# center = median of midpoints
|
||||
mids = sorted((lo + hi) // 2 for lo, hi in intervals)
|
||||
center = mids[len(mids) // 2]
|
||||
cross, left, right = [], [], []
|
||||
for lo, hi in intervals:
|
||||
if hi < center:
|
||||
left.append((lo, hi))
|
||||
elif lo > center:
|
||||
right.append((lo, hi))
|
||||
else:
|
||||
cross.append((lo, hi))
|
||||
node = _Node(center=center)
|
||||
node.cross_by_low = sorted(cross, key=lambda iv: iv[0])
|
||||
node.cross_by_high = sorted(cross, key=lambda iv: iv[1], reverse=True)
|
||||
node.left_intervals = left
|
||||
node.right_intervals = right
|
||||
node.left_child = _build(left)
|
||||
node.right_child = _build(right)
|
||||
return node
|
||||
|
||||
|
||||
class IntervalTree:
|
||||
def __init__(self, intervals: Iterable[Interval] = ()):
|
||||
self._intervals: List[Interval] = list(intervals)
|
||||
# validate
|
||||
for lo, hi in self._intervals:
|
||||
if lo > hi:
|
||||
raise ValueError(f"invalid interval: low {lo} > high {hi}")
|
||||
self._root = _build(self._intervals)
|
||||
|
||||
def _rebuild(self) -> None:
|
||||
self._root = _build(self._intervals)
|
||||
|
||||
def insert(self, low: int, high: int) -> None:
|
||||
if low > high:
|
||||
raise ValueError(f"invalid interval: low {low} > high {high}")
|
||||
self._intervals.append((low, high))
|
||||
self._rebuild()
|
||||
|
||||
def delete(self, low: int, high: int) -> bool:
|
||||
try:
|
||||
self._intervals.remove((low, high))
|
||||
except ValueError:
|
||||
return False
|
||||
self._rebuild()
|
||||
return True
|
||||
|
||||
def query_point(self, x: int) -> List[Interval]:
|
||||
res: List[Interval] = []
|
||||
node = self._root
|
||||
while node is not None:
|
||||
if x == node.center:
|
||||
res.extend(node.cross_by_low)
|
||||
# no need to recurse: all cross contain center==x;
|
||||
# left/right intervals are strictly separated
|
||||
break
|
||||
if x < node.center:
|
||||
for lo, hi in node.cross_by_low:
|
||||
if lo <= x: # hi >= center > x guaranteed
|
||||
res.append((lo, hi))
|
||||
else:
|
||||
break
|
||||
node = node.left_child
|
||||
else: # x > center
|
||||
for lo, hi in node.cross_by_high:
|
||||
if hi >= x: # lo <= center < x guaranteed
|
||||
res.append((lo, hi))
|
||||
else:
|
||||
break
|
||||
node = node.right_child
|
||||
return res
|
||||
|
||||
def all_intervals(self) -> List[Interval]:
|
||||
return list(self._intervals)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._intervals)
|
||||
@@ -0,0 +1,83 @@
|
||||
import random
|
||||
from interval_tree import IntervalTree
|
||||
|
||||
|
||||
def test_empty():
|
||||
t = IntervalTree()
|
||||
assert t.query_point(5) == []
|
||||
assert len(t) == 0
|
||||
|
||||
|
||||
def test_basic():
|
||||
t = IntervalTree([(1, 5), (2, 3), (4, 8), (10, 12)])
|
||||
assert sorted(t.query_point(3)) == [(1, 5), (2, 3)]
|
||||
assert sorted(t.query_point(4)) == [(1, 5), (4, 8)]
|
||||
assert sorted(t.query_point(11)) == [(10, 12)]
|
||||
assert t.query_point(0) == []
|
||||
assert t.query_point(100) == []
|
||||
|
||||
|
||||
def test_point_intervals():
|
||||
t = IntervalTree([(5, 5), (5, 5), (1, 9)])
|
||||
assert sorted(t.query_point(5)) == [(1, 9), (5, 5), (5, 5)]
|
||||
|
||||
|
||||
def test_insert_delete():
|
||||
t = IntervalTree()
|
||||
t.insert(1, 4)
|
||||
t.insert(3, 6)
|
||||
assert sorted(t.query_point(3)) == [(1, 4), (3, 6)]
|
||||
assert t.delete(1, 4) is True
|
||||
assert t.delete(1, 4) is False
|
||||
assert t.query_point(3) == [(3, 6)]
|
||||
assert len(t) == 1
|
||||
|
||||
|
||||
def test_invalid():
|
||||
try:
|
||||
IntervalTree([(2, 1)])
|
||||
raise AssertionError("expected ValueError")
|
||||
except ValueError:
|
||||
pass
|
||||
t = IntervalTree()
|
||||
try:
|
||||
t.insert(5, 1)
|
||||
raise AssertionError("expected ValueError")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def test_random_brute():
|
||||
rng = random.Random(42)
|
||||
for trial in range(200):
|
||||
n = rng.randint(0, 40)
|
||||
ivs = []
|
||||
for _ in range(n):
|
||||
a = rng.randint(-20, 20)
|
||||
b = rng.randint(-20, 20)
|
||||
lo, hi = (a, b) if a <= b else (b, a)
|
||||
ivs.append((lo, hi))
|
||||
t = IntervalTree(ivs)
|
||||
for x in range(-25, 26):
|
||||
got = sorted(t.query_point(x))
|
||||
exp = sorted(iv for iv in ivs if iv[0] <= x <= iv[1])
|
||||
assert got == exp, (trial, x, got, exp)
|
||||
# mutate
|
||||
for _ in range(5):
|
||||
if rng.random() < 0.5 and ivs:
|
||||
iv = rng.choice(ivs)
|
||||
t.delete(*iv)
|
||||
ivs.remove(iv)
|
||||
else:
|
||||
a = rng.randint(-20, 20); b = rng.randint(-20, 20)
|
||||
lo, hi = (a, b) if a <= b else (b, a)
|
||||
t.insert(lo, hi); ivs.append((lo, hi))
|
||||
for x in range(-25, 26):
|
||||
got = sorted(t.query_point(x))
|
||||
exp = sorted(iv for iv in ivs if iv[0] <= x <= iv[1])
|
||||
assert got == exp, (trial, x, got, exp)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest, sys
|
||||
sys.exit(pytest.main([__file__, "-q"]))
|
||||
Reference in new issue
Block a user