Interval scheduling asks for the largest set of non-overlapping intervals you can accept from a list of requests: bookings for a GPU node, maintenance windows on a link, slots on a broadcast channel. The classic answer, earliest finish first, fits in five lines and is provably optimal; its proofs, the orderings that fail, endpoint conventions, the weighted dynamic programme and the k-room best-fit rule are covered in activity selection, in depth. This article starts where that one stops.

Three questions come up as soon as interval scheduling meets production. How do you prove to a sceptical reviewer, or to a test suite, that a given schedule is optimal without trusting the algorithm? What happens when the problem changes slightly: weights on several machines, jobs with a choice of windows, requests arriving over time? And which of those variants stay easy and which quietly become NP-hard? We answer each with tested code and a worked example on a GPU reservation calendar, and finish with a decision map and a checklist.

The base problem in one paragraph

Requests are half-open intervals [s, e): a booking from 9 to 11 and one from 11 to 13 do not conflict. Sort by end time, scan once, and accept each interval whose start is at or after the end of the last accepted one. The cost is O(n log n) for the sort. The intuition behind every proof is the same: the interval that ends first leaves the most room for everything after it, so choosing it can never hurt. If that argument is new to you, read the full treatment first; the rest of this page assumes it.

A certificate of optimality

An optimal schedule comes with a free witness of its optimality. Suppose you can find k points in time such that every request contains at least one of them. Then no schedule can accept more than k requests, because accepted requests are pairwise disjoint and so each point lies in at most one of them. If you also have a schedule of k disjoint requests, both are optimal: the maximum number of disjoint intervals equals the minimum number of points that stab all of them. This is a min-max duality, the interval-graph cousin of max-flow min-cut.

The greedy produces both halves at once. Every time it accepts an interval, take the last instant inside it as a stabbing point. Any request rejected later started before the last accepted end, and it ends at or after it because of the sort, so it contains that instant. With integer timestamps (minutes, say) the last instant inside [s, e) is e - 1.

def schedule_with_certificate(intervals):
    """intervals: (start, end, name), half-open, integer times.
    Returns (chosen, points): chosen are pairwise disjoint, every interval
    contains a point, and len(chosen) == len(points), so both are optimal."""
    chosen, points, last_end = [], [], float("-inf")
    for s, e, name in sorted(intervals, key=lambda iv: (iv[1], iv[0])):
        if s >= last_end:
            chosen.append((s, e, name))
            points.append(e - 1)        # last instant inside [s, e)
            last_end = e
    return chosen, points

def verify(intervals, chosen, points):
    disjoint = all(a[1] <= b[0] for a, b in zip(chosen, chosen[1:]))
    stabbed = all(any(s <= p < e for p in points) for s, e, _ in intervals)
    return disjoint and stabbed and len(chosen) == len(points)

The verifier is independent of the algorithm and runs in O(n k), or O(n log k) with a binary search over sorted points. That makes it ideal for a property test, for a runtime assertion in a scheduler, or for an audit log that must justify why a booking was rejected: the rejected request contains a point already used by an accepted one.

Sweeping by start instead of end

Sometimes data arrives sorted by start, not end: a log of reservation requests in start order, or a stream you cannot buffer. A second greedy gives the same optimum. Keep a tentative last choice; when a new interval overlaps it and ends earlier, swap it in. The count never decreases and the last end only moves earlier, which is the same exchange the end-sorted proof uses.

def sweep_by_start(intervals):
    kept = []
    for s, e, name in sorted(intervals):
        if not kept or s >= kept[-1][1]:
            kept.append((s, e, name))
        elif e < kept[-1][1]:
            kept[-1] = (s, e, name)     # same count, earlier end
    return kept

Read the swap carefully before using it online. When the new interval arrives in start order, the one it replaces has already started, so in a live system the swap means abandoning running work. That is acceptable for preemptible jobs, such as a spot training run that checkpoints, and wrong for anything else. When requests arrive in booking order rather than start order and acceptances are binding, no deterministic rule can guarantee a constant fraction of the optimum: an adversary simply offers a long request first and many short ones under it afterwards. Production admission control therefore combines rules with policy, such as a maximum booking length or a booking horizon.

The variant map

Small changes to the problem move it between easy and hard. This map lists the variants that come up in schedulers and their status.

VariantStatusWhat to use
1 machine, maximise countpolynomialearliest finish first, O(n log n)
1 machine, maximise weightpolynomialDP over end-sorted intervals with binary search
k identical machines, countpolynomialend-sorted greedy with best fit
k identical machines, weightpolynomialmin-cost flow (below)
Each job has several candidate windowsNP-hard, APX-hardgreedy, at least half of optimal
Machines with eligibility (job can run only on some)strongly NP-hardILP or heuristics
Intervals with resource demands, capacity C, weightsNP-hard (contains knapsack)ILP, LP rounding
Cover all requests with fewest machinespolynomialsweep with a min-heap of end times

The hardness results are from the scheduling literature: Arkin and Silverberg (1987) showed the weighted k-machine case reduces to min-cost flow and that eligibility constraints make it strongly NP-hard; Spieksma (1999) proved the candidate-window problem APX-hard and gave the factor-two greedy. The demand row contains knapsack as the special case where every interval overlaps all the others.

Weighted jobs on k machines as min-cost flow

With weights on k identical machines the best-fit greedy is no longer optimal, but the problem is still polynomial. Build a graph whose nodes are the distinct endpoints in time order. Chain consecutive times with edges of capacity k and cost 0: a machine travelling along them is idle. For each request add a detour from its start to its end with capacity 1 and cost minus its weight: a machine taking it runs the job. Send k units of flow from the first time to the last at minimum cost. Each unit is a machine's day; at most k units cross any instant, so at most k jobs overlap, and the minimum cost is the maximum total weight.

