Interval tree in Python
Balanced tree over intervals that reports all intervals containing a query point or overlapping a query interval.
- File
interval_tree.py, 147 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/interval-tree/. The authors are listed in prov/8H8C9B.jsonl.
import random
from typing import Optional
class _Node:
__slots__ = ("lo", "hi", "uid", "prio", "left", "right", "max")
def __init__(self, lo: int, hi: int, uid: int, prio: float) -> None:
self.lo = lo
self.hi = hi
self.uid = uid
self.prio = prio
self.left: Optional["_Node"] = None
self.right: Optional["_Node"] = None
self.max = hi
def _mx(x: Optional[_Node]) -> float:
return x.max if x is not None else float("-inf")
def _pull(x: _Node) -> _Node:
x.max = max(x.hi, _mx(x.left), _mx(x.right))
return x
def _key(x: _Node) -> tuple[int, int, int]:
return (x.lo, x.hi, x.uid)
def _split(t: Optional[_Node], key: tuple[int, int, int]) -> tuple[Optional[_Node], Optional[_Node]]:
"""Split t into the nodes with key < key and those with key >= key."""
if t is None:
return None, None
if _key(t) < key:
a, b = _split(t.right, key)
t.right = a
return _pull(t), b
a, b = _split(t.left, key)
t.left = b
return a, _pull(t)
def _merge(a: Optional[_Node], b: Optional[_Node]) -> Optional[_Node]:
"""Merge two treaps where all keys of a are below all keys of b."""
if a is None:
return b
if b is None:
return a
if a.prio > b.prio:
a.right = _merge(a.right, b)
return _pull(a)
b.left = _merge(a, b.left)
return _pull(b)
class IntervalTree:
"""A dynamic multiset of closed intervals [lo, hi] in a treap keyed by lo,
with the largest right end of each subtree in the field max.
insert, delete and search take O(log n) expected time; report takes
O((k + 1) log n) expected time for k results. Space O(n).
"""
def __init__(self, seed: int = 0) -> None:
self.root: Optional[_Node] = None
self._rng = random.Random(seed)
self._next = 0
self.size = 0
def insert(self, lo: int, hi: int) -> None:
"""Add the interval [lo, hi]; requires lo <= hi."""
if lo > hi:
raise ValueError("lo must be at most hi")
node = _Node(lo, hi, self._next, self._rng.random())
self._next += 1
a, b = _split(self.root, _key(node))
self.root = _merge(_merge(a, node), b)
self.size += 1
def delete(self, lo: int, hi: int) -> bool:
"""Remove one copy of [lo, hi]; return False if there is none."""
a, rest = _split(self.root, (lo, hi, -1))
mid, b = _split(rest, (lo, hi + 1, -1))
found = mid is not None
if mid is not None:
# every node of mid holds [lo, hi]; drop its root
mid = _merge(mid.left, mid.right)
self.size -= 1
self.root = _merge(_merge(a, mid), b)
return found
def search(self, a: int, b: int) -> Optional[tuple[int, int]]:
"""One stored interval that overlaps [a, b], or None. O(height)."""
x = self.root # @line L1
while x is not None and not (x.lo <= b and a <= x.hi): # @line L2
if x.left is not None and x.left.max >= a: # @line L3
x = x.left
else: # @line L4
x = x.right
return (x.lo, x.hi) if x is not None else None # @line L5
def report(self, a: int, b: int) -> list[tuple[int, int]]:
"""All stored intervals that overlap [a, b], in order of (lo, hi)."""
out: list[tuple[int, int]] = []
stack: list[tuple[Optional[_Node], bool]] = [(self.root, False)]
while stack:
x, expanded = stack.pop()
if x is None:
continue
if not expanded:
if x.max < a: # @line L6
continue
if x.lo <= b: # @line L9
stack.append((x.right, False))
stack.append((x, True))
stack.append((x.left, False)) # @line L7
elif x.lo <= b and a <= x.hi: # @line L8
out.append((x.lo, x.hi))
return out
def check_invariant(self) -> None:
"""Check the search order, the heap order of priorities, the max
fields and the size."""
count = 0
stack: list[tuple[Optional[_Node], Optional[tuple[int, int, int]], Optional[tuple[int, int, int]]]] = [
(self.root, None, None)
]
while stack:
x, low, high = stack.pop()
if x is None:
continue
count += 1
k = _key(x)
assert low is None or low < k, "search order"
assert high is None or k < high, "search order"
assert x.lo <= x.hi, "empty interval"
for c in (x.left, x.right):
if c is not None:
assert c.prio <= x.prio, "heap order"
assert x.max == max(x.hi, _mx(x.left), _mx(x.right)), "max field"
stack.append((x.left, low, k))
stack.append((x.right, k, high))
assert count == self.size, "size"
test_interval_tree.py 85 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/interval-tree/. The authors are listed in prov/8H8C9B.jsonl.
import random
import pytest
from interval_tree import IntervalTree
EXAMPLE = [(16, 21), (8, 9), (25, 30), (5, 10), (15, 23), (17, 19), (26, 26), (0, 3), (6, 7), (19, 20)]
def overlaps(iv: tuple[int, int], a: int, b: int) -> bool:
return iv[0] <= b and a <= iv[1]
def test_example() -> None:
t = IntervalTree(seed=1)
for lo, hi in EXAMPLE:
t.insert(lo, hi)
t.check_invariant()
assert t.report(22, 25) == [(15, 23), (25, 30)]
assert t.report(9, 9) == [(5, 10), (8, 9)]
found = t.search(22, 25)
assert found in {(15, 23), (25, 30)}
assert t.search(11, 14) is None
assert t.report(11, 14) == []
assert t.root is not None and t.root.max == 30
def test_edges() -> None:
t = IntervalTree()
assert t.search(0, 0) is None and t.report(-5, 5) == []
assert not t.delete(1, 2)
with pytest.raises(ValueError):
t.insert(3, 2)
t.insert(4, 4)
t.insert(4, 4)
t.insert(4, 9)
assert t.report(4, 4) == [(4, 4), (4, 4), (4, 9)]
assert t.report(5, 5) == [(4, 9)]
assert t.delete(4, 4)
assert t.report(0, 10) == [(4, 4), (4, 9)]
assert t.delete(4, 4) and not t.delete(4, 4)
assert t.report(0, 10) == [(4, 9)]
t.check_invariant()
sorted_tree = IntervalTree()
for i in range(2000):
sorted_tree.insert(i, i + 1)
assert sorted_tree.report(999, 999) == [(998, 999), (999, 1000)]
sorted_tree.check_invariant()
def test_random_against_brute_force() -> None:
rng = random.Random(4242)
for trial in range(500):
t = IntervalTree(seed=trial)
bag: list[tuple[int, int]] = []
for _ in range(rng.randint(0, 40)):
r = rng.random()
if r < 0.45:
lo = rng.randint(-20, 20)
hi = lo + rng.randint(0, 10)
t.insert(lo, hi)
bag.append((lo, hi))
elif r < 0.6:
if bag and rng.random() < 0.8:
iv = rng.choice(bag)
else:
iv = (rng.randint(-20, 20), rng.randint(20, 30))
expected = iv in bag
assert t.delete(*iv) == expected
if expected:
bag.remove(iv)
else:
a = rng.randint(-25, 25)
b = a + rng.randint(0, 8)
want = sorted(iv for iv in bag if overlaps(iv, a, b))
assert t.report(a, b) == want
got = t.search(a, b)
if want:
assert got is not None and overlaps(got, a, b) and got in bag
else:
assert got is None
t.check_invariant()
What the next level needs
- the tests did not run in this build