Interval Scheduling
The family of interval problems: unweighted selection (greedy by finish), interval partitioning into minimum rooms (greedy by start with a min-heap), and weighted selection (DP with binary search).
Overview
Three closely related problems appear under this name, and knowing which greedy rule applies to which is the point. Unweighted selection (maximize count) is Activity Selection: sort by finish, take greedily. Interval partitioning (minimum number of rooms so every interval gets a room) is solved by sorting by start and assigning each interval to any room that is free, tracked with a Min-Heap of room end times; the answer equals the maximum depth of overlap. Weighted selection (maximize total weight) has no greedy solution; it is O(n log n) DP: dp[i] = max(dp[i−1], w_i + dp[p(i)]) where p(i) is the last interval finishing before i starts, found by Binary Search.
The partitioning greedy is optimal because the number of rooms it opens equals the maximum number of intervals alive at one instant, which is an obvious lower bound.
Intuition
A mental model before the formal terms.
Rooms: process meetings in start order. When a meeting starts, look at the room that frees up earliest. If it is free, reuse it; if not, every room is busy right now, so a new room is unavoidable — and at that moment you can see all the rooms overlapping at once.
Weighted: at each interval you either skip it (keep the best so far) or take it, in which case you are back to the best answer over intervals ending before it starts. Greedy cannot decide that without knowing the future, so you tabulate.
How it works
- Partitioning: sort by start. Maintain a min-heap of end times of occupied rooms. For each interval, if
heap.top ≤ start, pop (room freed); push the interval's end. The heap's maximum size is the number of rooms. - Weighted: sort by finish. For each
i, findp(i)= largestj < iwithfinish_j ≤ start_ivia binary search on finish times.dp[i] = max(dp[i−1], w_i + dp[p(i)]);dp[0] = 0. - Unweighted: see Activity Selection.
Why it works
Partitioning lower bound / greedy-choice. If k intervals all contain some instant t, any schedule needs ≥ k rooms. The greedy opens a new room only when the interval starting now conflicts with every existing room, i.e. all rooms' intervals contain the current start t — so at that instant depth is rooms + 1. Hence rooms opened ≤ max depth ≤ optimum; greedy is optimal. Sorting by start is essential: it guarantees that a room is busy only if its interval contains t.
Weighted DP. The last interval (in finish order) is either in the optimal set or not. If not, the answer is dp[n−1]. If yes, all other chosen intervals finish before it starts, so they form an optimal solution to the prefix 1..p(n). This exhaustive case split is the recurrence; greedy fails because "take or skip" depends on dp[p(i)], a global quantity.
Recognition
How to tell a problem wants this.
- "Minimum number of rooms / platforms / machines" → partitioning (heap or sweep).
- "Maximum total value / profit of non-overlapping jobs" → weighted DP.
- "Maximum number of non-overlapping" → unweighted greedy.
- "Can one person attend all meetings" → sort by start and check adjacent overlap.
Interactive visualization
Play, step, change the input. ← → and space work too.
No interactive visualization for this topic yet
Related visualizations are linked under Related.
Pseudocode
1# partitioning: minimum rooms2sort by start; heap = []3for (s, e) in intervals:4 if heap and heap.top <= s: heap.pop()5 heap.push(e)6 rooms = max(rooms, len(heap))7 8# weighted selection9sort by finish; dp[0] = 010for i in 1..n: p = last j < i with finish[j] <= start[i] (binary search)11 dp[i] = max(dp[i-1], w[i] + dp[p])Implementations
1import heapq2from bisect import bisect_right3from typing import NamedTuple4 5# The interval family: three problems that look alike and need three different6# tools. Intervals are [start, end) — touching is not overlapping.7 8 9class Interval(NamedTuple):10 start: int11 end: int12 weight: int = 013 14 151 · UNWEIGHTED selection — greedy by earliest END. Maximum count.16def max_non_overlapping(iv: list[Interval]) -> int:17 count = 018 last_end = float("-inf")19 for x in sorted(iv, key=lambda v: v.end):20 if x.start >= last_end:21 count += 122 last_end = x.end23 return count24 25 262 · PARTITIONING — minimum rooms. Greedy by earliest START plus a min-heap27# of the end times currently in use; the heap size is the answer.28def min_rooms(iv: list[Interval]) -> int:29 heap: list[int] = [] # end times of the rooms currently in use30 for x in sorted(iv, key=lambda v: v.start):31 # A room frees up whenever its meeting ended at or before this start32 if heap and heap[0] <= x.start:33 heapq.heappop(heap)34 heapq.heappush(heap, x.end)35 return len(heap)36 37 383 · The partitioning answer equals the maximum number of intervals39# overlapping at any single point — the "depth" of the arrangement40def max_depth(iv: list[Interval]) -> int:41 events: list[tuple[int, int]] = []42 for x in iv:43 events.append((x.start, 1))44 events.append((x.end, -1))45 # ends before starts at the same time, since [a,b) and [b,c) do not overlap46 events.sort() # (time, delta): -1 sorts before +1 at equal times47 cur = best = 048 for _, delta in events:49 cur += delta50 best = max(best, cur)51 return best52 53 544 · WEIGHTED selection — greedy fails, so this is DP plus a binary search55# for the last interval that ends at or before the current one starts56def max_weight(iv: list[Interval]) -> int:57 ordered = sorted(iv, key=lambda v: v.end)58 n = len(ordered)59 ends = [x.end for x in ordered]60 dp = [0] * (n + 1)61 for i, x in enumerate(ordered):625 · p = number of intervals fully before ordered[i]; take it or skip it63 p = bisect_right(ends, x.start, 0, i)64 dp[i + 1] = max(dp[i], dp[p] + x.weight)65 return dp[n]heapqsupplies the min-heap directly, somin_roomsis six lines — the shortest of the four versions by a wide margin.events.sort()with no key works because tuples compare lexicographically and-1 < 1, so ends sort before starts at equal times automatically. That is a real convenience, and it is also a trap if the delta encoding is ever flipped.bisect_right(ends, x.start, 0, i)searches only the prefix[0, i)and returns the count of intervals ending at or before this start — thelo/hiarguments avoid slicing.Interval(NamedTuple)withweight: int = 0gives a default so the unweighted problems can construct two-field intervals.sorted(iv, key=...)copies in every function, so none of them mutates the caller.
heapqis a min-heap, which is exactly what room-freeing wants — no comparator inversion, unlike C++.- Tuple lexicographic comparison makes the bare
events.sort()correct here, but it is worth a comment because the correctness depends on-1 < 1, not on any explicit intent. bisect_rightversusbisect_leftis the half-open-versus-closed decision, and thelo/hiparameters keep it allocation-free.NamedTuplefield defaults must come last, which is whyweightis the third field rather than the second.
- Relying on
events.sort()without understanding that the tie-break comes from-1 < 1— flipping the delta encoding silently breaks it. - Using
bisect_leftand dropping an interval that ends exactly when the next starts. - Slicing
ends[:i]for the binary search instead of passinghi=i, which allocates on every iteration.
- The min-heap decides the length of
minRooms:heapqmakes it six lines in Python,std::priority_queue(withstd::greater) makes it eight in C++, and JS/TS carry a 25-line inline heap. - Binary search on the prefix is a library call with bounds in Python (
bisect_right(ends, x, 0, i)) and C++ (upper_bound(begin, begin+i, x)), and hand-written in JS/TS. - Event-sweep tie-breaking is automatic in Python (tuples compare lexicographically and
-1 < 1) and needs an explicit comparator in C++ and JS/TS — where JavaScript would otherwise stringify the pairs. - Every language must copy before sorting to stay side-effect free, but only Python gets it by default through
sorted()versuslist.sort().
Complexity
Partitioning: sort + n heap operations. Weighted: sort + n binary searches + O(n) DP table.
Compare growth rates in the Complexity Explorer →When to use — and when not to
- Minimum resources for a set of time intervals (rooms, platforms, CPUs).
- Maximum-weight compatible subset of intervals (weighted jobs).
- Any sweep over interval endpoints where the relevant state is "how many are open right now".
- Greedy by finish time on weighted intervals: jobs
[1,4] w=3,[3,5] w=5,[0,6] w=8. Earliest-finish takes[1,4]then nothing compatible (total 3); optimum is[0,6]with 8. Use the DP. - Partitioning with intervals sorted by finish instead of start:
[1,10],[2,3],[4,5]in finish order puts[2,3]and[4,5]in room 1, then[1,10]conflicts — still 2, but with[1,3],[2,4],[3,5]… the finish-order assignment can over-allocate. Start order is the one with the proof. - Intervals with resource capacities or precedence constraints — general scheduling is NP-hard; use search or ILP.
Alternatives
Common mistakes
- Popping only one room per interval is correct; popping all free rooms is also correct but a common source of off-by-one when counting.
- Using
<instead of≤when an interval may start exactly when another ends. - In the weighted DP, binary-searching for
finish < startwhen the problem allows touching (finish ≤ start), or searching over unsorted finishes. - Forgetting that
p(i)must be searched among jobs beforeiin finish order.
Interview patterns
- Meeting Rooms I (sort and check adjacent), II (heap or sweep of +1/−1 events).
- Maximum Profit in Job Scheduling: weighted DP with binary search.
- Minimum platforms / car fleet style sweeps: sort events, track open count.
- Choosing between BFS, DFS, Dijkstra and DPAdvanced
- Where does O(n log n) come from?Beginner
- When space complexity mattersIntermediate
- Top K from a streamIntermediate
- Kth Largest Element in an ArrayIntermediate
- Network Delay TimeAdvanced
- Coin ChangeIntermediate