Greedy Algorithms/Scheduling & Interval Partitioning

Lesson 7.22,615 words

Scheduling & Interval Partitioning

Three classic scheduling problems all yield to greedy algorithms — and all three turn on a single design decision: which key to sort by. Interval scheduling sorts by finish time to pack the most compatible jobs; interval partitioning sorts by start time and proves the rooms needed equal the maximum overlap depth; minimizing maximum lateness sorts by deadline and is justified by an adjacent-swap exchange argument.

╌╌╌╌

The previous lesson introduced the greedy method on its canonical example: activity selection, where we pick a maximum-size set of mutually compatible intervals by repeatedly taking the one that finishes earliest. That was a single problem solved by a single sort key. This lesson covers the whole family of interval and scheduling problems that the greedy method solves; they are nearly the same algorithm with different sort keys. Sort by finish time to maximize how many jobs fit; by start time to minimize how many machines you need; by deadline to minimize how late the worst job runs. Choosing the key correctly, and proving that choice optimal, is the entire problem.

Interval scheduling, recapped

Recall the setup. We are given intervals, interval being the half-open , and two intervals are compatible when they do not overlap. We want a maximum-size set of pairwise compatible intervals, the most jobs we can run on one machine without conflict.1

The greedy rule, proved correct last lesson, is earliest finish time first: sort by , take the first interval, discard everything that overlaps it, and recurse on the rest. The selection itself is one linear scan, so after the sort the algorithm runs in .

Greedy never falls behind: pick the earliest-finishing interval each round, and greedy's -th choice frees the machine no later than any rival schedule's -th, so it always has at least as much room left.

Earliest-finish-first stays ahead. Greedy's -th interval (green) finishes no later than the -th interval of any other compatible schedule (gray), so greedy always has at least as much room left.

For example, take five requests for one machine, already sorted by finish time:

Interval
finish 45678

Scanning in finish order and keeping , the finish of the most recently accepted interval:

StepInterval before?Action
1accept,
2? noreject
3? noreject
4? yesaccept,
5? noreject

Greedy accepts , a maximum set of two: no three of these five intervals are pairwise compatible, since alone overlaps every other, and share the point . The rule discards and the moment they overlap the last acceptance, and never revisits them.

The output is a maximum-size set of mutually compatible intervals, computed in . Everything below reuses this skeleton (sort, scan, exchange-argument proof) with a different key and a different objective.

interval_scheduling.pypython
from typing import NamedTuple, Sequence

class Interval(NamedTuple):
  """
    A half-open interval [start, finish); two intervals are compatible\n
    when they do not overlap (one's start is at or after the other's finish).\n
  """
  start: float
  finish: float

def schedule_intervals(intervals: Sequence[Interval]) -> list[Interval]:
  """
    A maximum-size subset of `intervals` that are pairwise compatible,\n
    chosen by earliest finish time. Returns them in finish-time order.\n
  """
  by_finish: list[Interval] = sorted(intervals, key=lambda item: item.finish)
  chosen: list[Interval] = []
  last_finish: float = float("-inf")

  # take each interval only if it starts at or after the last chosen finish.
  for interval in by_finish:
    if interval.start >= last_finish:
      chosen.append(interval)
      last_finish = interval.finish

  return chosen

Interval partitioning: minimize the rooms

Now flip the question. Instead of dropping intervals to fit on one machine, we keep all of them and ask for the fewest machines (rooms, colors, frequencies) needed to run them, where two intervals sharing a machine must be compatible. Phrased as graph coloring: color the intervals so that any two overlapping intervals get different colors, using the minimum number of colors.2

The right invariant is depth. Define the depth at a point as the number of intervals containing , and let be the maximum over all points. Depth is a hard lower bound on rooms, and greedy matches it.

colors needed = max overlap depth (highlighted line crosses 3 intervals)

The greedy algorithm sorts by start time and keeps a min-heap of machines keyed by the time each becomes free. For each interval in start order, if the machine that frees earliest is already free by this interval's start, reuse it; otherwise open a new machine.

Algorithm:Partition(I)\textsc{Partition}(I) — fewest machines for intervals II
  1. 1
    sort II by start time sis_i ascending
  2. 2
    HH \gets empty min-heap of machines keyed by free time
  3. 3
    for each interval ii in start order do
  4. 4
    if HH nonempty and min(H).freesi\min(H).free \le s_i then
  5. 5
    mExtract-Min(H)m \gets \textsc{Extract-Min}(H)
    reuse earliest-freed machine
  6. 6
    else
  7. 7
    mm \gets new machine
    none free: open one
  8. 8
    m.freefim.free \gets f_i
  9. 9
    Insert(H,m)\textsc{Insert}(H, m)
  10. 10
    return H|H|
    total machines opened

We trace the scan. Intervals enter in start order; each reuses the machine that frees earliest if it is already free, otherwise it opens a new one. The third interval arrives while both open machines are still busy, so it forces a third; that instant is a point of depth , the witness for the lower bound.

assigning six intervals in start order to three machines. Interval (red) arrives while R1 and R2 are still busy, opening R3; later intervals reuse a machine that has freed. The forced opening at is exactly a depth- point.