import networkx as nx

def weighted_k_machines(intervals, k):
    """intervals: (start, end, weight, name), half-open, integer weights."""
    times = sorted({t for s, e, _, _ in intervals for t in (s, e)})
    G = nx.DiGraph()
    G.add_node("src", demand=-k)
    G.add_node("sink", demand=k)
    G.add_edge("src", times[0], capacity=k, weight=0)
    G.add_edge(times[-1], "sink", capacity=k, weight=0)
    for a, b in zip(times, times[1:]):       # idle machines move along the timeline
        G.add_edge(a, b, capacity=k, weight=0)
    for s, e, w, name in intervals:          # a job node avoids parallel-edge clashes
        G.add_edge(s, ("job", name), capacity=1, weight=-w)
        G.add_edge(("job", name), e, capacity=1, weight=0)
    flow = nx.min_cost_flow(G)
    return sorted(n for s, e, w, n in intervals if flow[s][("job", n)] == 1)

The intermediate job node matters: without it, two requests with the same endpoints, or a request spanning two adjacent times, would collide with each other or with the timeline edge in a simple directed graph, and one would silently disappear. Negative costs are safe here because the graph is acyclic. Checked by brute force on 150 random instances, the function returns the optimum every time.

Jobs with candidate windows

Real jobs are often flexible: a training run can take the 00:00 slot or the 06:00 slot, and you must pick at most one. This is the job interval selection problem, and it is NP-hard even on one machine. The natural greedy runs earliest finish first over all windows of all jobs and skips a window if its job is already placed.

def greedy_candidate_windows(jobs):
    """jobs: {job: [(start, end), ...]}; place each job at most once."""
    windows = sorted((e, s, job) for job, ws in jobs.items() for s, e in ws)
    used, picked, last_end = set(), [], float("-inf")
    for e, s, job in windows:
        if job not in used and s >= last_end:
            used.add(job)
            picked.append((job, s, e))
            last_end = e
    return picked

It always achieves at least half of the optimum. The charging argument is short: each window the greedy picks can block at most two windows of an optimal solution, namely the optimal window of the same job and the one optimal window that contains the instant just before the picked window ends. The bound is tight. With {"train": [(0, 2), (3, 5)], "eval": [(0, 3)]}, the greedy places train at (0, 2), which blocks eval, and then cannot use train's second window; the optimum runs eval at (0, 3) and train at (3, 5). When half is not good enough, solve small instances exactly with an integer programme and use the greedy as its starting solution and lower bound.

Worked example: a day on one GPU node

A shared eight-GPU node takes ten requests for tomorrow, in hours from midnight: A [0, 6), B [1, 3), C [2, 5), D [4, 7), E [5, 9), F [6, 8), G [8, 11), H [9, 12), I [10, 13) and J [11, 14). Each needs the whole node. Sorted by end, the greedy accepts B (ends 3), skips C and A, accepts D (starts 4, ends 7), skips E and F, accepts G (starts 8), skips H and I, and accepts J (starts 11, exactly when G ends). Four bookings.

The certificate is the points 2, 6, 10 and 13. Check them against the rejects: A and C contain hour 2, E and F contain hour 6, H and I contain hour 10. Every request contains a point, so no schedule can accept five; anyone disputing the result can verify it in seconds. The sweep-by-start version returns the same four bookings.

Ten GPU-node bookings (hours), the greedy picks and their stabbing pointsABCDEFGHIJ01234567891011121314hour 2hour 6hour 10hour 13
Accepted bookings in green. Each red line is a stabbing point, and every request crosses at least one, which proves that four is the maximum.

Failure modes

  • Closed intervals by accident. Using s > last_end rejects back-to-back bookings and loses capacity silently. Pick half-open and test the boundary.
  • Mixed time zones or float timestamps. Normalise to integer UTC minutes before sorting; the certificate's e - 1 point depends on it.
  • Treating count as the objective when value matters. Maximising the number of bookings favours short ones; if a long training run is worth more, use weights.
  • Running the greedy on a hard variant. Candidate windows, eligibility or resource demands break optimality. The greedy still runs and returns a plausible answer, which is why this failure goes unnoticed.
  • Swapping non-preemptible work. The sweep-by-start swap abandons a running job; never use it as an online policy for work that cannot restart.

Trade-offs

The greedy is optimal, fast and explainable, and its certificate makes it auditable. Min-cost flow is exact for weighted machines but needs a solver dependency and gives results that are harder to explain to users. Approximation greedies on the hard variants are fast and give a guarantee, but half of optimal can be a lot of idle GPU hours; integer programming closes the gap at the cost of unpredictable solve times. Online admission trades optimality for responsiveness, and policy limits are usually worth more there than algorithmic cleverness. See job scheduling with deadlines for the neighbouring family where jobs have durations and deadlines instead of fixed intervals.

What to do next

  1. Write down your variant precisely: machines, weights, flexibility, demands, and whether requests arrive online.
  2. Look it up in the variant map before choosing an algorithm.
  3. Normalise timestamps to integer half-open intervals in one place.
  4. Return a stabbing certificate with every single-machine schedule and assert it in tests.
  5. Brute-force-test any variant code on small random instances, as done here.
  6. For hard variants, log the greedy result next to an exact solver's result on sampled days to know how far from optimal you run.
  7. Read greedy algorithms, in depth to recognise when a new rule needs a proof.
Key takeaway: Earliest finish first solves basic interval scheduling, and it also produces stabbing points that prove its answer optimal, so every schedule can ship with a checkable certificate. Weighted jobs on several machines are still exact as a min-cost flow, but candidate windows, machine eligibility and resource demands make the problem NP-hard. Identify your variant before trusting any greedy rule.