You're handed a calendar full of meetings, and some of them clash. You want to cancel as few meetings as possible so that none of the survivors overlap. disjointIntervals(intervals) takes an array of [start, end] pairs and returns the minimum number of intervals you must remove so the rest are mutually non-overlapping. This is the classic non-overlapping intervals problem, the removal-counting twin of activity selection.
// intervals: an array of [start, end] pairs, each a half-open interval [start, end)
// returns: the minimum count of intervals to remove so the rest never overlap
function disjointIntervals(intervals: [number, number][]): number;
disjointIntervals([[1, 2], [2, 3], [3, 4], [1, 3]]);
// → 1
// Remove [1, 3] and the rest — [1,2], [2,3], [3,4] — are all disjoint.
disjointIntervals([[1, 2], [2, 3], [3, 4]]);
// → 0
// Already non-overlapping; nothing to remove. (Touching at an endpoint is fine.)
disjointIntervals([[1, 2], [1, 2], [1, 2]]);
// → 2
// All three occupy the same slot. Keep one, remove the other two.
[start, end] as [start, end). Two intervals that merely touch at an endpoint, like [1, 2] and [2, 3], do not overlap. Overlap requires the second to start strictly before the first one ends.disjointIntervals([]) returns 0. A single interval also returns 0; one interval can never clash with itself.You'll count the fewest intervals to drop so the survivors never overlap — a greedy problem that hinges entirely on which key you sort by.
Your calendar has a stack of meetings and some of them clash. You can't attend two at once, so you have to cancel a few. The question is: what's the smallest number of cancellations that leaves a clash-free schedule? You don't have to report which meetings you cancelled or which survive — just the count.
Flip the problem around and it gets easier to reason about. Minimising removals is the same as maximising keeps: if you can find the largest set of meetings that already fit together without clashing, then everything else is a removal. So removals = total − (largest non-overlapping set). That "largest non-overlapping set" is the classic activity-selection problem, and it has a famously short greedy answer.
One convention up front: we treat every interval as half-open, [start, end). A meeting from 1 to 2 and another from 2 to 3 do not clash — one ends exactly as the other begins. Overlap means the next interval starts strictly before the previous one ends.
Picture the intervals as bars on a number line. You want to pack in as many bars as possible without any two of them overlapping. Greedy intuition: every time you commit to keeping a bar, you'd like to leave as much room as possible for the bars still to come. The bar that leaves the most room is the one that finishes earliest — its right edge is as far left as it can be.
So the rule is: sort the intervals by their end value, walk left to right, and keep an interval whenever it starts at or after the last kept interval's end. Track only one number as you go — prevEnd, the end of the most recently kept interval. Anything that starts before prevEnd clashes with what you kept, so it's a removal.
The instinct most people have is: sort by start time (that's how a calendar is laid out), then walk the list, and whenever the next interval overlaps the one you are already holding, drop the newcomer and move on.
function disjointIntervalsNaive(intervals) {
if (intervals.length === 0) return 0;
const sorted = [...intervals].sort((a, b) => a[0] - b[0]); // by start
let removals = 0;
let prevEnd = sorted[0][1];
for (let i = 1; i < sorted.length; i++) {
const [start, end] = sorted[i];
if (start < prevEnd) {
// overlap — drop this interval, keep the one we already have
removals++;
} else {
prevEnd = end;
}
}
return removals;
}
Sorting by start sets it up to make a bad first choice. Feed it [[1,100],[2,3],[3,4],[4,5]]. Sorted by start, [1,100] comes first, so prevEnd starts at 100. Now [2,3], [3,4], and [4,5] each start before 100, so each looks like an overlap and gets dropped — 3 removals. But the right answer is 1: drop the single hog [1,100] and the three short intervals all survive untouched. The walk anchored on [1,100] purely because it started earliest, and an early start tells you nothing about which interval is safest to keep. The fix is to make the keep-or-drop decision on the interval that ends earliest instead.
The fix isn't a smarter overlap rule. It's sorting by the end value, so the first interval you commit to is the one that finishes earliest — the one that leaves the most room.
function disjointIntervals(intervals) {
if (intervals.length === 0) return 0;
// Sort by END value. The earliest-finishing interval is always safe to keep
// because it blocks the least future room.
const sorted = [...intervals].sort((a, b) => a[1] - b[1]);
let kept = 0;
let prevEnd = -Infinity; // the end of the last interval we decided to keep
for (const [start, end] of sorted) {
// start >= prevEnd: this interval begins at or after the last kept one ended,
// so it does NOT overlap (half-open: touching endpoints are fine). Keep it.
if (start >= prevEnd) {
kept++;
prevEnd = end;
}
// Otherwise it starts before prevEnd → it overlaps → it's an implicit removal.
}
// Removals = everything we didn't keep.
return intervals.length - kept;
}
module.exports = { disjointIntervals };
The whole algorithm is four moving parts, and the only change from the naive version is the sort key plus counting keeps instead of removals. Take each non-obvious choice in turn.
Why sort by a[1] - b[1] (end), not start. This is the entire problem. After sorting by end, the first interval in the list is the one that finishes earliest in the whole set. Keeping it can never be a mistake: any interval you could have kept instead finishes no earlier, so swapping it in only leaves less room. This "earliest deadline first" choice is the heart of activity selection, and it's provably optimal — you never have to backtrack.
Why prevEnd starts at -Infinity. The first interval should always be kept (there's nothing before it to clash with). Seeding prevEnd with -Infinity guarantees the very first start >= prevEnd check passes, so we don't need a special case for the first element.
Why start >= prevEnd and not start > prevEnd. Half-open intervals. [1, 2) and [2, 3) touch at the point 2 but don't share any actual time — the first is done the instant the second begins. So start === prevEnd is not an overlap, and we keep it. Using strict > here would wrongly treat touching intervals as clashing and remove one of them. (See the touching gotcha below.)
Why count keeps and subtract, instead of counting removals directly. You can do it either way, but counting keeps maps cleanly onto the activity-selection proof — kept is exactly the size of the largest non-overlapping set. Removals are then just "everyone else," intervals.length - kept. It also sidesteps a subtle bug: when several intervals in a row all overlap the kept one, you must count each as its own removal without advancing prevEnd. Counting keeps makes that automatic — you simply don't increment kept, and prevEnd stays put.
Why [...intervals] before sorting. Array.prototype.sort mutates the array in place. Copying first keeps the caller's array untouched — a courtesy that prevents action-at-a-distance bugs if they reuse the input.
Let's trace disjointIntervals([[1,3],[2,4],[3,5],[6,8],[7,9]]) end to end. First, sort by end value. The ends are 3, 4, 5, 8, 9 — already in order, so the sorted list is [[1,3],[2,4],[3,5],[6,8],[7,9]]. Start with kept = 0 and prevEnd = -Infinity.
[1,3] start 1 >= -Infinity → keep. kept = 1, prevEnd = 3
[2,4] start 2 >= 3 ? no → remove (overlaps the kept [1,3])
[3,5] start 3 >= 3 ? yes → keep. kept = 2, prevEnd = 5
[6,8] start 6 >= 5 ? yes → keep. kept = 3, prevEnd = 8
[7,9] start 7 >= 8 ? no → remove (overlaps the kept [6,8])
kept = 3, total = 5
return 5 - 3 = 2
Two intervals removed. Notice [3,5] is kept even though its start (3) exactly equals prevEnd (3) — that's the half-open touching rule firing: [1,3) and [3,5) share only the boundary point 3, so they don't actually clash. And notice [2,4] was removed without changing prevEnd: we kept [1,3], and [2,4] finishes later than [1,3], so there's no reason to prefer it. Leaving prevEnd at 3 is what lets [3,5] slot in right after.
[1,100] that blocks everything. You'll over-remove on inputs where one early interval spans many short ones. Always sort by the end value; the earliest finisher is the safe keep.> instead of >= for the no-overlap test. With half-open [start, end) intervals, [1,2) and [2,3) touch but don't overlap. The condition for "safe to keep" is start >= prevEnd. If you write start > prevEnd, you'll treat every touching pair as a clash and remove one needlessly — [[1,2],[2,3],[3,4]] would return 2 instead of 0. (If your problem defines intervals as closed [start, end] where touching does clash, then > is correct — match the convention you're given.)prevEnd must stay anchored to the kept interval through all of them. A common bug is advancing prevEnd to each removed interval's end, which lets a later interval sneak in that actually clashes. Counting keeps (and only advancing prevEnd when you keep) avoids this entirely.[[1,10],[2,9],[3,8]] — they all overlap each other, so you keep exactly one and remove the rest. Sorting by end keeps [3,8] (earliest end) and removes the two wider ones, for 2 removals. There's nothing special to handle; the start >= prevEnd test already rejects all of them after the first keep.prevEnd tracks the kept interval's end, not the previous list element's end. After a removal, prevEnd is unchanged. Beginners sometimes update prevEnd on every iteration regardless of keep/remove — that breaks the invariant that prevEnd always reflects the last surviving interval.sort sorts in place. If you sort intervals directly instead of a copy, the caller's array comes back reordered, which can corrupt their own later logic. Copy with [...intervals] first.prevEnd, and collect the ones that fail the start >= prevEnd test into a result array. The greedy choice is identical; you're just recording it instead of tallying it.O(n log n).kept count this solution computes is the answer to "what's the largest set of mutually compatible intervals?" — the classic activity-selection problem. If a question asks for that maximum directly, return kept instead of total - kept. Same greedy, different final line.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
You're handed a calendar full of meetings, and some of them clash. You want to cancel as few meetings as possible so that none of the survivors overlap. disjointIntervals(intervals) takes an array of [start, end] pairs and returns the minimum number of intervals you must remove so the rest are mutually non-overlapping. This is the classic non-overlapping intervals problem, the removal-counting twin of activity selection.
// intervals: an array of [start, end] pairs, each a half-open interval [start, end)
// returns: the minimum count of intervals to remove so the rest never overlap
function disjointIntervals(intervals: [number, number][]): number;
disjointIntervals([[1, 2], [2, 3], [3, 4], [1, 3]]);
// → 1
// Remove [1, 3] and the rest — [1,2], [2,3], [3,4] — are all disjoint.
disjointIntervals([[1, 2], [2, 3], [3, 4]]);
// → 0
// Already non-overlapping; nothing to remove. (Touching at an endpoint is fine.)
disjointIntervals([[1, 2], [1, 2], [1, 2]]);
// → 2
// All three occupy the same slot. Keep one, remove the other two.
[start, end] as [start, end). Two intervals that merely touch at an endpoint, like [1, 2] and [2, 3], do not overlap. Overlap requires the second to start strictly before the first one ends.disjointIntervals([]) returns 0. A single interval also returns 0; one interval can never clash with itself.You'll count the fewest intervals to drop so the survivors never overlap — a greedy problem that hinges entirely on which key you sort by.
Your calendar has a stack of meetings and some of them clash. You can't attend two at once, so you have to cancel a few. The question is: what's the smallest number of cancellations that leaves a clash-free schedule? You don't have to report which meetings you cancelled or which survive — just the count.
Flip the problem around and it gets easier to reason about. Minimising removals is the same as maximising keeps: if you can find the largest set of meetings that already fit together without clashing, then everything else is a removal. So removals = total − (largest non-overlapping set). That "largest non-overlapping set" is the classic activity-selection problem, and it has a famously short greedy answer.
One convention up front: we treat every interval as half-open, [start, end). A meeting from 1 to 2 and another from 2 to 3 do not clash — one ends exactly as the other begins. Overlap means the next interval starts strictly before the previous one ends.
Picture the intervals as bars on a number line. You want to pack in as many bars as possible without any two of them overlapping. Greedy intuition: every time you commit to keeping a bar, you'd like to leave as much room as possible for the bars still to come. The bar that leaves the most room is the one that finishes earliest — its right edge is as far left as it can be.
So the rule is: sort the intervals by their end value, walk left to right, and keep an interval whenever it starts at or after the last kept interval's end. Track only one number as you go — prevEnd, the end of the most recently kept interval. Anything that starts before prevEnd clashes with what you kept, so it's a removal.
The instinct most people have is: sort by start time (that's how a calendar is laid out), then walk the list, and whenever the next interval overlaps the one you are already holding, drop the newcomer and move on.
function disjointIntervalsNaive(intervals) {
if (intervals.length === 0) return 0;
const sorted = [...intervals].sort((a, b) => a[0] - b[0]); // by start
let removals = 0;
let prevEnd = sorted[0][1];
for (let i = 1; i < sorted.length; i++) {
const [start, end] = sorted[i];
if (start < prevEnd) {
// overlap — drop this interval, keep the one we already have
removals++;
} else {
prevEnd = end;
}
}
return removals;
}
Sorting by start sets it up to make a bad first choice. Feed it [[1,100],[2,3],[3,4],[4,5]]. Sorted by start, [1,100] comes first, so prevEnd starts at 100. Now [2,3], [3,4], and [4,5] each start before 100, so each looks like an overlap and gets dropped — 3 removals. But the right answer is 1: drop the single hog [1,100] and the three short intervals all survive untouched. The walk anchored on [1,100] purely because it started earliest, and an early start tells you nothing about which interval is safest to keep. The fix is to make the keep-or-drop decision on the interval that ends earliest instead.
The fix isn't a smarter overlap rule. It's sorting by the end value, so the first interval you commit to is the one that finishes earliest — the one that leaves the most room.
function disjointIntervals(intervals) {
if (intervals.length === 0) return 0;
// Sort by END value. The earliest-finishing interval is always safe to keep
// because it blocks the least future room.
const sorted = [...intervals].sort((a, b) => a[1] - b[1]);
let kept = 0;
let prevEnd = -Infinity; // the end of the last interval we decided to keep
for (const [start, end] of sorted) {
// start >= prevEnd: this interval begins at or after the last kept one ended,
// so it does NOT overlap (half-open: touching endpoints are fine). Keep it.
if (start >= prevEnd) {
kept++;
prevEnd = end;
}
// Otherwise it starts before prevEnd → it overlaps → it's an implicit removal.
}
// Removals = everything we didn't keep.
return intervals.length - kept;
}
module.exports = { disjointIntervals };
The whole algorithm is four moving parts, and the only change from the naive version is the sort key plus counting keeps instead of removals. Take each non-obvious choice in turn.
Why sort by a[1] - b[1] (end), not start. This is the entire problem. After sorting by end, the first interval in the list is the one that finishes earliest in the whole set. Keeping it can never be a mistake: any interval you could have kept instead finishes no earlier, so swapping it in only leaves less room. This "earliest deadline first" choice is the heart of activity selection, and it's provably optimal — you never have to backtrack.
Why prevEnd starts at -Infinity. The first interval should always be kept (there's nothing before it to clash with). Seeding prevEnd with -Infinity guarantees the very first start >= prevEnd check passes, so we don't need a special case for the first element.
Why start >= prevEnd and not start > prevEnd. Half-open intervals. [1, 2) and [2, 3) touch at the point 2 but don't share any actual time — the first is done the instant the second begins. So start === prevEnd is not an overlap, and we keep it. Using strict > here would wrongly treat touching intervals as clashing and remove one of them. (See the touching gotcha below.)
Why count keeps and subtract, instead of counting removals directly. You can do it either way, but counting keeps maps cleanly onto the activity-selection proof — kept is exactly the size of the largest non-overlapping set. Removals are then just "everyone else," intervals.length - kept. It also sidesteps a subtle bug: when several intervals in a row all overlap the kept one, you must count each as its own removal without advancing prevEnd. Counting keeps makes that automatic — you simply don't increment kept, and prevEnd stays put.
Why [...intervals] before sorting. Array.prototype.sort mutates the array in place. Copying first keeps the caller's array untouched — a courtesy that prevents action-at-a-distance bugs if they reuse the input.
Let's trace disjointIntervals([[1,3],[2,4],[3,5],[6,8],[7,9]]) end to end. First, sort by end value. The ends are 3, 4, 5, 8, 9 — already in order, so the sorted list is [[1,3],[2,4],[3,5],[6,8],[7,9]]. Start with kept = 0 and prevEnd = -Infinity.
[1,3] start 1 >= -Infinity → keep. kept = 1, prevEnd = 3
[2,4] start 2 >= 3 ? no → remove (overlaps the kept [1,3])
[3,5] start 3 >= 3 ? yes → keep. kept = 2, prevEnd = 5
[6,8] start 6 >= 5 ? yes → keep. kept = 3, prevEnd = 8
[7,9] start 7 >= 8 ? no → remove (overlaps the kept [6,8])
kept = 3, total = 5
return 5 - 3 = 2
Two intervals removed. Notice [3,5] is kept even though its start (3) exactly equals prevEnd (3) — that's the half-open touching rule firing: [1,3) and [3,5) share only the boundary point 3, so they don't actually clash. And notice [2,4] was removed without changing prevEnd: we kept [1,3], and [2,4] finishes later than [1,3], so there's no reason to prefer it. Leaving prevEnd at 3 is what lets [3,5] slot in right after.
[1,100] that blocks everything. You'll over-remove on inputs where one early interval spans many short ones. Always sort by the end value; the earliest finisher is the safe keep.> instead of >= for the no-overlap test. With half-open [start, end) intervals, [1,2) and [2,3) touch but don't overlap. The condition for "safe to keep" is start >= prevEnd. If you write start > prevEnd, you'll treat every touching pair as a clash and remove one needlessly — [[1,2],[2,3],[3,4]] would return 2 instead of 0. (If your problem defines intervals as closed [start, end] where touching does clash, then > is correct — match the convention you're given.)prevEnd must stay anchored to the kept interval through all of them. A common bug is advancing prevEnd to each removed interval's end, which lets a later interval sneak in that actually clashes. Counting keeps (and only advancing prevEnd when you keep) avoids this entirely.[[1,10],[2,9],[3,8]] — they all overlap each other, so you keep exactly one and remove the rest. Sorting by end keeps [3,8] (earliest end) and removes the two wider ones, for 2 removals. There's nothing special to handle; the start >= prevEnd test already rejects all of them after the first keep.prevEnd tracks the kept interval's end, not the previous list element's end. After a removal, prevEnd is unchanged. Beginners sometimes update prevEnd on every iteration regardless of keep/remove — that breaks the invariant that prevEnd always reflects the last surviving interval.sort sorts in place. If you sort intervals directly instead of a copy, the caller's array comes back reordered, which can corrupt their own later logic. Copy with [...intervals] first.prevEnd, and collect the ones that fail the start >= prevEnd test into a result array. The greedy choice is identical; you're just recording it instead of tallying it.O(n log n).kept count this solution computes is the answer to "what's the largest set of mutually compatible intervals?" — the classic activity-selection problem. If a question asks for that maximum directly, return kept instead of total - kept. Same greedy, different final line.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.