Master the Activity Selection Problem completely — problem definition, why sorting by end time is the correct greedy criterion, exchange argument proof, implementation variants (maximum activities, weighted interval scheduling, minimum rooms), and interval scheduling interview problems.
Problem Statement
Given n activities, each with a start time s[i] and finish time f[i], select the maximum number of non-overlapping activities that a single machine can perform.
Two activities i and j are compatible if they don't overlap: f[i] ≤ s[j] or f[j] ≤ s[i].
| Activities | [(1,4),(3,5),(0,6),(5,7),(3,9),(5,9),(6,10),(8,11),(8,12),(2,14),(12,16)] |
| Start | 1 3 0 5 3 5 6 8 8 2 12 |
| Finish | 4 5 6 7 9 9 10 11 12 14 16 |
| Optimal selection | 4 non-overlapping activities |
Implementation
def activity_selection(activities):
"""
Select maximum number of non-overlapping activities.
activities: list of (start, finish) tuples
Time: O(n log n) for sorting, O(n) for selection
Space: O(n) for result
"""
# Sort by finish time (greedy criterion)
sorted_activities = sorted(activities, key=lambda x: x[1])
selected = [sorted_activities[0]] # Always take the first (earliest finish)
last_finish = sorted_activities[0][1]
for start, finish in sorted_activities[1:]:
if start >= last_finish: # Compatible with last selected
selected.append((start, finish))
last_finish = finish
return selected
activities = [(1,4),(3,5),(0,6),(5,7),(3,9),(5,9),(6,10),(8,11),(8,12),(2,14),(12,16)]
result = activity_selection(activities)
print(f"Selected {len(result)} activities: {result}")
# 4 activities: (1,4), (5,7), (8,11), (12,16)
Proof of Correctness — Exchange Argument
Theorem: Sorting by earliest finish time and greedily selecting compatible activities yields a maximum-size set.
Proof:
Let G = greedy solution, OPT = any optimal solution.
Order both sets by finish time:
- G: g₁, g₂, ..., g_k (greedy selections)
- OPT: o₁, o₂, ..., o_m (optimal selections)
Claim: k = m (greedy selects as many as optimal).
Proof by induction on i: After i selections, greedy's i-th selected activity finishes no later than optimal's i-th activity.
Base case (i=1): Greedy picks g₁ with the earliest finish. f(g₁) ≤ f(o₁) because g₁ has the globally minimum finish time. ✓
Inductive step: Assume f(gᵢ) ≤ f(oᵢ). Then:
- oᵢ₊₁ starts after oᵢ finishes: s(oᵢ₊₁) ≥ f(oᵢ) ≥ f(gᵢ)
- So oᵢ₊₁ is compatible with gᵢ
- Greedy considers all activities compatible with gᵢ (including oᵢ₊₁)
- Greedy picks the earliest-finishing one: f(gᵢ₊₁) ≤ f(oᵢ₊₁) ✓
Since OPT selects m activities and at each step greedy leaves at least as much room, greedy also selects m activities. ∎
Variant 1 — Minimum Number of Rooms (Interval Partitioning)
import heapq
def min_meeting_rooms(intervals):
"""
Minimum number of rooms to schedule all meetings without overlap.
Greedy: for each new meeting, assign to room that freed up earliest.
Time: O(n log n)
"""
if not intervals: return 0
intervals.sort(key=lambda x: x[0]) # Sort by start time
heap = [] # Min-heap of finish times (one per room in use)
for start, finish in intervals:
if heap and heap[0] <= start:
# Reuse the room that became free earliest
heapq.heapreplace(heap, finish)
else:
# Need a new room
heapq.heappush(heap, finish)
return len(heap)
print(min_meeting_rooms([[0,30],[5,10],[15,20]])) # 2
print(min_meeting_rooms([[7,10],[2,4]])) # 1
print(min_meeting_rooms([[1,5],[2,3],[3,7]])) # 2
Variant 2 — Non-Overlapping Intervals (Minimum Removals)
def erase_overlap_intervals(intervals):
"""
Minimum intervals to remove so remaining are non-overlapping.
Equivalent: n - max_non_overlapping.
Sort by end time, count non-overlapping (greedy keeps them).
"""
if not intervals: return 0
intervals.sort(key=lambda x: x[1])
kept = 0
last_end = float('-inf')
for start, end in intervals:
if start >= last_end:
kept += 1
last_end = end
return len(intervals) - kept
print(erase_overlap_intervals([[1,2],[2,3],[3,4],[1,3]])) # 1 (remove [1,3])
print(erase_overlap_intervals([[1,2],[1,2],[1,2]])) # 2
Variant 3 — Weighted Interval Scheduling (Need DP)
When each activity has a value and you want to maximize total value:
def weighted_interval_scheduling(intervals):
"""
Maximize total weight of non-overlapping weighted intervals.
This requires DP — greedy doesn't work for weighted version!
intervals: list of (start, finish, weight)
Time: O(n log n)
"""
from bisect import bisect_right
intervals.sort(key=lambda x: x[1]) # Sort by finish
n = len(intervals)
# p[i] = index of last interval that finishes before interval i starts
starts = [intervals[i][0] for i in range(n)]
finishes = [intervals[i][1] for i in range(n)]
p = []
for i in range(n):
j = bisect_right(finishes, intervals[i][0] - 1) # Find compatible predecessor
p.append(j - 1)
dp = [0] * (n + 1)
for i in range(1, n+1):
_, _, w = intervals[i-1]
include = w + dp[p[i-1] + 1] # Include interval i
exclude = dp[i-1] # Exclude interval i
dp[i] = max(include, exclude)
return dp[n]
intervals = [(1,3,2),(2,5,4),(4,6,3),(3,8,5),(5,10,3),(7,9,4),(6,8,2)]
print(weighted_interval_scheduling(intervals)) # 9
Variant 4 — Merge Overlapping Intervals
def merge_intervals(intervals):
"""
Merge all overlapping intervals.
Sort by start, merge when current start ≤ previous finish.
"""
if not intervals: return []
intervals.sort(key=lambda x: x[0])
merged = [intervals[0]]
for start, end in intervals[1:]:
if start <= merged[-1][1]:
merged[-1][1] = max(merged[-1][1], end)
else:
merged.append([start, end])
return merged
print(merge_intervals([[1,3],[2,6],[8,10],[15,18]]))
# [[1,6],[8,10],[15,18]]
Complexity Summary
| Problem | Greedy Criterion | Time | Space |
|---|
| Max activities | Earliest finish first | O(n log n) | O(n) |
| Min rooms | Earliest start, reuse earliest-free | O(n log n) | O(n) |
| Min removals | Earliest finish first | O(n log n) | O(1) |
| Weighted (max) | DP required | O(n log n) | O(n) |
| Merge intervals | Sort by start | O(n log n) | O(n) |
Summary
Activity Selection is the canonical greedy problem:
- Greedy choice: Always pick the activity that finishes earliest
- Why correct: Leaves maximum remaining time for other activities
- Proof: Exchange argument — earliest-finishing activity can always replace any other first choice without reducing the total count
- Weighted variant: Greedy fails — use dynamic programming with binary search
*Next Lesson: Fractional Knapsack*