352. Data Stream as Disjoint Intervals
Problem Description
This problem asks you to design a data structure that can efficiently summarize a stream of integers as a collection of disjoint intervals.
You need to implement a SummaryRanges
class with the following functionality:
-
Constructor
SummaryRanges()
: Creates an empty object ready to receive integers from a data stream. -
Method
addNum(int value)
: Takes a non-negative integer and adds it to the data structure. As numbers are added, they should be incorporated into existing intervals when possible. -
Method
getIntervals()
: Returns the current summary of all numbers seen so far as a list of disjoint intervals. Each interval is represented as[start, end]
where both start and end are inclusive. The intervals must be sorted by their starting values.
The key challenge is to maintain these intervals efficiently as new numbers arrive. When a new number is added:
- If it can extend an existing interval (adjacent to its boundaries), the interval should be extended
- If it can merge two separate intervals (filling a gap of exactly 1), those intervals should be merged
- If it falls within an existing interval, no change is needed
- If it's isolated from all existing intervals, it creates a new single-point interval
[value, value]
For example, if you've seen numbers 1, 3, 7, 2, 6:
- After 1: intervals are
[[1,1]]
- After 1,3: intervals are
[[1,1], [3,3]]
- After 1,3,7: intervals are
[[1,1], [3,3], [7,7]]
- After 1,3,7,2: intervals are
[[1,3], [7,7]]
(1,2,3 form a continuous range) - After 1,3,7,2,6: intervals are
[[1,3], [6,7]]
(6,7 form a continuous range)
Intuition
The core insight is that we need to efficiently maintain sorted, non-overlapping intervals while handling new numbers that could affect existing intervals in various ways. This naturally suggests using a sorted data structure where we can quickly find where a new number fits relative to existing intervals.
When we add a new number val
, we need to determine its relationship with existing intervals. The key observation is that a number can interact with at most two intervals - the one immediately to its left and the one immediately to its right. This is because intervals are disjoint.
We can identify four scenarios when adding a number:
- Bridge scenario: The number connects two adjacent intervals (when left interval ends at
val-1
and right interval starts atval+1
) - Extend left: The number extends or falls within the left interval (when
val
is at most one more than the left interval's end) - Extend right: The number extends or falls within the right interval (when
val
is at most one less than the right interval's start) - Isolated: The number creates a new interval by itself
Using a SortedDict
(ordered map) with interval start points as keys makes sense because:
- We can use binary search (
bisect_right
) to quickly find where a new number fits - We maintain intervals sorted by start position automatically
- We can efficiently access and modify adjacent intervals
The clever part is storing each interval as [start, end]
with the start as the key. When we find where val
fits using bisect_right
, we immediately know:
- The interval to the right (if it exists) starts at the key found by
bisect_right
- The interval to the left (if it exists) is the one just before that position
This approach gives us O(log n)
time for adding numbers and O(n)
space, where n
is the number of disjoint intervals, making it efficient for streams where numbers tend to cluster into ranges.
Solution Approach
The solution uses a SortedDict
data structure to maintain intervals sorted by their starting points. Here's how each component works:
Initialization:
def __init__(self):
self.mp = SortedDict()
We initialize an empty SortedDict
where keys are interval start points and values are [start, end]
pairs.
Adding a Number:
When addNum(val)
is called, we follow these steps:
-
Find position using binary search:
ridx = self.mp.bisect_right(val) lidx = n if ridx == 0 else ridx - 1
ridx
finds the index of the first interval whose start is greater thanval
lidx
is the index of the interval immediately to the left (orn
if none exists)
-
Handle the merge case (bridging two intervals):
if (lidx != n and ridx != n and values[lidx][1] + 1 == val and values[ridx][0] - 1 == val): self.mp[keys[lidx]][1] = self.mp[keys[ridx]][1] self.mp.pop(keys[ridx])
If
val
perfectly bridges two intervals (left ends atval-1
, right starts atval+1
), merge them by extending the left interval to include the right interval's end, then remove the right interval. -
Handle extending or falling within left interval:
elif lidx != n and val <= values[lidx][1] + 1: self.mp[keys[lidx]][1] = max(val, self.mp[keys[lidx]][1])
If
val
is at most one more than the left interval's end, extend the left interval. Themax
ensures we don't shrink an interval ifval
is already within it. -
Handle extending or falling within right interval:
elif ridx != n and val >= values[ridx][0] - 1: self.mp[keys[ridx]][0] = min(val, self.mp[keys[ridx]][0])
If
val
is at most one less than the right interval's start, extend the right interval leftward. Since we're modifying the start point (which is the key), we update the value's start component. -
Create new isolated interval:
else: self.mp[val] = [val, val]
If
val
doesn't connect with any existing intervals, create a new single-point interval[val, val]
.
Getting Intervals:
def getIntervals(self) -> List[List[int]]:
return list(self.mp.values())
Simply return all interval values as a list. Since SortedDict
maintains order by keys (start points), the result is automatically sorted.
Time Complexity:
addNum
: O(log n) for binary search and O(1) for updates, where n is the number of intervalsgetIntervals
: O(n) to convert values to a list
Space Complexity: O(n) where n is the maximum number of disjoint intervals.
Ready to land your dream job?
Unlock your dream job with a 5-minute evaluator for a personalized learning plan!
Start EvaluatorExample Walkthrough
Let's walk through adding the sequence: 5, 2, 7, 6, 1
Initial state: mp = {}
(empty SortedDict)
Step 1: addNum(5)
bisect_right(5)
returns 0 (no intervals exist)ridx = 0
,lidx = 0
(n = 0, so both are "out of bounds")- No intervals to merge or extend
- Create new interval:
mp = {5: [5,5]}
- Intervals:
[[5,5]]
Step 2: addNum(2)
- Current:
mp = {5: [5,5]}
bisect_right(2)
returns 0 (2 < 5)ridx = 0
(interval at key 5),lidx = 1
(no left interval)- Check right interval:
[5,5]
- Is
2 >= 5-1
? No (2 < 4)
- Is
- Create new interval:
mp = {2: [2,2], 5: [5,5]}
- Intervals:
[[2,2], [5,5]]
Step 3: addNum(7)
- Current:
mp = {2: [2,2], 5: [5,5]}
bisect_right(7)
returns 2 (7 > all existing keys)ridx = 2
(no right interval),lidx = 1
(interval at key 5)- Check left interval:
[5,5]
- Is
7 <= 5+1
? No (7 > 6)
- Is
- Create new interval:
mp = {2: [2,2], 5: [5,5], 7: [7,7]}
- Intervals:
[[2,2], [5,5], [7,7]]
Step 4: addNum(6)
- Current:
mp = {2: [2,2], 5: [5,5], 7: [7,7]}
bisect_right(6)
returns 2 (6 > 5 but < 7)ridx = 2
(interval at key 7),lidx = 1
(interval at key 5)- Check merge condition:
- Left interval ends at 5, right interval starts at 7
- Is
5+1 == 6
and7-1 == 6
? YES!
- Merge intervals: Extend
[5,5]
to[5,7]
, remove[7,7]
- Result:
mp = {2: [2,2], 5: [5,7]}
- Intervals:
[[2,2], [5,7]]
Step 5: addNum(1)
- Current:
mp = {2: [2,2], 5: [5,7]}
bisect_right(1)
returns 0 (1 < 2)ridx = 0
(interval at key 2),lidx = 2
(no left interval)- Check right interval:
[2,2]
- Is
1 >= 2-1
? YES! (1 >= 1)
- Is
- Extend right interval leftward:
[2,2]
becomes[1,2]
- Update:
mp[2][0] = 1
- Result:
mp = {2: [1,2], 5: [5,7]}
- Intervals:
[[1,2], [5,7]]
Final Summary:
The stream 5, 2, 7, 6, 1 produces intervals [[1,2], [5,7]]
. Notice how:
- Individual numbers started as single-point intervals
- Number 6 bridged the gap between [5,5] and [7,7], merging them
- Number 1 extended the interval [2,2] leftward to become [1,2]
Solution Implementation
1from sortedcontainers import SortedDict
2from typing import List
3
4class SummaryRanges:
5 """
6 A class to maintain disjoint intervals as numbers are added to a stream.
7 When a number is added, it either creates a new interval, extends an existing one,
8 or merges two adjacent intervals.
9 """
10
11 def __init__(self):
12 """Initialize with an empty sorted dictionary to store intervals."""
13 # SortedDict maintains intervals sorted by their start points (keys)
14 # Each key maps to [start, end] of an interval
15 self.intervals_map = SortedDict()
16
17 def addNum(self, val: int) -> None:
18 """
19 Add a number to the data stream and update intervals accordingly.
20
21 Args:
22 val: The integer value to add to the stream
23 """
24 num_intervals = len(self.intervals_map)
25
26 # Find the position where val would be inserted (right bisection)
27 right_idx = self.intervals_map.bisect_right(val)
28
29 # Find the index of the interval that might contain or be adjacent to val
30 # If right_idx is 0, no interval starts before val, so set left_idx to num_intervals (invalid)
31 # Otherwise, left_idx is the interval just before where val would be inserted
32 left_idx = num_intervals if right_idx == 0 else right_idx - 1
33
34 # Get references to keys and values for easier access
35 interval_keys = self.intervals_map.keys()
36 interval_values = self.intervals_map.values()
37
38 # Case 1: val connects two adjacent intervals (merging case)
39 # Check if val is exactly between two intervals (end of left + 1 == val == start of right - 1)
40 if (left_idx != num_intervals and
41 right_idx != num_intervals and
42 interval_values[left_idx][1] + 1 == val and
43 interval_values[right_idx][0] - 1 == val):
44 # Merge the two intervals by extending the left interval to include the right
45 self.intervals_map[interval_keys[left_idx]][1] = self.intervals_map[interval_keys[right_idx]][1]
46 # Remove the right interval as it's now merged
47 self.intervals_map.pop(interval_keys[right_idx])
48
49 # Case 2: val extends or is within the left interval
50 elif left_idx != num_intervals and val <= interval_values[left_idx][1] + 1:
51 # Extend the end of the left interval if val is beyond it
52 # If val is already within the interval, keep the current end
53 self.intervals_map[interval_keys[left_idx]][1] = max(val, self.intervals_map[interval_keys[left_idx]][1])
54
55 # Case 3: val extends or is at the beginning of the right interval
56 elif right_idx != num_intervals and val >= interval_values[right_idx][0] - 1:
57 # Extend the start of the right interval to include val
58 # Use min to handle case where val might already be in the interval
59 self.intervals_map[interval_keys[right_idx]][0] = min(val, self.intervals_map[interval_keys[right_idx]][0])
60
61 # Case 4: val creates a new isolated interval
62 else:
63 # Create a new interval containing only val
64 self.intervals_map[val] = [val, val]
65
66 def getIntervals(self) -> List[List[int]]:
67 """
68 Get all current disjoint intervals in sorted order.
69
70 Returns:
71 A list of intervals, where each interval is [start, end]
72 """
73 return list(self.intervals_map.values())
74
75
76# Your SummaryRanges object will be instantiated and called as such:
77# obj = SummaryRanges()
78# obj.addNum(val)
79# param_2 = obj.getIntervals()
80
1class SummaryRanges {
2 // TreeMap to store intervals, key is the start of interval, value is [start, end]
3 private TreeMap<Integer, int[]> intervalMap;
4
5 /**
6 * Initialize the data structure
7 */
8 public SummaryRanges() {
9 intervalMap = new TreeMap<>();
10 }
11
12 /**
13 * Add a number to the data stream and merge intervals if necessary
14 * @param val the number to be added
15 */
16 public void addNum(int val) {
17 // Find the largest key that is less than or equal to val
18 Integer leftKey = intervalMap.floorKey(val);
19 // Find the smallest key that is greater than or equal to val
20 Integer rightKey = intervalMap.ceilingKey(val);
21
22 // Case 1: val connects two adjacent intervals - merge them
23 if (leftKey != null && rightKey != null &&
24 intervalMap.get(leftKey)[1] + 1 == val &&
25 intervalMap.get(rightKey)[0] - 1 == val) {
26
27 // Extend left interval to include right interval's end
28 intervalMap.get(leftKey)[1] = intervalMap.get(rightKey)[1];
29 // Remove the right interval as it's now merged
30 intervalMap.remove(rightKey);
31 }
32 // Case 2: val can extend or is within the left interval
33 else if (leftKey != null && val <= intervalMap.get(leftKey)[1] + 1) {
34 // Extend the end of left interval if necessary
35 intervalMap.get(leftKey)[1] = Math.max(val, intervalMap.get(leftKey)[1]);
36 }
37 // Case 3: val can extend or is within the right interval
38 else if (rightKey != null && val >= intervalMap.get(rightKey)[0] - 1) {
39 // Extend the start of right interval if necessary
40 intervalMap.get(rightKey)[0] = Math.min(val, intervalMap.get(rightKey)[0]);
41 }
42 // Case 4: val is isolated - create a new interval
43 else {
44 intervalMap.put(val, new int[] {val, val});
45 }
46 }
47
48 /**
49 * Return the current intervals as a 2D array
50 * @return array of intervals where each interval is [start, end]
51 */
52 public int[][] getIntervals() {
53 int[][] result = new int[intervalMap.size()][2];
54 int index = 0;
55
56 // Copy all intervals from the map to the result array
57 for (int[] interval : intervalMap.values()) {
58 result[index++] = interval;
59 }
60
61 return result;
62 }
63}
64
65/**
66 * Your SummaryRanges object will be instantiated and called as such:
67 * SummaryRanges obj = new SummaryRanges();
68 * obj.addNum(val);
69 * int[][] param_2 = obj.getIntervals();
70 */
71
1class SummaryRanges {
2private:
3 // Map to store intervals: key is the start of interval, value is {start, end}
4 map<int, vector<int>> intervals;
5
6public:
7 SummaryRanges() {
8 // Constructor - map is automatically initialized
9 }
10
11 void addNum(int value) {
12 // Find the first interval whose start is greater than value
13 auto rightInterval = intervals.upper_bound(value);
14
15 // Find the interval just before rightInterval (could be the one containing value)
16 auto leftInterval = (rightInterval == intervals.begin()) ? intervals.end() : prev(rightInterval);
17
18 // Case 1: Value bridges two intervals (connects left and right intervals)
19 if (leftInterval != intervals.end() && rightInterval != intervals.end() &&
20 leftInterval->second[1] + 1 == value && rightInterval->second[0] - 1 == value) {
21 // Merge the two intervals by extending left interval to include right interval
22 leftInterval->second[1] = rightInterval->second[1];
23 intervals.erase(rightInterval);
24 }
25 // Case 2: Value extends or is within the left interval
26 else if (leftInterval != intervals.end() && value <= leftInterval->second[1] + 1) {
27 // Extend the end of left interval if necessary
28 leftInterval->second[1] = max(value, leftInterval->second[1]);
29 }
30 // Case 3: Value extends or is at the start of the right interval
31 else if (rightInterval != intervals.end() && value >= rightInterval->second[0] - 1) {
32 // Extend the start of right interval to include value
33 // Need to update the key in map, so remove old entry and add new one
34 vector<int> updatedInterval = {min(value, rightInterval->second[0]), rightInterval->second[1]};
35 intervals.erase(rightInterval);
36 intervals[updatedInterval[0]] = updatedInterval;
37 }
38 // Case 4: Value is isolated, create a new interval
39 else {
40 intervals[value] = {value, value};
41 }
42 }
43
44 vector<vector<int>> getIntervals() {
45 // Convert map values to result vector
46 vector<vector<int>> result;
47 for (const auto& intervalPair : intervals) {
48 result.push_back(intervalPair.second);
49 }
50 return result;
51 }
52};
53
54/**
55 * Your SummaryRanges object will be instantiated and called as such:
56 * SummaryRanges* obj = new SummaryRanges();
57 * obj->addNum(val);
58 * vector<vector<int>> param_2 = obj->getIntervals();
59 */
60
1// Map to store intervals: key is the start of interval, value is [start, end]
2const intervals: Map<number, number[]> = new Map();
3
4/**
5 * Adds a number to the data structure and maintains disjoint intervals
6 * @param value - The number to add to the summary ranges
7 */
8function addNum(value: number): void {
9 // Find all interval starts and sort them
10 const intervalStarts = Array.from(intervals.keys()).sort((a, b) => a - b);
11
12 // Find the first interval whose start is greater than value
13 let rightIntervalIndex = intervalStarts.findIndex(start => start > value);
14 let rightIntervalStart = rightIntervalIndex !== -1 ? intervalStarts[rightIntervalIndex] : null;
15
16 // Find the interval just before rightInterval (could be the one containing value)
17 let leftIntervalIndex = rightIntervalIndex === -1 ? intervalStarts.length - 1 : rightIntervalIndex - 1;
18 let leftIntervalStart = leftIntervalIndex >= 0 ? intervalStarts[leftIntervalIndex] : null;
19
20 // Get the actual interval arrays
21 const leftInterval = leftIntervalStart !== null ? intervals.get(leftIntervalStart) : null;
22 const rightInterval = rightIntervalStart !== null ? intervals.get(rightIntervalStart) : null;
23
24 // Case 1: Value bridges two intervals (connects left and right intervals)
25 if (leftInterval && rightInterval &&
26 leftInterval[1] + 1 === value && rightInterval[0] - 1 === value) {
27 // Merge the two intervals by extending left interval to include right interval
28 leftInterval[1] = rightInterval[1];
29 intervals.delete(rightIntervalStart!);
30 }
31 // Case 2: Value extends or is within the left interval
32 else if (leftInterval && value <= leftInterval[1] + 1) {
33 // Extend the end of left interval if necessary
34 leftInterval[1] = Math.max(value, leftInterval[1]);
35 }
36 // Case 3: Value extends or is at the start of the right interval
37 else if (rightInterval && value >= rightInterval[0] - 1) {
38 // Extend the start of right interval to include value
39 // Need to update the key in map, so remove old entry and add new one
40 const updatedInterval = [Math.min(value, rightInterval[0]), rightInterval[1]];
41 intervals.delete(rightIntervalStart!);
42 intervals.set(updatedInterval[0], updatedInterval);
43 }
44 // Case 4: Value is isolated, create a new interval
45 else {
46 intervals.set(value, [value, value]);
47 }
48}
49
50/**
51 * Returns the current disjoint intervals as a sorted array
52 * @returns Array of intervals where each interval is [start, end]
53 */
54function getIntervals(): number[][] {
55 // Convert map values to result array and sort by start value
56 const result: number[][] = [];
57 const sortedKeys = Array.from(intervals.keys()).sort((a, b) => a - b);
58
59 for (const key of sortedKeys) {
60 result.push(intervals.get(key)!);
61 }
62
63 return result;
64}
65
Time and Space Complexity
Time Complexity:
-
__init__()
:O(1)
- Simply initializes an empty SortedDict. -
addNum(val)
:O(log n)
wheren
is the number of intervals in the SortedDict.bisect_right(val)
:O(log n)
- Binary search in the sorted keys.- Accessing keys and values lists:
O(1)
- These are views in SortedDict. - All merge operations (checking conditions and updating intervals):
O(1)
. pop()
operation in worst case (merging two intervals):O(log n)
.- Insertion of new interval:
O(log n)
. - Overall:
O(log n)
per operation.
-
getIntervals()
:O(n)
wheren
is the number of intervals.- Converting the values view to a list requires iterating through all intervals.
Space Complexity:
- Overall space:
O(n)
wheren
is the number of disjoint intervals stored.- In the best case (all numbers form one continuous range), space is
O(1)
. - In the worst case (all numbers are non-consecutive), space is
O(k)
wherek
is the number of unique values added. - The SortedDict stores at most one interval per disjoint range of consecutive numbers.
- In the best case (all numbers form one continuous range), space is
Common Pitfalls
1. Incorrect Interval Merging Logic
Pitfall: One of the most common mistakes is incorrectly handling the case where a new number should merge two existing intervals. Developers often forget to check both conditions properly or make off-by-one errors.
Example of incorrect code:
# Wrong: Checking if val equals the boundaries instead of being adjacent if (left_interval[1] == val and right_interval[0] == val): # This would only merge if val is exactly on both boundaries # Missing the case where val fills a gap of 1
Solution: Ensure you check if the value bridges the gap between two intervals:
if (interval_values[left_idx][1] + 1 == val and interval_values[right_idx][0] - 1 == val): # Correctly checks if val fills a gap of exactly 1
2. Not Handling Already-Contained Values
Pitfall: When a value falls within an existing interval, some implementations incorrectly try to modify the interval or create a duplicate.
Example of incorrect code:
# Wrong: Always extending the interval even if val is already inside if val <= interval_values[left_idx][1] + 1: self.intervals_map[interval_keys[left_idx]][1] = val # Wrong!
Solution: Use max()
to ensure the interval doesn't shrink:
self.intervals_map[interval_keys[left_idx]][1] = max(val, self.intervals_map[interval_keys[left_idx]][1])
3. Key Modification in SortedDict
Pitfall: Attempting to directly modify a key in SortedDict breaks the sorted order. When extending an interval's start point, you cannot simply change the key.
Example of incorrect approach:
# Wrong: Trying to change the key directly if val < interval_start: del self.intervals_map[interval_start] self.intervals_map[val] = [val, interval_end] # Inefficient and error-prone
Solution: The current implementation cleverly avoids this by only modifying the value's start component when extending leftward:
# Correct: Only modify the value, not the key
self.intervals_map[interval_keys[right_idx]][0] = min(val, self.intervals_map[interval_keys[right_idx]][0])
4. Binary Search Index Confusion
Pitfall: Misunderstanding what bisect_right
returns and incorrectly calculating the left interval index.
Example of incorrect code:
# Wrong: Using bisect_left or incorrectly calculating left_idx left_idx = self.intervals_map.bisect_left(val) - 1 # Can be -1! if left_idx >= 0: # Need to check bounds # process...
Solution: Use consistent index handling with a sentinel value:
right_idx = self.intervals_map.bisect_right(val) left_idx = num_intervals if right_idx == 0 else right_idx - 1 # Now left_idx == num_intervals serves as "no left interval" indicator
5. Order of Condition Checking
Pitfall: Checking conditions in the wrong order can lead to incorrect behavior. For example, checking single interval extensions before checking the merge case.
Example of problematic ordering:
# Wrong order: Extending first might prevent proper merging if extends_left_interval: extend_left() elif extends_right_interval: extend_right() elif merges_two_intervals: # This might never be reached! merge()
Solution: Always check the merge case first, as it's the most specific condition:
if merges_two_intervals: merge() elif extends_left_interval: extend_left() elif extends_right_interval: extend_right() else: create_new_interval()
6. Edge Case: Single-Point Intervals
Pitfall: Not properly handling single-point intervals where start equals end.
Solution: The implementation correctly handles this by initializing new intervals as [val, val]
and using inclusive boundaries throughout the logic.
Which of the following is a good use case for backtracking?
Recommended Readings
Binary Search Speedrun For each of the Speedrun questions you will be given a binary search related problem and a corresponding multiple choice question The multiple choice questions are related to the techniques and template s introduced in the binary search section It's recommended that you have gone through at
Coding Interview Patterns Your Personal Dijkstra's Algorithm to Landing Your Dream Job The goal of AlgoMonster is to help you get a job in the shortest amount of time possible in a data driven way We compiled datasets of tech interview problems and broke them down by patterns This way
Recursion Recursion is one of the most important concepts in computer science Simply speaking recursion is the process of a function calling itself Using a real life analogy imagine a scenario where you invite your friends to lunch https assets algo monster recursion jpg You first call Ben and ask
Want a Structured Path to Master System Design Too? Don’t Miss This!