Merge intervals: preserve the covered setLESSON 2.10 · 10 OF 43 IN CHAPTER
PART A / Coding problems and trade-offs
Step 36 of 252
LESSON 2.10 · 10 OF 43 IN CHAPTERTry it, then open the solution

Merge intervals: preserve the covered set

Constructed practice problem; no company attribution. Prerequisites: ordered data.

Candidate brief

THE PROBLEM

A calendar service receives busy time intervals out of order. Return the same covered time as a sorted list of disjoint intervals, combining touching blocks as well as overlaps. Clarify whether endpoints are included before choosing your comparison.

Write this:

def merge_intervals(intervals):
    ...
Contract Decision
Input List/tuple of pairs of integer endpoints; every pair has start < end
Output New sorted list of (start, end) half-open intervals; touching intervals merge
Boundaries Empty gives []; duplicates/nesting allowed; input remains unchanged
Invalid input Malformed pairs, bool/noninteger endpoints, or start >= end raise ValueError
Excluded Time zones, recurring events, and preserving event identities
Optional refresher · the underlying tool

An interval has two endpoints. This problem uses half-open intervals: [1,3) excludes 3 and [3,4) includes it. They do not overlap, but their union has no gap, so this contract explicitly merges touching ranges. Sorting by start gives one current merged boundary:

intervals = [[3, 4], [1, 3]]
print(sorted(intervals))  # [[1, 3], [3, 4]]

Compare the next start with the current end; extending with max matters for a fully nested interval. A reservation system may choose to preserve separate touching meetings even though their covered time is contiguous.

A design choice worth saying aloud

Make a sorted copy of the intervals if the caller retains ownership of its input; sorting the received list in place would be an observable side effect. The active merged interval stores the covered end, so use max(current_end, next_end) for nesting. State half-open endpoint semantics and the separate merge-touching policy before choosing the comparison.

What the interviewer expects

The interviewer gives you the scenario and the contract above. Explain what a successful call returns, walk through one example below, and name what your state means before choosing a data structure.

Done means: New sorted list of (start, end) half-open intervals; touching intervals merge.

Now predict each output before looking at the reference; invalid input should leave any existing state unchanged unless the contract says otherwise.

Test-case scenarios to settle before coding

01 · Representative

Input / starting state
[(5,7),(1,3),(3,6)]
Expected result
[(1,7)]

What it is testing: Sorting exposes one unresolved covered range.

02 · Empty

Input / starting state
[]
Expected result
[]

What it is testing: No placeholder interval is returned.

03 · Disjoint

Input / starting state
[(1,2),(3,4)]
Expected result
both intervals in sorted order

What it is testing: A real gap stays visible.

04 · Touching

Input / starting state
[(1,3),(3,5)]
Expected result
[(1,5)]

What it is testing: This contract merges half-open boundaries that touch.

05 · Nested/duplicate

Input / starting state
[(1,10),(2,3),(1,10)]
Expected result
[(1,10)]

What it is testing: Contained coverage adds no new range.

06 · Invalid/atomic

Input / starting state
[(3,3)] or malformed pair
Expected result
ValueError; input unchanged

What it is testing: Reject zero duration and bad structure.

For each case, show which branch or state change produces that result.

merge_intervals([(5, 7), (1, 3), (3, 6)]) == [(1, 7)]. merge_intervals([(1, 2), (3, 4)]) == [(1, 2), (3, 4)]. merge_intervals([(2, 2)]) raises ValueError: zero-duration events are excluded.

Before opening the explanation, restate the contract, trace the smallest useful example, implement a baseline, and identify the repeated work. Then implement your improvement independently and derive tests from the contract. Say what your state means before saying which data structure stores it.

Worked lesson, changed requirements, and reference

Sort so only one unresolved boundary remains

A baseline repeatedly finds an overlapping or touching pair and replaces it with its union until none remain. It is correct but awkward to order, and a naive full rescan after each merge can take O(n³) time. Sorting by start gives a simpler baseline improvement: all future starts are at least the current start, so only the last output interval can still grow.

Sorted input interval Output before Boundary test Output after
[1, 3) empty first interval [1, 3)
[3, 6) [1, 3) 3 ≤ 3: touching [1, 6)
[5, 7) [1, 6) 5 ≤ 6: overlap [1, 7)

