Part II · Ordering and Rearranging Pattern 4 4 problems

Merge Intervals

Every interval problem is decided before the first line of logic, by the choice of sort key. Pick the right one and the rest is a single greedy pass.

Intervals show up as meetings, bookings, ranges of IDs, time windows on a chart. Unsorted, comparing every pair is O(n²). Sorted, each interval only ever needs to be compared with the one thing you are carrying forward, and the pass is O(n). The interesting content of this pattern is which key to sort by, because different questions want different keys.

Contents

  1. When to use
  2. Core idea
  3. Choosing the sort key
  4. The templates
  5. Common mistakes
  6. Merge Intervals
  7. Insert Interval
  8. Non-overlapping Intervals
  9. Meeting Rooms II
  10. Recap

When to use

The trigger. The input is a collection of ranges, each with a start and an end, and the question is about how they interact: which overlap, how many overlap at once, how few to delete so none overlap, what the union looks like.
SignalWhat it looks like
Pairs of numbers[start, end], meetings, bookings, flight times, version ranges.
Overlap language“merge”, “conflict”, “collide”, “can attend all”, “how many rooms”.
Coverage language“total time covered”, “free gaps”, “the union of the ranges”.
Removal language“minimum number to remove so the rest are disjoint”.

The one definition to nail first

Two intervals a and b overlap when a.start < b.end and b.start < a.end. Equivalently, they are disjoint when one ends before the other begins. If they are already sorted by start, so a.start ≤ b.start, this collapses to a single test: b.start < a.end.
Ask whether touching counts as overlapping. Do [1, 3] and [3, 5] conflict? For merging ranges of integers, usually yes, so the test is . For meeting rooms, usually no, because a meeting ending at 3 frees the room for one starting at 3, so the test is <. This single character decides several of the problems below. Ask, do not guess.

Core idea

Sort once. Then sweep left to right carrying one piece of state: the interval currently being extended, or the earliest end time still in play. Each new interval is compared against that state only, never against the whole set. Sorting costs O(n log n), the sweep costs O(n), so the sort dominates.
sorted by start 036912 [1, 4] [2, 5] [7, 9] [8, 11] merged [1, 5] [7, 11]
Figure 4.1 — After sorting by start, an overlap can only be with the interval immediately being built. Nothing further back can reach forward.

Why one piece of state is enough

After sorting by start time, every interval yet to be processed starts at or after the current one. So if the next interval does not overlap the range being built, no later interval can overlap it either, since they all start even later. The current range is finished and can be emitted. This is the loop invariant that makes the sweep correct in one pass.

Choosing the sort key

This table is the pattern. Everything else is bookkeeping.

QuestionSort byCarryGreedy rule
Merge overlapping rangesstartthe range being extendedExtend if it overlaps, else emit and restart.
Insert into a sorted listalready sorted by startthe growing new rangeAbsorb every range that touches it.
Keep the most non-overlappingendthe last kept end timeKeep an interval if it starts at or after that end.
Max simultaneous overlapstart, plus a heap of endsthe earliest end still runningReuse a room if its end is free, else open a new one.
The one line worth memorising. Sort by start when you are combining intervals. Sort by end when you are choosing intervals. Choosing the one that frees up soonest is the classic interval-scheduling greedy, and it is provably optimal.

The templates

Template A — merge by start
def sweep_merge(intervals: list[list[int]]) -> list[list[int]]:
    """Union of overlapping ranges."""
    if not intervals:
        return []

    intervals.sort(key=lambda pair: pair[0])
    out = [list(intervals[0])]

    for start, end in intervals[1:]:
        last = out[-1]
        if start <= last[1]:          # overlap or touch
            last[1] = max(last[1], end)
        else:
            out.append([start, end])

    return out

max(last[1], end), not end. A short interval fully inside a long one must not shrink it.

Template B — greedy selection by end
def max_non_overlapping(intervals: list[list[int]]) -> int:
    """Largest set of mutually disjoint intervals."""
    if not intervals:
        return 0

    intervals.sort(key=lambda pair: pair[1])
    kept = 1
    last_end = intervals[0][1]

    for start, end in intervals[1:]:
        if start >= last_end:         # no conflict with the last one kept
            kept += 1
            last_end = end

    return kept
Template C — sweep line over events
def max_concurrent(intervals: list[list[int]]) -> int:
    """Peak number of intervals alive at any instant."""
    events: list[tuple[int, int]] = []
    for start, end in intervals:
        events.append((start, +1))
        events.append((end, -1))

    # At a tie, process the end (-1) before the start (+1), so a range that
    # finishes exactly when another begins does not count as concurrent.
    events.sort()

    running = best = 0
    for _, delta in events:
        running += delta
        best = max(best, running)

    return best

