Problem overview
The function nonOverlappingIntervals(intervals) receives an Array<IntArray> where each IntArray is a pair [start, end]. It returns the largest number of intervals you can select so that no two of them overlap. Intervals that only touch at an end point, like [1, 2] and [2, 3], do not overlap. Each interval's end is larger than its start.
For [[1, 2], [2, 3], [3, 4], [1, 3]] the answer is 3: [1, 2], [2, 3] and [3, 4] fit side by side, while [1, 3] overlaps two of them. The statement asks for O(n log n) time.
Approach: always take the interval that ends first
Checking every subset of intervals takes exponential time. A greedy rule finds the best selection with one sort and one pass. Sort the intervals by their end points. The interval that ends earliest leaves the most room for everything after it, so taking it never hurts: any best selection can swap its first interval for the earliest-ending one without creating an overlap. After taking it, repeat the argument on the intervals that start at or after its end.
In practice this is a single scan. Keep the last interval you took. For each next interval in sorted order, if its start is at least the last taken end, it doesn't overlap, so take it. Otherwise skip it. Sorting by end is the key detail: sorting by start would let a long interval block many short ones.
On the example, sorting by end gives [1, 2], [2, 3] and [1, 3] (the sort is stable, so ties keep their original order), then [3, 4]. Take [1, 2]. [2, 3] starts at 2, which is not before 2, so take it. [1, 3] starts at 1, before 3, so skip it. [3, 4] starts at 3, so take it. The count is 3.
Why the greedy choice is safe: suppose some best selection starts with a different interval X. The earliest-ending interval E ends no later than X does, so replacing X by E cannot create a clash with the intervals that come after X. The selection keeps its size, so a best selection containing E always exists.
Step by step
- Sort by end point. The starter code already does this with
intervals.sortBy { it[1] }, which orders the array in place by each pair's second value. - Take the first interval. After sorting it ends earliest, so taking it is always safe. Start
resultas avarat 1. - Remember the last taken interval. Keep its index in a
var lastTaken, initially 0, so the next comparison can readintervals[lastTaken][1]. - Loop
ifrom 1 untilintervals.size. Index 0 is already in the answer. - Test for overlap. The interval at
iis compatible whenintervals[i][0] >= intervals[lastTaken][1]. The comparison is>=because touching intervals don't overlap. - Take it. Increment
resultand setlastTakentoi. - Return
resultafter the loop.
The solution in Kotlin
class Solution {
fun nonOverlappingIntervals(intervals: Array<IntArray>): Int {
intervals.sortBy { it[1] }
var result = 1
var lastTaken = 0
for (i in 1 until intervals.size) {
if (intervals[i][0] >= intervals[lastTaken][1]) {
result++
lastTaken = i
}
}
return result
}
}
The Kotlin details worth a closer look:
sortBy { it[1] }. It sorts anArrayin place using the value the lambda returns for each element as the sort key. Hereitis oneIntArraypair andit[1]is its end. The sort is stable, so intervals with equal ends keep their original relative order. CallingsortBychanges the caller's array: after sorting[[1, 3], [2, 3], [1, 2]]it reads[[1, 2], [1, 3], [2, 3]].sortByversussortedBy.sortByon an array sorts that array.sortedByreturns a new list and leaves the array alone.varfor values that change.resultandlastTakenare reassigned inside the loop, so they can't beval.result++. Kotlin has the same increment operator as Java, and here it is used as a statement.until.1 until intervals.sizestops at the last index, so no read goes out of range.
Complexity
Time: O(n log n). The sort dominates. The scan afterwards is one pass of O(n).
Space: O(1) beyond the sort. The scan keeps two integers. The array is sorted in place, although the library's sort may use temporary memory of its own while it runs.
Edge cases and common mistakes
- Touching intervals.
[[1, 2], [2, 3]]returns 2. With>instead of>=the result would be 1. - Identical intervals.
[[1, 2], [1, 2], [1, 2]]returns 1: only one copy can be chosen. - One long interval covering short ones.
[[1, 10], [2, 3], [4, 5]]returns 2, because the two short intervals beat the long one. Sorting by start would pick the long one first and answer 1. - A single interval.
[[1, 3]]returns 1. - An empty array. The code starts
resultat 1, so an empty array returns 1 instead of 0. The statement doesn't say the input is non-empty; if it could be, return 0 first whenintervals.isEmpty(). - Comparing with the previous interval instead of the last taken. A skipped interval must not become the reference. Always compare with
intervals[lastTaken]. - Sorting by the wrong key.
it[0]sorts by start and breaks the greedy argument.
Testing
[[1, 2], [2, 3], [3, 4], [1, 3]]gives3.[[1, 2], [1, 2], [1, 2]]gives1.[[1, 2], [2, 3]]gives2.[[1, 10], [2, 3], [4, 5]]gives2.[[1, 3]]gives1.