GUIDE · Patterns
Intervals
[start, end] pairs; almost always sort first, then sweep once.
At a glance
- WHAT
- [start, end] pairs; almost always sort first, then sweep once.
- TELL
- "intervals", "meetings", "overlap", "merge", "rooms", "schedule", "balloons / arrows", "free time", "busy", "non-overlapping".
- SAY
- sort by start (merging), sort by end (greedy max non-overlapping), sweep line, overlap test a.start <= b.end && b.start <= a.end, min-heap of end times, two-pointer over starts/ends.
- BIG-O
- O(n log n) for the sort; sweep is O(n). Heap variants O(n log n).
The idea#
Picture a wooden ruler and strips of colored tape. Each strip covers a start inch and an end inch. Piano is the tape from 1 to 3. Soccer is the tape from 2 to 6. They share the inches from 2 to 3, so they overlap. A third strip from 8 to 10 sits in a gap. You can see the overlap with one test: each strip starts at or before the other one ends. a starts at or before b ends, and b starts at or before a ends.
Almost every question on this ruler starts the same way. Sort the strips, then sweep a finger from left to right once. Which end you sort by is the whole decision. Sort by start when you are merging, inserting, or asking what is open right now. The finger extends the last piece of tape whenever the next strip starts before that piece ends. Sort by end when you want as many strips as possible that do not overlap, or as few arrows as possible to burst every balloon. The strip that finishes earliest leaves the most ruler for everyone else. Picking it never spoils a later choice. That is the greedy step, the same one as activity selection.
Why it matters#
You reach for this when the problem says intervals, meetings, overlap, merge, rooms, schedule, balloons or arrows, free time, busy, or non-overlapping. Drawing every pair and testing it is quadratic. The sort puts the ruler in order. The sweep is a single finger.
The sort is O(n log n). The sweep after it is O(n). Versions that keep a min-heap of end times stay O(n log n).
A small example#
function mergeTape(blocks) { const sorted = blocks.slice().sort((a, b) => a[0] - b[0]); const out = []; for (const [start, end] of sorted) { const last = out[out.length - 1]; if (last && start <= last[1]) last[1] = Math.max(last[1], end); else out.push([start, end]); } return out;} const day = mergeTape([[1, 3], [2, 6], [8, 10]]);Sorted by start, [1, 3] and [2, 6] fuse into [1, 6], and [8, 10] stays its own strip. day is [[1, 6], [8, 10]].
What the code below is doing#
The sheet starts with two comparators, byStart and byEnd, and the overlap test for closed intervals. Merge intervals sorts by start and extends last.end when the current start falls at or before it. Insert interval assumes the input is already sorted and non-overlapping: copy everything that ends before the new strip, fuse everything that overlaps it, then copy the rest. That one is linear, because the sort was already done.
Non-overlapping intervals, the minimum number to remove, sorts by end. You keep a strip when its start is at or after the last kept end. Removals are n minus how many you kept. Meeting rooms I sorts by start and reports a conflict when any meeting starts before the previous one ends. Meeting rooms II, the minimum number of rooms, has two shapes. The two-pointer shape sorts starts and ends separately: a start before the next end means a new room, otherwise you reuse one. The heap shape sorts by start and keeps a min-heap of end times, popping any room that has freed up. The heap's size is the room count.
Minimum arrows to burst balloons sorts by end and fires a new arrow only when a balloon starts after the last arrow's position. Interval-list intersection takes two already-sorted lists and walks two fingers: the overlap runs from the later start to the earlier end, and you advance the list that ends first. That is O(n + m). Employee free time flattens every schedule, sorts by start, merges, and reads the gaps between merged blocks as the free stretches.
The last section is the heuristic in one place. Sort by start for merging, inserting, any-overlap, and sweeping resources. Sort by end for the maximum number of non-overlapping intervals, minimum arrows, and minimum removals. Rooms II is the case that sorts both.
When you talk about it, say this: sort, then sweep, and say whether you sorted by the start or by the end.
Everything below is the demonstration. Same idea, written as code you can run.
'use strict';const assert = require('node:assert/strict'); const byStart = (a, b) => a[0] - b[0];const byEnd = (a, b) => a[1] - b[1];const overlaps = (a, b) => a[0] <= b[1] && b[0] <= a[1]; // closed intervals; use < for half-openassert.equal(overlaps([1, 3], [3, 5]), true);assert.equal(overlaps([1, 2], [3, 4]), false);Merge intervals#
sort by start; if cur.start <= last.end extend last.end
function merge(intervals) { const out = []; for (const [s, e] of [...intervals].sort(byStart)) { const last = out[out.length - 1]; if (last && s <= last[1]) last[1] = Math.max(last[1], e); else out.push([s, e]); } return out;}Verified by 3 asserts
assert.deepEqual(merge([[1, 3], [2, 6], [8, 10], [15, 18]]), [[1, 6], [8, 10], [15, 18]]);assert.deepEqual(merge([[1, 4], [2, 3]]), [[1, 4]]); // containedassert.deepEqual(merge([]), []);Insert interval (input sorted, non-overlapping)#
add all ending before; merge all overlapping; add the rest
function insert(intervals, newI) { const out = []; let i = 0; const n = intervals.length; while (i < n && intervals[i][1] < newI[0]) out.push(intervals[i++]); while (i < n && intervals[i][0] <= newI[1]) { newI = [Math.min(newI[0], intervals[i][0]), Math.max(newI[1], intervals[i][1])]; i++; } out.push(newI); while (i < n) out.push(intervals[i++]); return out;}Verified by 3 asserts
assert.deepEqual(insert([[1, 3], [6, 9]], [2, 5]), [[1, 5], [6, 9]]);assert.deepEqual(insert([[1, 2], [3, 5], [6, 7], [8, 10], [12, 16]], [4, 8]), [[1, 2], [3, 10], [12, 16]]);assert.deepEqual(insert([], [5, 7]), [[5, 7]]);Non-overlapping intervals (min removals)#
sort by END; keep interval if start >= lastEnd; removals = n - kept
// SAY: "greedy by earliest end time leaves the most room; classic activity selection."function eraseOverlapIntervals(intervals) { let kept = 0, lastEnd = -Infinity; for (const [s, e] of [...intervals].sort(byEnd)) if (s >= lastEnd) { kept++; lastEnd = e; } return intervals.length - kept;}Verified by 3 asserts
assert.equal(eraseOverlapIntervals([[1, 2], [2, 3], [3, 4], [1, 3]]), 1);assert.equal(eraseOverlapIntervals([[1, 2], [1, 2], [1, 2]]), 2);assert.equal(eraseOverlapIntervals([]), 0);Meeting rooms I (can attend all?)#
sort by start; any start < previous end => conflict
function canAttendMeetings(intervals) { const s = [...intervals].sort(byStart); for (let i = 1; i < s.length; i++) if (s[i][0] < s[i - 1][1]) return false; return true;}Verified by 3 asserts
assert.equal(canAttendMeetings([[0, 30], [5, 10], [15, 20]]), false);assert.equal(canAttendMeetings([[7, 10], [2, 4]]), true);assert.equal(canAttendMeetings([]), true);Meeting rooms II (min rooms), two-pointer#
sort starts and ends separately; start < end[j] => new room else reuse
function minMeetingRooms(intervals) { const starts = intervals.map((x) => x[0]).sort((a, b) => a - b); const ends = intervals.map((x) => x[1]).sort((a, b) => a - b); let rooms = 0, j = 0; for (const s of starts) { if (s < ends[j]) rooms++; // a meeting is still running else j++; // one ended; reuse its room } return rooms;}Verified by 3 asserts
assert.equal(minMeetingRooms([[0, 30], [5, 10], [15, 20]]), 2);assert.equal(minMeetingRooms([[7, 10], [2, 4]]), 1);assert.equal(minMeetingRooms([]), 0);Meeting rooms II, heap version#
sort by start; min-heap of end times; pop if earliest end <= start; size = rooms
function minMeetingRoomsHeap(intervals) { const ends = []; // tiny min-heap on numbers const push = (x) => { ends.push(x); for (let i = ends.length - 1, p; i > 0 && ends[p = (i - 1) >> 1] > ends[i]; i = p) [ends[i], ends[p]] = [ends[p], ends[i]]; }; const pop = () => { const top = ends[0], last = ends.pop(); if (ends.length) { ends[0] = last; for (let i = 0;;) { const l = 2 * i + 1, r = l + 1; let m = i; if (l < ends.length && ends[l] < ends[m]) m = l; if (r < ends.length && ends[r] < ends[m]) m = r; if (m === i) break; [ends[i], ends[m]] = [ends[m], ends[i]]; i = m; } } return top; }; for (const [s, e] of [...intervals].sort(byStart)) { if (ends.length && ends[0] <= s) pop(); push(e); } return ends.length;}Verified by 3 asserts
assert.equal(minMeetingRoomsHeap([[0, 30], [5, 10], [15, 20]]), 2);assert.equal(minMeetingRoomsHeap([[1, 5], [2, 6], [3, 7], [8, 9]]), 3);assert.equal(minMeetingRoomsHeap([]), 0);Minimum arrows to burst balloons#
sort by END; new arrow when start > lastArrowPos
function findMinArrowShots(points) { if (points.length === 0) return 0; let arrows = 1, pos = null; for (const [s, e] of [...points].sort(byEnd)) { if (pos === null || s > pos) { arrows += pos === null ? 0 : 1; pos = e; } } return arrows;}Verified by 3 asserts
assert.equal(findMinArrowShots([[10, 16], [2, 8], [1, 6], [7, 12]]), 2);assert.equal(findMinArrowShots([[1, 2], [3, 4], [5, 6], [7, 8]]), 4);assert.equal(findMinArrowShots([[1, 2], [2, 3], [3, 4], [4, 5]]), 2);Interval list intersections (two sorted lists)#
two pointers; lo = max starts, hi = min ends; advance the one ending first
function intervalIntersection(A, B) { const out = []; let i = 0, j = 0; while (i < A.length && j < B.length) { const lo = Math.max(A[i][0], B[j][0]), hi = Math.min(A[i][1], B[j][1]); if (lo <= hi) out.push([lo, hi]); if (A[i][1] < B[j][1]) i++; else j++; } return out;}Verified by 3 asserts
assert.deepEqual(intervalIntersection([[0, 2], [5, 10], [13, 23], [24, 25]], [[1, 5], [8, 12], [15, 24], [25, 26]]), [[1, 2], [5, 5], [8, 10], [15, 23], [24, 24], [25, 25]]);assert.deepEqual(intervalIntersection([[1, 3]], [[4, 5]]), []);assert.deepEqual(intervalIntersection([], [[1, 2]]), []);Employee free time sketch#
flatten all schedules, sort by start, merge; gaps between merged blocks = free
function employeeFreeTime(schedule) { const merged = merge(schedule.flat()); const out = []; for (let i = 1; i < merged.length; i++) out.push([merged[i - 1][1], merged[i][0]]); return out;}Verified by 3 asserts
assert.deepEqual(employeeFreeTime([[[1, 2], [5, 6]], [[1, 3]], [[4, 10]]]), [[3, 4]]);assert.deepEqual(employeeFreeTime([[[1, 3], [6, 7]], [[2, 4]], [[2, 5], [9, 12]]]), [[5, 6], [7, 9]]);assert.deepEqual(employeeFreeTime([[[1, 2]]]), []);Sort-by-start vs sort-by-end heuristic#
// sort by START : merging, inserting, "is there any overlap", sweeping rooms/resources (what is open right now).// sort by END : greedy selection of the MAX number of non-overlapping intervals (activity selection), min arrows,// min removals. Picking the earliest finishing interval never hurts (exchange argument).// Both : rooms II two-pointer sorts starts and ends independently.const activities = [[1, 4], [3, 5], [0, 6], [5, 7], [3, 9], [5, 9], [6, 10], [8, 11], [8, 12], [2, 14], [12, 16]];Verified by 1 assert
assert.equal(activities.length - eraseOverlapIntervals(activities), 4); // max non-overlapping = 4See also