Merge intervals: preserve the covered set
Constructed practice problem; no company attribution. Prerequisites: ordered data.
Candidate brief
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
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.
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
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()