Introduction to the Merge Intervals Problem
The Merge Intervals problem is one of the most fundamental and frequently encountered algorithmic challenges in software engineering. At its core, it asks you to take a collection of intervals—each defined by a start and end point—and produce a new set where all overlapping intervals have been combined into contiguous blocks. This problem appears constantly in coding interviews, production systems, and anywhere that involves scheduling, time-range management, or spatial coordinate merging.
What makes this problem particularly instructive is that it sits at the intersection of sorting, greedy algorithms, and data structure design. The optimal solution is elegant and surprisingly simple once you understand the key insight: sort first, then merge in a single pass. But there are multiple valid approaches, each with different trade-offs in terms of time complexity, space complexity, and adaptability to real-world constraints like streaming data or immutability requirements.
Problem Statement and Formal Definition
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Given a collection of intervals where each interval is represented as [start, end] with start ≤ end, merge all overlapping intervals and return a new array of non-overlapping intervals that cover all the same ranges.
Overlapping condition: Two intervals [a_start, a_end] and [b_start, b_end] overlap if a_start ≤ b_end AND b_start ≤ a_end (assuming we merge intervals that touch at a point, which is the most common convention—always clarify this with your interviewer or in your specs).
Example Scenarios
Input: [[1,3], [2,6], [8,10], [15,18]]
Output: [[1,6], [8,10], [15,18]]
Explanation: [1,3] and [2,6] overlap, so they merge into [1,6].
Input: [[1,4], [4,5]]
Output: [[1,5]] // if touching intervals merge; otherwise [[1,4],[4,5]]
Input: [[1,4], [0,2], [3,5]]
Output: [[0,5]] // after sorting, everything cascades into one interval
Input: []
Output: []
Input: [[5,7]]
Output: [[5,7]] // single interval, no merging needed
Why Merge Intervals Matters: Real-World Applications
This isn't just an abstract puzzle. The merge intervals pattern powers numerous production systems:
- Calendar systems — When you view a user's availability across multiple calendars (work, personal, shared), the system must merge overlapping busy blocks to show free slots.
- Database query optimization — Range index scans on partitioned data often produce overlapping row ID ranges that need consolidation before fetching.
- Network bandwidth scheduling — Merging overlapping time reservations for a shared resource (like a video conference bridge) prevents double-booking.
- Genomic interval analysis — In bioinformatics, aligning sequenced reads to a reference genome involves merging overlapping alignment intervals to identify contiguous coverage regions.
- Video timeline rendering — When compositing multiple video clips with overlapping time ranges on a timeline, the rendering engine merges intervals to determine which frames to decode.
- Log file time-range queries — Tools like Splunk or Elasticsearch merge overlapping time buckets when aggregating results across shards.
Solution 1: Sorting + Linear Scan (Optimal Greedy Approach)
This is the canonical solution and the one you should reach for first. The algorithm has two phases:
Algorithm Steps
- Sort all intervals by their start value in ascending order. If two intervals share the same start, sort by end value (though this is often unnecessary for correctness).
- Initialize a result list with the first interval (or empty if input is empty).
- Iterate through the remaining intervals. For each interval, compare it against the last interval in the result list:
- If the current interval's start ≤ the last result interval's end → they overlap. Merge by extending the last result interval's end to
max(last.end, current.end). - Otherwise, they are disjoint. Append the current interval as a new entry in the result list.
- If the current interval's start ≤ the last result interval's end → they overlap. Merge by extending the last result interval's end to
Why Sorting First Is Necessary
Without sorting, you'd need to compare every interval against every other interval, leading to O(n²) time. Sorting brings all potentially overlapping intervals adjacent to each other, guaranteeing that once you've placed an interval in the result and moved past it, no future interval can overlap with it (since all future intervals have larger start values, and if they overlapped, they would have been merged already).
Complete Python Implementation
from typing import List
def merge_intervals(intervals: List[List[int]]) -> List[List[int]]:
"""
Merges all overlapping intervals in a list and returns
a list of non-overlapping intervals in sorted order.
Time Complexity: O(n log n) — dominated by sorting
Space Complexity: O(n) for the result list (or O(1)
additional if we don't count the output)
"""
# Handle empty input edge case
if not intervals:
return []
# Sort by interval start; if starts are equal,
# sort by end to keep processing predictable
intervals.sort(key=lambda x: (x[0], x[1]))
# Seed the result with the first interval
merged = [intervals[0]]
for current in intervals[1:]:
last = merged[-1]
# Overlap check: current start <= last end
if current[0] <= last[1]:
# Merge: extend the last interval's end
# to the maximum of both ends
last[1] = max(last[1], current[1])
else:
# Disjoint: append as a new interval
merged.append(current)
return merged
# ---------- Test Cases ----------
if __name__ == "__main__":
test_cases = [
([[1,3],[2,6],[8,10],[15,18]], [[1,6],[8,10],[15,18]]),
([[1,4],[4,5]], [[1,5]]),
([[1,4],[0,2],[3,5]], [[0,5]]),
([], []),
([[5,7]], [[5,7]]),
([[1,4],[2,3],[3,5]], [[1,5]]), # nested intervals
([[1,10],[2,3],[4,5],[6,7],[8,9]], [[1,10]]),
]
for intervals, expected in test_cases:
result = merge_intervals(intervals)
status = "✓" if result == expected else "✗"
print(f"{status} Input: {intervals}")
print(f" Expected: {expected}")
print(f" Got: {result}\n")
In-Place Variation (Modifying Input Array)
If you're allowed to mutate the input and want to avoid allocating extra space for the result (beyond what's needed for the final output), you can merge directly into the sorted array using a write pointer:
def merge_intervals_inplace(intervals: List[List[int]]) -> List[List[int]]:
"""
Merges intervals in-place after sorting.
Returns the merged intervals using the same list reference.
Space Complexity: O(1) extra (excluding sorting overhead)
"""
if not intervals:
return []
# Sort in-place
intervals.sort(key=lambda x: (x[0], x[1]))
# write_idx points to the last "kept" interval
write_idx = 0
for read_idx in range(1, len(intervals)):
current = intervals[read_idx]
last = intervals[write_idx]
if current[0] <= last[1]:
# Overlap: merge into the last kept interval
last[1] = max(last[1], current[1])
else:
# Disjoint: advance write pointer and copy
write_idx += 1
intervals[write_idx] = current
# Trim the list to only the kept elements
# (or return a slice)
return intervals[:write_idx + 1]
Solution 2: Using a Stack (Explicit Overlap Resolution)
While essentially equivalent to the linear scan approach, explicitly using a stack can make the merge logic more transparent, especially when you need to handle complex overlap semantics (like partial overlaps, splitting intervals, or merging with constraints).
def merge_intervals_stack(intervals: List[List[int]]) -> List[List[int]]:
"""
Stack-based merge. We sort, then push/pop from a stack
to resolve overlaps explicitly.
This approach is useful when you need to maintain
intermediate state or apply custom merge policies.
"""
if not intervals:
return []
intervals.sort(key=lambda x: (x[0], x[1]))
stack = [intervals[0]]
for current in intervals[1:]:
top = stack[-1]
if current[0] <= top[1]:
# Pop the top, merge, and push back
stack.pop()
merged = [top[0], max(top[1], current[1])]
stack.append(merged)
else:
stack.append(current)
return stack
The stack approach shines when your merge logic becomes more complex. For instance, if you need to track which original intervals contributed to a merged interval (for audit trails), you can push metadata alongside each interval onto the stack.
Solution 3: Handling Unsorted Streaming Data with a Balanced BST
In production systems, intervals often arrive as a stream and you need to maintain a continuously merged view. Sorting every time new data arrives is inefficient. Instead, you can maintain the merged state in a balanced binary search tree (like a TreeMap in Java or a sortedcontainers library in Python) keyed by start time, and insert each new interval with on-the-fly merging.
Algorithm for Streaming Inserts
- For each new interval, find its insertion point in the sorted collection of existing merged intervals.
- Identify all existing intervals that overlap with the new interval (these will be contiguous in the sorted order).
- Remove those overlapping intervals and insert a single merged interval spanning from the minimum start to the maximum end among all affected intervals.
import bisect
class StreamingIntervalMerger:
"""
Maintains a sorted, merged list of intervals that
supports efficient insertion of new intervals one at a time.
Uses Python's bisect module for O(log n) find + O(k) merge
where k is the number of overlapping intervals removed.
"""
def __init__(self):
self.intervals = [] # always kept sorted and merged
def add_interval(self, start: int, end: int) -> None:
new_interval = [start, end]
# Find where this interval would be inserted
# bisect_left gives the first index where start >= existing start
i = bisect.bisect_left(
[iv[0] for iv in self.intervals], start
)
# Also check the interval just before the insertion point
# because it might overlap with our new interval
if i > 0 and self.intervals[i - 1][1] >= start:
i -= 1
# Collect all overlapping intervals starting from index i
to_merge = []
merge_start = start
merge_end = end
j = i
while j < len(self.intervals) and self.intervals[j][0] <= merge_end:
merge_start = min(merge_start, self.intervals[j][0])
merge_end = max(merge_end, self.intervals[j][1])
j += 1
# Remove the overlapping range [i:j] and insert the merged one
self.intervals[i:j] = [[merge_start, merge_end]]
def get_merged(self) -> List[List[int]]:
return self.intervals
# Example usage
merger = StreamingIntervalMerger()
merger.add_interval(1, 3)
merger.add_interval(2, 6)
merger.add_interval(8, 10)
merger.add_interval(5, 9) # this bridges [1,6] and [8,10] into [1,10]
print(merger.get_merged()) # Output: [[1, 10]]
This pattern is invaluable for real-time systems like live calendar updates, IoT sensor data aggregation, or network flow monitoring where data arrives continuously and you need instant access to the current merged state.
Solution 4: Functional / Immutable Approach (Map-Reduce Style)
In environments favoring immutability (like React state management, Redux reducers, or functional data pipelines), you may want a pure function without mutation. The sorting + fold pattern works beautifully:
from functools import reduce
def merge_intervals_functional(intervals: List[List[int]]) -> List[List[int]]:
"""
Pure functional merge using reduce (fold).
No mutation — each step produces a new accumulator list.
"""
if not intervals:
return []
sorted_intervals = sorted(intervals, key=lambda x: (x[0], x[1]))
def merge_step(acc: List[List[int]], current: List[int]) -> List[List[int]]:
if not acc:
return [current]
last = acc[-1]
if current[0] <= last[1]:
# Return new list with last element replaced by merged interval
return acc[:-1] + [[last[0], max(last[1], current[1])]]
else:
return acc + [current]
return reduce(merge_step, sorted_intervals, [])
# This version is well-suited for frameworks like React/Redux
# where state must be replaced, not mutated.
Complexity Analysis Across All Solutions
Understanding the trade-offs is critical for choosing the right approach in different contexts:
Time Complexity
| Approach | Initial Setup | Per-Insert / Query | Total for n intervals |
|---|---|---|---|
| Sort + Scan | O(n log n) sort | N/A (batch) | O(n log n) |
| Stack-Based | O(n log n) sort | N/A (batch) | O(n log n) |
| Streaming BST | O(1) empty | O(k + log n) where k = overlapping count | O(n log n) if all inserted; amortized better for sparse overlaps |
| Functional Reduce | O(n log n) sort | N/A (batch) | O(n log n) with O(n²) worst-case list concatenation overhead |
| Brute Force (all-pairs) | O(1) | N/A | O(n²) — avoid for production |
Space Complexity
- Sort + Scan (with new result list): O(n) for the output array. The sorting itself typically requires O(n) auxiliary space (Timsort in Python).
- In-place variant: O(1) extra space beyond the input array (though sorting still uses temporary space depending on the sorting algorithm).
- Stack-based: O(n) for the stack, which is essentially the result.
- Streaming BST: O(n) to store all merged intervals in memory. The BST structure adds O(n) pointer overhead.
- Functional reduce: O(n²) worst-case if list concatenation creates many intermediate copies. Use
list.appendin afoldl-style accumulator for O(n) if you relax strict immutability.
When to Use Each Approach
- Batch processing, one-time computation: Sort + Scan — simplest, fastest, lowest overhead.
- Streaming data, real-time updates: Streaming BST — avoids re-sorting the entire dataset on each insert.
- Functional codebases (React, Redux, ClojureScript): Functional reduce — keeps data flow pure and debuggable.
- When you need to preserve original interval identity: Stack-based with metadata — lets you track which original intervals merged into each result interval.
Edge Cases and Common Pitfalls
1. Touching vs. Overlapping Intervals
The most common point of confusion is whether intervals that merely touch at a point should be merged. Given [1,4] and [4,5], some definitions treat these as overlapping (because the point 4 is shared), while others treat them as disjoint. Always clarify this upfront. The condition current[0] ≤ last[1] merges touching intervals. Use strict inequality current[0] < last[1] to keep them separate.
# Merging touching intervals (≤)
if current[0] <= last[1]: # [1,4] and [4,5] → [1,5]
# Keeping touching intervals separate (<)
if current[0] < last[1]: # [1,4] and [4,5] → two separate intervals
2. Empty Input
Always guard against empty input. The sort + scan approach crashes if you try to seed merged[0] on an empty list. Return an empty list immediately.
3. Single Interval
A single-interval input should be returned as-is (wrapped in a list). The loop simply doesn't execute and the result contains the seed interval.
4. Nested Intervals
Intervals completely contained within others (e.g., [1,10] and [2,5]) must be absorbed correctly. The max(last[1], current[1]) ensures the outer interval's end is preserved. Sorting by start then end ensures the outer interval comes first.
5. Unsorted Input with No Overlaps
If intervals are disjoint and unsorted, sorting still imposes O(n log n) but the scan simply appends each one. This is the best-case post-sort behavior.
6. Large Coordinate Values
Intervals spanning large integer ranges (e.g., [-10^9, 10^9]) don't affect the algorithm's logic but may cause overflow in languages with fixed-size integers if you compute midpoints or differences. The merge algorithm itself only uses comparisons, so it's safe.
Best Practices for Production Code
- Always sort by both start and end: Sorting by
(start, end)ensures deterministic ordering. When two intervals share the same start, the one with the larger end comes first (or second, depending on sort direction), which guarantees that the wider interval acts as the "container" during merging. - Use a dedicated Interval type: Instead of raw
[start, end]lists, define a named tuple or dataclass. This prevents off-by-one errors where someone accidentally mutates the wrong index. - Write unit tests for edge cases: Empty input, single interval, all disjoint, all overlapping in a chain, nested intervals, reverse-sorted input, intervals with negative values, and large-volume stress tests.
- Document the overlap convention: In your function's docstring, explicitly state whether touching intervals (e.g.,
[1,4]and[4,5]) are merged or kept separate. - Consider immutability: If the input comes from an external source, copy it before sorting. Mutating caller data is a common source of hard-to-debug issues.
- Profile for your data shape: If most intervals are already sorted or nearly sorted, Python's Timsort gives near-O(n) performance. If your data is huge and mostly disjoint, the streaming BST may outperform batch sorting for incremental updates.
Putting It All Together: A Production-Ready Implementation
Here is a robust, well-documented implementation suitable for use in real codebases, incorporating all the best practices discussed:
from typing import List, Tuple, Optional
from dataclasses import dataclass
import bisect
@dataclass(frozen=True)
class Interval:
"""Immutable interval representation."""
start: int
end: int
def __post_init__(self):
if self.start > self.end:
raise ValueError(
f"Invalid interval: start ({self.start}) > end ({self.end})"
)
def overlaps_with(self, other: 'Interval', merge_touching: bool = True) -> bool:
"""Check if two intervals overlap."""
if merge_touching:
return self.start <= other.end and other.start <= self.end
else:
return self.start < other.end and other.start < self.end
def merge_with(self, other: 'Interval') -> 'Interval':
"""Create a new interval spanning both."""
return Interval(
start=min(self.start, other.start),
end=max(self.end, other.end)
)
def to_list(self) -> List[int]:
return [self.start, self.end]
def merge_intervals_production(
intervals: List[Interval],
merge_touching: bool = True
) -> List[Interval]:
"""
Production-grade interval merging.
Args:
intervals: List of Interval objects (may be unsorted, may be empty).
merge_touching: If True, intervals that touch at a point
(e.g., [1,4] and [4,5]) are merged. If False, they remain separate.
Returns:
A new list of non-overlapping Interval objects in sorted order.
Time: O(n log n)
Space: O(n) for the result list
"""
if not intervals:
return []
# Sort by start, then by end (descending end for same start
# ensures outer intervals come first, simplifying containment)
sorted_intervals = sorted(
intervals,
key=lambda iv: (iv.start, -iv.end)
)
merged: List[Interval] = [sorted_intervals[0]]
for current in sorted_intervals[1:]:
last = merged[-1]
if current.overlaps_with(last, merge_touching=merge_touching):
# Replace the last element with the merged interval
merged[-1] = last.merge_with(current)
else:
merged.append(current)
return merged
# ---------- Comprehensive Tests ----------
def run_tests():
"""Verify correctness across a wide range of cases."""
def iv(start, end):
return Interval(start, end)
def assert_merged(input_intervals, expected, merge_touching=True):
result = merge_intervals_production(input_intervals, merge_touching)
result_lists = [r.to_list() for r in result]
expected_lists = [e.to_list() for e in expected]
assert result_lists == expected_lists, \
f"Failed: {input_intervals} → expected {expected_lists}, got {result_lists}"
print(f"✓ Test passed")
# Test 1: Classic overlapping case
assert_merged(
[iv(1,3), iv(2,6), iv(8,10), iv(15,18)],
[iv(1,6), iv(8,10), iv(15,18)]
)
# Test 2: Touching intervals with merge_touching=True
assert_merged(
[iv(1,4), iv(4,5)],
[iv(1,5)],
merge_touching=True
)
# Test 3: Touching intervals with merge_touching=False
assert_merged(
[iv(1,4), iv(4,5)],
[iv(1,4), iv(4,5)],
merge_touching=False
)
# Test 4: Empty input
assert_merged([], [])
# Test 5: Single interval
assert_merged([iv(5,7)], [iv(5,7)])
# Test 6: Nested intervals
assert_merged(
[iv(1,10), iv(2,3), iv(4,5), iv(6,7), iv(8,9)],
[iv(1,10)]
)
# Test 7: All disjoint
assert_merged(
[iv(1,2), iv(4,5), iv(7,8)],
[iv(1,2), iv(4,5), iv(7,8)]
)
# Test 8: Unsorted cascading merge
assert_merged(
[iv(3,6), iv(1,4), iv(2,5), iv(8,10)],
[iv(1,6), iv(8,10)]
)
# Test 9: Negative and positive spans
assert_merged(
[iv(-10, -5), iv(-7, 0), iv(1, 3)],
[iv(-10, 0), iv(1, 3)]
)
# Test 10: Large values
assert_merged(
[iv(-1000000000, 500000000), iv(400000000, 1000000000)],
[iv(-1000000000, 1000000000)]
)
print("All tests passed successfully!")
if __name__ == "__main__":
run_tests()
Variations and Related Problems
Once you master the basic merge intervals pattern, several related problems become approachable with similar techniques:
- Insert Interval (LeetCode 57): Given a sorted, non-overlapping list and a new interval, insert and merge in O(n) time. Uses the same overlap logic but exploits pre-sorted input to avoid a full sort.
- Meeting Rooms II (LeetCode 253): Find the minimum number of meeting rooms needed. Uses a sweep-line approach with two pointers on sorted start and end arrays—a close cousin to merge intervals.
- Interval List Intersections (LeetCode 986): Given two sorted interval lists, find all pairwise intersections. Uses two pointers with overlap detection.
- Remove Covered Intervals (LeetCode 1288): Filter out intervals that are completely contained within others. Uses sorting + linear scan with a variation on the merge condition.
- Non-overlapping Intervals (LeetCode 435): Find the minimum number of removals to make intervals non-overlapping. Uses greedy sorting by end time rather than start time.
Each of these variants builds on the core insight: sorting intervals by a strategic key and then walking through them linearly unlocks efficient greedy solutions.
Conclusion
The Merge Intervals problem is deceptively simple yet profoundly useful. Its optimal solution—sorting followed by a single linear scan—is a textbook example of how a preprocessing step can collapse a quadratic problem into linearithmic time. We've explored multiple implementations: the classic sort-and-scan, an in-place variant for memory-constrained environments, a stack-based approach for extensibility, a streaming BST inserter for real-time data, and a functional reduce for immutable state management.
The key takeaways are clear: always sort first (unless your data arrives pre-sorted or you're using a tree structure that maintains order), clarify your overlap semantics (touching vs. strictly overlapping), guard against empty inputs, and choose your implementation style based on your runtime context—batch vs. streaming, mutable vs. immutable, simple vs. extensible.
With the production-ready code and comprehensive test suite provided, you now have a solid foundation to handle interval merging in any real-world scenario. The pattern extends far beyond simple integer ranges—it applies to time spans, spatial coordinates, version ranges, and any domain where overlapping linear segments need consolidation. Master this pattern, and you'll find it recurring throughout your career in countless unexpected places.