Learn/DSA Patterns
DSA PatternsArrays & Two Pointerseasy13 min read

Merge Intervals

How to collapse a messy list of overlapping time slots into the smallest clean set. One sorting step plus one left-to-right sweep is all it takes — and once you see why, you'll never forget it.

#intervals#merge-intervals#sorting#greedy#beginner#interview
Table of contents

Before we start

Intervals feel different from array-value problems — you're dealing with ranges instead of single numbers. But the core pattern is beautifully simple. By the end you will be able to:

  • See overlapping intervals as overlapping events on a timeline.
  • Explain why sorting by start time and a single pass is all you need.
  • Apply the pattern to merging, inserting, and counting intervals.

Stop at every Pause & Think box.


Picture this first (no code yet)

A real-life story

You're a calendar assistant. Your boss gives you a list of meetings for today, completely out of order:

10:00–11:30,   9:00–10:15,   12:00–13:00,   10:45–12:30

Your job: produce the simplified calendar — the fewest possible "blocked" time ranges that cover exactly the same time.

You grab a pen and draw the meetings on a timeline:

 9:00        10:00       11:00       12:00       13:00
  |            |           |           |           |
  [===9:00–10:15===]
               [======10:00–11:30======]
                         [=====10:45–12:30=====]
                                     [===12:00–13:00===]

You squint at it and see: the first three meetings are all tangled together — they overlap into one long block from 9:00 to 12:30. The last meeting touches that block (12:00 exactly) so it merges in too. Result: one big block 9:00–13:00.

How did you figure that out? You sorted the meetings by their start time, then read left to right: whenever a new meeting starts before the current block ends, you just extend the block. When there's a clear gap, you start a new block.

That's the entire pattern.


The actual problem

Given a list of intervals [start, end], merge all overlapping intervals and return the result.

input  = [[1,3], [2,6], [8,10], [15,18]]
output = [[1,6], [8,10], [15,18]]

explanation:
  [1,3] and [2,6] overlap (23) → merged into [1,6]
  [8,10] has no overlap
  [15,18] has no overlap

First, the slow way (so you feel the pain)

Naive approach: For every interval, check every other interval to see if they overlap. If so, merge and repeat until nothing changes.

# Pairwise comparison — keeps looping until stable
def merge_naive(intervals):
    changed = True
    while changed:
        changed = False
        result = []
        used = [False] * len(intervals)
        for i in range(len(intervals)):
            if used[i]: continue
            cur = intervals[i]
            for j in range(i+1, len(intervals)):
                if used[j]: continue
                if cur[0] <= intervals[j][1] and intervals[j][0] <= cur[1]:
                    cur = [min(cur[0], intervals[j][0]),
                           max(cur[1], intervals[j][1])]
                    used[j] = True
                    changed = True
            result.append(cur)
        intervals = result
    return intervals

Count the horror:

n = 10        →  up to 100 pair comparisons, multiple passes
n = 10,000    →  up to 100,000,000 comparisons per pass

The clean solution does it in a single pass after one sort — O(n log n) total, dominated by the sort. The merge pass itself is O(n).


The turning point

Pause & think

Here's the key question. Suppose you sort the intervals by their start time. Now you're walking left to right. You're currently holding interval [1, 6] and the next one is [4, 8].

How do you decide if they overlap? And if they do, what should the merged interval be?

What if the next one is [7, 10] — a clear gap? What do you do with [1, 6] at that point?

Two intervals [a, b] and [c, d] (where a ≤ c because we sorted) overlap if and only if c ≤ b — the second one starts before the first one ends. When they overlap, the merged interval is [a, max(b, d)] — same start, the later end.

When there's no overlap (c > b), the current interval is done — it can never grow again (because everything to the right starts even later). Push it to the result and start fresh with [c, d].

The sorting step is the whole key. Without it, an interval that should merge might be anywhere in the list. With it, all intervals that could possibly merge with the current one are adjacent in the sorted order — you see them one by one, in sequence, and never have to look back.


The one idea to remember

The entire pattern in one sentence

Sort by start time, then sweep left to right: if the next interval's start is ≤ the current interval's end, they overlap — extend the end; otherwise the current interval is final — save it and start a new one.


Watch it happen, frame by frame

Input after sorting by start: [[1,3], [2,6], [8,10], [15,18]]

Start: current = [1, 3], result = []

Step 1: next = [2, 6]
  Does 2 ≤ 3?  YES → overlap.  Extend: current = [1, max(3,6)] = [1, 6]

Step 2: next = [8, 10]
  Does 8 ≤ 6?  NO → gap.  Push [1,6] to result. current = [8, 10]
  result = [[1, 6]]

Step 3: next = [15, 18]
  Does 15 ≤ 10?  NO → gap.  Push [8,10] to result. current = [15, 18]
  result = [[1, 6], [8, 10]]

End of array: Push final current = [15, 18]
result = [[1, 6], [8, 10], [15, 18]]  ✅

