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.

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.

Reading the figure. The top rows are the intervals sorted by start. The bottom row is the merged result. Bars that overlap their neighbour fuse into one range. A gap between bars closes the range for good.

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 list is the pattern. Everything else is bookkeeping.

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
public static class SweepMergeTemplate
{
    /// <summary>Union of overlapping ranges.</summary>
    /// <param name="intervals">Pairs [start, end]. Sorted in place.</param>
    /// <returns>Disjoint ranges in start order.</returns>
    /// <example><c>SweepMerge([[1, 4], [2, 5], [7, 9], [8, 11]])</c>
    /// returns [[1, 5], [7, 11]].</example>
    public static int[][] SweepMerge(int[][] intervals)
    {
        if (intervals.Length == 0)
            return [];                       // nothing to merge

        // a[0]: the start. Sort by start so overlaps sit next to each other.
        Array.Sort(intervals, (a, b) => a[0].CompareTo(b[0]));
        // [0]: the first interval opens the output. Copy it so we never alias.
        List<int[]> output = [[intervals[0][0], intervals[0][1]]];

        // i: index of the incoming interval. Start at 1: index 0 is already in output.
        // Invariant: output is the merged union of intervals[0..i-1].
        for (int i = 1; i < intervals.Length; i++)
        {
            int start = intervals[i][0], end = intervals[i][1];   // [0] start, [1] end
            int[] last = output[^1];         // ^1: the interval still being built
            // last[1]: its end. A start at or before it means the two overlap.
            if (start <= last[1])            // overlap or touch
                last[1] = Math.Max(last[1], end);   // Max: never shrink, it may sit inside
            else
                output.Add([start, end]);    // a gap: start a new merged interval
        }

        return output.ToArray();
    }
}

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

Template B — greedy selection by end
public static class MaxNonOverlappingTemplate
{
    /// <summary>Largest set of mutually disjoint intervals.</summary>
    /// <param name="intervals">Pairs [start, end]. Sorted in place.</param>
    /// <returns>How many intervals can be kept with no overlap.</returns>
    /// <example><c>MaxNonOverlapping([[1, 3], [2, 4], [3, 5], [6, 7]])</c> returns 3.</example>
    public static int MaxNonOverlapping(int[][] intervals)
    {
        if (intervals.Length == 0)
            return 0;                        // 0: no intervals, none can be kept

        // a[1]: the end. Earliest finish first leaves the most room after it.
        Array.Sort(intervals, (a, b) => a[1].CompareTo(b[1]));
        int kept = 1;                        // 1: the earliest-finishing one is always kept
        int lastEnd = intervals[0][1];       // [0][1]: end of that first kept interval

        // i: index of the interval being judged. 1: index 0 is already kept.
        // Invariant: kept is the best count for intervals seen, ending at lastEnd.
        for (int i = 1; i < intervals.Length; i++)
        {
            if (intervals[i][0] >= lastEnd)  // [0]: its start. No conflict with the last kept
            {
                kept++;                      // +1: take this interval too
                lastEnd = intervals[i][1];   // [1]: its end, the new finish line to beat
            }
        }

        return kept;
    }
}
012345678 sorted by end [1, 3] [1, 3] keep, the seed [2, 4] [2, 4] drop, 2 < 3 [3, 5] [3, 5] keep, 3 ≥ 3 [6, 7] [6, 7] keep, 6 ≥ 5 kept 3 of 4
Figure 4.2 — Keeping the interval that ends first leaves the most room, so the greedy keeps three of four.

Reading the figure. Bars are drawn on a time axis in end order. Green bars are kept. The red dashed bar is dropped because it starts before lastEnd, the end of the last green bar.

