LeetCode 56. 合并区间:排序详解
1. 这道题到底在问什么
LeetCode 56「合并区间」给出若干区间:
text
intervals[i] = [start, end]
要求把所有有重叠的区间合并,最终返回一组互不重叠的区间。
例如:
text
intervals = [[1, 3], [2, 6], [8, 10], [15, 18]]
其中:
text
[1, 3] 和 [2, 6] 有重叠。
合并后是:
text
[1, 6]
另外两个区间不和它重叠,所以最终结果是:
text
[[1, 6], [8, 10], [15, 18]]
2. 为什么必须先排序
如果区间是乱序的,很难判断一个区间还会不会与后面的区间重叠。
例如:
text
[[8, 10], [1, 3], [2, 6]]
直接从左到右处理时,先看到 [8, 10],之后又看到 [1, 3] 和 [2, 6],当前区间顺序没有规律,合并状态会混乱。
因此先按照每个区间的左边界从小到大排序:
text
[[1, 3], [2, 6], [8, 10]]
排序后有一个重要性质:
text
后面区间的左边界不会比前面更小。
这个性质让我们能够从左到右扫描,只维护一个当前正在合并的区间。
3. 用两个状态理解 Java 写法
Java 版本最清晰的地方,是把事情分成两个状态:
text
1. 当前暂存区间:[start, rightmostRightBound]
2. 已经确定的结果区间:res
当前暂存区间
text
[start, rightmostRightBound]
表示:
text
当前已经合并到什么范围,但它暂时还不能放入最终答案。
因为后面可能还有区间和它重叠,需要继续扩大它的右边界。
已确定结果集合
java
List<int[]> res = new ArrayList<>();
res 中保存的是:
text
已经确认不可能再和后续区间合并的区间。
区间一旦加入 res,后面就不再修改它。
为什么可以确认?
因为区间已经按左边界排好序。当当前暂存区间的右边界是 rightmostRightBound,下一个区间的左边界是 currentStart,如果:
text
currentStart > rightmostRightBound
就说明二者完全不重叠。
而后续区间的左边界只会更大,因此后面也不可能再和当前暂存区间重叠。
所以当前暂存区间可以正式加入 res。
4. 每个新区间只有两种情况
排序后,遍历当前区间:
text
[currentStart, currentEnd]
与当前暂存区间:
text
[start, rightmostRightBound]
比较时只有两种情况。
情况一:不重叠
如果:
text
currentStart > rightmostRightBound
例如:
text
当前暂存区间:[1, 6]
当前新区间:[8, 10]
因为:
text
8 > 6
所以二者不重叠。
此时要做两件事:
text
1. [1, 6] 已经确定,加入 res。
2. [8, 10] 成为新的当前暂存区间。
情况二:重叠或端点相接
如果:
text
currentStart <= rightmostRightBound
例如:
text
当前暂存区间:[1, 3]
当前新区间:[2, 6]
因为:
text
2 <= 3
它们重叠,合并后左边界仍然是 1,右边界取两者较大值:
text
[1, max(3, 6)] = [1, 6]
只更新右边界:
java
rightmostRightBound = Math.max(rightmostRightBound, currentEnd);
为什么左边界不用更新?
因为我们已经按左边界升序排序,当前暂存区间的 start 一定不大于当前新区间的 currentStart。
还有一个细节:判断不重叠必须写成:
java
currentStart > rightmostRightBound
不能写成 >=。
例如:
text
[1, 4] 和 [4, 5]
它们在端点 4 相接,题目认为应该合并成:
text
[1, 5]
因此当:
text
currentStart == rightmostRightBound
时,仍然要走合并逻辑。
5. 用示例完整模拟
示例:
text
intervals = [[1, 3], [2, 6], [8, 10], [15, 18]]
排序后顺序不变。
初始化第一个暂存区间:
text
start = 1
rightmostRightBound = 3
res = []
当前暂存区间是:
text
[1, 3]
处理 [2, 6]:
text
2 <= 3,重叠。
更新右边界:
text
rightmostRightBound = max(3, 6) = 6
当前暂存区间变成:
text
[1, 6]
处理 [8, 10]:
text
8 > 6,不重叠。
此时 [1, 6] 已经不可能再与后续区间合并:
text
res = [[1, 6]]
把 [8, 10] 设为新的暂存区间:
text
start = 8
rightmostRightBound = 10
处理 [15, 18]:
text
15 > 10,不重叠。
先收集当前暂存区间:
text
res = [[1, 6], [8, 10]]
新的暂存区间是:
text
[15, 18]
循环结束后,最后这个暂存区间还没有加入结果集,因此补上:
text
res = [[1, 6], [8, 10], [15, 18]]
6. 为什么循环结束后还要补一次 res.add
暂存区间在什么情况下会加入 res?
答案是:
text
遇到下一个不重叠区间时。
最后一个暂存区间后面没有下一个区间,因此没有机会触发"不重叠"逻辑。
例如:
text
intervals = [[1, 3], [2, 6]]
处理 [2, 6] 后,暂存区间更新为:
text
[1, 6]
然后循环结束。
如果没有最后的:
java
res.add(new int[]{start, rightmostRightBound});
[1, 6] 将永远不会加入结果集。
所以循环结束后必须补上最后一个暂存区间。
7. 为什么 Java 用 List<int\[\]>,不能直接用 int\[\]\[\]
每个区间是两个整数:
text
[start, end]
在 Java 中,它可以用:
java
int[] interval = {start, end};
最终结果是一组区间,因此返回类型是:
java
int[][]
但在遍历开始前,我们不知道最终会得到多少个不重叠区间。
例如:
text
[[1, 10], [2, 3]]
最终可能只有一个区间:
text
[[1, 10]]
而:
text
[[1, 2], [5, 6], [9, 10]]
最终可能有三个区间。
Java 的二维数组创建后,第一维长度固定:
java
int[][] result = new int[3][2];
创建时必须提前知道结果有多少行。
本题开始时不知道答案数量,因此先使用能动态扩容的:
java
List<int[]> res = new ArrayList<>();
每确定一个区间,就加入:
java
res.add(new int[]{start, rightmostRightBound});
最后再转成题目要求的二维数组。
8. 为什么 Java 需要转换,C++ 不需要
Java 代码中:
java
List<int[]> res = new ArrayList<>();
但方法返回类型要求:
java
int[][]
List<int[]> 和 int[][] 是不同的类型,不能直接返回。
因此需要:
java
return res.toArray(new int[res.size()][]);
它表示:
text
把动态长度的 List<int[]>,转换为长度刚好等于结果数量的 int[][]。
C++ 中:
cpp
vector<vector<int>> result;
而方法返回类型也是:
cpp
vector<vector<int>>
result 的类型和返回类型完全相同,所以可以直接:
cpp
return result;
两种语言的对应关系:
| C++ | Java | 含义 |
|---|---|---|
vector<vector<int>> result |
List<int[]> res |
动态保存多个结果区间 |
result.push_back(interval) |
res.add(interval) |
添加一个区间 |
result.back() |
获取 res 最后一个元素 |
获取最后一个结果区间 |
return result |
res.toArray(...) |
按题目返回类型返回结果 |
9. Java 代码完整注释
java
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
class Solution {
public int[][] merge(int[][] intervals) {
// 题目约束通常保证 intervals 非空。
// 保留判断后,独立调用时也能正常处理空数组。
if (intervals.length == 0) {
return new int[0][];
}
// 按区间左边界从小到大排序。
Arrays.sort(intervals, (x, y) -> Integer.compare(x[0], y[0]));
// 保存已经确定、不再可能和后续区间合并的结果区间。
List<int[]> res = new ArrayList<>();
// [start, rightmostRightBound] 是当前正在合并的暂存区间。
int start = intervals[0][0];
int rightmostRightBound = intervals[0][1];
// 从第二个区间开始,依次与当前暂存区间比较。
for (int i = 1; i < intervals.length; i++) {
int currentStart = intervals[i][0];
int currentEnd = intervals[i][1];
// 当前区间在暂存区间右侧,且没有重叠。
if (currentStart > rightmostRightBound) {
// 暂存区间已经确定,收集到结果中。
res.add(new int[]{start, rightmostRightBound});
// 当前区间成为新的暂存区间。
start = currentStart;
rightmostRightBound = currentEnd;
} else {
// 两个区间重叠或端点相接。
// 左边界无需更新,只扩展到更靠右的右边界。
rightmostRightBound = Math.max(rightmostRightBound, currentEnd);
}
}
// 最后一个暂存区间没有后续区间触发收集,因此循环结束后补上。
res.add(new int[]{start, rightmostRightBound});
// List<int[]> 转为题目要求的 int[][]。
return res.toArray(new int[res.size()][]);
}
}
10. 复杂度分析
时间复杂度:
text
O(n log n)
排序需要 O(n log n),后续遍历需要 O(n),整体由排序主导。
空间复杂度:
text
O(n)
res 最坏情况下会保存 n 个不重叠区间。
如果不计算返回结果占用的空间,合并过程本身只使用了几个普通变量;不过 Java 的对象数组排序实现可能还会使用额外辅助空间,因此不宜简单断言为 O(log n)。
11. 总结
这道题的核心是:
text
先按左边界排序,再从左到右维护一个正在合并的区间。
Java 写法中有两个清晰状态:
text
[start, rightmostRightBound]:当前暂存、仍可能继续扩展的合并区间。
res:已经彻底确定、不会再变化的结果区间。
处理一个新区间时:
text
重叠:更新当前暂存区间的右边界。
不重叠:收集当前暂存区间,把新区间作为新的暂存区间。
最后别忘了:
text
循环结束后,把最后一个暂存区间加入 res。
可以把整题记成一句话:
text
排序后,能合并就扩右边界;不能合并就收集旧区间,并从新区间重新开始。