LeetCode 1288 - Remove Covered Intervals
The problem gives us a list of intervals, where each interval is represented as [li, ri], corresponding to the half open
Difficulty: 🟡 Medium
Topics: Array, Sorting
Solution
Problem Understanding
The problem gives us a list of intervals, where each interval is represented as [li, ri], corresponding to the half open interval [li, ri). We need to remove every interval that is completely covered by another interval in the list.
An interval [a, b) is considered covered by [c, d) if:
c <= ab <= d
This means the entire interval [a, b) lies inside [c, d).
The task is not to return the remaining intervals themselves. Instead, we only need to return how many intervals remain after removing all covered intervals.
For example, if we have:
[[1,4],[3,6],[2,8]]
The interval [3,6] is fully contained inside [2,8], because:
2 <= 36 <= 8
So [3,6] is removed, leaving two intervals.
The constraints are relatively small:
- Up to
1000intervals - Interval endpoints up to
100000
Because the input size is moderate, even an O(n^2) solution would technically pass. However, the problem is designed to reward a more elegant sorting-based approach.
Several edge cases are important to consider:
- Intervals with the same starting point
- Intervals nested deeply inside another interval
- Intervals that overlap but are not covered
- A single interval input
- Intervals already sorted or completely unsorted
The problem also guarantees that all intervals are unique, which removes ambiguity around duplicate intervals.
Approaches
Brute Force Approach
The most direct solution is to compare every interval with every other interval.
For each interval i, we check whether there exists another interval j such that:
j.start <= i.start
j.end >= i.end
If such an interval exists, then interval i is covered and should not be counted.
This approach is correct because it explicitly tests the definition of coverage for every pair of intervals. However, it requires checking all pairs, resulting in quadratic time complexity.
With n intervals, we perform up to n * n comparisons, giving a time complexity of O(n^2).
Optimal Approach
The key insight is that sorting the intervals allows us to detect covered intervals in a single pass.
We sort intervals using two rules:
- Sort by starting point in ascending order
- If two intervals have the same start, sort by ending point in descending order
The second rule is extremely important.
Consider:
[1,4], [1,6]
If we sorted ends in ascending order, [1,4] would appear first, making it harder to recognize that it is covered by [1,6].
By sorting ends in descending order, larger intervals come first:
[1,6], [1,4]
Now we only need to track the largest ending point seen so far.
As we scan through the sorted intervals:
- If the current interval's end is less than or equal to the maximum end seen so far, it is covered
- Otherwise, it is not covered, and we update the maximum end
This reduces the problem to a single linear scan after sorting.
| Approach | Time Complexity | Space Complexity | Notes |
|---|---|---|---|
| Brute Force | O(n²) | O(1) | Compare every interval against every other interval |
| Optimal | O(n log n) | O(1) or O(log n) | Sort intervals, then scan once to count uncovered intervals |
Algorithm Walkthrough
- Sort the intervals by starting point in ascending order.
This ensures intervals are processed from left to right. 2. When two intervals have the same starting point, sort by ending point in descending order.
This guarantees that larger intervals appear before smaller contained intervals. Without this rule, covered intervals with equal starts could be missed. 3. Initialize two variables:
remaining_intervals, initially0max_end, initially0
max_end tracks the farthest ending point encountered so far.
4. Iterate through the sorted intervals one by one.
5. For each interval [start, end]:
-
If
end <= max_end, then this interval is covered by a previously processed interval. -
Otherwise, the interval is not covered:
-
Increment
remaining_intervals -
Update
max_end = end
- After processing all intervals, return
remaining_intervals.
Why it works
After sorting, every previously processed interval has a start point less than or equal to the current interval's start point.
Because intervals with equal starts are sorted by decreasing end, any interval capable of covering the current interval must appear earlier in the traversal.
Therefore, if the current interval's end is less than or equal to the maximum end seen so far, some earlier interval completely covers it.
This invariant guarantees correctness throughout the scan.
Python Solution
from typing import List
class Solution:
def removeCoveredIntervals(self, intervals: List[List[int]]) -> int:
intervals.sort(key=lambda interval: (interval[0], -interval[1]))
remaining_intervals = 0
max_end = 0
for start, end in intervals:
if end > max_end:
remaining_intervals += 1
max_end = end
return remaining_intervals
The implementation begins by sorting the intervals according to the required ordering. The lambda key sorts first by starting position ascending, then by ending position descending.
The variable max_end stores the farthest right endpoint encountered so far. During traversal, if the current interval ends after max_end, it cannot be covered by any previous interval, so we count it and update max_end.
If the current interval ends at or before max_end, then a previously processed interval already fully contains it, so it is skipped.
The entire algorithm after sorting is a single linear scan.
Go Solution
package main
import "sort"
func removeCoveredIntervals(intervals [][]int) int {
sort.Slice(intervals, func(i, j int) bool {
if intervals[i][0] == intervals[j][0] {
return intervals[i][1] > intervals[j][1]
}
return intervals[i][0] < intervals[j][0]
})
remainingIntervals := 0
maxEnd := 0
for _, interval := range intervals {
end := interval[1]
if end > maxEnd {
remainingIntervals++
maxEnd = end
}
}
return remainingIntervals
}
The Go implementation follows the same logic as the Python version.
The main difference is the use of sort.Slice to provide a custom comparator for sorting intervals.
Go slices are reference-like structures, so sorting modifies the original slice directly. Since interval endpoints are constrained to at most 100000, integer overflow is not a concern.
The algorithm uses only a few integer variables, so memory usage remains minimal.
Worked Examples
Example 1
Input:
[[1,4],[3,6],[2,8]]
After sorting:
[[1,4],[2,8],[3,6]]
| Current Interval | max_end Before | Covered? | remaining_intervals | max_end After |
|---|---|---|---|---|
| [1,4] | 0 | No | 1 | 4 |
| [2,8] | 4 | No | 2 | 8 |
| [3,6] | 8 | Yes | 2 | 8 |
Final answer:
2
Example 2
Input:
[[1,4],[2,3]]
After sorting:
[[1,4],[2,3]]
| Current Interval | max_end Before | Covered? | remaining_intervals | max_end After |
|---|---|---|---|---|
| [1,4] | 0 | No | 1 | 4 |
| [2,3] | 4 | Yes | 1 | 4 |
Final answer:
1
Complexity Analysis
| Measure | Complexity | Explanation |
|---|---|---|
| Time | O(n log n) | Sorting dominates the runtime |
| Space | O(1) or O(log n) | Only a few variables are used, aside from sorting overhead |
The scan itself is linear, requiring O(n) time. However, sorting requires O(n log n) time, which becomes the dominant factor.
The algorithm uses constant extra variables. Depending on the language implementation of sorting, recursion stack usage may contribute O(log n) auxiliary space.
Test Cases
from typing import List
class Solution:
def removeCoveredIntervals(self, intervals: List[List[int]]) -> int:
intervals.sort(key=lambda interval: (interval[0], -interval[1]))
remaining_intervals = 0
max_end = 0
for start, end in intervals:
if end > max_end:
remaining_intervals += 1
max_end = end
return remaining_intervals
solution = Solution()
assert solution.removeCoveredIntervals([[1,4],[3,6],[2,8]]) == 2 # basic example
assert solution.removeCoveredIntervals([[1,4],[2,3]]) == 1 # one interval covered
assert solution.removeCoveredIntervals([[1,2]]) == 1 # single interval
assert solution.removeCoveredIntervals([[1,10],[2,9],[3,8],[4,7]]) == 1 # deeply nested intervals
assert solution.removeCoveredIntervals([[1,2],[2,3],[3,4]]) == 3 # overlapping but not covered
assert solution.removeCoveredIntervals([[1,4],[1,5]]) == 1 # same start, larger interval covers smaller
assert solution.removeCoveredIntervals([[1,5],[1,4],[1,3]]) == 1 # multiple equal starts
assert solution.removeCoveredIntervals([[0,10],[5,12]]) == 2 # overlap without coverage
assert solution.removeCoveredIntervals([[1,100000],[2,99999]]) == 1 # large endpoint values
assert solution.removeCoveredIntervals([[3,10],[4,10],[5,11]]) == 2 # equal ends with different starts
| Test | Why |
|---|---|
[[1,4],[3,6],[2,8]] |
Validates the main example |
[[1,4],[2,3]] |
Tests direct coverage |
[[1,2]] |
Tests smallest possible input |
[[1,10],[2,9],[3,8],[4,7]] |
Tests multiple nested intervals |
[[1,2],[2,3],[3,4]] |
Ensures overlap is not confused with coverage |
[[1,4],[1,5]] |
Tests identical starts |
[[1,5],[1,4],[1,3]] |
Verifies descending-end sorting behavior |
[[0,10],[5,12]] |
Tests partial overlap without coverage |
[[1,100000],[2,99999]] |
Tests large values |
[[3,10],[4,10],[5,11]] |
Tests intervals sharing the same end |
Edge Cases
One important edge case occurs when multiple intervals share the same starting point. For example:
[[1,5],[1,4]]
A naive sorting strategy that sorts ending points ascending could process [1,4] first, making it difficult to recognize that it is covered. Sorting ending points in descending order ensures the larger interval appears first, allowing the algorithm to detect coverage correctly.
Another important case is overlapping intervals that are not covered:
[[1,4],[3,6]]
These intervals overlap, but neither fully contains the other. A buggy implementation might incorrectly remove one interval simply because overlap exists. The algorithm avoids this mistake by checking only whether end <= max_end.
Deeply nested intervals are another common source of bugs:
[[1,10],[2,9],[3,8],[4,7]]
Every interval except the first is covered. The algorithm handles this efficiently because max_end remains 10 throughout the scan, causing every later interval to be recognized as covered immediately.
A final edge case involves the smallest possible input:
[[1,2]]
Since no other interval exists to cover it, the answer must be 1. The algorithm naturally handles this because the first interval always increases max_end and is counted.