Template C — sweep line over events
public static class MaxConcurrentTemplate
{
    /// <summary>Peak number of intervals alive at any instant.</summary>
    /// <param name="intervals">Pairs [start, end].</param>
    /// <returns>The largest number of intervals that overlap at one time.</returns>
    /// <example><c>MaxConcurrent([[1, 5], [2, 6], [4, 8], [7, 9]])</c> returns 3.</example>
    public static int MaxConcurrent(int[][] intervals)
    {
        var events = new List<(int Time, int Delta)>();
        foreach (int[] iv in intervals)
        {
            events.Add((iv[0], +1));         // [0] start. +1: one more interval is alive
            events.Add((iv[1], -1));         // [1] end. -1: one interval stops here
        }

        // 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.
        // Value tuples sort by Time first, then by Delta, and -1 sorts before +1.
        events.Sort();

        int running = 0, best = 0;           // 0: nothing alive before the first event
        // delta: +1 or -1 from above. The time is not needed once sorted.
        // Invariant: running is how many intervals are alive just after this event.
        foreach (var (_, delta) in events)
        {
            running += delta;
            best = Math.Max(best, running);  // keep the peak
        }

        return best;
    }
}

C# value tuples compare item by item, so Array.Sort on (int Time, int Delta) does the tie-break for free, because -1 < +1. That is a neat detail worth pointing out in an interview.

0246810 [1, 5] [2, 6] [4, 8] [7, 9] events running +1 1 +1 2 +1 3 −1 2 −1 1 +1 2 −1 1 −1 0
Figure 4.3 — Summing +1 and −1 events in time order gives the live count, and its peak is the answer.

Reading the figure. Blue bars are the intervals. Each start adds one and each end takes one away. The running row is the count after each event. The amber band is where three intervals overlap, the peak.

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

public static class MergeIntervals
{
    /// <summary>Merge every set of overlapping intervals into one.</summary>
    /// <param name="intervals">Pairs [start, end] with start &lt;= end, in any order.
    /// Sorted in place as a side effect.</param>
    /// <returns>Disjoint intervals in increasing order covering the same union.</returns>
    /// <example><c>Merge([[1, 3], [2, 6], [8, 10], [15, 18]])</c>
    /// returns [[1, 6], [8, 10], [15, 18]].</example>
    public static int[][] Merge(int[][] intervals)
    {
        if (intervals.Length == 0)
            return [];                       // nothing to merge

        // a[0]: the start. Sorted by start, any overlap is with the last merged one.
        Array.Sort(intervals, (a, b) => a[0].CompareTo(b[0]));
        // [0]: the first interval seeds the output. A fresh array, so no aliasing.
        List<int[]> merged = [[intervals[0][0], intervals[0][1]]];

        // i: index of the incoming interval. 1: index 0 is already in merged.
        // Invariant: merged is the sorted, disjoint union of intervals[0..i-1].
        for (int i = 1; i < intervals.Length; i++)
        {
            int start = intervals[i][0], end = intervals[i][1];   // [0] start, [1] end
            int[] last = merged[^1];         // ^1: the interval still being built

            // last[1]: its end. A start at or before it means overlap.
            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] = Math.Max(last[1], end);
            }
            else
            {
                merged.Add([start, end]);    // a gap: open a new merged interval
            }
        }

        return merged.ToArray();
    }
}

Walkthrough

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

0369121518 [2, 6] vs last [1, 3] 2 ≤ 3: extend to [1, 6] [1, 3] [2, 6] [8, 10] vs last [1, 6] 8 > 6: emit, open [8, 10] [1, 6] [8, 10] [15, 18] vs [8, 10] 15 > 10: emit, open new [1, 6] [8, 10] [15, 18] result [1, 6] [8, 10] [15, 18]
Figure 4.4 — Each incoming interval is tested only against last, and the sweep ends with three ranges.

Reading the figure. Amber is last, the range being built. Blue is the incoming interval. Green ranges are finished and emitted. An incoming start at or before the end of last extends it.

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

