区间问题:一套排序,解决一类题

先排好,再扫一遍——区间问题的答案往往就在这两步里。


写在前面

区间问题可以说是 LeetCode 里自成一体的一个题系。题目说法各异——合并区间、插入区间、无重叠区间、射气球——但大体上都绕不开两类排序思路:

  1. 合并 / 插入类:按左端点排序,从左到右扫描,维护一个「当前区间」
  2. 选择 / 贪心类:按右端点排序,优先保留结束更早的区间

理解了“该按哪一端排序”,这类题基本都能自己推出来,不需要死记模板。


区间的基本概念

一个区间用 [l, r] 表示从 l 到 r 的连续范围。两个区间的关系只有三种:

1
2
3
4
5
6
7
8
区间 A: [───────]
区间 B: [────] → 不相交(B 在 A 右边)

区间 A: [───────]
区间 B: [────] → 相交(有重叠部分)

区间 A: [───────────]
区间 B: [─────] → A 包含 B

判断两个区间 [a, b][c, d] 是否相交(含端点):

1
2
相交条件:a <= d && c <= b
不相交条件:b < c || d < a

这个条件记住,后面反复用到。


例题讲解

例题一:合并区间(LeetCode 56)

给出一组区间,合并所有重叠的区间,返回合并后的结果。

输入:[[1,3],[2,6],[8,10],[15,18]]
输出:[[1,6],[8,10],[15,18]]

问题分析

多个区间可能互相重叠,需要把能连成一片的都合并掉。难点在于区间顺序是乱的,不排序很难判断哪些能合并。

思路

先按左端点排序,然后从左往右扫,维护一个「当前正在合并的区间」[cur_l, cur_r]

  • 如果下一个区间的左端点 ≤ cur_r,说明有重叠,把右端点更新为 max(cur_r, next_r)
  • 否则,当前区间合并完毕,把它存入结果,开始处理下一个
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
vector<vector<int>> merge(vector<vector<int>>& intervals) {
// 按左端点排序
sort(intervals.begin(), intervals.end());

vector<vector<int>> res;
int cur_l = intervals[0][0];
int cur_r = intervals[0][1];

for (int i = 1; i < (int)intervals.size(); i++) {
int l = intervals[i][0], r = intervals[i][1];
if (l <= cur_r) {
// 有重叠,扩展右端点
cur_r = max(cur_r, r);
} else {
// 无重叠,保存当前区间,移动到下一个
res.push_back({cur_l, cur_r});
cur_l = l;
cur_r = r;
}
}
res.push_back({cur_l, cur_r}); // 别忘了最后一个区间

return res;
}

执行过程

1
2
3
4
5
6
7
8
9
10
输入(排序后):[1,3] [2,6] [8,10] [15,18]

初始:cur = [1, 3]

i=1: [2,6],2 <= 3,有重叠 → cur = [1, max(3,6)] = [1, 6]
i=2: [8,10],8 > 6,无重叠 → 保存 [1,6],cur = [8, 10]
i=3: [15,18],15 > 10,无重叠 → 保存 [8,10],cur = [15, 18]
结束:保存 [15, 18]

结果:[[1,6], [8,10], [15,18]]

复杂度

  • 时间:O(n log n),排序主导
  • 空间:O(n),结果数组

例题二:插入区间(LeetCode 57)

给定一组已经排好序且不重叠的区间,插入一个新区间,返回合并后的结果。

输入:intervals = [[1,2],[3,5],[6,7],[8,10],[12,16]]newInterval = [4,8]
输出:[[1,2],[3,10],[12,16]]

问题分析

区间已经有序,不需要重新排序。插入新区间后,它可能和一批已有区间重叠,需要统一合并。

思路

分三段处理:

  1. 把所有在新区间左边(完全不重叠)的区间直接放进结果
  2. 与新区间有重叠的区间,全部合并进去
  3. 剩下在右边的区间直接放进结果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
