beam: cells for interval_overlap.py -- CHECK 202 -> 214, rc 0; docstring names has_any_overlap, early exit added (Q84)
This commit is contained in:
1 parent
83769897c1
commit
b14d2e42cb
2 files changed
+151
-3
No files matched your search
@@ -1,10 +1,14 @@
|
|||||||
"""Interval overlap detection via sweep-line (O(n log n)).
|
"""Interval overlap detection via sweep-line.
|
||||||
|
|
||||||
Given a list of half-open intervals [start, end), returns all unordered
|
Given a list of half-open intervals [start, end), returns all unordered
|
||||||
pairs (i, j) with i < j whose intervals intersect (share at least one
|
pairs (i, j) with i < j whose intervals intersect (share at least one
|
||||||
point). Half-open semantics: [1,2) and [2,3) do NOT overlap.
|
point). Half-open semantics: [1,2) and [2,3) do NOT overlap.
|
||||||
|
|
||||||
Also exposes `count_overlaps` for the cheaper "is any overlap" style query.
|
Complexity of `find_overlapping_pairs` is O(n log n + k), where n is the
|
||||||
|
number of intervals and k is the number of overlapping pairs it returns
|
||||||
|
(k can be Theta(n^2), so the sort bound alone does not describe it).
|
||||||
|
`has_any_overlap` answers the boolean question in O(n log n) and stops at
|
||||||
|
the first overlapping pair, without building the pair list.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from typing import Iterable, List, Tuple
|
from typing import Iterable, List, Tuple
|
||||||
@@ -46,7 +50,27 @@ def find_overlapping_pairs(intervals: Iterable[Interval]) -> List[Tuple[int, int
|
|||||||
|
|
||||||
|
|
||||||
def has_any_overlap(intervals: Iterable[Interval]) -> bool:
|
def has_any_overlap(intervals: Iterable[Interval]) -> bool:
|
||||||
return bool(find_overlapping_pairs(intervals))
|
"""True if any two half-open intervals overlap; stops at the first pair.
|
||||||
|
|
||||||
|
O(n log n) time, O(n) space: it never materialises the pair list, so a
|
||||||
|
fully-overlapping input costs one comparison rather than n*(n-1)/2 pairs.
|
||||||
|
"""
|
||||||
|
iv = list(intervals)
|
||||||
|
for s, e in iv:
|
||||||
|
if e < s:
|
||||||
|
raise ValueError(f"end < start: {(s, e)}")
|
||||||
|
# Sorted by start, every earlier start is <= the current one, so an
|
||||||
|
# overlap exists iff some earlier non-empty interval ends after this
|
||||||
|
# start. Tracking the running maximum end is enough.
|
||||||
|
max_end = None
|
||||||
|
for s, e in sorted(iv):
|
||||||
|
if s >= e: # zero-length interval: empty set, never overlaps
|
||||||
|
continue
|
||||||
|
if max_end is not None and s < max_end:
|
||||||
|
return True
|
||||||
|
if max_end is None or e > max_end:
|
||||||
|
max_end = e
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def brute_force_pairs(intervals: Iterable[Interval]) -> List[Tuple[int, int]]:
|
def brute_force_pairs(intervals: Iterable[Interval]) -> List[Tuple[int, int]]:
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""Cells for interval_overlap.py -- collected by `unittest discover` in beam/.
|
||||||
|
|
||||||
|
Covers half-open adjacency, zero-length intervals, the end<start refusal, an
|
||||||
|
exhaustive cross-check of the sweep against the brute-force definition, and
|
||||||
|
`has_any_overlap` in both directions -- including the structural claim that it
|
||||||
|
does not build the pair list to answer a boolean.
|
||||||
|
"""
|
||||||
|
import itertools
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import interval_overlap as mod
|
||||||
|
|
||||||
|
|
||||||
|
def _domain(low=-2, high=3):
|
||||||
|
return [(s, e) for s in range(low, high + 1) for e in range(s, high + 1)]
|
||||||
|
|
||||||
|
|
||||||
|
class IntervalOverlapTest(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_half_open_adjacency_both_orders(self):
|
||||||
|
self.assertEqual(mod.find_overlapping_pairs([(1, 2), (2, 3)]), [])
|
||||||
|
self.assertEqual(mod.find_overlapping_pairs([(2, 3), (1, 2)]), [])
|
||||||
|
|
||||||
|
def test_zero_length_against_wide_interval(self):
|
||||||
|
self.assertEqual(mod.find_overlapping_pairs([(5, 5), (1, 10)]), [])
|
||||||
|
self.assertEqual(mod.find_overlapping_pairs([(1, 10), (5, 5)]), [])
|
||||||
|
|
||||||
|
def test_zero_length_touching_boundary(self):
|
||||||
|
self.assertEqual(mod.find_overlapping_pairs([(2, 2), (1, 2)]), [])
|
||||||
|
self.assertEqual(mod.find_overlapping_pairs([(1, 2), (2, 2)]), [])
|
||||||
|
|
||||||
|
def test_zero_length_pair_is_empty(self):
|
||||||
|
self.assertEqual(mod.find_overlapping_pairs([(3, 3), (3, 3)]), [])
|
||||||
|
self.assertEqual(mod.find_overlapping_pairs([(0, 0)]), [])
|
||||||
|
|
||||||
|
def test_nested_and_disjoint_pairs(self):
|
||||||
|
self.assertEqual(
|
||||||
|
mod.find_overlapping_pairs([(1, 10), (2, 3), (4, 5)]),
|
||||||
|
[(0, 1), (0, 2)],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
mod.find_overlapping_pairs([(1, 3), (2, 4)]),
|
||||||
|
[(0, 1)],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_valueerror_when_end_before_start(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
mod.find_overlapping_pairs([(3, 1)])
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
mod.has_any_overlap([(0, 5), (9, 2)])
|
||||||
|
|
||||||
|
def test_exhaustive_multisets_against_brute_force(self):
|
||||||
|
intervals = _domain()
|
||||||
|
self.assertEqual(len(intervals), 21)
|
||||||
|
cases = 0
|
||||||
|
for size in range(4):
|
||||||
|
for case in itertools.combinations_with_replacement(intervals, size):
|
||||||
|
cases += 1
|
||||||
|
ivs = list(case)
|
||||||
|
self.assertEqual(
|
||||||
|
mod.find_overlapping_pairs(ivs),
|
||||||
|
mod.brute_force_pairs(ivs),
|
||||||
|
msg="case %r" % (ivs,),
|
||||||
|
)
|
||||||
|
# Pins the enumeration itself: 21 multisets of size 0-3 -> 2024 cases.
|
||||||
|
self.assertEqual(cases, 2024)
|
||||||
|
|
||||||
|
def test_randomised_against_brute_force(self):
|
||||||
|
rng = random.Random(42)
|
||||||
|
for _ in range(2000):
|
||||||
|
ivs = []
|
||||||
|
for _ in range(rng.randint(0, 12)):
|
||||||
|
a = rng.randint(-5, 5)
|
||||||
|
ivs.append((a, a + rng.randint(0, 6)))
|
||||||
|
self.assertEqual(
|
||||||
|
mod.find_overlapping_pairs(ivs),
|
||||||
|
mod.brute_force_pairs(ivs),
|
||||||
|
msg="case %r" % (ivs,),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_has_any_overlap_true_and_false(self):
|
||||||
|
self.assertTrue(mod.has_any_overlap([(1, 3), (2, 4)]))
|
||||||
|
self.assertTrue(mod.has_any_overlap([(1, 10), (2, 3)]))
|
||||||
|
self.assertFalse(mod.has_any_overlap([(1, 2), (2, 3)]))
|
||||||
|
self.assertFalse(mod.has_any_overlap([(5, 5), (1, 10)]))
|
||||||
|
self.assertFalse(mod.has_any_overlap([]))
|
||||||
|
self.assertFalse(mod.has_any_overlap([(1, 2)]))
|
||||||
|
|
||||||
|
def test_has_any_overlap_agrees_with_pairs(self):
|
||||||
|
rng = random.Random(7)
|
||||||
|
for _ in range(500):
|
||||||
|
ivs = []
|
||||||
|
for _ in range(rng.randint(0, 10)):
|
||||||
|
a = rng.randint(-4, 4)
|
||||||
|
ivs.append((a, a + rng.randint(0, 5)))
|
||||||
|
self.assertEqual(
|
||||||
|
mod.has_any_overlap(ivs),
|
||||||
|
bool(mod.find_overlapping_pairs(ivs)),
|
||||||
|
msg="case %r" % (ivs,),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_has_any_overlap_does_not_build_pair_list(self):
|
||||||
|
# The "cheaper" claim is structural: answering the boolean must not
|
||||||
|
# route through the pair-list function. If it does, this cell dies.
|
||||||
|
with mock.patch.object(
|
||||||
|
mod, "find_overlapping_pairs", side_effect=AssertionError("materialised pairs")
|
||||||
|
):
|
||||||
|
self.assertTrue(mod.has_any_overlap([(0, 1), (0, 1)]))
|
||||||
|
|
||||||
|
def test_has_any_overlap_large_overlapping_input_is_cheap(self):
|
||||||
|
# 2000 intervals all sharing [0, 1): one comparison answers it. The
|
||||||
|
# pair-list function takes tens of seconds on this input.
|
||||||
|
ivs = [(0, 1000)] * 2000
|
||||||
|
start = time.perf_counter()
|
||||||
|
self.assertTrue(mod.has_any_overlap(ivs))
|
||||||
|
elapsed = time.perf_counter() - start
|
||||||
|
self.assertLess(elapsed, 5.0, "took %.2fs" % elapsed)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main(verbosity=2)
|
||||||
Reference in new issue
Block a user