public static class InsertInterval
{
    /// <summary>Insert one interval into a sorted, disjoint list, merging as needed.</summary>
    /// <param name="intervals">Sorted, pairwise non-overlapping [start, end] pairs.</param>
    /// <param name="newInterval">The interval to add.</param>
    /// <returns>A new array, still sorted and non-overlapping.</returns>
    /// <example><c>Insert([[1, 3], [6, 9]], [2, 5])</c> returns [[1, 5], [6, 9]].</example>
    public static int[][] Insert(int[][] intervals, int[] newInterval)
    {
        int start = newInterval[0], end = newInterval[1];   // [0] start, [1] end
        var output = new List<int[]>();
        int i = 0, n = intervals.Length;     // 0: scan from the first interval

        // Phase 1: everything that finishes before the new interval begins.
        // [i][1]: the end of interval i. It ends before start, so no overlap.
        while (i < n && intervals[i][1] < start)
        {
            output.Add(intervals[i]);
            i++;                             // +1: move to the next interval
        }

        // Phase 2: everything that overlaps. Widen, then emit once.
        // [i][0]: the start of interval i. At or before end means overlap.
        while (i < n && intervals[i][0] <= end)
        {
            start = Math.Min(start, intervals[i][0]);   // [0]: grow left to the earlier start
            end = Math.Max(end, intervals[i][1]);       // [1]: grow right to the later end
            i++;                             // +1: this one is absorbed, move on
        }
        output.Add([start, end]);

        // Phase 3: everything that starts after the merged interval ends.
        while (i < n)
        {
            output.Add(intervals[i]);
            i++;                             // +1: move to the next interval
        }

        return output.ToArray();
    }
}

Walkthrough

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

0246810121416 input new [4, 8] [3, 5] [8, 10] [12, 16] [4, 8] phase 1 copies [1, 2] phase 2 absorbs 3 phase 3 copies [12, 16] [3, 5] [8, 10] [12, 16] [3, 10] result [3, 10] [12, 16]
Figure 4.5 — The new interval absorbs every range it touches and grows to [3, 10].

Reading the figure. Blue bars are copied as they are. Amber is the new interval as it grows. Red dashed bars are absorbed into it. Green is the result, still sorted and non-overlapping.

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

public static class EraseOverlapIntervals
{
    /// <summary>Fewest intervals to delete so the remainder are pairwise disjoint.</summary>
    /// <param name="intervals">Pairs [start, end]. Sorted in place as a side effect.</param>
    /// <returns>The number of intervals that must be removed.</returns>
    /// <example><c>MinRemovals([[1, 2], [2, 3], [3, 4], [1, 3]])</c> returns 1.</example>
    public static int MinRemovals(int[][] intervals)
    {
        if (intervals.Length == 0)
            return 0;                        // 0: nothing to remove

        // Earliest finishing time first: it leaves the most room for what follows.
        Array.Sort(intervals, (a, b) => a[1].CompareTo(b[1]));   // a[1]: the end

        int kept = 1;                        // 1: the earliest-finishing one is always kept
        int lastEnd = intervals[0][1];       // [0][1]: end of that first kept interval

        // i: index of the interval being judged. 1: index 0 is already kept.
        // Invariant: kept is the most disjoint intervals among intervals[0..i-1].
        for (int i = 1; i < intervals.Length; i++)
        {
            if (intervals[i][0] >= lastEnd)  // touching is allowed, so >= not >
            {
                kept++;                      // +1: keep this one too
                lastEnd = intervals[i][1];   // [1]: the new finish line to beat
            }
        }

        // Removing the fewest is the same as keeping the most.
        return intervals.Length - kept;
    }
}

Walkthrough

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

012345 [1, 2] [1, 2] keep, the seed [1, 3] [1, 3] 1 < 2: drop [2, 3] [2, 3] 2 ≥ 2: keep [3, 4] [3, 4] 3 ≥ 3: keep kept 3 of 4, so remove 1
Figure 4.6 — Sorted by end, only [1, 3] crosses the last kept end, so one removal is enough.

