目录
[1. 什么是区间?](#1. 什么是区间?)
[2. 什么是"重叠"?](#2. 什么是“重叠”?)
[3. 最核心的痛点是什么?](#3. 最核心的痛点是什么?)
[编写 C++ 代码](#编写 C++ 代码)
[1. 排序:建立数轴秩序](#1. 排序:建立数轴秩序)
[2. 线性扫描:局部决策](#2. 线性扫描:局部决策)
以数组 intervals 表示若干个区间的集合,其中单个区间为 intervals[i] = [starti, endi] 。请你合并所有重叠的区间,并返回 一个不重叠的区间数组,该数组需恰好覆盖输入中的所有区间 。
示例 1:
输入:intervals = [[1,3],[2,6],[8,10],[15,18]]
输出:[[1,6],[8,10],[15,18]]
解释:区间 [1,3] 和 [2,6] 重叠, 将它们合并为 [1,6].
示例 2:
输入:intervals = [[1,4],[4,5]]
输出:[[1,5]]
解释:区间 [1,4] 和 [4,5] 可被视为重叠区间。
示例 3:
输入:intervals = [[4,7],[1,4]]
输出:[[1,7]]
解释:区间 [1,4] 和 [4,7] 可被视为重叠区间。
(来源:Leecode)
回归本质,拆解"区间"
1. 什么是区间?
在数轴上,一个区间 [start, end] 代表一段连续的范围。
2. 什么是"重叠"?
假设有两个区间 A[s1, e1] 和 B[s2, e2]。它们重叠的本质真理是:
其中一个区间的起点,落在另一个区间的范围之内。
3. 最核心的痛点是什么?
如果数组是乱序的(如示例 3:[[4,7], [1,4]]),我们很难判断当前区间该和谁合并。为了判断 [4,7] 是否能合并,我们需要扫描整个数组去找谁的结尾大于等于 4。
- 结论 :无序是效率的敌人。在数轴上处理问题,天然的物理顺序是"从左到右"。
第一性原理推导逻辑
如果我们把所有区间按起点排好序,会发生什么?
一旦按起点排序,对于连续的两个区间 A 和 B,我们能确定 s1 <= s2。此时,重叠的情况被极度简化了:
情况 A:s2 <= e1。说明 B 的起点在 A 的范围内。新的右边界是这两个区间右边界的最大值,即 max(e1, e2)。
情况 B:s2 > e1。说明 B 已经完全超出了 A 的范围。A 已经"定型"了,再往后的区间起点只会比 s2 更大,绝对不可能再和 A 重叠。我们可以把 A 存入结果。
编写 C++ 代码
1. 排序:建立数轴秩序
我们需要让区间按 start 升序排列。
cpp
#include <vector>
#include <algorithm>
using namespace std;
vector<vector<int>> merge(vector<vector<int>>& intervals) {
if (intervals.empty()) return {};
// 第一步:基于第一性原理的"秩序化"
// 按区间的起点进行升序排序
sort(intervals.begin(), intervals.end(), [](const vector<int>& a, const vector<int>& b) {
return a[0] < b[0];
});
vector<vector<int>> merged;
2. 线性扫描:局部决策
我们维护一个"当前正在合并"的区间,拿它和下一个区间比较。
cpp
// 先把第一个区间放进去作为"种子"
merged.push_back(intervals[0]);
for (int i = 1; i < intervals.size(); ++i) {
// 取出结果集中最后一个已经合并好的区间(当前的基准)
int& curr_start = merged.back()[0];
int& curr_end = merged.back()[1];
// 下一个区间的起点和终点
int next_start = intervals[i][0];
int next_end = intervals[i][1];
// 核心逻辑判断:
if (next_start <= curr_end) {
// 情况 A:重叠了,更新当前基准的右边界
// 为什么取 max?因为可能出现 [[1,10], [2,5]] 这种包含关系
curr_end = max(curr_end, next_end);
} else {
// 情况 B:没重叠,当前的基准彻底完成,开启一个新区间
merged.push_back(intervals[i]);
}
}
return merged;
}
深度复盘
为什么排序是第一步?
如果不排序,我们需要比较 n² 次(每两个区间都比一下)。排序后,我们只需要比较相邻的区间,时间复杂度从 O(n²) 降到了 O(n log n)(排序的代价)+O(n)(扫描的代价)。
为什么只比较右边界?
因为我们已经按左边界排序了。对于后面进来的区间,它的左边界一定大于或等于当前区间的左边界。所以,右边界成了唯一的变量,决定了这段"连续能量"能否延续。
面试回答
在面试中讲解这道题时,最忌讳直接说"先排序再合并"。面试官想看的是你如何从无序中建立秩序。
你可以按照以下三个层级来组织你的表达:
第一步:明确物理模型(寻找矛盾点)
"首先,合并区间的本质是处理数轴上的覆盖问题。
如果区间是乱序的,比如 [[4,7], [1,4]],我无法立即知道当前区间 [4,7] 应该和谁合并,除非我扫描全集。这说明**'无序'是导致算法低效的根本原因**。
为了简化问题,我的第一直觉是建立秩序:将所有区间按照左端点(Start)进行升序排列。这样我们就把一个复杂的空间问题,转化为了一个在数轴上从左向右移动的线性问题。"
第二步:推导合并逻辑(分类讨论)
"在排好序的基础上,我们只需要观察相邻 的两个区间。假设当前合并后的区间是 A,下一个待处理的区间是 B。
由于我们排过序,B 的起点一定大于等于 A 的起点。那么它们的关系只剩两种可能:
-
重叠(Overlapping) :
B的起点 <=A的终点。此时它们可以连成一片。新的终点取max(A.end, B.end)。之所以取max,是因为要处理包含关系(比如[1, 10]和[2, 5])。 -
断开(Disjoint) :
B的起点 >A的终点。这意味着A已经完全封闭了,后面不可能再有区间能和A合并(因为后面的起点只会更靠后)。我们可以把A放入结果集,并把B当作下一个待合并的起点。"
第三步:总结效率与实现
"最后,在代码实现上,我可以使用一个结果数组(或 vector)。每次拿输入数组里的新区间与结果数组里的最后一个区间进行对比:
-
如果重叠,直接原地修改结果数组最后一个元素的
end。 -
如果不重叠,直接
push_back进去。
这个算法的时间复杂度主要取决于排序,是 O(nlog n);空间复杂度取决于排序所需的栈空间或结果数组,是 O(n)。"