Pause & think

Cover the trace below. Sort [[3,5],[1,4],[7,9],[2,6]] by start time, then run the merge sweep. What's the result?

Check your trace
Sorted: [[1,4],[2,6],[3,5],[7,9]]

current = [1,4]
next=[2,6]: 24 → extend to [1, max(4,6)]=[1,6]
next=[3,5]: 36 → extend to [1, max(6,5)]=[1,6]  (no change, 5 < 6)
next=[7,9]: 76? NO → push [1,6]. current=[7,9]
End → push [7,9]

result = [[1,6],[7,9]]  ✅

Note: [3,5] was completely swallowed inside [1,6] — the max(6,5)=6 step handles this naturally.


Now, the code — line by line

def merge(intervals):
    if not intervals:
        return []

    intervals.sort(key=lambda x: x[0])    # sort by start time

    result = [intervals[0]]                # seed with the first interval

    for start, end in intervals[1:]:       # sweep from second onward
        last_end = result[-1][1]           # end of the interval we're currently extending

        if start <= last_end:              # overlap: this interval starts inside the current one
            result[-1][1] = max(last_end, end)   # extend the end (might be swallowed)
        else:                              # gap: current interval is final
            result.append([start, end])   # start a new block

    return result

Line by line with the calendar story:

  • intervals.sort(...) — sort all meetings by start time, so overlapping ones are adjacent.
  • result = [intervals[0]] — the first meeting is always the start of some block.
  • for start, end in intervals[1:] — walk through remaining meetings one by one.
  • last_end = result[-1][1] — the end of the meeting block we're currently in.
  • if start <= last_end: — new meeting starts before current block ends → they overlap.
  • result[-1][1] = max(last_end, end) — extend the block to the later endpoint. Use max because the new meeting might end earlier (completely inside the current block).
  • else: result.append(...) — clear gap; the current block is sealed, start a fresh one.

Why does it work?

After sorting by start, the array has a beautiful property: if interval i doesn't overlap with j (where i comes before j), then i doesn't overlap with any interval after j either (they all start even later). So when we declare the current interval "done" and move on, we know with certainty it will never be extended again.

max(last_end, end) handles the case where a later interval is completely contained inside the current one:

current: [1, 10]
next:    [3, 6]    → completely inside
max(10, 6) = 10    → current stays [1, 10], correctly unchanged
Formal argument (for the curious)

Claim. After sorting by start, a single left-to-right pass produces the minimal merge of all intervals.

Key property. For sorted intervals, if intervals[i] and intervals[j] overlap (i < j), then intervals[i] also overlaps with every interval between them (i < k < j) — because those intervals have start[k] ≥ start[i] and start[k] ≤ start[j] ≤ end[i]. So all intervals between i and j form one connected overlap chain.

Consequence. The left-to-right sweep finds the exact maximal extent of each chain: it extends the current block as long as there's overlap, and the first non-overlapping interval definitively ends the chain (since all further intervals start even later). No back-tracking needed. ∎


Why is it so fast?

The sort is O(n log n). The sweep is a single for-loop — exactly n-1 iterations, O(n). The sort dominates.

n = 100,000   →  ~1,700,000 operations (sort) + 100,000 (sweep)
ApproachTimeExtra memory
Pairwise comparison, repeated passesO(n²) per passO(n)
Sort + single sweepO(n log n)O(n) result only

When should I reach for this? (the trigger list)

  • Problem involves intervals, ranges, or time slots.
  • You need to find overlapping, non-overlapping, or covering ranges.
  • Keywords: "merge intervals," "meeting rooms," "free time," "overlap."
  • Brute force would require pairwise comparison — O(n²).
  • The key smell: after sorting by start, overlaps become adjacent.

The same trick in three disguises