Reading the figure. Rows are in end order. The amber line is lastEnd when that row is checked. A bar that starts left of the line overlaps and turns red. Green bars are kept.

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

public static class MeetingRooms
{
    /// <summary>Minimum rooms needed so no two meetings share a room.</summary>
    /// <param name="intervals">Pairs [start, end] with start &lt; end.
    /// Sorted in place as a side effect.</param>
    /// <returns>The peak number of simultaneously running meetings.</returns>
    /// <example><c>MinRooms([[0, 30], [5, 10], [15, 20]])</c> returns 2.</example>
    public static int MinRooms(int[][] intervals)
    {
        if (intervals.Length == 0)
            return 0;                        // 0: no meetings, no rooms

        // a[0]: the start. Hand out rooms in the order meetings begin.
        Array.Sort(intervals, (a, b) => a[0].CompareTo(b[0]));

        // Min-heap of end times. Element and priority are both the end time.
        // The top is the room that frees up soonest.
        var rooms = new PriorityQueue<int, int>();

        // Invariant: rooms holds one end time per room opened so far.
        foreach (int[] meeting in intervals)
        {
            int start = meeting[0], end = meeting[1];   // [0] start, [1] end
            // Peek: the earliest end. At or before start means that room is free.
            if (rooms.TryPeek(out _, out int earliest) && earliest <= start)
                rooms.DequeueEnqueue(end, end);   // reuse: pop the free room, push ours
            else
                rooms.Enqueue(end, end);          // every room is busy, open one more
        }

        return rooms.Count;                  // rooms never close, so the count is the peak
    }
}
DequeueEnqueue is one sift instead of the two you get from Dequeue followed by Enqueue. Same result, half the work, and it reads as the single action it is. Two more C# facts: PriorityQueue has no decrease-key, and enumerating it does not give priority order. Here the end time is both the element and the priority.

Solution: sweep line

public static class MeetingRoomsSweep
{
    /// <summary>Same answer via a sweep over start and end events.</summary>
    /// <param name="intervals">Pairs [start, end].</param>
    /// <returns>The peak number of rooms in use at once.</returns>
    /// <example><c>MinRooms([[0, 30], [5, 10], [15, 20]])</c> returns 2.</example>
    public static int MinRooms(int[][] intervals)
    {
        // Two events per meeting. * 2: one start and one end each.
        var events = new (int Time, int Delta)[intervals.Length * 2];
        for (int i = 0; i < intervals.Length; i++)
        {
            events[2 * i] = (intervals[i][0], 1);        // 2 * i: even slot. 1: room in use
            events[2 * i + 1] = (intervals[i][1], -1);   // + 1: odd slot. -1: room frees
        }

        // 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.
        Array.Sort(events);

        int running = 0, best = 0;           // 0: no rooms in use before the first event
        // delta: 1 or -1 from above. The time is not needed once sorted.
        // Invariant: running is the rooms in use just after this event.
        foreach (var (_, delta) in events)
        {
            running += delta;
            best = Math.Max(best, running);  // keep the peak
        }

        return best;
    }
}

Walkthrough

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

051015202530 room 1 room 2 [0, 30] [5, 10] [15, 20] PriorityQueue of end times after each meeting: [0, 30]: open a room heap [30] [5, 10]: 30 > 5, open heap [10, 30] [15, 20]: 10 ≤ 15, reuse heap [20, 30] heap size peaks at 2, so 2 rooms
Figure 4.7 — The meeting at 15 reuses the room freed at 10, so two rooms are enough.

Reading the figure. Blue bars open a room. The green bar reuses a room, since the earliest end in the heap was 10 and 10 ≤ 15. The amber line marks time 15. The boxes show the PriorityQueue of end times after each meeting.

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.


← 03 — Fast and Slow Pointers 05 — Cyclic Sort →