← Applied algorithms
Matching and making choices When time blocks value

Weighted interval scheduling

Choose compatible value, not one impressive talk.

A conference day has seven talks with start times, finish times, and interest scores. You can attend only one talk at a time, and the goal is to maximize the total interest.

The highest-value-first shortcut takes Deep dive and Opening for 16 points. A predecessor-aware dynamic program schedules five shorter talks for 26.

TypeScriptGoOne interval contract in each language

01 / The idea

The most interesting talk can leave the best day empty.

Deep dive is worth 12 points from 3 to 9. A highest-value-first policy grabs it, then can fit only Opening before it: 16 points in total.

Weighted interval scheduling maximizes the sum of values from non-overlapping intervals. It is not trying to attend the most talks, finish earliest, or pick the single best score.

The compatible sequence Opening, Workshop, Panel, Closing, and Lightning reaches 26. The trick is to make every talk ask about the best schedule that could come before it.

weighted interval scheduling

Choose compatible value, not one impressive talk.

CONFERENCE DAY · HOURS 0–10All talks 0 interest
Opening0–2 · 4 pts
Keynote1–5 · 11 pts
Workshop2–4 · 6 pts
Panel4–7 · 7 pts
Deep dive3–9 · 12 pts
Closing7–8 · 5 pts
Lightning8–10 · 4 pts

selected talk available talk

01/ 03
Read the talk schedule

Put every talk on one timeline.

Each talk has a start, finish, and interest score. A valid schedule may touch at an endpoint, but two talks cannot overlap.

Reduced motion: choose a scene to see its completed state.

Read this scene

Each talk has a start, finish, and interest score. A valid schedule may touch at an endpoint, but two talks cannot overlap.

Seven talks are ready on one timeline. No schedule has been chosen.

Watch and Step through replay the schedule story. Try it changes the talk set and runs the same predecessor-based implementation.

Step through the shortcut, then let the finish-time order expose each talk’s predecessor. The visual keeps interval endpoints visible: a talk ending at 4 can be followed by one starting at 4.

02 / Name the rule

Point to the last talk that can safely come before.

Order talks by finish time. For talk i, let p(i) be the largest earlier index whose finish is at or before i’s start. Then the best schedule through i chooses between two complete possibilities:

dp[i] = max(dp[i - 1], value[i] + dp[p(i)])

The first branch skips the talk. The second takes it and jumps back to the best compatible prefix, so overlapping talks never sneak into the same answer.

Order

Sort by finish

Give every later decision a stable prefix of talks that have already ended.

Link

Find p(i)

Binary search finds the last endpoint that does not overlap the current start.

Choose

Skip or take

Keep whichever value is larger, then follow the winning links backward.

Why earliest finish is not enoughThat greedy proof has a different objective

Earliest finish is optimal when every talk has equal value and the objective is the number of talks. Once values differ, finishing early can still give up a score worth several shorter talks. The recurrence preserves both options instead of relying on a single safe local choice.

03 / Read the shape

The predecessor array turns overlaps into one jump.

Basic form is solveSchedule with its helpers: sort by finish, binary-search each predecessor, fill the take-or-skip value array, and backtrack the selected IDs. In the wild adds the types, input validation, and the highest-value-first baseline. At the call site compares the weighted result with a highest-value-first baseline and reports the interest recovered.

solveSchedule: sort talks by finish, binary-search each predecessor, fill the take-or-skip recurrence, and follow predecessor links back to a compatible schedule.

TypeScriptReading
schedule.ts
export function solveSchedule(problem: ScheduleProblem): ScheduleResult {
	validate(problem);
	const orderedTalks = orderByFinish(problem.talks);
	const predecessors = orderedTalks.map((_, index) => predecessorFor(orderedTalks, index));
	const dp = Array<number>(orderedTalks.length + 1).fill(0);
	const snapshots: ScheduleSnapshot[] = [];
	for (let index = 1; index <= orderedTalks.length; index += 1) {
		const talk = orderedTalks[index - 1];
		const predecessor = predecessors[index - 1];
		const skip = dp[index - 1];
		const take = dp[predecessor + 1] + talk.value;
		dp[index] = Math.max(skip, take);
		snapshots.push({
			talk: talk.id,
			index,
			predecessor,
			bestValue: dp[index],
			selectedIds: selectedAt(orderedTalks, predecessors, dp, index),
			dp: dp.slice(0, index + 1)
		});
	}
	return {
		selectedIds: selectedAt(orderedTalks, predecessors, dp, orderedTalks.length),
		totalValue: dp[orderedTalks.length],
		orderedTalks,
		predecessors,
		dp,
		snapshots
	};
}

