Merging overlapping intervals
Sort ranges by their start, then join each one into the previous range when they overlap, giving a short list of ranges that don't overlap.
O(n log n)Space O(n)6 practice problemsStep-through pictureThe idea
Many inputs are ranges written as [start, end]: highlighted text from character 4 to 9, a video selection from second 12 to 30, a booked slot from 10:00 to 11:00. When ranges overlap, you often want to join them, so [1, 4] and [3, 7] become [1, 7].
The trick is to sort the ranges by their start. After sorting, a range can only overlap the range just before it (or the merged range built so far), never one further back. So you walk the sorted list once, keeping a list merged. If the next range starts before the last merged range ends, stretch the last range's end. Otherwise, start a new merged range.
The result is the smallest list of ranges that covers exactly the same points, sorted, with gaps between them. Many range questions become easy after this step: total covered length, free time between bookings, or whether two ranges collide at all.
An everyday example
Laying strips of tape on a ruler
You have strips of tape to stick along a ruler, each from one mark to another. You lay them in order of where they start. If the next strip starts before the tape you've already laid ends, it just extends the taped stretch (or hides under it). If it starts after a gap, it begins a new taped stretch. At the end, the ruler shows a few solid taped stretches with gaps between them.
| In the example | In the code |
|---|---|
| Laying strips by where they start | sorted by a[0] - b[0] |
| The stretch of tape you're building | last = merged[merged.length - 1] |
| The next strip starts before the tape ends | start <= last[1] |
| Extending the taped stretch | last[1] = Math.max(last[1], end) |
| Starting a new stretch after a gap | merged.push([start, end]) |
From the slow way to merging overlapping intervals
- The slow way compares every pair of ranges. Whenever two overlap, join them and start over, because the joined range may now overlap others. One round of pair checks costs
O(n^2), and you may need up tonrounds, so the work can reachO(n^3). - The waste comes from checking pairs that can't overlap. In random order, any two ranges might touch, so you have to check them all. But after sorting by start, the ranges that can touch a given range sit right next to it in the list.
- So sort by start and walk the list, keeping the ranges built so far in
merged. The next range starts at or after every range already seen. It can only reach back into the most recent merged range,last, because all the earlier merged ranges end beforelastbegins. - That leaves two cases at each step. If
start <= last[1], the new range overlaps or toucheslast, so stretchlast[1]to whichever end is further. If not, there is a gap, and the new range starts a new merged range. That loop is merging overlapping intervals.
How it works
- If there are no intervals, return an empty list.
- Sort a copy of the intervals by start.
- Put a copy of the first interval in
merged. - For each next interval
[start, end], look atlast, the last range inmerged. - If
start <= last[1], the two overlap (or touch): stretchlast[1]toMath.max(last[1], end). - Otherwise there's a gap: push
[start, end]as a new range. - Return
merged.
Watch it run
This picture runs the code below on a real input. Press Step to move one step at a time, or Play to watch. The highlighted line is the one that just ran. Change the input to see other cases.
See every step at onceThe same run, laid out side by side
The code
Point at a note to light up the lines it explains.
function mergeRanges(intervals) {if (intervals.length === 0) return [];const sorted = [...intervals].sort((a, b) => a[0] - b[0]);const merged = [[...sorted[0]]];for (let i = 1; i < sorted.length; i++) {const [start, end] = sorted[i];const last = merged[merged.length - 1];if (start <= last[1]) {last[1] = Math.max(last[1], end);} else {merged.push([start, end]);}}return merged;}
How fast it is
| Time | O(n log n) |
|---|---|
| Extra memory | O(n) |
Sorting costs O(n log n). The merge pass looks at each range once. The sorted copy and the result each hold up to n ranges.
- Best case
O(n)The ranges are already sorted by start. V8's TimSort finishes in one pass, and the merge is another pass.
- Average case
O(n log n)Ranges in random order: sorting costs
O(n log n)and the merge passO(n). - Worst case
O(n log n)Any unsorted input. How many ranges merge doesn't matter, since each range is compared with
lastonce.
Common mistakes
- Forgetting to sort first. Without sorting,
[1, 2],[5, 6],[2, 5]would never be joined into one range. - Setting
last[1] = endinstead ofMath.max(last[1], end), which shrinks a range when a smaller range sits inside it. - Pushing the caller's own interval arrays into
mergedand then changing them, which silently edits the input. - Using
<when touching ranges should merge, or<=when they shouldn't. Check whether the end is included.
When to use it
Look for these clues in a problem:
- The input is a list of ranges, and the question mentions overlap, union, or covered length.
- You need to join, combine, or simplify ranges, such as selections, highlights, or bookings.
- You need the gaps between ranges: free time, unselected parts, empty space.
- The ranges come in any order, and the answer should be sorted ranges that don't overlap.
Check yourself
Answer each one in your head first, then open it.
Why is comparing the new range with last enough?
After sorting by start, every merged range before last ends before last starts, so it also ends before the new range starts. Only last can overlap the new range.
What goes wrong with last[1] = end instead of Math.max(last[1], end)?
A range inside last shrinks it. For [1, 10] then [3, 4], the result becomes [1, 4], and the points from 4 to 10 are lost.
Why copy the first range with [...sorted[0]] instead of pushing it directly?
The loop changes last[1]. Without the copy, that would change the caller's own range array.
If none of the n ranges overlap or touch, how long is merged?
n: every step finds a gap and pushes a new range.
Could this loop work with the ranges sorted by end instead of start?
No. A range that starts very early could come late and overlap several merged ranges, not just last. For [[2, 3], [4, 5], [1, 6]] sorted by end, it returns [[2, 3], [4, 6]] instead of [[1, 6]].
Practice problems
Stuck on one? Tuto is in the playground too, and gives you hints, never the answer.
0 of 6 solved. Solve one to earn the bronze medal, half for silver, and all of them for gold.
Start practicingmedium
Go further
You don't need these to start practicing. Open one when you want more.
Why it works
The reasoning that makes it always right
The rule the loop keeps: after handling sorted[0] to sorted[i], the ranges in merged are in order, no two of them overlap or touch, and together they cover exactly the same points as sorted[0] to sorted[i]. At the start, merged holds a copy of the first range, so the rule is true.
Each step keeps the rule. Every merged range before last ends before last starts, and last starts at or before the new start because of the sort, so the new range can only overlap last. If it does, setting last[1] to Math.max(last[1], end) adds exactly the new points. If it doesn't, the new range lies wholly after last, and pushing it keeps merged in order with a gap between.
The loop ends after n - 1 steps, and then the rule covers the whole input. merged covers the same points as all the ranges, and no two of its ranges overlap or touch, so none of them could be joined further. That makes it the smallest list of ranges with that coverage.
Going deeper
3 more ideas
Why only the last merged range matters
After sorting by start, every later interval starts at or after the current one. The merged ranges before last all end before last starts (that's why they're separate), so they end before the current interval starts too. The current interval can only overlap last. That's why one pass with a look at last is enough.
Why Math.max when stretching
An interval can sit completely inside the last range, like [2, 3] inside [1, 6]. Setting last[1] = end would shrink the range to [1, 3] and lose 3 to 6. Math.max(last[1], end) only ever makes the range longer.
Touching ranges and the cost
With start <= last[1], [1, 3] and [3, 5] merge into [1, 5], which suits closed ranges where 3 belongs to both. If ranges are half-open (the end isn't included) and touching ranges should stay apart, use start < last[1]. Sorting costs O(n log n) and the pass is O(n).
Work it by hand
Every step in a table, with why
Merge 6 ranges given in mixed order. One range sits inside another, and two only touch at an end.
intervals = [[6, 8], [1, 4], [11, 13], [3, 5], [2, 3], [8, 9]]
| Step | i | [start, end] | last | start <= last[1] | merged | What happens | Why |
|---|---|---|---|---|---|---|---|
| 1 | - | - | - | - | [[1, 4]] | sort, copy the first | Sorted by start: [1, 4], [2, 3], [3, 5], [6, 8], [8, 9], [11, 13]. A copy of the first range starts merged. |
| 2 | 1 | [2, 3] | [1, 4] | 2 <= 4 | [[1, 4]] | Math.max(4, 3) keeps 4 | [2, 3] sits inside [1, 4]. Math.max keeps the end at 4; writing last[1] = end would shrink it to 3. |
| 3 | 2 | [3, 5] | [1, 4] | 3 <= 4 | [[1, 5]] | stretch the end to 5 | It overlaps from 3 to 4 and reaches further, so last grows to [1, 5]. |
| 4 | 3 | [6, 8] | [1, 5] | 6 <= 5 is false | [[1, 5], [6, 8]] | gap: push | Nothing covers the gap from 5 to 6, so a new merged range starts. |
| 5 | 4 | [8, 9] | [6, 8] | 8 <= 8 | [[1, 5], [6, 9]] | touching: stretch to 9 | The two ranges share only the point 8. With <=, touching ranges merge. |
| 6 | 5 | [11, 13] | [6, 9] | 11 <= 9 is false | [[1, 5], [6, 9], [11, 13]] | gap: push | Another gap, so another new range. This was the last range, so the loop ends. |
It returns [[1, 5], [6, 9], [11, 13]]: 6 ranges became 3. After the sort, each range was compared with last exactly once.
Compared with other ways
When to pick something else
| Approach | Time | Extra memory | Choose it when |
|---|---|---|---|
| Merging overlapping intervals | O(n log n) | O(n) | Ranges come in any order and you want the fewest ranges that cover the same points. |
| Insert into an already merged list | O(n) | O(n) | The list is already merged and ranges arrive one at a time, like a user adding text highlights. |
| Difference array over time units | O(n + T) | O(T) | Times are small whole numbers, like minutes in a day, so T is small, and you also want to know which units are covered. |
| Join pairs until nothing changes | O(n^3) | O(n) | Never in practice. It's only the slow starting point that shows why sorting helps. |
Extra memory: O(n): the sorted copy holds n ranges, and merged holds up to n ranges when nothing overlaps.
How the steps grow
- Merging overlapping intervals
O(n log n) - Insert into an already merged list
O(n) - Difference array over time units
O(n + T) - Join pairs until nothing changes
O(n^3)
| Approach | n = 10 | n = 1,000 | n = 1,000,000 |
|---|---|---|---|
| Merging overlapping intervals | 33 | 9,966 | 20 million |
| Insert into an already merged list | 10 | 1,000 | 1 million |
| Difference array over time units | 20 | 2,000 | 2 million |
| Join pairs until nothing changes | 1,000 | 1 billion | more than a quadrillion |
Inputs that need care
6 tricky inputs
- No ranges:
[] - The first line returns
[]. Without it,sorted[0]would beundefinedand[...sorted[0]]would throw. - One range:
[[4, 9]] mergedstarts as[[4, 9]]and the loop never runs. It returns a copy, not the caller's array.- A range inside another:
[[1, 10], [3, 4]] Math.max(10, 4)keeps the end at10, so the result is[[1, 10]].- Touching ranges:
[[1, 3], [3, 5]] 3 <= 3is true, so they merge to[[1, 5]]. For half-open ranges, where the end isn't included, usestart < last[1]to keep them apart.- Many ranges with the same start:
[[2, 3], [2, 8], [2, 5]] - They overlap
lastone after another, andMath.maxkeeps the biggest end, so the result is[[2, 8]]. - A range written backwards:
[[7, 2], [3, 4]] - The code assumes
start <= end. Here it returns[[3, 4], [7, 2]], though[2, 7]covers[3, 4]. Fix each range withMath.minandMath.maxbefore merging.
Variations and follow-ups
Twists interviewers add
Insert one range into a merged list
If the list is already merged and sorted, don't sort again. Copy every range that ends before the new one starts, join every range that overlaps it into one, then copy the rest. That is one pass, O(n).
Free time between bookings
Merge everyone's busy ranges. The gaps between neighbors in merged, from merged[i][1] to merged[i + 1][0], are the free slots.
Overlap of two merged lists
Given two lists that are each merged and sorted, walk both with two pointers. The overlap of the current pair runs from the larger start to the smaller end, if that isn't empty. Then move past whichever range ends first.
Total covered length
Merge first, then add up end - start for each merged range. Without merging, the overlapping parts would be counted twice.
In an interview
Spot it, explain it, answer follow-ups
- Spot it from "merge overlapping", "total time covered", "free slots", or "can one person attend every meeting". The last one is a merge where any overlap means the answer is no.
- Explain why sorting by start makes one pass enough: after sorting, a new range can only overlap the last merged range. Say this before coding, because it's the whole idea.
- Ask whether touching ranges like
[1, 3]and[3, 5]should merge. The answer decides<=versus<. - Test by hand: an empty list, a range inside another, and touching ranges. A common follow-up is "insert one new range into a sorted, merged list": do it in one
O(n)pass without sorting.
Where frontend code uses it
The same idea in real UI code
- A rich-text editor stores bold or highlighted ranges as
[start, end]offsets and merges them when a user highlights text that touches an existing highlight. - A video editor or player merges selected time ranges, and the browser's
HTMLMediaElement.bufferedproperty reports buffered media as a list of ranges that don't overlap. - A booking calendar merges busy slots from several calendars before showing free time.