Disguise 1 — Merge Intervals (LC #56)

The pattern itself. Sort by start, sweep and extend or push.

Disguise 2 — Insert Interval (LC #57)

Given a list of already-merged (non-overlapping) intervals and a new interval, insert it and re-merge. Three phases:

def insert(intervals, newInterval):
    result = []
    i = 0
    n = len(intervals)

    # Phase 1: add all intervals that end before newInterval starts (no overlap)
    while i < n and intervals[i][1] < newInterval[0]:
        result.append(intervals[i])
        i += 1

    # Phase 2: merge all overlapping intervals into newInterval
    while i < n and intervals[i][0] <= newInterval[1]:
        newInterval[0] = min(newInterval[0], intervals[i][0])
        newInterval[1] = max(newInterval[1], intervals[i][1])
        i += 1
    result.append(newInterval)

    # Phase 3: add all intervals that start after newInterval ends (no overlap)
    while i < n:
        result.append(intervals[i])
        i += 1

    return result

Same overlap condition (start ≤ end), same max extension. Three explicit phases instead of one loop.

Disguise 3 — Meeting Rooms (LC #252)

Can a person attend all meetings? Simply check if any two consecutive meetings (sorted by start) overlap:

def canAttendMeetings(intervals):
    intervals.sort(key=lambda x: x[0])
    for i in range(1, len(intervals)):
        if intervals[i][0] < intervals[i-1][1]:   # overlap
            return False
    return True
Level up — Meeting Rooms II: minimum conference rooms needed (LC #253)

How many rooms to hold all meetings simultaneously? Track the minimum number of concurrent meetings at any point using a min-heap:

import heapq

def minMeetingRooms(intervals):
    if not intervals:
        return 0
    intervals.sort(key=lambda x: x[0])
    heap = []                              # stores end times of active meetings
    for start, end in intervals:
        if heap and heap[0] <= start:
            heapq.heapreplace(heap, end)   # recycle a room that's freed up
        else:
            heapq.heappush(heap, end)      # need a new room
    return len(heap)

The heap gives the earliest-ending active meeting. If a room frees up before the next meeting starts, reuse it. The heap size at the end is the number of rooms needed.


Traps that catch beginners

Watch out for these

  • Forgetting to sort first. Without sorting, overlapping intervals are scattered; the sweep misses them. Always sort by start time before sweeping.
  • Using > instead of >= for overlap. Intervals [1,3] and [3,5] share the point 3 — they do touch. The condition is start <= last_end, not start < last_end. Whether touching counts as overlapping depends on the problem — read carefully.
  • Not using max when extending. If the new interval ends earlier than the current end (e.g., completely contained), using end directly would wrongly shrink the block. Always max(last_end, end).
  • Modifying the input list. Sorting in-place modifies the caller's list. If the problem expects the original to be unchanged, sort a copy: intervals = sorted(intervals, key=lambda x: x[0]).
BugFix
Forgot to sortintervals.sort(key=lambda x: x[0]) before sweeping
start < last_end instead of Use <= unless problem says touching is not overlapping
result[-1][1] = endUse max(result[-1][1], end) to handle containment

Say it like a pro (interview one-liner)

"I'll sort the intervals by start time, then do a single sweep. I keep track of the interval I'm currently extending. For each next interval, if it starts before the current one ends, they overlap — I extend the end to the maximum of the two. Otherwise there's a gap — I push the current to results and start fresh. O(n log n) for the sort, O(n) for the sweep."


Remember this forever

Merge Intervals

Sort by start. Sweep left to right. Two intervals [a,b] and [c,d] (a ≤ c) overlap if c ≤ b. Merge → [a, max(b,d)]. Gap → push current, start fresh with [c,d].


Cost: O(n log n) time (sort dominates), O(n) space (result) · Trigger: intervals / ranges / time slots / overlapping events · Key detail: always max(last_end, end) — not just end — to handle containment


Check yourself

Why must we sort by start time? Could we sort by end time instead?

Sorting by start time guarantees that if two intervals overlap, the one that starts earlier appears first and will be the "current" interval when the later one is processed. If we sorted by end time, we might encounter an interval whose start is far to the left of the current block, causing us to miss merges or create incorrect blocks. Start-time sorting ensures all intervals that could extend the current block are processed in sequence.

Intervals [1,5] and [5,8] — do they overlap? How does the code treat them?

Yes — they share the point 5. The condition start <= last_end gives 5 <= 5 = True, so they merge into [1,8]. If the problem defines "overlapping" as strictly sharing more than a point (start < last_end), you'd adjust to < and they'd stay separate [1,5], [5,8]. Always check the problem statement.

Why do we use max(last_end, end) instead of just end?

Consider [[1,10],[2,4]]. After sorting, current = [1,10], next = [2,4]. They overlap (2 ≤ 10). If we wrote result[-1][1] = end = 4, the block would shrink from [1,10] to [1,4] — wrong. max(10, 4) = 10 keeps the correct outer boundary. The new interval is completely contained; max handles it transparently.

What is the difference between Merge Intervals and Insert Interval?

Merge Intervals (LC #56): intervals may be unsorted and overlapping — sort first, then sweep. Insert Interval (LC #57): the existing intervals are already sorted and non-overlapping — you don't need to sort, just find where the new interval fits and merge with whatever it touches. Insert Interval uses three phases (before, overlap, after) instead of a general sweep.


Practice problems

ProblemDifficultyWhat to noticeLink
Merge IntervalsMediumCore pattern — sort + sweepLC #56
Insert IntervalMediumThree-phase insert; list is pre-sortedLC #57
Meeting RoomsEasyCan one person attend all? Sort + check adjacentLC #252
Meeting Rooms IIMediumMin rooms = max concurrent meetings; min-heapLC #253
Non-overlapping IntervalsMediumNext pattern preview — greedy removalLC #435

When you can solve LC #56 from memory and explain why max(last_end, end) is needed, you've learned this pattern.

Next up: Interval Scheduling — instead of merging intervals, we greedily select the maximum number that fit without overlapping.