vector<vector<int>> insert(vector<vector<int>>& intervals,
vector<int>& newInterval) {
vector<vector<int>> res;
int i = 0, n = intervals.size();

// 第一段:在新区间左边,完全不重叠
while (i < n && intervals[i][1] < newInterval[0]) {
res.push_back(intervals[i++]);
}

// 第二段:有重叠,统一合并
while (i < n && intervals[i][0] <= newInterval[1]) {
newInterval[0] = min(newInterval[0], intervals[i][0]); // 扩展左端点
newInterval[1] = max(newInterval[1], intervals[i][1]); // 扩展右端点
i++;
}
res.push_back(newInterval); // 合并后的新区间入队

// 第三段:在新区间右边,完全不重叠
while (i < n) {
res.push_back(intervals[i++]);
}

return res;
}

执行过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
intervals = [[1,2],[3,5],[6,7],[8,10],[12,16]]
newInterval = [4, 8]

第一段:
[1,2]:2 < 4,在左边 → 直接放入结果
[3,5]:5 >= 4,停止

第二段(有重叠):
[3,5]:3 <= 8,重叠 → new = [min(4,3), max(8,5)] = [3, 8]
[6,7]:6 <= 8,重叠 → new = [min(3,6), max(8,7)] = [3, 8]
[8,10]:8 <= 8,重叠 → new = [min(3,8), max(8,10)] = [3, 10]
[12,16]:12 > 8,停止
放入 [3, 10]

第三段:
[12,16] 直接放入

结果:[[1,2], [3,10], [12,16]]

复杂度

  • 时间:O(n),只扫一遍
  • 空间:O(n)

例题三:无重叠区间(LeetCode 435)

给定一组区间,找出需要移除的最少区间数量,使得剩余区间互不重叠。

