Overlaps and merging
Picture a row of booked meetings drawn as bars on a timeline. Two bars overlap if they share any time. If you sort the bars by their start time, something useful happens: a bar can only overlap the bars just before it, never one far back that already ended. So you only ever compare a bar with the latest one you’ve kept.
Interval problems reduce to two decisions: the order (by start for merging and covering, by end for selecting non-overlapping ones) and the boundary convention (closed [a, b], where [1, 4] and [4, 5] overlap, or half-open [a, b), where back-to-back meetings don’t). Clarify the second one out loud; it changes < to <= in every comparison, and it’s the source of most wrong answers.
Two intervals a and b overlap when each one starts before the other ends: a.start <= b.end && b.start <= a.end (with < if touching ends don’t count). Once the list is sorted by start, that simplifies to one check against the last kept interval: does the current one start before the last one ends?
Merge Intervals. Merge all overlapping intervals.
[[1, 3], [2, 6], [8, 10], [15, 18]]→[[1, 6], [8, 10], [15, 18]].[[1, 4], [4, 5]]→[[1, 5]].
func merge(_ intervals: [[Int]]) -> [[Int]] {
let sorted = intervals.sorted { $0[0] < $1[0] }
var merged: [[Int]] = []
for interval in sorted {
if let last = merged.last, interval[0] <= last[1] {
merged[merged.count - 1][1] = max(last[1], interval[1]) // overlaps: extend
} else {
merged.append(interval) // gap: start a new one
}
}
return merged
}fun merge(intervals: Array<IntArray>): Array<IntArray> {
val sorted = intervals.sortedBy { it[0] }
val merged = mutableListOf<IntArray>()
for (interval in sorted) {
val last = merged.lastOrNull()
if (last != null && interval[0] <= last[1]) {
last[1] = maxOf(last[1], interval[1]) // overlaps: extend
} else {
merged.add(interval.copyOf()) // gap: start a new one
}
}
return merged.toTypedArray()
}int[][] merge(int[][] intervals) {
int[][] sorted = intervals.clone();
Arrays.sort(sorted, Comparator.comparingInt(a -> a[0]));
List<int[]> merged = new ArrayList<>();
for (int[] interval : sorted) {
int[] last = merged.isEmpty() ? null : merged.get(merged.size() - 1);
if (last != null && interval[0] <= last[1]) {
last[1] = Math.max(last[1], interval[1]); // overlaps: extend
} else {
merged.add(interval.clone()); // gap: start a new one
}
}
return merged.toArray(new int[0][]);
}function merge(intervals: number[][]): number[][] {
const sorted = [...intervals].sort((a, b) => a[0] - b[0]);
const merged: number[][] = [];
for (const [start, end] of sorted) {
const last = merged[merged.length - 1];
if (last && start <= last[1]) {
last[1] = Math.max(last[1], end); // overlaps: extend
} else {
merged.push([start, end]); // gap: start a new one
}
}
return merged;
}The sort is O(n log n), and the loop looks at each interval once, so the whole thing is O(n log n). Notice the copies (copyOf, clone, a new array in TypeScript): the code changes last[1] in place, and without a copy it would also change the caller’s input.
Tap the line with the bug.
func merge(_ intervals: [[Int]]) -> [[Int]] {
let sorted = intervals.sorted { $0[0] < $1[0] }
var merged: [[Int]] = []
for interval in sorted {
if let last = merged.last, interval[0] <= last[1] {
merged[merged.count - 1][1] = interval[1]
} else {
merged.append(interval)
}
}
return merged
}fun merge(intervals: Array<IntArray>): Array<IntArray> {
val sorted = intervals.sortedBy { it[0] }
val merged = mutableListOf<IntArray>()
for (interval in sorted) {
val last = merged.lastOrNull()
if (last != null && interval[0] <= last[1]) {
last[1] = interval[1]
} else {
merged.add(interval.copyOf())
}
}
return merged.toTypedArray()
}int[][] merge(int[][] intervals) {
int[][] sorted = intervals.clone();
Arrays.sort(sorted, Comparator.comparingInt(a -> a[0]));
List<int[]> merged = new ArrayList<>();
for (int[] interval : sorted) {
int[] last = merged.isEmpty() ? null : merged.get(merged.size() - 1);
if (last != null && interval[0] <= last[1]) {
last[1] = interval[1];
} else {
merged.add(interval.clone());
}
}
return merged.toArray(new int[0][]);
}function merge(intervals: number[][]): number[][] {
const sorted = [...intervals].sort((a, b) => a[0] - b[0]);
const merged: number[][] = [];
for (const [start, end] of sorted) {
const last = merged[merged.length - 1];
if (last && start <= last[1]) {
last[1] = end;
} else {
merged.push([start, end]);
}
}
return merged;
}Hint: Try [[1, 10], [2, 3], [5, 12]]. What does the merged interval look like after [2, 3]?
An interval can lie completely inside the last one. [2, 3] overlaps [1, 10], and the code sets the end to 3, shrinking the merged interval to [1, 3]. Then [5, 12] no longer overlaps and becomes a separate interval: [[1, 3], [5, 12]] instead of [[1, 12]]. The end must be the larger of the two ends: max(last end, current end).
Insert Interval
intervals is sorted by start and has no overlaps. Insert newInterval, merging where needed, and return the result, still sorted and without overlaps. Do it in O(n), without sorting again.
intervals = [[1, 3], [6, 9]], new = [2, 5] → [[1, 5], [6, 9]]
intervals = [[1, 2], [3, 5], [6, 7], [8, 10]], new = [4, 8] → [[1, 2], [3, 10]]›
The list falls into three groups: intervals that end before the new one starts, intervals that overlap it, and intervals that start after it ends.
›
Copy the first group as is. For the second, grow the new interval: start = min of the starts, end = max of the ends. Add it, then copy the third group.
SolutionTry it yourself firstTime O(n)Space O(n) for the result
func insert(_ intervals: [[Int]], _ newInterval: [Int]) -> [[Int]] {
var result: [[Int]] = []
var current = newInterval
var i = 0
while i < intervals.count && intervals[i][1] < current[0] { // ends before: keep
result.append(intervals[i]); i += 1
}
while i < intervals.count && intervals[i][0] <= current[1] { // overlaps: absorb
current = [min(current[0], intervals[i][0]), max(current[1], intervals[i][1])]
i += 1
}
result.append(current)
result.append(contentsOf: intervals[i...]) // starts after: keep
return result
}fun insert(intervals: Array<IntArray>, newInterval: IntArray): Array<IntArray> {
val result = mutableListOf<IntArray>()
var start = newInterval[0]; var end = newInterval[1]
var i = 0
while (i < intervals.size && intervals[i][1] < start) result.add(intervals[i++]) // ends before
while (i < intervals.size && intervals[i][0] <= end) { // overlaps
start = minOf(start, intervals[i][0])
end = maxOf(end, intervals[i][1])
i++
}
result.add(intArrayOf(start, end))
while (i < intervals.size) result.add(intervals[i++]) // starts after
return result.toTypedArray()
}int[][] insert(int[][] intervals, int[] newInterval) {
List<int[]> result = new ArrayList<>();
int start = newInterval[0], end = newInterval[1];
int i = 0;
while (i < intervals.length && intervals[i][1] < start) result.add(intervals[i++]); // ends before
while (i < intervals.length && intervals[i][0] <= end) { // overlaps
start = Math.min(start, intervals[i][0]);
end = Math.max(end, intervals[i][1]);
i++;
}
result.add(new int[] { start, end });
while (i < intervals.length) result.add(intervals[i++]); // starts after
return result.toArray(new int[0][]);
}function insert(intervals: number[][], newInterval: number[]): number[][] {
const result: number[][] = [];
let [start, end] = newInterval;
let i = 0;
while (i < intervals.length && intervals[i][1] < start) result.push(intervals[i++]); // ends before
while (i < intervals.length && intervals[i][0] <= end) { // overlaps
start = Math.min(start, intervals[i][0]);
end = Math.max(end, intervals[i][1]);
i++;
}
result.push([start, end]);
while (i < intervals.length) result.push(intervals[i++]); // starts after
return result;
}Because the input is already sorted, one pass is enough: O(n) instead of the O(n log n) of “append and call merge”. Using the existing order instead of sorting again is exactly the kind of improvement interviewers hope you’ll notice.
Comparators that overflow
Predict the output
What does this print?
let intervals = [[2_000_000_000, 2_000_000_001], [-2_000_000_000, 0]]
let sorted = intervals.sorted { $0[0] < $1[0] }
print(sorted[0][0])val intervals = arrayOf(intArrayOf(2_000_000_000, 2_000_000_001), intArrayOf(-2_000_000_000, 0))
val sorted = intervals.sortedWith { a, b -> a[0] - b[0] }
println(sorted[0][0])int[][] intervals = { { 2_000_000_000, 2_000_000_001 }, { -2_000_000_000, 0 } };
Arrays.sort(intervals, (a, b) -> a[0] - b[0]);
System.out.println(intervals[0][0]);const intervals = [[2_000_000_000, 2_000_000_001], [-2_000_000_000, 0]];
const sorted = [...intervals].sort((a, b) => a[0] - b[0]);
console.log(sorted[0][0]);The closure compares with <, so there's no arithmetic to overflow: the interval starting at -2,000,000,000 comes first. (Had it computed $0[0] - $1[0] < 0, Swift would trap on overflow rather than misorder.)
a[0] - b[0] is 2,000,000,000 − (−2,000,000,000) = 4,000,000,000, which wraps to a negative Int. The comparator now says the bigger start is smaller, and the sort keeps the wrong order. Use compareBy { it[0] }, sortedBy, or a[0].compareTo(b[0]).
a[0] - b[0] overflows int (4,000,000,000 wraps to −294,967,296), so the comparator lies and the order is wrong, silently. Use Comparator.comparingInt(a -> a[0]) or Integer.compare(a[0], b[0]).
Numbers are 64-bit floats, so the subtraction is exact at this size and the order is correct. The comparator-by-subtraction idiom is safe in TypeScript for normal integer ranges (up to 2⁵³).
Timestamps in milliseconds or large IDs hit this in production; LeetCode-style constraints (-10⁹ ≤ x ≤ 10⁹) are chosen to trigger it too. Comparing with compare/compareTo is a habit worth showing.
Variants worth naming
- Coverage questions (“is
[a, b]fully covered?”, “total covered length”) are merge plus a sum. - Open intervals or touching rules change
<=to<; ask before coding. - Many queries on a static set: merge once, then binary search the merged starts for each query, O(log n) per query.
- Streaming inserts with queries (a dynamic calendar): an ordered map keyed by start (Java
TreeMap, see the last part), or an interval tree.
- Overlap:
a.start <= b.end && b.start <= a.end(<for half-open). Clarify the convention. - Merge: sort by start, compare with the last merged, extend with max of ends (nested intervals). O(n log n).
- Insert into a sorted, disjoint list: three phases (before, overlapping, after) in O(n), no re-sort.
- Comparators:
a[0] - b[0]overflowsInt/inton the JVM; usecompareBy/Integer.compare. Swift<is safe; TS subtraction is exact below 2⁵³.
Check this part
After sorting by start, which interval must the current one be compared with?
Merging [1, 10] with [2, 3] gives…
A Java teammate sorts intervals with
(a, b) -> a[0] - b[0]on millisecond timestamps. What can go wrong?sortedWith { a, b -> a[0] - b[0] }on values up to ±2·10⁹. What can go wrong?(a, b) -> a[0] - b[0]on values up to ±2·10⁹. What can go wrong?A Java teammate sorts intervals with(a, b) -> a[0] - b[0]on values up to ±2·10⁹. What can go wrong?Insert one interval into a sorted, non-overlapping list. Best approach?