40. Min On-Call Points Cover
Problem
Each escalation needs at least one on-call shift starting inside its window. Given a list
of escalation time-windows (start, end), each of which must contain at least
one chosen shift-start "point," find the minimum number of points such that every window
contains at least one of them.
This is the classic greedy interval point cover problem: sort intervals by end, greedily
place a point at the end of the first not-yet-covered interval, skip every interval that
point already covers (start <= point <= end), and repeat. It runs in
O(n log n) time, dominated by the sort.
Source: src/40_min_oncall_points_cover.py
def min_points_to_cover(intervals: list[tuple[int, int]]) -> int:
>>> min_points_to_cover([(1, 4), (2, 6), (5, 8), (7, 9)])
2
>>> min_points_to_cover([(1, 10), (2, 5), (3, 4)])
1
>>> min_points_to_cover([(1, 2), (3, 4), (5, 6)])
3
Step-by-Step Approach
- Handle the empty-input case: no intervals means zero points are needed.
- Sort all intervals by their
endvalue ascending. This ordering is what makes the greedy choice provably optimal. - Walk through the sorted intervals, tracking the most recently placed point (initially none).
- For each interval, check whether the current point already falls inside it (
start <= last_point <= end). If so, this interval is already covered for free — skip it. - If the current point does not cover this interval (or no point has been placed yet), place a new point at this interval's
end. Placing it as late as possible maximizes the chance that later, overlapping intervals get covered by the same point. - Increment the point counter each time a new point is placed.
- Return the total number of points placed once every interval has been processed.
The key insight is an exchange argument: among all points that could cover the earliest-ending uncovered interval, choosing that interval's own end point is never worse than choosing any earlier point, because the end point covers the maximal range of any interval that could possibly overlap this one going forward — so the greedy choice never loses to an optimal solution.
Reference solution
def min_points_to_cover(intervals: list[tuple[int, int]]) -> int:
# sort by end ascending, greedily place a point at the end of each first
# uncovered interval -- classic exchange-argument greedy, O(n log n) time
if not intervals:
return 0
# sort by end, not start
ordered = sorted(intervals, key=lambda iv: iv[1])
points = 0
# sentinel: no point placed yet
last_point = None
# tuple unpacking
for start, end in ordered:
# strictly past last point = uncovered
if last_point is None or start > last_point:
last_point = end
points += 1
return points
Key Functions & Tricks
sorted(intervals, key=lambda iv: iv[1])— new list ordered by each tuple's end value, needed for greedy optimality.for start, end in ordered— tuple unpacking destructures each pair in the loop header.last_point = None— sentinel for "no point placed yet," distinct from any real integer point.start > last_point— strict inequality; a start exactly at the last point is still covered.
How to Recognize This Pattern
Signals: a set of intervals/windows/ranges, and a request for the minimum number of "markers," "points," "arrows," or "shifts" such that every interval contains at least one. Any time the goal is minimum coverage of overlapping ranges by discrete points (not maximum non-overlapping selection), sort by end and greedily place points — this is the mirror image of "maximum number of non-overlapping intervals," which also sorts by end but counts kept intervals instead of placed points.
Common variations: "minimum number of arrows to burst balloons" (identical problem,
different framing); assigning meeting rooms or resources to overlapping time ranges
(interval scheduling / minimum platforms, which typically uses a different technique —
sweeping start/end events with a counter, or a min-heap of end times); or requiring each
point to cover at least k intervals instead of just one.
Common pitfall: sorting by start instead of end breaks the
greedy's optimality — sorting by start doesn't guarantee that placing a point as late as
possible within the first interval maximizes coverage of subsequent overlapping
intervals. Also watch the comparison direction when checking coverage: it must be
start > last_point (strictly greater) to skip an interval that the last
point already covers, not start >= last_point, since a window
boundary equal to the point is still covered.