所有代码详见:https://github.com/jingminglake/Leetcode
总体思路
一、为什么单独立一篇
区间题在之前的笔记里散落在五篇,而且分类依据不统一——有的按数据形态分,有的按用了什么工具分,有的按方法分:
| 题 | 之前在哪 | 按什么分的类 |
|---|---|---|
| 56、57、763 | 《总结(一)》数组和字符串 | 数据形态 |
| 435、452、253 | 《总结(十五)》贪心法 | 方法(253 其实归错了) |
| 436 | 《总结(三)》二分搜索 | 工具 |
| 1235 | 《总结(十三)》动态规划 | 方法 |
| 218 | 《总结(十一)》堆、栈和队列 | 工具 |
一个连贯的家族被切成五块,结果是:遇到新的区间题,不知道该往哪个抽屉里找。
本篇按「这道题在问什么」重新组织。原文都保留在各自篇里,本篇给出统一的判据和心智模型,并补上之前缺的几道。
二、先问一句:这道题在问什么
几乎所有区间题都落在下面六类之一。先认出问的是哪一类,方法就定了。
| 问什么 | 方法本质 | 代表题 |
|---|---|---|
| 合并 / 插入一个区间 | 排序 + 三段式扫描 | 56、57 |
| 最多能留几个互不重叠的 | 真贪心(按右端排序 + 交换论证) | 435、452 |
| 某一刻最多同时叠了几个 | 扫描线 / 差分计数 | 252、253、1094 |
| 轮廓 / 极值随扫描如何变化 | 扫描线 + 堆 | 218 |
| 区间带权,求最优选择 | DP + 二分 | 1235 |
| 两个有序区间列表求交集 | 双指针 | 986 |
最容易混的是第二类和第三类,它们问的是相反的量:
- 第二类求最大不重叠数——能挑出多少个互不相交的(挑出来的越多越好)
- 第三类求最大同时重叠数——某一点上最多压了几层(这是个客观量,没有可挑的)
三、前置动作:排序,以及按哪一端排
区间题几乎都要先排序,但按左端还是按右端,取决于转移需要什么信息:
- 按左端排:处理「合并 / 插入」时用。因为合并的判断是「下一个的起点是否落在当前已合并段的终点之前」,需要起点有序。
- 按右端排:处理「最多留几个不重叠」时用。因为贪心的依据是「结束得越早,给后面留的空间越大」。
- 按右端排:1235 这种带权 DP 也用。因为转移要查询「在我开始之前已经全部结束的那些区间」,这是关于结束时间的查询,只有按结束时间排序,二分才成立。
- 不排序,改成拆事件:第三类(求最大重叠数)根本不需要保持区间完整,把每个区间拆成「起点 +1、终点 −1」两个事件,按时间排序即可。
四、等号归属:端点相接算不算重叠
这是整个区间家族最高频的坑,几乎每道题都要单独确认一次,而且各题约定不同:
| 题 | [1,2] 与 [2,3] |
代码里的判断 |
|---|---|---|
| 56 合并区间 | 算重叠(要合成 [1,3]) |
next.start <= cur.end 就合并 |
| 57 插入区间 | 算重叠(要合并) | intervals[i][0] <= end 就吞 |
| 435 最少删几个 | 不算重叠(两个都能留) | s >= last 就保留 |
| 452 最少几箭 | 算重叠(一箭可穿两个) | s > last 才要新箭 |
| 252/253 会议室 | 不冲突(可以共用) | s >= 上一个 end 就复用 |
435 和 452 只差一个等号,结论却相反。所以做题时第一件事是拿 [1,2] 和 [2,3] 去问一遍题目,别靠记忆。
在扫描线写法里,这个等号表现为同一时刻事件的排序优先级:相接算不冲突,就要让 −1(结束)排在 +1(开始)前面。
具体题目
类型一:合并与插入
56. Merge Intervals / 57. Insert Interval
按左端排序后三段式扫描:完全在左边的原样搬运、有交集的一整段吞掉并边吞边撑大、完全在右边的原样搬运。
关键是三段的循环条件都写成 i < n && ...,每一段都允许执行 0 次,于是「目标在最前 / 在最后 / 夹在中间 / 数组为空」四种边界全部由「循环跑 0 次」自动覆盖,不需要单独讨论。
详细推导见《总结(一)》57 题。
类型二:最多留几个互不重叠 —— 真贪心
435. Non-overlapping Intervals / 452. Minimum Number of Arrows
按结束时间升序排序,然后能留就留。正确性靠交换论证:一定存在一个最优解包含「结束最早」的那个区间,因为把最优解里结束最早的那个换成全局结束最早的那个,不会破坏互不重叠,个数也不变。
直觉就是一句话:结束得越早,给后面留的空间越大。
两题只差一个等号(相接算不算重叠),详见《总结(十五)》。
类型三:某一刻最多叠了几个 —— 扫描线 / 差分计数
252. Meeting Rooms
题意:给出会议时间区间数组,判断一个人能否参加全部会议。
解:按开始时间排序,然后看相邻两个是否冲突即可。
bool canAttendMeetings(vector<vector<int>>& intervals) {
sort(intervals.begin(), intervals.end());
for (int i = 1; i < intervals.size(); i++)
if (intervals[i][0] < intervals[i-1][1]) return false; // 严格小于才算冲突
return true;
}
用严格的 <,意味着端点相接不算冲突(上一个会 10 点结束、下一个 10 点开始,能参加)。
本质上它是 253 的判定版:能参加全部会议,等价于「最大同时重叠数 <= 1」。
253. Meeting Rooms II
题意:给出会议时间区间数组,求最少需要几间会议室。
解:先要看清一件事——
答案 = 最大同时重叠区间数
两个方向一夹就能证明:下界是某一刻有 k 个会同时进行,那一刻就必须有 k 间房;上界是 k 间房一定够用(按时间扫描分配即可构造出来)。下界等于上界,所以答案就是它。
这不是贪心题。虽然常被归到贪心(”复用最早结束的房间”听起来很贪心),但换成复用任意一个空闲房间,答案完全一样——不存在会影响答案的选择,所以不存在贪心选择,也就不需要交换论证。堆只是一个判断「有没有房间空着」的高效工具。
对比 435:选 [1,100] 还是选 [2,3],答案真的不同(1 个 vs 2 个),那才是真贪心。
写法一:堆里装「当前正在开的会」(建议先想这个)
这是最贴近题目定义的模型:堆里存正在进行中的会议的结束时间,堆的大小就是当前重叠数,答案是这个大小在整个过程中的峰值。
int minMeetingRooms(vector<vector<int>>& intervals) {
sort(intervals.begin(), intervals.end()); // 按开始时间
priority_queue<int, vector<int>, greater<int>> pq; // 堆里存结束时间
int res = 0;
for (auto& it : intervals) {
while (!pq.empty() && pq.top() <= it[0]) pq.pop(); // ① 先清掉已结束的
pq.push(it[1]); // ② 再把自己加进来
res = max(res, (int)pq.size()); // ③ 再记录峰值
}
return res;
}
三步顺序不能乱:若先压入再清理,[[1,2],[3,4]] 会先得到 {2,4}、size=2,答案算成 2(正确是 1)。必须先把过期的清掉,再数当前重叠数。
复杂度仍是 O(NlogN)。内层那个 while 单次可能弹很多个,但每个会议一生只进堆一次、出堆至多一次,所以整个过程弹堆总次数不超过 N,摊还下来每轮 O(1) 次。这个「单次可能很贵,但总量有上界」的论证,和滑动窗口里「left/right 只增不减,总计 O(2N)」、单调栈里「每个元素进栈一次出栈一次」是同一个摊还论证的第三次出现。
这个写法的好处是语义一路透明:堆 = 当前在开的会,堆大小 = 当前重叠数,答案 = 峰值,每一步都直接对应「最大同时重叠数」这个定义,不需要任何额外的转换。
写法二:堆里装「已经开出的房间」(代码更短,但语义拐了个弯)
int minMeetingRooms(vector<vector<int>>& intervals) {
sort(intervals.begin(), intervals.end()); // 按开始时间
priority_queue<int, vector<int>, greater<int>> pq; // 堆里存结束时间
for (auto& it : intervals) {
if (!pq.empty() && it[0] >= pq.top()) pq.pop(); // 相接可复用,用 >=
pq.push(it[1]);
}
return pq.size();
}
比写法一少了一个 while、少了一个 res 变量,但代价是语义不再直白。它有五个环节必须都想通,少一个就会觉得”缺了点什么”。
(0)堆里存的是什么——这是最容易第一眼没看见的一点
堆里每个元素 = 一间正在被占用的房间,值是它什么时候空出来。所以:
堆的大小 = 到目前为止一共开了几间房
return pq.size() 不是”剩下几个会没排”,是”开了几间房”。这句话是整个解法的语义基础,没建立起来的话,后面每一步都会觉得别扭。
(1)为什么只需要检查堆顶一个
堆顶是最早结束的那间。如果连它都还没结束(start < 堆顶),那其余房间结束得更晚,必然也都还占着。所以不必遍历所有房间。这正是堆把 O(N^2) 降到 O(NlogN) 的原因。
(2)为什么每次最多只弹一个
假设此刻有三间房都空了,为什么只弹一个?因为我们只要安排一个会,只需要一间房。
那剩下两个”已结束但没被弹出”的条目留在堆里,会不会让答案多算?不会:
- 它们代表的房间确实已经被分配过了,本来就该计入总数;
- 由于开始时间单调不减,它们在后面的轮次里仍然可以被复用(堆顶总是最小的 end,只要还
<= start就会被弹)。
所以「懒惰地一次只弹一个」既不多算也不漏用。三间房空着、接着来三个会,就是三轮各弹一个,全部复用上,堆大小不变。
(3)为什么最终 size 就是答案,不用记录过程中的最大值
每一轮最多弹 1 个、必定压 1 个,所以堆的大小只增不减。既然单调不减,终值就是最大值,不需要额外维护 res = max(res, pq.size())。
这正是写法二省掉 res 变量的秘诀:它把「记录历史最大值」偷换成了「让堆大小自己只增不减」。省了一个变量,代价是语义从「当前重叠数」变成了「已开房间数」。
(4)桥:为什么「开了几间房」等于「最大同时重叠数」
这是把本题的两种说法(模拟分配 vs 求最大重叠数)对上的那一环。
堆大小什么时候 +1?只在 start < pq.top() 的时候——也就是连最早结束的那间都还没空。而堆顶是最小的 end,所以:
start < 堆顶(最小 end)
等价于 start 小于堆里所有的 end
等价于 已开的每一间房此刻都还在用
等价于 此刻同时进行的会议数 = 已开房间数 + 1 ← 创了新高
所以堆大小每 +1,就精确对应一次「同时重叠数刷新纪录」。反过来,能复用(start >= 堆顶)时堆大小不变,说明重叠数没有超过历史最高。
堆大小的增长史,就是最大重叠数的刷新史。终值 = 刷新次数 = 最大同时重叠数 = 答案。
也就是说,堆解法并不是在解另一个问题,它是一边分配房间、一边把最大重叠数数出来。
(5)>= 不能写成 >
[[1,5],[5,10]] 应该共用一间,答案是 1;写成 > 就会判成要新开一间,答案变成 2。
另外,为什么必须按开始时间排序:因为要按时间顺序处理会议的到达。判断”此刻有没有房间空着”,前提是”此刻”有意义——若不排序,后来处理的会议可能开始得更早,start >= pq.top() 这个比较就失去含义了。而且正是「start 单调不减」这个性质,保证了上面第(2)点里那句”残留条目后面仍可被复用”。排序不只是为了顺序,它是第(2)点成立的前提。
写法三:事件排序(扫描线)
int minMeetingRooms(vector<vector<int>>& intervals) {
vector<pair<int,int>> ev;
for (auto& it : intervals) {
ev.push_back({it[0], +1}); // 开始,占用 +1
ev.push_back({it[1], -1}); // 结束,释放 -1
}
sort(ev.begin(), ev.end()); // 同一时刻,-1 自动排在 +1 前(因为 -1 < 1)
int cur = 0, res = 0;
for (auto& [t, d] : ev) { cur += d; res = max(res, cur); }
return res;
}
这个写法把「答案 = 最大同时重叠数」直接表达出来了,而且同一时刻 −1 排在 +1 之前恰好就是「相接可复用」那个等号约定——不用额外处理。
写法一其实就是写法三的堆实现:那个 while 弹出对应「处理掉所有 −1 事件」,push 对应一个 +1 事件,res 对应扫描线里 cur 的峰值。三种写法本质是一件事,区别只在语义摆在明面上还是藏起来。
也可以把起点和终点各排一个数组,双指针推进:starts[i] < ends[j] 时 cur++,否则 cur--。这里用严格 < 是同一个道理。
1094. Car Pooling
题意:车的容量为 capacity,给出若干行程 [人数, 上车点, 下车点],车只能向前开,判断能否完成所有行程。
解:坐标范围很小(0 到 1000),直接用差分数组,不需要排序或堆。
bool carPooling(vector<vector<int>>& trips, int capacity) {
int diff[1001] = {0};
for (auto& t : trips) {
diff[t[1]] += t[0]; // 上车点加人
diff[t[2]] -= t[0]; // 下车点减人
}
int cur = 0;
for (int i = 0; i <= 1000; i++) {
cur += diff[i];
if (cur > capacity) return false;
}
return true;
}
在下车点 t[2] 处就把人减掉,正好体现「到站即下车、同一点可以先下后上」,又是那个等号约定。
差分数组和事件排序的关系:两者是同一件事的两种实现。坐标范围小且是整数就用差分数组,O(值域);坐标范围大或是浮点就拆事件排序,O(NlogN)。
类型四:轮廓随扫描变化 —— 扫描线 + 堆
218. The Skyline Problem
和 253 是同一个家族:沿 x 轴扫描,用堆维护「当前活跃的所有高度」,每次堆顶变化就产生一个关键点。区别是 253 只关心活跃集合的大小,218 关心活跃集合的最大值。
详见《总结(十一)》218 题。
类型五:区间带权求最优 —— DP + 二分
1235. Maximum Profit in Job Scheduling
按结束时间排序,dp[i] 表示只考虑前 i 个工作的最大利润,转移时用 upper_bound 找出「结束时间不超过当前开始时间的工作数 k」,然后 dp[i] = max(dp[i-1], dp[k] + profit)。
这题与 435 的分水岭只有一个字:权重。435 每个区间等权(删一个就是删一个),贪心成立;1235 带 profit,贪心立刻崩——一个 profit=100 的长活能打过一堆 profit=1 的小活。
等权则贪心,带权则 DP。
反过来看,435 就是 1235 中所有 profit 都等于 1 的特例。详见《总结(十三)》。
类型六:两个有序区间列表求交 —— 双指针
986. Interval List Intersections
题意:给出两个各自有序且内部不重叠的区间列表,求它们的交集列表。
解:双指针各指一个列表。两个区间的交集是 [max(起点), min(终点)],如果 起点 <= 终点 就是一个有效交集。然后移动结束更早的那个指针——因为它不可能再和对面后面的区间有交集了。
vector<vector<int>> intervalIntersection(vector<vector<int>>& A, vector<vector<int>>& B) {
vector<vector<int>> res;
int i = 0, j = 0;
while (i < A.size() && j < B.size()) {
int lo = max(A[i][0], B[j][0]);
int hi = min(A[i][1], B[j][1]);
if (lo <= hi) res.push_back({lo, hi});
if (A[i][1] < B[j][1]) i++; else j++; // 结束早的那个先退休
}
return res;
}
「移动结束更早的那个」这个动作,和 11、42 里「移动矮的那根」是同一种论证:被移动的那个已经把它的潜力用尽了(它的终点是瓶颈,对面再往后都够不着它),可以安全退休。见《总结(二十二)》第九节。
方法归类:贪心、计数、还是 DP
区间题很容易被笼统地叫成「贪心」,但三类的方法本质不同,判据是「有没有会影响答案的选择」:
| 有没有选择 | 要不要交换论证 | 代表 | |
|---|---|---|---|
| 真贪心 | 有,选哪些留下会改变答案 | 要 | 435、452 |
| 转化 + 计数 | 没有,答案是个客观量 | 不需要 | 252、253、1094、218 |
| DP | 有,但局部最优不成立 | 不适用,要枚举状态 | 1235 |
判别方法很简单:问自己「换一个做法,答案会变吗」。
- 435 里换一个区间留下 → 答案会变 → 真贪心
- 253 里换一间空闲房间用 → 答案不变 → 不是贪心,只是在数一个量
速查表
| 题号 | 题目 | 问什么 | 方法 | 排序依据 | 相接算重叠吗 | 详见 |
|---|---|---|---|---|---|---|
| 56 | Merge Intervals | 合并 | 三段式 | 左端 | 算 | 总结(一) |
| 57 | Insert Interval | 插入 | 三段式 | 左端 | 算 | 总结(一) |
| 435 | Non-overlapping Intervals | 最少删几个 | 真贪心 | 右端 | 不算 | 总结(十五) |
| 452 | Minimum Arrows | 最少几箭 | 真贪心 | 右端 | 算 | 总结(十五) |
| 252 | Meeting Rooms | 能否全参加 | 相邻比较 | 左端 | 不算 | 本篇 |
| 253 | Meeting Rooms II | 最少几间房 | 计数(堆/扫描线) | 左端/拆事件 | 不算 | 本篇 |
| 1094 | Car Pooling | 会不会超载 | 差分数组 | 不排序 | 不算 | 本篇 |
| 218 | Skyline | 轮廓关键点 | 扫描线 + 堆 | 拆事件 | — | 总结(十一) |
| 1235 | Job Scheduling | 最大报酬 | DP + 二分 | 右端 | 不算 | 总结(十三) |
| 986 | Interval Intersections | 求交集 | 双指针 | 已有序 | 算 | 本篇 |
| 436 | Find Right Interval | 找右邻 | 排序 + 二分 | 左端 | — | 总结(三) |
蓝图三问
看到一道新的区间题,依次问:
- 在问什么? 合并/最多留几个不重叠/最多叠几层/轮廓/带权最优/求交集。认出类别,方法就定了。
- 按哪一端排序? 合并按左端;贪心和带权 DP 按右端;求最大重叠数干脆拆成事件不保留区间。
- 相接算不算重叠? 拿
[1,2]和[2,3]去问一遍题目,定下是<还是<=。这一族每道题都要单独确认,别靠记忆。