The min-heap is what makes each step cheap: it holds one entry per open machine, keyed by the time that machine next frees, so the earliest-freeing machine is always at the root. Walking the six intervals through it shows the heap state at each step. Each row lists the arriving interval, the root's free time, whether that machine can be reused, and the heap after the step.

Intervalroot free timereuse?machinesheap after (free times)
— (empty)open R11
? no, open R22
? no, open R33
? yes, reuse R13
? yes, reuse R23
? yes, reuse R33

The machine count climbs to over the first three intervals — precisely the depth- overlap of , , — and every later interval finds a freed machine at the root, so the count never rises again. The answer is , matching .

Because we processed intervals in start order, no interval we open a machine for overlaps a future interval that already passed, so the depth witness is real, not an artifact of order. The sort is and each interval does heap work, for overall.3 This is exactly LeetCode's Meeting Rooms II and Minimum Number of Arrows in disguise: the first asks for directly; the second asks for the complementary count of points that stab all intervals.

interval_partitioning.pypython
import heapq
from typing import NamedTuple, Sequence

class Interval(NamedTuple):
  """
    A half-open interval [start, finish) to be assigned to a machine.\n
  """
  start: float
  finish: float

class Machine:
  """
    One machine (room, color) with the half-open intervals assigned to it,\n
    in the order they were placed.\n
  """

  def __init__(self) -> None:
    self.intervals: list[Interval] = []

  @property
  def free_at(self) -> float:
    """
      The time this machine next becomes free — the finish of its last\n
      interval, or -infinity when it is still empty.\n
    """
    if not self.intervals:
      return float("-inf")
    return self.intervals[-1].finish

  def assign(self, interval: Interval) -> None:
    """
      Place `interval` on this machine.\n
    """
    self.intervals.append(interval)

def partition_intervals(intervals: Sequence[Interval]) -> list[Machine]:
  """
    Assign every interval to a machine so that no machine holds two\n
    overlapping intervals, using the fewest machines. The number of\n
    machines returned equals the maximum overlap depth.\n
  """
  by_start: list[Interval] = sorted(intervals, key=lambda item: item.start)
  machines: list[Machine] = []

  # heap of (free_time, insertion_index, machine); index breaks ties so two
  # equally-free machines never compare Machine objects.
  available: list[tuple[float, int, Machine]] = []

  for interval in by_start:

    # reuse the earliest-freed machine if it is free, else open a new one.
    if available and available[0][0] <= interval.start:
      _, order, machine = heapq.heappop(available)
    else:
      machine = Machine()
      order = len(machines)
      machines.append(machine)

    # place the interval and re-queue the machine keyed by its new free time.
    machine.assign(interval)
    heapq.heappush(available, (interval.finish, order, machine))

  return machines

def minimum_machines(intervals: Sequence[Interval]) -> int:
  """
    The fewest machines needed — equivalently, the maximum overlap depth.\n
  """
  return len(partition_intervals(intervals))

Minimizing maximum lateness

The third problem changes the objective from count to timing. We have one machine and jobs; job needs units of processing and has a deadline . We must order the jobs (the machine runs one at a time, no preemption); if job finishes at time its lateness is , and we want to minimize the maximum lateness across all jobs.1

The greedy rule is earliest deadline first (EDF): ignore the processing times entirely, sort the jobs by deadline , and run them back-to-back in that order with no idle gaps. The proof uses both qualifiers: no idle time and deadline order.

Algorithm:Min-Max-Lateness(t,d)\textsc{Min-Max-Lateness}(t, d) — order nn jobs to minimize worst lateness
  1. 1
    sort jobs so that d1d2dnd_1 \le d_2 \le \cdots \le d_n
    earliest deadline first
  2. 2
    f0f \gets 0
    running finish time, no idle gaps
  3. 3
    L0L \gets 0
    worst lateness so far
  4. 4
    for j1j \gets 1 to nn do
  5. 5
    ff+tjf \gets f + t_j
    job jj finishes here
  6. 6
    Lmax(L, fdj)L \gets \max(L,\ f - d_j)
    update max lateness
  7. 7
    return order 1,,n1,\dots,n with lateness LL
an adjacent EDF swap: exchanging an inversion () never raises max lateness

So minimizing maximum lateness is, again, sort-and-scan: sort by deadline in , run in that order, done. In a worked run, with the jobs in deadline order, each finish time falls out of the running total, and the lateness of the worst job is what we report.

Earliest-deadline-first on one machine. Jobs run back-to-back in deadline order (); each lateness is , and the schedule minimizes the worst, here .

Here jobs (length , deadline ), (length , deadline ), and (length , deadline ) run in deadline order . Finish times are the running sums , , ; latenesses are , , , so . To see that no order does better, try the intuitive shortest-job-first order : now finishes at (lateness ), at (lateness ), and at (lateness ), giving — three times worse. Sorting by length optimized the wrong thing; only the deadline order controls the worst lateness, exactly what the adjacent-swap argument guarantees.

