A clear explanation of designing a range module that can add, query, and remove half-open intervals.
Problem Restatement
We need to design a data structure called RangeModule.
It tracks ranges of numbers.
Each range is represented as a half-open interval:
[left, right)This means the interval includes left, but does not include right.
The data structure must support three operations:
| Operation | Meaning |
|---|---|
addRange(left, right) | Start tracking every number in [left, right) |
queryRange(left, right) | Return whether every number in [left, right) is tracked |
removeRange(left, right) | Stop tracking every number in [left, right) |
The intervals may overlap, merge, or split.
Input and Output
| Method | Input | Output |
|---|---|---|
RangeModule() | None | Creates an empty module |
addRange(left, right) | Two integers | None |
queryRange(left, right) | Two integers | Boolean |
removeRange(left, right) | Two integers | None |
Important constraints:
| Constraint | Meaning |
|---|---|
1 <= left < right <= 10^9 | Large coordinate range |
At most 10^4 calls | We need efficient operations |
| Intervals are half-open | [left, right) excludes right |
The class shape is:
class RangeModule:
def __init__(self):
...
def addRange(self, left: int, right: int) -> None:
...
def queryRange(self, left: int, right: int) -> bool:
...
def removeRange(self, left: int, right: int) -> None:
...Examples
Example:
rangeModule = RangeModule()
rangeModule.addRange(10, 20)
rangeModule.removeRange(14, 16)
rangeModule.queryRange(10, 14)
rangeModule.queryRange(13, 15)
rangeModule.queryRange(16, 17)After adding [10, 20), the tracked interval is:
[10, 20)After removing [14, 16), the tracked intervals become:
[10, 14)
[16, 20)Now:
| Query | Result | Why |
|---|---|---|
[10, 14) | True | Fully covered |
[13, 15) | False | The part [14, 15) was removed |
[16, 17) | True | Fully covered |
First Thought: Track Every Number
A simple idea is to store every tracked number in a set.
For addRange(left, right), insert every integer from left to right - 1.
For removeRange(left, right), remove every integer from left to right - 1.
For queryRange(left, right), check every integer from left to right - 1.
This fails for two reasons.
First, the range can go up to 10^9.
Second, the problem describes real numbers in the interval, not only integers. We should track intervals, not individual points.
Key Insight
Store a sorted list of disjoint tracked intervals.
For example, after some operations, we may store:
[(10, 14), (16, 20), (30, 40)]The invariant is:
- Intervals are sorted by start.
- Intervals do not overlap.
- Adjacent or overlapping intervals are merged.
With this representation:
addRangemerges overlapping intervals.removeRangecuts overlapping intervals.queryRangechecks whether one stored interval fully covers the query.
Because there are at most 10^4 operations, a sorted list is simple and accepted in Python.
Algorithm: Add Range
To add [left, right):
- Create an empty list
new_intervals. - Scan every existing interval
(start, end). - If the interval ends before
left, keep it. - If the interval starts after
right, insert the merged new interval once, then keep the rest. - If the interval overlaps
[left, right), merge it by updating:left = min(left, start)right = max(right, end)
- At the end, insert the merged interval if it has not been inserted yet.
Overlapping intervals become one larger tracked interval.
Algorithm: Query Range
To query [left, right):
- Find the interval whose start is the greatest start less than or equal to
left. - Check whether that interval ends at or after
right.
If such an interval exists, then the whole query range is tracked.
Otherwise, some part is missing.
Algorithm: Remove Range
To remove [left, right):
- Create an empty list
new_intervals. - Scan every existing interval
(start, end). - If it does not overlap
[left, right), keep it unchanged. - If it overlaps:
- Keep the left leftover
[start, left)ifstart < left. - Keep the right leftover
[right, end)ifright < end.
- Keep the left leftover
- Replace the interval list.
Removing a range may split one interval into two.
Correctness
The data structure stores exactly the tracked set as a sorted list of disjoint half-open intervals.
For addRange, every interval that does not overlap the added range remains unchanged. Every interval that overlaps the added range is merged into one interval whose boundaries are the minimum left endpoint and maximum right endpoint of all overlapping parts. Therefore the new interval covers exactly the old tracked values plus every value in [left, right), with no gaps inside the merged region.
For queryRange, the query can be fully tracked only if one stored disjoint interval contains it. Since the stored intervals are sorted and non-overlapping, it is sufficient to check the interval immediately before or at left. If that interval ends at or after right, every number in [left, right) is tracked. Otherwise, no other interval can cover the missing part beginning at left.
For removeRange, every non-overlapping interval remains tracked. For each overlapping interval, the algorithm removes the intersection with [left, right) and keeps only the portions before left and after right. These leftovers are exactly the parts that should remain tracked.
After each operation, the list remains sorted and disjoint. Therefore all operations preserve the correct representation of the tracked ranges.
Complexity
Let n be the number of stored intervals.
| Operation | Time | Space | Why |
|---|---|---|---|
addRange | O(n) | O(n) | Scan and rebuild interval list |
queryRange | O(log n) | O(1) | Binary search by interval start |
removeRange | O(n) | O(n) | Scan and rebuild interval list |
The coordinate range is large, but the number of stored intervals is bounded by the number of operations.
Implementation
from bisect import bisect_right
class RangeModule:
def __init__(self):
self.intervals = []
def addRange(self, left: int, right: int) -> None:
new_intervals = []
placed = False
for start, end in self.intervals:
if end < left:
new_intervals.append((start, end))
elif right < start:
if not placed:
new_intervals.append((left, right))
placed = True
new_intervals.append((start, end))
else:
left = min(left, start)
right = max(right, end)
if not placed:
new_intervals.append((left, right))
self.intervals = new_intervals
def queryRange(self, left: int, right: int) -> bool:
starts = [start for start, _ in self.intervals]
index = bisect_right(starts, left) - 1
if index < 0:
return False
start, end = self.intervals[index]
return start <= left and right <= end
def removeRange(self, left: int, right: int) -> None:
new_intervals = []
for start, end in self.intervals:
if end <= left or right <= start:
new_intervals.append((start, end))
else:
if start < left:
new_intervals.append((start, left))
if right < end:
new_intervals.append((right, end))
self.intervals = new_intervalsCode Explanation
The module stores disjoint intervals:
self.intervals = []In addRange, we rebuild the interval list.
If an old interval is completely before the new range, keep it:
if end < left:
new_intervals.append((start, end))If an old interval is completely after the new range, place the new merged range before it:
elif right < start:
if not placed:
new_intervals.append((left, right))
placed = TrueOtherwise, the two intervals overlap or touch, so merge them:
else:
left = min(left, start)
right = max(right, end)For queryRange, we find the interval with the largest start not greater than left:
index = bisect_right(starts, left) - 1Then we check whether it covers the full query:
return start <= left and right <= endFor removeRange, if an interval does not overlap the removed range, keep it:
if end <= left or right <= start:
new_intervals.append((start, end))If it overlaps, keep only the leftovers:
if start < left:
new_intervals.append((start, left))
if right < end:
new_intervals.append((right, end))Half-Open Interval Details
The interval [left, right) includes left and excludes right.
This affects boundary checks.
These two intervals touch but do not overlap in real-number coverage:
[10, 14)
[14, 20)For tracking ranges, we may merge them into:
[10, 20)because their union has no gap.
For removal, if we remove:
[14, 16)from:
[10, 20)we keep:
[10, 14)
[16, 20)The point 14 is removed because it belongs to [14, 16).
The point 16 remains because the removed interval excludes 16.
Testing
def test_range_module():
rm = RangeModule()
rm.addRange(10, 20)
rm.removeRange(14, 16)
assert rm.queryRange(10, 14) is True
assert rm.queryRange(13, 15) is False
assert rm.queryRange(16, 17) is True
rm = RangeModule()
rm.addRange(10, 20)
rm.addRange(15, 25)
assert rm.queryRange(10, 25) is True
assert rm.queryRange(20, 24) is True
assert rm.queryRange(25, 26) is False
rm.removeRange(12, 22)
assert rm.queryRange(10, 12) is True
assert rm.queryRange(12, 13) is False
assert rm.queryRange(22, 25) is True
rm = RangeModule()
rm.addRange(1, 5)
rm.addRange(5, 10)
assert rm.queryRange(1, 10) is True
print("all tests passed")
test_range_module()Test coverage:
| Test | Why |
|---|---|
| Add then remove middle | Confirms interval splitting |
| Query fully covered range | Confirms true result |
| Query partially removed range | Confirms false result |
| Merge overlapping ranges | Confirms add behavior |
| Remove from merged range | Confirms leftover intervals |
| Touching intervals | Confirms half-open boundary handling |