Files
troupe-standup/wizard-work/beam/interval_overlap.py
T

112 lines
4.0 KiB
Python

"""Interval overlap detection via sweep-line.
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
point). Half-open semantics: [1,2) and [2,3) do NOT overlap.
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 typing import Iterable, List, Tuple
Interval = Tuple[int, int]
def find_overlapping_pairs(intervals: Iterable[Interval]) -> List[Tuple[int, int]]:
"""Return sorted list of (i, j) index pairs (i<j) whose intervals overlap."""
iv = list(intervals)
n = len(iv)
# Validate
for s, e in iv:
if e < s:
raise ValueError(f"end < start: {(s, e)}")
# Sweep events: (point, kind, index). kind 0 = start, 1 = end so that
# an interval ending at x is removed before one starting at x (half-open).
events: List[Tuple[int, int, int]] = []
for idx, (s, e) in enumerate(iv):
events.append((s, 1, idx)) # start (kind 1) processed after ends at same point
events.append((e, 0, idx)) # end (kind 0) first for half-open semantics
events.sort(key=lambda x: (x[0], x[1]))
active: set = set()
pairs: set = set()
for point, kind, idx in events:
if kind == 1: # start
s, e = iv[idx]
if s >= e: # zero-length interval: empty set, never overlaps
continue
for other in active:
a, b = (idx, other) if idx < other else (other, idx)
pairs.add((a, b))
active.add(idx)
else: # end: remove
active.discard(idx)
return sorted(pairs)
def has_any_overlap(intervals: Iterable[Interval]) -> bool:
"""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]]:
iv = list(intervals)
n = len(iv)
out = []
for i in range(n):
for j in range(i + 1, n):
s1, e1 = iv[i]
s2, e2 = iv[j]
# half-open overlap: max(starts) < min(ends)
if max(s1, s2) < min(e1, e2):
out.append((i, j))
return out
if __name__ == "__main__":
import random
random.seed(42)
for trial in range(2000):
n = random.randint(0, 12)
ivs = []
for _ in range(n):
a = random.randint(-5, 5)
b = a + random.randint(0, 6)
ivs.append((a, b))
got = find_overlapping_pairs(ivs)
exp = brute_force_pairs(ivs)
if got != exp:
print("MISMATCH", ivs, got, exp)
raise SystemExit(1)
print("all 2000 random trials passed")
# edge cases
assert find_overlapping_pairs([(1, 2), (2, 3)]) == []
assert find_overlapping_pairs([(1, 3), (2, 4)]) == [(0, 1)]
assert find_overlapping_pairs([]) == []
assert find_overlapping_pairs([(5, 5)]) == [] # zero-length never overlaps
print("edge cases passed")