The tuple sort does the tie-break for free, because -1 < +1. That is a neat detail worth pointing out in an interview.

Common mistakes

The problems

1. Merge Intervals Medium

Problem

Given a list of intervals, merge all overlapping ones and return the non-overlapping intervals that cover exactly the same ground. For [[1,3],[2,6],[8,10],[15,18]] the answer is [[1,6],[8,10],[15,18]].

Approach

Solution

def merge_intervals(intervals: list[list[int]]) -> list[list[int]]:
    """Merge every set of overlapping intervals into one.

    Args:
        intervals: Pairs [start, end] with start <= end, in any order.
                   Sorted in place as a side effect.

    Returns:
        Disjoint intervals in increasing order covering the same union.

    Example:
        >>> merge_intervals([[1, 3], [2, 6], [8, 10], [15, 18]])
        [[1, 6], [8, 10], [15, 18]]
    """
    if not intervals:
        return []

    intervals.sort(key=lambda pair: pair[0])
    merged: list[list[int]] = [list(intervals[0])]   # copy, do not alias

    for start, end in intervals[1:]:
        last = merged[-1]

        if start <= last[1]:
            # Overlaps or touches. Extend, but never shrink: the incoming
            # interval may sit entirely inside the one we are building.
            last[1] = max(last[1], end)
        else:
            merged.append([start, end])

    return merged

Walkthrough

[[1,3],[2,6],[8,10],[15,18]], already sorted by start:

incominglasttestresult
[2, 6][1, 3]2 ≤ 3, overlaplast becomes [1, 6]
[8, 10][1, 6]8 > 6, disjointemit and open [8, 10]
[15, 18][8, 10]15 > 10, disjointemit and open [15, 18]
TimeO(n log n)SpaceO(n) output, O(1) extra

Edge cases to raise

Say this out loud: “Once sorted by start, if the next interval does not touch the one I am building, nothing later can either, so I can safely close it out.”

2. Insert Interval Medium

Problem

You are given a list of already sorted, non-overlapping intervals and one new interval. Insert it, merging where needed, and return the result still sorted and non-overlapping.

Approach

Solution

def insert_interval(
    intervals: list[list[int]], new_interval: list[int]
) -> list[list[int]]:
    """Insert one interval into a sorted, disjoint list, merging as needed.

    Args:
        intervals: Sorted, pairwise non-overlapping [start, end] pairs.
        new_interval: The interval to add.

    Returns:
        A new list, still sorted and non-overlapping.

    Example:
        >>> insert_interval([[1, 3], [6, 9]], [2, 5])
        [[1, 5], [6, 9]]
    """
    start, end = new_interval
    out: list[list[int]] = []
    i, n = 0, len(intervals)

    # Phase 1: everything that finishes before the new interval begins.
    while i < n and intervals[i][1] < start:
        out.append(intervals[i])
        i += 1

    # Phase 2: everything that overlaps. Widen, then emit once.
    while i < n and intervals[i][0] <= end:
        start = min(start, intervals[i][0])
        end = max(end, intervals[i][1])
        i += 1
    out.append([start, end])

    # Phase 3: everything that starts after the merged interval ends.
    while i < n:
        out.append(intervals[i])
        i += 1

    return out

Walkthrough

intervals = [[1,2],[3,5],[6,7],[8,10],[12,16]], inserting [4, 8]:

PhaseHandlesState
1[1, 2]2 < 4, copied
2[3, 5], [6, 7], [8, 10]new interval grows 4→3 then 8→10, emits [3, 10]
3[12, 16]copied

Result: [[1,2],[3,10],[12,16]].

TimeO(n)SpaceO(n) output

Edge cases to raise

The follow-up. If the list is huge and you insert often, binary search for the phase-1 boundary in O(log n). The merge phase is still O(k) in the number of overlaps, so the total becomes O(log n + k). Mention it; do not write it unless asked.
Say this out loud: “The input is already sorted, so this is linear, not n log n. Three phases: before, overlapping, after.”

3. Non-overlapping Intervals Medium

Problem

Given a list of intervals, return the minimum number to remove so that the rest are pairwise non-overlapping.

Approach

Why sorting by end is optimal