minimum_lateness.pypython
from typing import NamedTuple, Sequence

class Job(NamedTuple):
  """
    One job: how long it runs and the deadline it should finish by.\n
  """
  name: str
  processing_time: float
  deadline: float

class ScheduledJob(NamedTuple):
  """
    A job placed in the schedule: its finish time and resulting lateness.\n
  """
  job: Job
  finish: float
  lateness: float

def minimize_max_lateness(jobs: Sequence[Job]) -> list[ScheduledJob]:
  """
    Order `jobs` by earliest deadline first and run them with no idle gaps,\n
    returning each job's finish time and lateness in run order. This order\n
    minimizes the maximum lateness across all jobs.\n
  """
  by_deadline: list[Job] = sorted(jobs, key=lambda item: item.deadline)
  schedule: list[ScheduledJob] = []
  finish: float = 0.0

  # run jobs back-to-back, recording each finish time and its lateness.
  for job in by_deadline:
    finish += job.processing_time
    lateness: float = max(0.0, finish - job.deadline)
    schedule.append(ScheduledJob(job, finish, lateness))

  return schedule

def max_lateness(jobs: Sequence[Job]) -> float:
  """
    The minimum achievable maximum lateness for `jobs`. Empty input has\n
    lateness 0.\n
  """
  schedule: list[ScheduledJob] = minimize_max_lateness(jobs)
  if not schedule:
    return 0.0
  return max(entry.lateness for entry in schedule)

The trio, and the one decision

These three — interval scheduling, interval partitioning, and maximum lateness — are the canonical greedy-on-intervals trio. They share a single shape: sort the intervals or jobs by one key, then make one pass. Everything distinctive about each lives in the key:

ProblemObjectiveSort keyOptimality proof
Schedulingmax compatible jobsfinish timestays-ahead / exchange
Partitioningmin machinesstart timedepth lower bound = greedy
Max latenessmin worst latenessdeadlineadjacent-swap exchange

The lesson is that for greedy interval problems the design work is almost entirely choosing the sort key, and the verification work is a short exchange or stays-ahead argument confirming the choice. Get the key right and the rest is a linear scan.

Weights, online arrivals, and where greedy stops

The three interval problems above all yield to a single sort. Two natural generalizations break the greedy rule, and the boundary is instructive.

Weighted interval scheduling. Give each interval a weight and ask for the maximum-weight compatible subset, not the maximum-count one. Earliest-finish greedy now fails: a single high-weight interval can be worth more than many cheap ones that would displace it. To address this, use dynamic programming. Sort by finish time, precompute for each interval the largest index of an interval that ends at or before starts, and set — take (and jump to the last compatible predecessor) or skip it. The recurrence runs in and is the canonical example that the unweighted problem is greedy while the weighted one is DP.4 It is the same failure as fractional versus 0/1 knapsack: weights destroy the greedy-choice property.

Weighted interval scheduling needs DP. Earliest-finish greedy would take the two light intervals (gray, weight ) and reject the single heavy one (blue, weight ); the optimum keeps the heavy interval.

Online interval scheduling. When intervals arrive one at a time and each must be accepted or rejected on the spot — a machine-reservation feed, say — no deterministic online algorithm can be competitive against the offline optimum in the worst case: an adversary reveals a short interval, and whether you take it or not it can follow with intervals that make the other choice far better, forcing an unbounded competitive ratio for arbitrary lengths.5 Restricting interval lengths or admitting randomization restores bounded ratios, which is why real reservation systems either batch requests (recovering the offline sort) or accept a provable approximation. The greedy of this lesson depends on seeing the whole instance at once.

Takeaways

  • Interval scheduling maximizes compatible jobs by earliest finish first; a stays-ahead exchange argument proves optimality, in .
  • Interval partitioning minimizes machines by earliest start first with a min-heap of free times; the answer equals the maximum depth , since depth forces machines and greedy never opens more.
  • Minimizing maximum lateness on one machine uses earliest deadline first; an adjacent-swap exchange argument shows removing inversions never raises .
  • All three are the same sort-then-scan skeleton: the entire design decision is the sort key (finish, start, or deadline), and the proof is a short exchange argument.

Footnotes

  1. CLRS, Ch. 16 — Greedy Algorithms (§16.1): activity selection by earliest finish time and the structure of single-machine scheduling. 2
  2. Skiena, § — Scheduling: interval partitioning / coloring and the equivalence of minimum machines with maximum overlap depth.
  3. Erickson, Ch. — Greedy Algorithms: greedy scheduling proofs by exchange arguments and the sort-key-is-the-algorithm framing.
  4. Kleinberg, J. & Tardos, É. (2005), Algorithm Design, Ch. 6 — Dynamic Programming (§6.1): weighted interval scheduling solved by the recurrence, the standard example that adding weights turns a greedy problem into a DP one.
  5. Borodin, A. & El-Yaniv, R. (1998), Online Computation and Competitive Analysis, Cambridge University Press — the competitive-analysis framework and lower bounds showing deterministic online interval scheduling has no bounded competitive ratio for arbitrary interval lengths.
Practice

╌╌ END ╌╌