一、题目
给定一个区间数组:
intervals[i] = [starti, endi]
要求合并所有重叠区间,并返回一个互不重叠的区间数组。
例如:
输入:
[[1,3],[2,6],[8,10],[15,18]]
输出:
[[1,6],[8,10],[15,18]]
因为:
[1,3]
和
[2,6]
发生重叠,所以合并为:
[1,6]
二、这道题最关键的一步:先排序
一开始这道题容易不知道从哪里入手。
真正的突破口是:
先按照区间左端点从小到大排序。
例如原始区间:
[[8,10],[1,3],[2,6]]
排序以后:
[[1,3],[2,6],[8,10]]
排序之后有一个非常重要的性质:
当前区间只需要和前面已经合并好的最后一个区间比较。
因为所有区间的左端点已经递增,后面的区间不可能重新跑到更前面去。
因此问题就从:
"当前区间和前面所有区间比较"
变成:
"当前区间只和 ans 最后一个区间比较"
这就是整道题的核心优化思想。
三、最终采用的代码
java
import java.util.*;
class Solution {
public int[][] merge(int[][] intervals) {
// 按照区间左端点升序排序
Arrays.sort(
intervals,
(a, b) -> Integer.compare(a[0], b[0])
);
// 保存最终合并结果
List<int[]> ans = new ArrayList<>();
// 依次遍历每一个区间
for (int[] p : intervals) {
int m = ans.size();
// ans 不为空,并且当前区间和最后一个区间重叠
if (m > 0 && p[0] <= ans.get(m - 1)[1]) {
// 更新最后一个合并区间的右端点
ans.get(m - 1)[1] =
Math.max(ans.get(m - 1)[1], p[1]);
} else {
// 不重叠,直接作为新区间加入答案
ans.add(p);
}
}
// List<int[]> 转换为 int[][]
return ans.toArray(new int[ans.size()][]);
}
}
这是我目前更推荐面试时使用的版本。
四、第一步:按照左端点排序
代码:
Arrays.sort(
intervals,
(a, b) -> Integer.compare(a[0], b[0])
);
这里:
a
b
分别代表两个区间。
例如:
a = [1,3]
b = [8,10]
那么:
a[0]
就是:
1
而:
b[0]
就是:
8
所以:
Integer.compare(a[0], b[0])
就是按照:
区间第 0 个元素
也就是左端点
进行升序排序。
五、为什么用 Integer.compare?
有些答案会写:
Arrays.sort(intervals, (a, b) -> a[0] - b[0]);
这种写法通常也能通过。
但更规范的是:
Integer.compare(a[0], b[0])
因为:
a[0] - b[0]
理论上可能发生整数溢出。
所以面试时更推荐:
Arrays.sort(intervals,
(a, b) -> Integer.compare(a[0], b[0]));
六、List<int[]> ans 是什么意思?
代码:
List<int[]> ans = new ArrayList<>();
这里需要理解:
List<int[]>
表示:
List 里面每一个元素都是一个
int[]。
而每个区间刚好就是:
int[]
例如:
[1,6]
在 Java 中实际上可以表示为:
new int[]{1, 6}
所以:
ans
最终可能是:
[
[1,6],
[8,10],
[15,18]
]
只是它此时的 Java 类型是:
List<int[]>
七、为什么不用 int\[\]\[\] 直接保存答案?
因为合并之前我们不知道最终会有多少个区间。
比如输入 10 个区间:
可能全部重叠
→ 最终只有 1 个
也可能:
全部不重叠
→ 最终还是 10 个
所以使用:
ArrayList
这种可以动态扩容的集合更加方便。
八、增强 for 循环
代码:
for (int[] p : intervals) {
表示:
依次取出
intervals中的每一个区间,当前区间保存到p中。
例如:
intervals =
[
[1,3],
[2,6],
[8,10]
]
那么遍历过程:
第一次:
p = [1,3]
第二次:
p = [2,6]
第三次:
p = [8,10]
这里:
p[0]
表示当前区间左端点。
p[1]
表示当前区间右端点。
九、int m = ans.size()
int m = ans.size();
表示:
当前已经合并完成多少个区间。
例如:
ans =
[
[1,6],
[8,10]
]
那么:
ans.size()
就是:
2
所以:
m = 2
十、ans.get(m - 1) 为什么是最后一个区间?
Java List 下标从:
0
开始。
如果:
ans.size() = 3
那么三个元素下标:
0
1
2
最后一个下标就是:
size - 1
也就是:
m - 1
所以:
ans.get(m - 1)
表示:
ans 中最后一个区间。
例如:
ans =
[
[1,6],
[8,10]
]
那么:
ans.get(1)
就是:
[8,10]
十一、为什么只需要和 ans 最后一个比较?
这是这版代码最重要的思想。
因为已经按照左端点排序。
例如:
ans =
[
[1,6],
[8,10]
]
当前:
p = [9,15]
只需要检查:
[8,10]
因为:
[1,6]
已经结束得更早。
当前左端点:
9
已经大于:
6
所以不可能再和 [1,6] 合并。
因此:
当前区间只需要和 ans 最后一个区间比较。
十二、最核心的重叠判断
代码:
if (m > 0 && p[0] <= ans.get(m - 1)[1]) {
拆成两个条件。
第一部分:
m > 0
意思:
ans 里面至少已经有一个区间。
否则不能直接调用:
ans.get(m - 1)
因为第一次:
m = 0
会变成:
ans.get(-1)
显然不合法。
第二部分:
p[0] <= ans.get(m - 1)[1]
含义:
当前区间左端点
<=
答案最后一个区间右端点
说明两个区间发生重叠。
例如:
最后一个区间:
[1,6]
当前区间:
[2,8]
因为:
2 <= 6
所以重叠。
十三、为什么 && 不会导致 get(-1)?
Java 的:
&&
具有短路特性。
代码:
m > 0 && p[0] <= ans.get(m - 1)[1]
如果:
m > 0
已经为:
false
Java 不会继续执行右边。
所以第一次:
m = 0
不会真的执行:
ans.get(-1)
这也是 Java 中:
短路与
的典型使用场景。
十四、重叠以后怎么合并?
代码:
ans.get(m - 1)[1] =
Math.max(ans.get(m - 1)[1], p[1]);
例如:
最后一个区间:
[1,6]
当前区间:
[2,8]
那么:
旧右端点 = 6
当前右端点 = 8
更新:
Math.max(6, 8)
得到:
8
最终:
[1,6]
变成:
[1,8]
十五、为什么要 Math.max?
例如:
最后一个区间:
[1,10]
当前区间:
[2,5]
当前区间完全包含在前一个区间中。
如果直接:
right = p[1];
就会错误变成:
[1,5]
因此必须:
Math.max(10,5)
仍然得到:
10
所以:
ans.get(m - 1)[1] =
Math.max(ans.get(m - 1)[1], p[1]);
是标准写法。
十六、为什么只更新右端点?
因为已经按照左端点升序排序。
例如:
[1,6]
[2,10]
第二个区间左端点一定不会比第一个更小。
所以合并:
[1,10]
左端点:
1
不用修改。
只需要扩大右端点。
十七、不重叠怎么办?
代码:
else {
ans.add(p);
}
例如:
ans 最后一个:
[1,6]
当前:
[8,10]
因为:
8 > 6
所以不重叠。
此时:
[1,6]
已经不可能再被后面的区间影响。
于是当前:
[8,10]
直接作为一个新的合并区间加入:
ans.add(p);
得到:
ans =
[
[1,6],
[8,10]
]
十八、第一次循环是怎么处理的?
一开始:
ans = []
第一个区间:
p = [1,3]
那么:
m = ans.size();
得到:
m = 0
判断:
m > 0
false。
所以直接进入:
else
执行:
ans.add(p);
得到:
ans = [[1,3]]
所以这个版本不需要单独初始化:
start
end
这也是它比另一种写法更简洁的原因。
十九、为什么这一版不用循环结束后再处理最后一个区间?
这是我学习过程中比较容易困惑的地方。
另一种写法会自己维护:
start
end
例如:
int start = intervals[0][0];
int end = intervals[0][1];
然后只有遇到新区间时才:
ans.add(new int[]{start,end});
所以最后一个正在维护的区间可能还没有正式加入 ans。
因此需要循环结束后:
ans.add(new int[]{start,end});
补一次。
但是这一版不同。
这一版对于每一个:
p
都会立即做两种操作之一:
重叠
→ 直接合并进 ans 最后一个区间
不重叠
→ 直接 ans.add(p)
也就是说:
每个区间在遍历过程中就已经进入了 ans 的状态。
所以最后一个区间无论:
重叠
还是:
不重叠
都会在循环内部完成处理。
因此不需要循环外额外补。
可以简单记:
start/end 版本:
延迟提交
→ 最后需要补
ans 最后一个版本:
边遍历边直接维护 ans
→ 不需要补
二十、最后一行:toArray
代码:
return ans.toArray(new int[ans.size()][]);
这是 Java 类型转换。
当前:
ans
类型:
List<int[]>
但题目要求:
int[][]
所以需要转换:
List<int[]>
↓
int[][]
二十一、new int[ans.size()][] 是什么?
假设:
ans.size() = 3
那么:
new int[ans.size()][]
等于:
new int[3][]
表示创建一个:
第一维长度为 3,每一个位置未来放一个
int[]的二维数组。
可以暂时理解为:
[
null,
null,
null
]
然后:
ans.toArray(...)
把:
[1,6]
[8,10]
[15,18]
放进去。
最终得到:
int[][] result = {
{1,6},
{8,10},
{15,18}
};
二十二、完整执行过程
输入:
[[1,3],[2,6],[8,10],[15,18]]
排序后:
[[1,3],[2,6],[8,10],[15,18]]
初始:
ans = []
第一次
p = [1,3]
m = 0
无法比较。
直接:
ans.add(p);
得到:
ans = [[1,3]]
第二次
p = [2,6]
m = 1
最后一个区间:
[1,3]
判断:
2 <= 3
成立。
所以:
右端点 = max(3,6)
= 6
得到:
ans = [[1,6]]
第三次
p = [8,10]
最后一个:
[1,6]
判断:
8 <= 6
false。
所以:
ans.add(p);
得到:
ans =
[
[1,6],
[8,10]
]
第四次
p = [15,18]
最后一个:
[8,10]
判断:
15 <= 10
false。
加入:
ans =
[
[1,6],
[8,10],
[15,18]
]
最后转换为:
int[][]
返回。
二十三、复杂度分析
排序:
O(n log n)
遍历:
O(n)
所以总时间复杂度:
O(n log n)
主要瓶颈是排序。
结果集合最多存:
n
个区间。
二十四、面试讲法
如果面试官让我讲,可以这样回答:
我先按照所有区间的左端点进行升序排序。排序之后,对于当前区间,只需要判断它是否和结果集中最后一个区间重叠。
如果当前区间左端点小于等于最后一个区间的右端点,说明存在重叠,此时更新最后一个区间的右端点为两个右端点的最大值。
如果不重叠,就直接把当前区间加入结果集合。
由于每个区间在遍历时就已经被加入或合并到结果集合中,所以循环结束以后不需要额外处理最后一个区间。
排序时间复杂度为 O(n log n),遍历为 O(n),总体时间复杂度为 O(n log n)。
二十五、这道题的通用思维
以后看到:
区间
合并
重叠
覆盖
第一反应可以考虑:
按照左端点排序
排序以后经常会变成:
从左往右扫描
↓
只和前一个 / 当前合并区间比较
这是一类非常常见的区间题套路。
二十六、面试前 30 秒速记
看到「合并区间」:
1. 按 start 排序
2. ans 保存合并结果
3. 当前区间 p
只和 ans 最后一个区间比较
4. 如果:
p[0] <= last[1]
说明重叠
last[1] = max(last[1], p[1])
5. 否则:
ans.add(p)
代码模板:
java
Arrays.sort(
intervals,
(a, b) -> Integer.compare(a[0], b[0])
);
List<int[]> ans = new ArrayList<>();
for (int[] p : intervals) {
int m = ans.size();
if (m > 0 && p[0] <= ans.get(m - 1)[1]) {
ans.get(m - 1)[1] =
Math.max(ans.get(m - 1)[1], p[1]);
} else {
ans.add(p);
}
}
return ans.toArray(new int[ans.size()][]);
Java API:
Arrays.sort()
数组排序
List<int[]>
保存多个 int[] 区间
ans.size()
List 元素数量
ans.get(i)
获取第 i 个元素
ans.add(p)
添加元素
Math.max(a,b)
取较大值
toArray()
List 转数组
二十七、最终总结
这道题真正需要记住的不是某一行 API,而是:
区间不好直接处理
↓
先按照左端点排序
↓
区间关系变得有序
↓
当前区间只需要和最后一个合并区间比较
排序以后,每个新区间只有两种情况:
能合并
→ 更新 ans 最后一个区间右端点
不能合并
→ 直接加入 ans
最终核心判断:
p[0] <= ans.get(m - 1)[1]
核心更新:
ans.get(m - 1)[1] =
Math.max(ans.get(m - 1)[1], p[1]);
这道题同时也顺带巩固了:
二维数组
增强 for
Lambda 排序
List<int[]>
ArrayList
get()
size()
add()
Math.max()
toArray()
短路 &&
对于面试来说,这是一道非常典型的:
排序 + 贪心扫描
题目。
以后遇到类似区间问题,可以优先往这个方向思考。其他写法:
java
import java.util.*;
class Solution {
public int[][] merge(int[][] intervals) {
Arrays.sort(intervals, (a, b) -> Integer.compare(a[0], b[0]));
List<int[]> ans = new ArrayList<>();
int start = intervals[0][0];
int end = intervals[0][1];
for (int i = 1; i < intervals.length; i++) {
int nextStart = intervals[i][0];
int nextEnd = intervals[i][1];
if (nextStart <= end) {
end = Math.max(end, nextEnd);
} else {
ans.add(new int[]{start, end});
start = nextStart;
end = nextEnd;
}
}
ans.add(new int[]{start, end});
return ans.toArray(new int[ans.size()][]);
}
}