You have a list of time blocks — think busy slots on a calendar, each a [start, end] pair. Some of them overlap or butt up against each other, and you want one clean view: the consolidated stretches of busy time, with no two blocks touching. Implement intervalsCombineOverlapping(intervals), which takes a list of intervals in any order and returns a new list of merged, non-overlapping intervals sorted by start.
// intervals: Array<[number, number]>
// A list of [start, end] pairs. CLOSED intervals: each one includes both
// endpoints, so [1, 3] covers 1, 3, and everything between. The list may be
// in any order; start <= end for every pair.
// returns: Array<[number, number]>
// A NEW array of merged, non-overlapping intervals, sorted by start.
// Two intervals merge if they overlap OR merely touch at an endpoint:
// [1, 3] and [3, 5] become [1, 5].
function intervalsCombineOverlapping(intervals): Array<[number, number]>;
// An overlapping set collapses; the gap-separated blocks stay apart.
intervalsCombineOverlapping([
[1, 3],
[2, 6],
[8, 10],
[15, 18],
]);
// → [[1, 6], [8, 10], [15, 18]]
// [1, 3] and [2, 6] overlap → [1, 6]; the other two have gaps, so they stay.
// Touching at an endpoint counts as overlapping (closed intervals).
intervalsCombineOverlapping([
[1, 3],
[3, 5],
]);
// → [[1, 5]]
// They share the point 3, so they merge into one block.
// Unsorted input with a chain only reachable after sorting.
intervalsCombineOverlapping([
[1, 4],
[5, 6],
[2, 5],
]);
// → [[1, 6]]
// Sorted, this is [1,4],[2,5],[5,6]: [1,4]+[2,5]=[1,5], then +[5,6]=[1,6].
[1, 3] covers everything from 1 to 3 inclusive.[1, 3] and [3, 5] — merge into [1, 5]. A one-unit gap like [1, 2] and [3, 4] does not merge; 2 and 3 are distinct points.[start, end] pair should change — return fresh arrays.start > end, or non-numeric coordinates — those are out of scope.You'll collapse a pile of [start, end] intervals into the smallest set of intervals that covers the same ground, with nothing overlapping.
Picture your calendar for the day. You have meetings as time blocks — [9, 10], [9:30, 11], [14, 15] — and you want to know your actual busy stretches, not the raw blocks. The [9, 10] and [9:30, 11] blocks overlap, so together they're one busy stretch [9, 11]; the [14, 15] block stands alone. Merging overlapping intervals is exactly this: given blocks in any order, return the consolidated busy stretches, sorted, with no two touching.
These are closed intervals — each one includes both endpoints. That detail decides one edge case up front: [1, 3] and [3, 5] share the single point 3, so they count as overlapping and merge into [1, 5]. A gap of even one unit — [1, 2] next to [3, 4] — does not merge, because 2 and 3 are different points.
Here's the key insight that makes this tractable: if you sort the intervals by start, every interval that should merge into a given block sits immediately after it. Once they're in start order, you can walk left to right keeping a single "current" interval. For each next interval, you either extend the current one (they touch or overlap) or push the current one and start fresh (there's a gap). One pass, one variable. No comparing every pair against every other pair.
The instinct before you spot the sorting trick is to compare intervals against each other directly: scan the list, and whenever two overlap, fuse them. No sorting — just look at every pair.
function mergePairwise(intervals) {
const result = intervals.map(([s, e]) => [s, e]); // copy so we can mutate freely
for (let i = 0; i < result.length; i++) {
for (let j = i + 1; j < result.length; j++) {
const [s1, e1] = result[i];
const [s2, e2] = result[j];
// Do intervals i and j overlap (or touch)?
if (s1 <= e2 && s2 <= e1) {
result[i] = [Math.min(s1, s2), Math.max(e1, e2)]; // fuse into i
result.splice(j, 1); // remove j
j = i; // restart the inner scan against the grown i
}
}
}
return result;
}
Run it on [[1, 4], [5, 6], [2, 5]]. Start with i = 0 ([1, 4]). Compare against [5, 6]: is 1 <= 6 && 5 <= 4? 5 <= 4 is false — no overlap, skip. Compare [1, 4] against [2, 5]: 1 <= 5 && 2 <= 4? Both true — fuse to [1, 5], drop [2, 5]. Now result is [[1, 5], [5, 6]] and the inner loop restarts. [1, 5] vs [5, 6]: 1 <= 6 && 5 <= 5? Both true — fuse to [1, 6]. Final: [[1, 6]].
So on this input it actually works — but only because the restart-the-scan trick (j = i) keeps re-examining the grown interval. That patch is exactly the smell. Without it, you'd fuse [1, 4] and [2, 5] into [1, 5], the inner loop would march past [5, 6] and never look back, and you'd miss the chain. And even with the patch, each fuse restarts an inner scan, so a fully-overlapping list of n intervals does on the order of n² comparisons (plus splice is O(n) each time). The pairwise shape is fighting the problem. Sorting removes the need to ever look backward.
function intervalsCombineOverlapping(intervals) {
// Sort a COPY by start so we never mutate the caller's array. Each pair is
// also copied so the result shares no references with the input.
const sorted = intervals.map(([start, end]) => [start, end]);
sorted.sort((a, b) => a[0] - b[0]);
const merged = [];
for (const [start, end] of sorted) {
const current = merged[merged.length - 1];
// Touching counts as overlapping (closed intervals): start <= current end.
if (current && start <= current[1]) {
// Extend with max — a nested interval must not shrink the running end.
current[1] = Math.max(current[1], end);
} else {
merged.push([start, end]);
}
}
return merged;
}
module.exports = { intervalsCombineOverlapping };
The shift from the naive version is that sorting lets the last interval in merged be the only thing we ever compare against. Once intervals are in start order, anything that overlaps the current block must start at or before the current block's end — so a single start <= current[1] check decides extend-or-push, and we never look backward. Walk through the non-obvious lines:
Why copy before sorting. Array.prototype.sort sorts in place — it reorders the array you hand it. If we wrote intervals.sort(...), the caller's array would come back reordered, which violates the no-mutation contract. intervals.map(([start, end]) => [start, end]) builds a fresh outer array and fresh [start, end] pairs, so neither the array nor any interval inside it is shared with the input. (Mutating current[1] later then touches only our copies.)
Why a[0] - b[0] and not a[0] < b[0]. sort's comparator must return a number — negative, zero, or positive — not a boolean. Subtraction gives exactly that ordering signal. A comparator that returns true/false produces implementation-defined garbage order because true coerces to 1 and false to 0, so "stay" and "swap" get conflated.
Why start <= current[1] with <=, not <. This is the closed-interval convention in code. current[1] is the current block's end. If the next interval starts at that end (start === current[1]), the two touch at a shared point and should merge — so we need <=. Switch to < and touching intervals like [1, 3] and [3, 5] would be treated as separate, producing [[1, 3], [3, 5]] instead of [[1, 5]].
Why Math.max(current[1], end) instead of just end. The next interval can be nested inside the current one — [1, 10] followed by [3, 5]. Its start (3) is <= 10, so we merge, but its end (5) is smaller than the current end (10). Blindly assigning current[1] = end would shrink the block to [1, 5] and lose coverage. Taking the max keeps the further-right end. (When the next interval genuinely extends the block — [1, 5] then [2, 9] — the max picks 9, which is what we want.)
Why comparing only against merged[merged.length - 1]. Because the list is sorted by start, the most recently pushed block always has the largest start so far, and therefore the largest reach to the right among the blocks we've finalized. Any earlier block ended before this one started — there's nothing left to merge backward into. So the last block is the only candidate, and the algorithm is a single linear pass: O(n log n) for the sort, O(n) for the sweep.
Let's trace the unsorted chain [[1, 4], [5, 6], [2, 5]] — the input the naive version had to bolt on a restart hack to handle.
First, copy and sort by start: [[1, 4], [2, 5], [5, 6]]. Now sweep, watching merged and the running current:
sorted = [ [1,4], [2,5], [5,6] ]
merged = []
next [1,4] merged is empty (no current) → PUSH
merged = [ [1,4] ] current = [1,4]
next [2,5] start 2 <= current end 4 → EXTEND
current[1] = max(4, 5) = 5
merged = [ [1,5] ] current = [1,5]
next [5,6] start 5 <= current end 5 → EXTEND (touching: 5 <= 5)
current[1] = max(5, 6) = 6
merged = [ [1,6] ] current = [1,6]
return [ [1,6] ]
The interesting step is the last one. [5, 6] starts exactly where the running block ends (5), so 5 <= 5 is true and they merge — the closed-interval rule earning its keep. And because we sorted first, [5, 6] arrives after the block already grew to [1, 5], so there's no backward look and no restart loop. One left-to-right pass does it.
[[1, 4], [5, 6], [2, 5]], you'd compare [1, 4] to [5, 6] (gap, push), then [2, 5] would arrive out of place and you'd never fold it back into [1, 4]. The result would wrongly keep three or two blocks. The sort is what guarantees mergeable intervals are adjacent — it's not optional. Fix: sorted.sort((a, b) => a[0] - b[0]) before the sweep.< instead of <= for the overlap check. With closed intervals, touching intervals merge. [1, 3] and [3, 5] share the point 3; start < current[1] (3 < 3 is false) would split them into [[1, 3], [3, 5]]. The fix is start <= current[1]. (If your intervals were open or half-open and touching should NOT merge, you'd flip to strict < — state your convention either way.)intervals.sort(...) reorders the caller's array as a side effect, and a later current[1] = ... would even rewrite their interval objects if you skipped the inner copy. Both surprise a caller who passed a list they still need. Fix: copy first — intervals.map(([s, e]) => [s, e]) — then sort and mutate the copy.[3, 5] follows [1, 10], assigning current[1] = end shrinks the block to [1, 5] and drops coverage. The end must only ever grow during a merge: current[1] = Math.max(current[1], end).[], the sorted copy is empty, the loop body never runs, and merged stays [] — which is correct. But a guard like if (!intervals.length) return null (or return intervals, handing back the very array you promised not to alias) breaks the contract. Let the loop fall through and return the fresh merged array.[start, end] and re-merge in a single O(n) pass without re-sorting: copy the intervals that end before the new one starts, merge the overlapping run by taking min-start/max-end, then copy the rest. This is the classic "insert interval" follow-up.[max(start1, start2), min(end1, end2)] whenever that range is non-empty. Same sweep mindset, different combine step.+1 at start and a -1 at end, sort the events by coordinate, and track the running sum. The peak of that sum is the answer. The same start-sorted sweep idea generalizes from "merge" to "count overlaps" to "schedule" once you think in events rather than bars.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
You have a list of time blocks — think busy slots on a calendar, each a [start, end] pair. Some of them overlap or butt up against each other, and you want one clean view: the consolidated stretches of busy time, with no two blocks touching. Implement intervalsCombineOverlapping(intervals), which takes a list of intervals in any order and returns a new list of merged, non-overlapping intervals sorted by start.
// intervals: Array<[number, number]>
// A list of [start, end] pairs. CLOSED intervals: each one includes both
// endpoints, so [1, 3] covers 1, 3, and everything between. The list may be
// in any order; start <= end for every pair.
// returns: Array<[number, number]>
// A NEW array of merged, non-overlapping intervals, sorted by start.
// Two intervals merge if they overlap OR merely touch at an endpoint:
// [1, 3] and [3, 5] become [1, 5].
function intervalsCombineOverlapping(intervals): Array<[number, number]>;
// An overlapping set collapses; the gap-separated blocks stay apart.
intervalsCombineOverlapping([
[1, 3],
[2, 6],
[8, 10],
[15, 18],
]);
// → [[1, 6], [8, 10], [15, 18]]
// [1, 3] and [2, 6] overlap → [1, 6]; the other two have gaps, so they stay.
// Touching at an endpoint counts as overlapping (closed intervals).
intervalsCombineOverlapping([
[1, 3],
[3, 5],
]);
// → [[1, 5]]
// They share the point 3, so they merge into one block.
// Unsorted input with a chain only reachable after sorting.
intervalsCombineOverlapping([
[1, 4],
[5, 6],
[2, 5],
]);
// → [[1, 6]]
// Sorted, this is [1,4],[2,5],[5,6]: [1,4]+[2,5]=[1,5], then +[5,6]=[1,6].
[1, 3] covers everything from 1 to 3 inclusive.[1, 3] and [3, 5] — merge into [1, 5]. A one-unit gap like [1, 2] and [3, 4] does not merge; 2 and 3 are distinct points.[start, end] pair should change — return fresh arrays.start > end, or non-numeric coordinates — those are out of scope.You'll collapse a pile of [start, end] intervals into the smallest set of intervals that covers the same ground, with nothing overlapping.
Picture your calendar for the day. You have meetings as time blocks — [9, 10], [9:30, 11], [14, 15] — and you want to know your actual busy stretches, not the raw blocks. The [9, 10] and [9:30, 11] blocks overlap, so together they're one busy stretch [9, 11]; the [14, 15] block stands alone. Merging overlapping intervals is exactly this: given blocks in any order, return the consolidated busy stretches, sorted, with no two touching.
These are closed intervals — each one includes both endpoints. That detail decides one edge case up front: [1, 3] and [3, 5] share the single point 3, so they count as overlapping and merge into [1, 5]. A gap of even one unit — [1, 2] next to [3, 4] — does not merge, because 2 and 3 are different points.
Here's the key insight that makes this tractable: if you sort the intervals by start, every interval that should merge into a given block sits immediately after it. Once they're in start order, you can walk left to right keeping a single "current" interval. For each next interval, you either extend the current one (they touch or overlap) or push the current one and start fresh (there's a gap). One pass, one variable. No comparing every pair against every other pair.
The instinct before you spot the sorting trick is to compare intervals against each other directly: scan the list, and whenever two overlap, fuse them. No sorting — just look at every pair.
function mergePairwise(intervals) {
const result = intervals.map(([s, e]) => [s, e]); // copy so we can mutate freely
for (let i = 0; i < result.length; i++) {
for (let j = i + 1; j < result.length; j++) {
const [s1, e1] = result[i];
const [s2, e2] = result[j];
// Do intervals i and j overlap (or touch)?
if (s1 <= e2 && s2 <= e1) {
result[i] = [Math.min(s1, s2), Math.max(e1, e2)]; // fuse into i
result.splice(j, 1); // remove j
j = i; // restart the inner scan against the grown i
}
}
}
return result;
}
Run it on [[1, 4], [5, 6], [2, 5]]. Start with i = 0 ([1, 4]). Compare against [5, 6]: is 1 <= 6 && 5 <= 4? 5 <= 4 is false — no overlap, skip. Compare [1, 4] against [2, 5]: 1 <= 5 && 2 <= 4? Both true — fuse to [1, 5], drop [2, 5]. Now result is [[1, 5], [5, 6]] and the inner loop restarts. [1, 5] vs [5, 6]: 1 <= 6 && 5 <= 5? Both true — fuse to [1, 6]. Final: [[1, 6]].
So on this input it actually works — but only because the restart-the-scan trick (j = i) keeps re-examining the grown interval. That patch is exactly the smell. Without it, you'd fuse [1, 4] and [2, 5] into [1, 5], the inner loop would march past [5, 6] and never look back, and you'd miss the chain. And even with the patch, each fuse restarts an inner scan, so a fully-overlapping list of n intervals does on the order of n² comparisons (plus splice is O(n) each time). The pairwise shape is fighting the problem. Sorting removes the need to ever look backward.
function intervalsCombineOverlapping(intervals) {
// Sort a COPY by start so we never mutate the caller's array. Each pair is
// also copied so the result shares no references with the input.
const sorted = intervals.map(([start, end]) => [start, end]);
sorted.sort((a, b) => a[0] - b[0]);
const merged = [];
for (const [start, end] of sorted) {
const current = merged[merged.length - 1];
// Touching counts as overlapping (closed intervals): start <= current end.
if (current && start <= current[1]) {
// Extend with max — a nested interval must not shrink the running end.
current[1] = Math.max(current[1], end);
} else {
merged.push([start, end]);
}
}
return merged;
}
module.exports = { intervalsCombineOverlapping };
The shift from the naive version is that sorting lets the last interval in merged be the only thing we ever compare against. Once intervals are in start order, anything that overlaps the current block must start at or before the current block's end — so a single start <= current[1] check decides extend-or-push, and we never look backward. Walk through the non-obvious lines:
Why copy before sorting. Array.prototype.sort sorts in place — it reorders the array you hand it. If we wrote intervals.sort(...), the caller's array would come back reordered, which violates the no-mutation contract. intervals.map(([start, end]) => [start, end]) builds a fresh outer array and fresh [start, end] pairs, so neither the array nor any interval inside it is shared with the input. (Mutating current[1] later then touches only our copies.)
Why a[0] - b[0] and not a[0] < b[0]. sort's comparator must return a number — negative, zero, or positive — not a boolean. Subtraction gives exactly that ordering signal. A comparator that returns true/false produces implementation-defined garbage order because true coerces to 1 and false to 0, so "stay" and "swap" get conflated.
Why start <= current[1] with <=, not <. This is the closed-interval convention in code. current[1] is the current block's end. If the next interval starts at that end (start === current[1]), the two touch at a shared point and should merge — so we need <=. Switch to < and touching intervals like [1, 3] and [3, 5] would be treated as separate, producing [[1, 3], [3, 5]] instead of [[1, 5]].
Why Math.max(current[1], end) instead of just end. The next interval can be nested inside the current one — [1, 10] followed by [3, 5]. Its start (3) is <= 10, so we merge, but its end (5) is smaller than the current end (10). Blindly assigning current[1] = end would shrink the block to [1, 5] and lose coverage. Taking the max keeps the further-right end. (When the next interval genuinely extends the block — [1, 5] then [2, 9] — the max picks 9, which is what we want.)
Why comparing only against merged[merged.length - 1]. Because the list is sorted by start, the most recently pushed block always has the largest start so far, and therefore the largest reach to the right among the blocks we've finalized. Any earlier block ended before this one started — there's nothing left to merge backward into. So the last block is the only candidate, and the algorithm is a single linear pass: O(n log n) for the sort, O(n) for the sweep.
Let's trace the unsorted chain [[1, 4], [5, 6], [2, 5]] — the input the naive version had to bolt on a restart hack to handle.
First, copy and sort by start: [[1, 4], [2, 5], [5, 6]]. Now sweep, watching merged and the running current:
sorted = [ [1,4], [2,5], [5,6] ]
merged = []
next [1,4] merged is empty (no current) → PUSH
merged = [ [1,4] ] current = [1,4]
next [2,5] start 2 <= current end 4 → EXTEND
current[1] = max(4, 5) = 5
merged = [ [1,5] ] current = [1,5]
next [5,6] start 5 <= current end 5 → EXTEND (touching: 5 <= 5)
current[1] = max(5, 6) = 6
merged = [ [1,6] ] current = [1,6]
return [ [1,6] ]
The interesting step is the last one. [5, 6] starts exactly where the running block ends (5), so 5 <= 5 is true and they merge — the closed-interval rule earning its keep. And because we sorted first, [5, 6] arrives after the block already grew to [1, 5], so there's no backward look and no restart loop. One left-to-right pass does it.
[[1, 4], [5, 6], [2, 5]], you'd compare [1, 4] to [5, 6] (gap, push), then [2, 5] would arrive out of place and you'd never fold it back into [1, 4]. The result would wrongly keep three or two blocks. The sort is what guarantees mergeable intervals are adjacent — it's not optional. Fix: sorted.sort((a, b) => a[0] - b[0]) before the sweep.< instead of <= for the overlap check. With closed intervals, touching intervals merge. [1, 3] and [3, 5] share the point 3; start < current[1] (3 < 3 is false) would split them into [[1, 3], [3, 5]]. The fix is start <= current[1]. (If your intervals were open or half-open and touching should NOT merge, you'd flip to strict < — state your convention either way.)intervals.sort(...) reorders the caller's array as a side effect, and a later current[1] = ... would even rewrite their interval objects if you skipped the inner copy. Both surprise a caller who passed a list they still need. Fix: copy first — intervals.map(([s, e]) => [s, e]) — then sort and mutate the copy.[3, 5] follows [1, 10], assigning current[1] = end shrinks the block to [1, 5] and drops coverage. The end must only ever grow during a merge: current[1] = Math.max(current[1], end).[], the sorted copy is empty, the loop body never runs, and merged stays [] — which is correct. But a guard like if (!intervals.length) return null (or return intervals, handing back the very array you promised not to alias) breaks the contract. Let the loop fall through and return the fresh merged array.[start, end] and re-merge in a single O(n) pass without re-sorting: copy the intervals that end before the new one starts, merge the overlapping run by taking min-start/max-end, then copy the rest. This is the classic "insert interval" follow-up.[max(start1, start2), min(end1, end2)] whenever that range is non-empty. Same sweep mindset, different combine step.+1 at start and a -1 at end, sort the events by coordinate, and track the running sum. The peak of that sum is the answer. The same start-sorted sweep idea generalizes from "merge" to "count overlaps" to "schedule" once you think in events rather than bars.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.