输入:[[1,2],[2,3],[3,4],[1,3]]
输出:1(移除 [1,3]

问题分析

移除最少,等价于保留最多不重叠的区间,然后用总数减去保留数。

贪心思路

这是经典的「区间调度」问题。贪心策略是按右端点排序,每次在不冲突的前提下优先选右端点最小的区间——这样给后续区间留的空间最多。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int eraseOverlapIntervals(vector<vector<int>>& intervals) {
// 按右端点排序(贪心关键)
sort(intervals.begin(), intervals.end(),
[](const vector<int>& a, const vector<int>& b) {
return a[1] < b[1];
});

int keep = 1; // 至少保留第一个
int last_r = intervals[0][1]; // 当前已选区间的右端点

for (int i = 1; i < (int)intervals.size(); i++) {
if (intervals[i][0] >= last_r) {
// 不冲突,可以保留
keep++;
last_r = intervals[i][1];
}
// 冲突就跳过(相当于移除)
}

return (int)intervals.size() - keep; // 总数 - 保留数 = 移除数
}

为什么按右端点排序,而不是左端点?

1
2
3
4
5
6
7
8
9
10
11
12
反例:按左端点排序
区间:[1, 100], [2, 3], [4, 5]
↑ 左端点最小但右端点极大,一旦选它,后面全冲突

按右端点排序:
区间:[2, 3], [4, 5], [1, 100]
先选 [2,3],再选 [4,5],[1,100] 冲突跳过
→ 保留 2 个,移除 1 个(正确)

按左端点排序:
先选 [1,100],[2,3] 冲突,[4,5] 冲突
→ 保留 1 个,移除 2 个(错误)

执行过程

1
2
3
4
5
6
7
8
9
输入(按右端点排序后):[1,2] [2,3] [1,3] [3,4]

初始:keep=1,last_r=2

i=1: [2,3],2 >= 2,不冲突 → keep=2,last_r=3
i=2: [1,3],1 < 3,冲突 → 跳过
i=3: [3,4],3 >= 3,不冲突 → keep=3,last_r=4

移除数 = 4 - 3 = 1

复杂度

  • 时间:O(n log n)
  • 空间:O(1)

例题四:用最少数量的箭引爆气球(LeetCode 452)

每个气球用区间 [x_start, x_end] 表示其水平范围,一支箭可以引爆所有覆盖该 x 坐标的气球。
求最少需要多少支箭。

输入:[[10,16],[2,8],[1,6],[7,12]]
输出:2

问题分析

这和「保留最多不重叠区间」是同一类贪心,只是问法从「移除多少」变成了「需要几组」。每一支箭对应一组互相重叠的区间,最少箭数 = 最多不重叠的区间组数。

注意这道题里端点相等也算重叠(箭从 x=6 能同时引爆 [1,6][6,8]),判断条件稍有不同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int findMinArrowShots(vector<vector<int>>& points) {
// 按右端点排序
sort(points.begin(), points.end(),
[](const vector<int>& a, const vector<int>& b) {
return a[1] < b[1];
});

int arrows = 1;
int last_r = points[0][1]; // 当前这支箭的位置(右端点)

for (int i = 1; i < (int)points.size(); i++) {
if (points[i][0] > last_r) {
// 这个气球在当前箭射不到的地方,需要新箭
arrows++;
last_r = points[i][1];
}
// points[i][0] <= last_r,当前箭能引爆,不用新箭
}

return arrows;
}

和 LeetCode 435 的细微差别

需要新箭/不重叠条件 端点相等时
LeetCode 435 intervals[i][0] >= last_r 端点相等算不重叠
LeetCode 452 points[i][0] > last_r 端点相等算重叠(同一支箭能射到)

执行过程

1
2
3
4
5
6
7
8
9
输入(按右端点排序后):[1,6] [2,8] [7,12] [10,16]

初始:arrows=1,last_r=6(第一支箭射 x=6)

i=1: [2,8],2 <= 6,当前箭能射到 → 不用新箭
i=2: [7,12],7 > 6,射不到 → arrows=2,last_r=12(第二支箭射 x=12)
i=3: [10,16],10 <= 12,当前箭能射到 → 不用新箭

结果:2 支箭

复杂度

  • 时间:O(n log n)
  • 空间:O(1)

容易踩的坑

1. 合并区间别漏掉最后一个

扫描结束时,最后一个「当前区间」还没被放入结果,一定要在循环结束后手动 push_back

1
2
// 循环结束,最后一个区间还没存
res.push_back({cur_l, cur_r}); // 不能省

2. 合并时右端点要取 max

相交的两个区间合并,右端点取 max,不是直接覆盖。因为可能遇到「大区间包含小区间」的情况:

1
2
3
[1, 10] 和 [2, 5]
cur_r 已经是 10,如果直接用 5 覆盖就错了
→ cur_r = max(10, 5) = 10 ✓

3. 按右端点排序 vs 按左端点排序

两类问题分别对应不同的排序方式:

问题类型 排序依据 原因
合并区间 / 插入区间 左端点升序 从左到右处理,保证顺序
保留最多 / 最少箭数 右端点升序 贪心:右端点小的先选,给后面留更多空间

4. 端点相等的判断

  • LeetCode 56(合并):l <= cur_r 时合并,端点相等算重叠
  • LeetCode 435(不重叠):l >= last_r 时不重叠,端点相等可以相邻
  • LeetCode 452(射气球):l > last_r 时才是新区间,端点相等一支箭能射到

三题的边界条件不一样,要仔细看题意。


总结

mindmap
  root((区间问题))
    核心思想
      按左端点排序
        合并/插入类问题
      按右端点排序
        保留最多不重叠
        最少箭数/组数
    经典题目
      LeetCode 56 合并区间
      LeetCode 57 插入区间
      LeetCode 435 无重叠区间
      LeetCode 452 最少箭数
    常见陷阱
      合并后漏掉最后区间
      右端点用max而非覆盖
      端点相等的边界

各题核心对比

题目 排序方式 扫描逻辑 时间复杂度
56 合并区间 左端点升序 有重叠就合并,无重叠就保存 O(n log n)
57 插入区间 无需排序(已有序) 三段处理:左/重叠/右 O(n)
435 无重叠区间 右端点升序 不冲突就保留,冲突就跳过 O(n log n)
452 最少箭数 右端点升序 射不到才加新箭 O(n log n)

区间问题的魅力在于,看起来各不相同,但底层都是同一套思路。刷完这几道,之后再见到类似的题,基本上排一下序就能想清楚了。


推荐阅读