83 lines
2.5 KiB
Python
83 lines
2.5 KiB
Python
"""Interval overlap/merge utilities with well-defined boundary semantics.
|
|
|
|
Boundary convention: intervals are half-open [start, end). Two intervals
|
|
overlap iff they share at least one point, i.e. max(s1,s2) < min(e1,e2).
|
|
Touching at a point (e.g. [1,2) and [2,3)) does NOT overlap and is NOT
|
|
merged (they remain distinct). This matches the common scheduling convention.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Iterable, List, Tuple
|
|
|
|
Interval = Tuple[int, int]
|
|
|
|
|
|
def _validate(iv: Interval) -> None:
|
|
if not (isinstance(iv, tuple) and len(iv) == 2):
|
|
raise ValueError(f"interval must be 2-tuple, got {iv!r}")
|
|
s, e = iv
|
|
if not (isinstance(s, int) and isinstance(e, int)):
|
|
raise ValueError(f"endpoints must be int, got {iv!r}")
|
|
if s > e:
|
|
raise ValueError(f"start > end in {iv!r}")
|
|
|
|
|
|
def normalize(intervals: Iterable[Interval]) -> List[Interval]:
|
|
"""Validate, drop empties, return sorted by (start, end)."""
|
|
out: List[Interval] = []
|
|
for iv in intervals:
|
|
_validate(iv)
|
|
s, e = iv
|
|
if s == e:
|
|
continue # empty interval contributes nothing
|
|
out.append((s, e))
|
|
out.sort(key=lambda x: (x[0], x[1]))
|
|
return out
|
|
|
|
|
|
def overlaps(a: Interval, b: Interval) -> bool:
|
|
_validate(a); _validate(b)
|
|
return max(a[0], b[0]) < min(a[1], b[1])
|
|
|
|
|
|
def merge(intervals: Iterable[Interval]) -> List[Interval]:
|
|
"""Merge overlapping intervals (half-open). Touching intervals stay separate."""
|
|
ivs = normalize(intervals)
|
|
if not ivs:
|
|
return []
|
|
merged: List[Interval] = [ivs[0]]
|
|
for s, e in ivs[1:]:
|
|
ls, le = merged[-1]
|
|
if s < le: # strict: touching (s == le) does not merge
|
|
merged[-1] = (ls, max(le, e))
|
|
else:
|
|
merged.append((s, e))
|
|
return merged
|
|
|
|
|
|
def total_covered(intervals: Iterable[Interval]) -> int:
|
|
"""Total length of union of intervals (half-open length = end - start)."""
|
|
return sum(e - s for s, e in merge(intervals))
|
|
|
|
|
|
def find_gaps(intervals: Iterable[Interval], lo: int, hi: int) -> List[Interval]:
|
|
"""Gaps within [lo, hi) not covered by any interval."""
|
|
if lo > hi:
|
|
raise ValueError("lo > hi")
|
|
merged = merge(intervals)
|
|
gaps: List[Interval] = []
|
|
cur = lo
|
|
for s, e in merged:
|
|
if e <= cur or s >= hi:
|
|
continue
|
|
ss = max(s, lo)
|
|
ee = min(e, hi)
|
|
if ss > cur:
|
|
gaps.append((cur, ss))
|
|
cur = max(cur, ee)
|
|
if cur >= hi:
|
|
break
|
|
if cur < hi:
|
|
gaps.append((cur, hi))
|
|
return gaps
|