题目描述
以数组 intervals 表示若干个区间的集合,其中单个区间为 intervals[i] = [start_i, end_i]。请你合并所有重叠的区间,并返回 一个不重叠的区间数组,该数组需恰好覆盖输入中的所有区间。
输入
二维整数数组 intervals,每个元素是 [start_i, end_i] 表示一个区间
输出
合并重叠后的区间数组
约束条件
1 <= intervals.length <= 10^4intervals[i].length == 20 <= start_i <= end_i <= 10^4
示例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] 在端点 4 处相接,视为重叠区间,合并为 [1,5]。
解题思路
这道题的核心是判断两个区间是否重叠,以及如何高效地将重叠的区间合并。两个区间 和 重叠的充要条件是:
换句话说——只要一个区间的起点不超过另一个区间的终点,它们就有交集。合并后的区间就是 。
💡 一句话概括:把区间按起点排序,然后从左到右扫描——当前区间能和上一个合并就合并,不能就”封存”上一个,开始一个新的。
从暴力枚举出发
最朴素的想法是:反复扫描所有区间对,发现重叠就合并,合并后重新扫描,直到没有任何区间可以合并为止:
class Solution: def merge(self, intervals: List[List[int]]) -> List[List[int]]: if not intervals: return []
res = [] used = [False] * len(intervals)
for i in range(len(intervals)): if used[i]: continue l, r = intervals[i] # 反复扫描,不断扩展当前区间,直到无法再扩展 changed = True while changed: changed = False for j in range(len(intervals)): if used[j]: continue a, b = intervals[j] if a <= r and l <= b: # 发现重叠 l = min(l, a) r = max(r, b) used[j] = True changed = True res.append([l, r])
return res以每个未合并区间为起点,反复扫描剩余区间并不断吞并重叠者。最坏情况下(区间呈”链式重叠”,每次只合并一个),复杂度达到 。
瓶颈分析:暴力法花了大量时间在”寻找与当前区间重叠的下一个区间”上——区间是无序的,每次都要全量扫描。如果能让区间按某种顺序排列,使得可能重叠的区间彼此相邻,一次从左到右的扫描就能完成所有合并。
🧠 直觉理解:想象你面前有一堆纸条,每张纸条写着起止时间,散乱地摊在桌上。暴力法是你随手拿起一张,然后翻遍全桌找能和它接上的——找到一张接一张,接不上了再换下一张。如果先把纸条按起始时间从左到右排好,你会发现——能合并的纸条一定彼此相邻,从左到右一趟就能全部处理完。
排序 + 贪心扫描
将所有区间按起点升序排序后,一个关键性质浮出水面:
性质:排序后,如果当前区间 不能与前面正在合并的区间 合并(即 ),那么后面所有的区间也不可能与 合并——因为后面的起点只会更大。
这意味着我们可以用”甩手掌柜”的方式处理:维护一个”当前正在合并”的区间 ,从左到右扫描每个区间——
- 如果 (重叠),就扩展右边界:
- 如果 (不重叠),就把 存入结果,然后开启一个新的合并区间
扫描结束后,别忘了把最后一个 也存进去。
class Solution: def merge(self, intervals: List[List[int]]) -> List[List[int]]: intervals.sort() # 按起点排序 res = [] l, r = intervals[0] # 当前正在合并的区间
for x, y in intervals: if x <= r: # 重叠:扩展右边界 r = max(r, y) else: # 不重叠:封存当前区间,开启新区间 res.append([l, r]) l, r = x, y
res.append([l, r]) # 封存最后一个区间 return res⚠️ 注意:
intervals.sort()对二元列表默认按第一个元素排序,第一个相同时按第二个元素排序。这里我们只需要按起点排序,intervals.sort()刚好满足。如果面试官要求显式表达,可以写成intervals.sort(key=lambda x: x[0])。
执行过程追踪
以 intervals = [[1,3],[2,6],[8,10],[15,18]] 为例(排序后不变):
| 步骤 | 当前 [x,y] | l | r | 判断 | 操作 | res |
|---|---|---|---|---|---|---|
| 初始 | — | 1 | 3 | — | — | [] |
| 1 | [1,3] | 1 | 3 | 1 ≤ 3 | r = max(3, 3) = 3(无变化) | [] |
| 2 | [2,6] | 1 | 3 | 2 ≤ 3 | r = max(3, 6) = 6 | [] |
| 3 | [8,10] | 1 | 6 | 8 > 6 | 保存 [1, 6],新 l=8, r=10 | [[1,6]] |
| 4 | [15,18] | 8 | 10 | 15 > 10 | 保存 [8,10],新 l=15, r=18 | [[1,6],[8,10]] |
| 结束 | — | 15 | 18 | — | 保存 [15,18] | [[1,6],[8,10],[15,18]] |
💡 小贴士:注意步骤 1——
l, r由intervals[0]初始化,然后for循环再次遍历到了intervals[0]。此时x = l且y = r,所以x ≤ r恒成立,r = max(r, y)不改变任何值,属于一次”空操作”。这一步看似多余,但它让循环逻辑保持统一——所有区间(包括第一个)都经过同一套判断逻辑,代码更简洁,且 的额外开销完全可以忽略。
再来看示例 2 [[1,4],[4,5]],端点相接的情况:
| 步骤 | 当前 [x,y] | l | r | 判断 | 操作 |
|---|---|---|---|---|---|
| 初始 | — | 1 | 4 | — | — |
| 1 | [1,4] | 1 | 4 | 1 ≤ 4 | r = max(4, 4) = 4 |
| 2 | [4,5] | 1 | 4 | 4 ≤ 4 | r = max(4, 5) = 5 |
| 结束 | — | 1 | 5 | — | 保存 [1,5] |
关键在步骤 2:判断条件 x ≤ r 即 4 ≤ 4 成立,因此 [4,5] 被视为与 [1,4] 重叠。这正是题目要求的——端点相接也算重叠。
为什么贪心是对的?
排序后从左到右扫描,每次遇到不重叠的区间就”封存”当前——为什么这样不会错过更优的合并方式?
🧠 直觉理解:排序保证了区间的起点是单调递增的。假设当前合并区间 与某个后面的区间 不重叠(即 ),那 之后的所有区间起点 ,更不可能与 重叠。所以此时”封存” 一定是安全的——它已经”长到最大了”,不会再被任何后续区间扩展。
复杂度
- 时间复杂度:,其中 是区间个数。排序占 ,扫描占 。
- 空间复杂度:(不计输出数组
res),或 (计入输出)。仅使用了l、r两个额外变量。
已是理论最优——因为区间合并问题至少需要排序,而比较排序的下界就是 。如果输入本身已按起点有序(如某些变体题目的设定),则可做到 。
⚠️ 注意:Python 的
list.sort()是原地排序,空间复杂度为 (TimSort 的临时空间为 但通常不计入)。如果你不希望修改输入数组,可以sorted(intervals)代替,但空间变为 。
代码优化
写法对比:l, r vs res[-1]
上面使用了 l, r 两个变量追踪正在合并的区间。另一种常见写法是直接在结果数组的最后一个区间上操作:
class Solution: def merge(self, intervals: List[List[int]]) -> List[List[int]]: intervals.sort() res = [intervals[0]]
for x, y in intervals[1:]: if x <= res[-1][1]: res[-1][1] = max(res[-1][1], y) else: res.append([x, y])
return res两种写法的对比:
| 对比项 | l, r 变量 | res[-1] 操作 |
|---|---|---|
| 可读性 | ⭐⭐⭐⭐ 变量名直观 | ⭐⭐⭐ [-1] 需要一点适应 |
| 简洁度 | ⭐⭐⭐ 需要尾部 append | ⭐⭐⭐⭐⭐ 循环结束即完成 |
| 避免浅拷贝问题 | ✅ 直接用 l, r | ⚠️ 修改 res[-1][1] 是原地修改,恰好安全 |
| 适用场景 | 适合讲解、教学 | 适合生产、竞赛 |
l, r 版本的好处是思路更清晰——“我正在合并一个区间,还没想好要不要放进结果”。res[-1] 版本的好处是代码更紧凑——边界处理被 res 初始化吃掉,循环从 intervals[1:] 开始,省去了尾部的那句 res.append([l, r])。
💡 小贴士:在面试中建议先写出
l, r版本,把思路讲清楚;如果面试官要求更简洁,再优化到res[-1]版本。一上来就甩出res[-1]可能会让面试官觉得你在背模板。
intervals.sort() vs intervals.sort(key=lambda x: x[0])
两种写法在本题中完全等价——因为 Python 的列表比较是按元素顺序逐个比较的:
[1, 3] < [2, 6]先比第一个元素1 < 2,结果为True,不再比第二个[1, 3] < [1, 4]第一个相等1 == 1,比第二个3 < 4
这意味着 intervals.sort() 等价于 “按起点排序,起点相同时按终点排序”。而题目只需要按起点排序——起点相同时按终点排不排都不影响正确性,但恰好也能正常工作。
显式写成 key=lambda x: x[0] 的好处是意图表达更明确,但性能上反而略慢(多了 lambda 调用开销)。两种写法都可以,看个人偏好。
处理空输入
虽然约束条件保证 n >= 1,但在工程实践中防御式编程不会错:
class Solution: def merge(self, intervals: List[List[int]]) -> List[List[int]]: if not intervals: return [] # ...如果约束真的可能为 0,intervals[0] 会在初始化 l, r 时抛出 IndexError。
总结
「合并区间」是贪心算法的经典入门题。它的核心技巧只有三步:
- 排序——让区间按起点有序
- 扫描——从左到右逐个判断是否重叠
- 合并——重叠就扩展,不重叠就封存
这个”排序 + 一次扫描”的模式广泛适用于一大类区间问题。掌握了它,下面这些题目你会觉得似曾相识:
- 57. 插入区间:给定一个已排序的不重叠区间列表,插入新区间后合并——相当于”手动的排序 + 合并”中的合并步骤
- 435. 无重叠区间:移除最少的区间使剩余区间不重叠——同样是排序 + 贪心,但贪心策略不同
- 452. 用最少数量的箭引爆气球:气球 = 区间,箭 = 公共交点——排序 + 贪心扫描的变体
🧠 核心收获:区间问题八成从排序入手,排序后重叠的区间会”挤在一起”,剩下的就是设计贪心扫描逻辑。别被题面吓到,先问自己——“如果区间已经排好序了,怎么做?”
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时









