Cairn
algorithms · dynamic programming · O(n log n)

back to Dynamic Programming

Weighted Interval Scheduling

The site's fifth dynamic programming entry, and the answer to a question Activity Selection's own Pitfalls section left open: what happens once activities stop being interchangeable? Give every activity a weight — a room booking's revenue, a job's payoff, whatever makes one activity worth more than another — and change the goal from "accept as many activities as possible" to "accept the subset with the highest total weight." Activity Selection's finish-time greedy rule, provably optimal for maximizing count, stops being correct the moment weights differ: a single high-value activity can be worth skipping several lower-value ones for, and finish time alone can't see that. This page closes that forward reference with a dynamic-programming recurrence instead — over the same finish-time-sorted order, but with a real either/or decision per activity in place of a one-pass greedy walk.

Try it

Activity Selection's own eleven-activity default dataset, unchanged, with one weight added: activity 10 [2,14] is worth 20, and every other activity stays at the default weight of 1. Press Step or Run to watch dp[i] fill in left to right: each cell holds the best total weight achievable using only the first i activities (in finish-time order). For each activity the demo also finds its predecessor — the latest earlier activity that doesn't overlap it — by binary search over the already-sorted finish times, the same narrowing Binary Search itself uses, just searching for a boundary instead of an exact value. Once the array is full, the demo backtracks from dp[11] to recover which activities are actually in the optimal set — watch the timeline bars light up green (taken) or fade (skipped). A comparison line underneath, filled in once the run is done, shows what Activity Selection's own earliest-finish-time greedy would have picked on this exact same weighted data, and how much total weight it leaves on the table.

timeline (sorted by finish time)

step 0
Press Step or Run.

Why it works

Sort every activity by finish time first, same as Activity Selection — call the sorted order 1..n. The base case is trivial: with zero activities available, dp[0] = 0, nothing to choose from yet. From there, every activity i faces exactly one yes-or-no question, the same shape 0/1 Knapsack's own recurrence asks of every item: is activity i in the optimal set or not? Skip it, and the best achievable is whatever it was without it, dp[i-1]. Take it, and the best achievable is its own weight plus the best achievable among activities that don't conflict with it — every activity that finishes at or before i starts. That compatible set is always a prefix of the sorted order (finishing earlier keeps an activity eligible), so it has a well-defined latest member: call it p(i), found by binary search since the finish times are already sorted. Taking activity i is worth weighti + dp[p(i)]. The recurrence keeps whichever option is better: dp[i] = max(dp[i-1], weighti + dp[p(i)]). Both branches only ever depend on strictly smaller subproblems already solved, the same optimal-substructure argument every dynamic-programming page on this site makes — here with a binary search standing in for the array-index arithmetic 0/1 Knapsack uses to find its own "one item back" cell.

On this page's own dataset, activity 10 [2,14] (weight 20) overlaps every other activity except activity 11 [12,16], which starts before it finishes anyway (12 < 14) and so is never actually compatible either — activity 10 is compatible with nothing at all in this particular set. The DP correctly finds that taking activity 10 alone (20) beats every combination that leaves it out, including the four-activity set {1, 4, 8, 11} that maximizes count — the exact set Activity Selection's own greedy proof shows is optimal when every weight is equal — confirmed against an independent brute-force search over all 2¹¹ subsets, not just asserted.

Reference implementation

Builds the array left to right, finding each activity's predecessor by binary search, then walks it backward from the last cell to recover which activities were actually chosen:

function weightedIntervalScheduling(activities) {
  const sorted = [...activities].sort((a, b) => a.end - b.end);
  const n = sorted.length;

  // binary search for the latest earlier activity that finishes at or before sorted[i-1].start
  function predecessor(i) {
    const target = sorted[i - 1].start;
    let lo = 0, hi = i - 2, result = 0;
    while (lo <= hi) {
      const mid = (lo + hi) >> 1;
      if (sorted[mid].end <= target) { result = mid + 1; lo = mid + 1; }
      else hi = mid - 1;
    }
    return result; // 1-indexed; 0 means "no compatible activity before this one"
  }

  const p = new Array(n + 1).fill(0);
  const dp = new Array(n + 1).fill(0);
  for (let i = 1; i <= n; i++) {
    p[i] = predecessor(i);
    dp[i] = Math.max(dp[i - 1], sorted[i - 1].weight + dp[p[i]]);
  }

  // backtrack from the last cell to recover which activities were taken
  let i = n;
  const chosen = [];
  while (i > 0) {
    if (dp[i] === dp[i - 1]) {
      i--; // value didn't change by considering activity i — it wasn't used
    } else {
      chosen.push(sorted[i - 1]);
      i = p[i];
    }
  }
  chosen.reverse();

  return { maxWeight: dp[n], chosen };
}

Pitfalls

Finish-time greedy still runs without complaint on weighted data — it just quietly stops being optimal. Nothing about Activity Selection's one-pass rule checks weight at all, so it happily returns an answer on this page's own dataset: activities 1, 4, 8, and 11, total weight 4 — the same count-maximizing set that page's own exchange-argument proof guarantees is optimal, which is exactly the problem, since count is no longer the goal. The DP above finds 20 — activity 10 alone — a difference from skipping four activities entirely to make room for the one expensive one greedy never reconsiders. Both numbers come from running the exact code on this page, not a hand-picked illustration: press Run above and the comparison line reports both live. As a sanity check in the other direction — not shown in the demo, checked separately — setting activity 10's weight back down to 1, matching everyone else, and rerunning this page's own DP on the result recovers that identical {1, 4, 8, 11} set, confirming this recurrence genuinely generalizes activity selection rather than just resembling it.

The predecessor binary search only works because the array is already sorted by finish time. predecessor(i) assumes sorted[0..i-2]'s finish times are non-decreasing and narrows a search range on that assumption alone — the same precondition Binary Search's own Pitfalls section already flags for its classic form. Skip the initial sort, or sort by anything other than finish time, and the binary search still returns an index without erroring — just not necessarily the correct predecessor, silently corrupting every dp[i] downstream of the first bad lookup.

A tie between skipping and taking is resolved by an arbitrary rule, not a fact the problem forces — the same caveat 0/1 Knapsack's own Pitfalls section raises about its backtracking. This page's backtrack checks dp[i] === dp[i-1] first, so any exact tie is reported as "skipped." Checked directly, not just asserted: three activities A[0,2] weight 5, B[3,5] weight 5, and C[0,6] weight 10 (spanning both) all tie at total weight 10 two different ways — {A, B} or {C} alone. This page's code always recovers {A, B}, since dp[3] = dp[2] exactly and the skip branch is checked first; a version that checked "take" first would recover {C} instead, an equally valid but different optimal set. The weight is never ambiguous — the specific set the code reports can be.

Complexity

Time: O(n log n) — sorting costs O(n log n), and each of the n predecessor lookups costs O(log n) by binary search (a naive linear scan for each predecessor would cost O(n) per lookup, O(n²) overall — the same "binary search turns a linear scan into a logarithmic one" trade Longest Increasing Subsequence makes for its own predecessor search). Unlike 0/1 Knapsack's O(n · capacity), this bound depends only on the number of activities, never on the numeric magnitude of any start or end time — genuinely polynomial, not pseudo-polynomial. Space: O(n) for the sorted copy, the predecessor array, and the DP array.

Weighted interval scheduling shows up anywhere Activity Selection's plain count-maximizing version is too blunt an objective: booking a single resource (a conference room, a machine, an ad slot) where different bookings pay different amounts, or choosing which non-overlapping jobs to run in a fixed maintenance window when each job has its own payoff. The recurrence itself — sort, then choose skip-or-take against a binary-searched predecessor — also generalizes past a single resource: job scheduling to minimize weighted lateness and other single-machine scheduling variants build on the same finish-time-ordered skip/take shape, though with a different per-job cost function than this page's fixed weight.

This site's guide, Choosing a Dynamic Programming Approach, compares this entry against the other ten Dynamic Programming entries side by side.