Exchange argument. Let f be the interval with the earliest end time. Take any optimal solution OPT that does not contain f, and let g be the first interval in OPT. Since f ends no later than g, swapping g for f cannot create a new conflict with anything later in OPT. So there is an optimal solution containing f. Remove f and everything it conflicts with, and repeat the argument on what remains. Greedy by earliest end is therefore optimal.
Sorting by start is wrong. Take [[1, 100], [2, 3], [4, 5]]. Sorted by start, the greedy keeps [1, 100] first and then must drop both others, keeping 1. Sorted by end, it keeps [2, 3] and [4, 5], keeping 2. Have this counterexample ready; interviewers ask for it.

Solution

def erase_overlap_intervals(intervals: list[list[int]]) -> int:
    """Fewest intervals to delete so the remainder are pairwise disjoint.

    Args:
        intervals: Pairs [start, end]. Sorted in place as a side effect.

    Returns:
        The number of intervals that must be removed.

    Example:
        >>> erase_overlap_intervals([[1, 2], [2, 3], [3, 4], [1, 3]])
        1
    """
    if not intervals:
        return 0

    # Earliest finishing time first: it leaves the most room for what follows.
    intervals.sort(key=lambda pair: pair[1])

    kept = 1
    last_end = intervals[0][1]

    for start, end in intervals[1:]:
        if start >= last_end:      # touching is allowed, so >= not >
            kept += 1
            last_end = end

    return len(intervals) - kept

Walkthrough

[[1,2],[2,3],[3,4],[1,3]] sorted by end becomes [[1,2],[1,3],[2,3],[3,4]]:

intervallast_enddecisionkept
[1, 2]keep, it is the seed1
[1, 3]21 < 2, conflict, drop1
[2, 3]22 ≥ 2, keep2
[3, 4]33 ≥ 3, keep3

Kept 3 of 4, so remove 1.

TimeO(n log n)SpaceO(1) extra

Edge cases to raise

Say this out loud: “Minimising removals is maximising keeps, which is interval scheduling. Sort by earliest finish, because finishing early leaves the most room for everything after.”

4. Meeting Rooms II Medium

Problem

Given meeting time intervals, return the minimum number of conference rooms needed to hold them all. A meeting that ends at time t frees its room for a meeting starting at t.

Approach

Solution: heap of end times

import heapq


def min_meeting_rooms(intervals: list[list[int]]) -> int:
    """Minimum rooms needed so no two meetings share a room.

    Args:
        intervals: Pairs [start, end] with start < end.
                   Sorted in place as a side effect.

    Returns:
        The peak number of simultaneously running meetings.

    Example:
        >>> min_meeting_rooms([[0, 30], [5, 10], [15, 20]])
        2
    """
    if not intervals:
        return 0

    intervals.sort(key=lambda pair: pair[0])

    # Min-heap of end times. heap[0] is the room that frees up soonest.
    rooms: list[int] = []

    for start, end in intervals:
        if rooms and rooms[0] <= start:
            heapq.heapreplace(rooms, end)   # reuse: pop the free room, push ours
        else:
            heapq.heappush(rooms, end)      # every room is busy, open one more

    return len(rooms)
heapreplace is one sift instead of the two you get from heappop followed by heappush. Same result, half the work, and it reads as the single action it is.

Solution: sweep line

def min_meeting_rooms_sweep(intervals: list[list[int]]) -> int:
    """Same answer via a sweep over start and end events."""
    events: list[tuple[int, int]] = []
    for start, end in intervals:
        events.append((start, 1))
        events.append((end, -1))

    # Sorting tuples puts (t, -1) before (t, 1), so a room freed at time t
    # is available to a meeting starting at t. That is the tie-break we want.
    events.sort()

    running = best = 0
    for _, delta in events:
        running += delta
        best = max(best, running)

    return best

Walkthrough

[[0, 30], [5, 10], [15, 20]], heap version:

meetingrooms[0]decisionheap after
[0, 30]open a room[30]
[5, 10]3030 > 5, still busy, open[10, 30]
[15, 20]1010 ≤ 15, reuse[20, 30]

Two rooms.

TimeO(n log n)SpaceO(n)

Both solutions are dominated by the sort. The heap holds at most one entry per concurrent meeting.

Edge cases to raise

Meeting Rooms I is the same setup, different question: can one person attend all meetings? Sort by start and check that no interval begins before the previous one ends. It is the two-line warm-up, and interviewers often ask it right before this one.
Say this out loud: “The number of rooms equals the maximum overlap at any instant. I can get that from a min-heap of end times, or from a sweep over plus-one and minus-one events.”

Recap

The six things to carry forward

Where this goes next

Pattern 5, Cyclic Sort, is the other “rearrange in place” pattern. Where intervals lean on sorting, cyclic sort exploits a much stronger promise: the values are drawn from a known bounded range, so each one already knows where it belongs.


3 — Fast and Slow Pointers 5 — Cyclic Sort