export function orderByFinish(talks: Talk[]): Talk[] {
	return talks
		.map((talk) => ({ ...talk }))
		.sort((a, b) => a.end - b.end || a.start - b.start || byId(a.id, b.id));
}

export function predecessorFor(orderedTalks: Talk[], index: number): number {
	let low = 0;
	let high = index - 1;
	let answer = -1;
	const start = orderedTalks[index].start;
	while (low <= high) {
		const middle = Math.floor((low + high) / 2);
		if (orderedTalks[middle].end <= start) {
			answer = middle;
			low = middle + 1;
		} else {
			high = middle - 1;
		}
	}
	return answer;
}

function selectedAt(
	orderedTalks: Talk[],
	predecessors: number[],
	dp: number[],
	count: number
): string[] {
	const selected: string[] = [];
	let index = count;
	while (index > 0) {
		const talk = orderedTalks[index - 1];
		const predecessor = predecessors[index - 1];
		const take = dp[predecessor + 1] + talk.value;
		if (take > dp[index - 1]) {
			selected.push(talk.id);
			index = predecessor + 1;
		} else {
			index -= 1;
		}
	}
	return selected.reverse();
}
GoAlongside
schedule.go
func solveSchedule(problem ScheduleProblem) (ScheduleResult, error) {
	if err := validate(problem); err != nil {
		return ScheduleResult{}, err
	}
	ordered := orderByFinish(problem.Talks)
	predecessors := make([]int, len(ordered))
	for index := range ordered {
		predecessors[index] = predecessorFor(ordered, index)
	}
	dp := make([]int, len(ordered)+1)
	snapshots := make([]ScheduleSnapshot, 0, len(ordered))
	for index := 1; index <= len(ordered); index++ {
		talk := ordered[index-1]
		predecessor := predecessors[index-1]
		skip := dp[index-1]
		take := dp[predecessor+1] + talk.Value
		if take > skip {
			dp[index] = take
		} else {
			dp[index] = skip
		}
		snapshots = append(snapshots, ScheduleSnapshot{
			Talk:        talk.ID,
			Index:       index,
			Predecessor: predecessor,
			BestValue:   dp[index],
			SelectedIDs: selectedAt(ordered, predecessors, dp, index),
			DP:          append([]int(nil), dp[:index+1]...),
		})
	}
	return ScheduleResult{
		SelectedIDs:  selectedAt(ordered, predecessors, dp, len(ordered)),
		TotalValue:   dp[len(ordered)],
		OrderedTalks: ordered,
		Predecessors: predecessors,
		DP:           dp,
		Snapshots:    snapshots,
	}, nil
}
func orderByFinish(talks []Talk) []Talk {
	ordered := append([]Talk(nil), talks...)
	sort.SliceStable(ordered, func(i, j int) bool {
		if ordered[i].End != ordered[j].End {
			return ordered[i].End < ordered[j].End
		}
		if ordered[i].Start != ordered[j].Start {
			return ordered[i].Start < ordered[j].Start
		}
		return ordered[i].ID < ordered[j].ID
	})
	return ordered
}

func predecessorFor(ordered []Talk, index int) int {
	low, high, answer := 0, index-1, -1
	start := ordered[index].Start
	for low <= high {
		middle := (low + high) / 2
		if ordered[middle].End <= start {
			answer = middle
			low = middle + 1
		} else {
			high = middle - 1
		}
	}
	return answer
}

func selectedAt(ordered []Talk, predecessors, dp []int, count int) []string {
	selected := []string{}
	index := count
	for index > 0 {
		talk := ordered[index-1]
		predecessor := predecessors[index-1]
		take := dp[predecessor+1] + talk.Value
		if take > dp[index-1] {
			selected = append(selected, talk.ID)
			index = predecessor + 1
		} else {
			index--
		}
	}
	for left, right := 0, len(selected)-1; left < right; left, right = left+1, right-1 {
		selected[left], selected[right] = selected[right], selected[left]
	}
	return selected
}
Reading the TypeScriptFinish order and binary search