After a processed prefix, output is sorted, represents exactly its covered set, and has strict gaps between intervals. If the next start is greater than the last end, it cannot meet any earlier output interval either, so append. Otherwise extend the last end to the maximum of both ends. The maximum matters for nesting: merging [1, 10) with [2, 3) must not shrink covered time. Earlier output intervals remain final because future starts cannot move backward across a gap.

Half-open means the start is included and end excluded. [1, 3) and [3, 6) do not overlap, yet their union is exactly [1, 6) with no gap, so the explicit touching policy permits merging. Sorting a copy plus scanning costs O(n log n) time and O(n) auxiliary space in Python, with O(n) possible output. Claiming O(1) space would ignore the sorted copy and sorting workspace. No event values are mutated, and new tuples prevent output edits from changing input pairs.

Follow-up 1: preserve separate touching reservations

Predict the one comparison that changes. Merge only when start < previous_end, so an endpoint equality starts a new result. The covered set is unchanged but the grouping policy now communicates reservation boundaries.

Inputs Original: merge touching Changed: overlap only
[1, 3), [3, 6) [1, 6) [1, 3), [3, 6)
[1, 4), [3, 6) [1, 6) [1, 6)

If preserving every event identity matters, merged ranges alone are insufficient; attach provenance or return a separate mapping. “Same covered time” is weaker than “same booking information.”

Follow-up 2: intervals arrive in arbitrary order forever

Diagram: Follow-up 2: intervals arrive in arbitrary order forever

A balanced ordered index avoids sorting the entire collection on each arrival; an insertion must still inspect every interval it absorbs. A senior candidate states endpoint policy and proves why only the last sorted result is mutable. A lead candidate defines concurrent update ownership, provenance, and whether deleting one original booking must reconstruct previously merged coverage.

Run and check

From the repository root:

cd curriculum/01-code/02-data-structures-algorithms/problems/09-merge-intervals
python -m unittest -v test_solution.py

Reference implementation (download file, source below) · Contract and oracle tests (download file, source below). Read the tests after your attempt. A green reference suite verifies the supplied implementation; it does not demonstrate independent transfer. Reimplement one follow-up with the reference closed and explain which old invariant no longer holds.

Reference implementation · solution.py
def _intervals(intervals):
    if not isinstance(intervals, (list, tuple)):
        raise ValueError("expected a list or tuple of intervals")
    for pair in intervals:
        if (not isinstance(pair, (list, tuple)) or len(pair) != 2
                or any(type(x) is not int for x in pair) or pair[0] >= pair[1]):
            raise ValueError("intervals require integer start < end")

def merge_intervals(intervals):
    _intervals(intervals)
    result = []
    for start, end in sorted(intervals, key=lambda pair: (pair[0], pair[1])):
        if result and start <= result[-1][1]:
            result[-1] = (result[-1][0], max(result[-1][1], end))
        else:
            result.append((start, end))
    return result
Contract and oracle tests · test_solution.py
import random
import unittest
from solution import merge_intervals

class MergeTests(unittest.TestCase):
    def test_contract(self):
        self.assertEqual(merge_intervals([(5, 7), (1, 3), (3, 6)]), [(1, 7)])
        self.assertEqual(merge_intervals([(1, 10), (2, 3), (1, 10)]), [(1, 10)])
        self.assertEqual(merge_intervals([]), [])
        self.assertEqual(merge_intervals([[5, 7], (1, 3), [3, 6]]), [(1, 7)])
        pairs = [[1, 2], [3, 4]]
        result = merge_intervals(pairs)
        self.assertEqual(result, [(1, 2), (3, 4)])
        self.assertEqual(pairs, [[1, 2], [3, 4]])
        for value in [[(2, 2)], [(3, 1)], [(1, True)], [(1,)], None]:
            with self.assertRaises(ValueError):
                merge_intervals(value)

    def test_discrete_coverage_oracle(self):
        rng = random.Random(109)
        for _ in range(400):
            pairs = []
            for _ in range(rng.randrange(15)):
                start = rng.randrange(-8, 8)
                pairs.append((start, start + rng.randrange(1, 6)))
            result = merge_intervals(pairs)
            expected = {x for start, end in pairs for x in range(start, end)}
            actual = {x for start, end in result for x in range(start, end)}
            self.assertEqual(actual, expected)
            self.assertTrue(all(result[i][1] < result[i+1][0] for i in range(len(result)-1)))
            self.assertEqual(merge_intervals(result), result)

if __name__ == "__main__":
    unittest.main()