predecessorFor searches only the earlier finish-sorted prefix. Because finishes are non-decreasing, the last compatible index can be found without scanning every earlier talk.

Reading the GoThe same recurrence

Go copies the input before sorting, stores predecessors as zero-based indices, and returns the same per-talk snapshots (the film on this page replays the TypeScript ones). Both examples print the selected IDs in finish order, the order the backtrack recovers them in.

What is refusedKeep endpoint semantics explicit

Both versions require non-empty talks with unique lowercase IDs, integer endpoints in a bounded range, and a strictly positive duration. A zero-length talk or a fractional timestamp needs a different contract.

04 / Try a decision

When do two talks stop overlapping?

The predecessor is the boundary that keeps the recurrence honest. Choose the endpoint rule used by the implementation.

When can two talks share a schedule?

05 / Follow the cost

Pay once to order, then jump through the schedule.

Weighted interval scheduling: time and extra space per operation
OperationTimeExtra spaceWhat it assumes
Validate the talk listO(n)O(1)Read every start, finish, and interest value once before making decisions.
Order by finish timeO(n log n)O(n)A sorted finish order makes earlier compatible prefixes stable and searchable.
Find predecessorsO(n log n)O(n)Binary search each talk for the last finish at or before its start.
Fill and backtrack the DPO(n²) as shownO(n²) as shownEach talk compares skip with take-plus-prefix, and one backtrack recovers the IDs: O(n) time and space. The shown code also backtracks and copies the DP prefix after every talk for the film, which makes it O(n²); without those snapshots it is O(n).

With n talks, sorting costs O(n log n), predecessor lookup costs O(n log n), and one DP pass plus one backtrack is O(n), so the algorithm is O(n log n) time and O(n) extra space. The version shown also backtracks and copies the DP prefix after every talk so the film can replay it; that costs O(n²) time and space, so drop the snapshots in production.

If talks are already ordered and predecessor links arrive from an index, the ordering cost can disappear. The weighted choices still need the one-dimensional DP state; a greedy earliest-finish pass cannot replace it when values differ.

06 / Give it a real job

Build one attendee’s day.

A conference app has a “Build my day” button. The agenda service takes the day’s talks and the scores this attendee gave them, calls solveSchedule, and returns the selected IDs in finish order, which is already the order the attendee will walk through them.

The solver owns one decision: which talks fit together for the highest total. It doesn’t own the scores, a room that fills up, or the walk between rooms. The endpoint rule treats a talk ending at 4 and one starting at 4 as compatible, which is right in the same room and wrong across a large venue. When the walk matters, the service adds it to each talk’s finish before the call, so the rule stays simple and the schedule stays walkable.

This runs in the agenda service that holds the talk list and each attendee’s scores; nothing in a component computes it, and the agenda screen only draws the day it gets back.

07 / Make the call

Define whether value, count, or room is the objective.

Use weighted interval scheduling when intervals compete for one timeline, values add, and the objective is the highest compatible total. It fits talks, maintenance windows with priorities, and a single operator’s booked work.

Use earliest-finish greedy scheduling when every interval is worth one and the goal is the largest count. Use 0/1 knapsack when the constraint is a capacity budget rather than time overlap.

Three boundaries to rememberSimilar words, different states
  • Unweighted intervals: count compatible talks; earliest finish has the greedy proof.
  • Weighted intervals: add interest values; take-or-skip needs predecessor links.
  • Multiple rooms: allow parallel tracks; one predecessor per talk is no longer enough.

08 / Take the idea with you

Explain the best day without saying “weighted interval scheduling.”

“Line the talks up by when they end. For each talk, find the last one that ends by the time it starts. The best day up to this talk is either the best day without it, or this talk plus the best day up to that earlier one; keep the bigger. At the end, walk back through the choices to name the talks.”

Before moving on, open a calendar with a busy day. Pick the one meeting you value most, and check whether the meetings it overlaps would be worth more together.

Connections to follow nextRelated lessons
  • Dynamic programming is the general idea: the best answer for a prefix, built from the best answers for shorter ones.
  • Binary search finds each talk’s last compatible predecessor in the finish-sorted list.
  • 0/1 knapsack makes the same take-or-skip choice against a budget of room instead of a timeline.