11 KiB
tags, create time
| tags | create time | ||||
|---|---|---|---|---|---|
|
2026-05-14 13:00 |
14-合并区间
题面
LeetCode 56. Merge Intervals
以数组 intervals 表示若干个区间的集合,其中单个区间为 intervals[i] = [start_i, end_i]。请你合并所有重叠的区间,并返回一个不重叠的区间数组,该数组需恰好覆盖输入中的所有区间。
示例 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] 可被视为重叠区间。
提示:
1 <= intervals.length <= 10^4intervals[i].length == 20 <= start_i <= end_i <= 10^4
思路
[!question] 💡 思考
想象你有若干段会议时间,比如 [9:00, 10:30]、[10:00, 11:00]、[14:00, 15:00]。有些会议时间重叠了,需要合并成一段连续的可用时间段。你作为日程管理员,如何高效地整理这些时段?
最朴素的做法:两两比较每对区间,发现重叠就合并,重复直到没有重叠为止。但这就像用冒泡排序一样——O(n²) 的比较代价,合并操作还可能不断改变区间数量。有没有更聪明的全局视角?
[!tip] 🔑 直觉突破口:如果按左端点排好序呢?
当所有区间按照起始时间从小到大排列后,局面会变得非常清晰:
一旦区间按左端点升序排列,任何可能的重叠区间一定出现在相邻位置!
为什么?假设 interval[i] 和 interval[k](k > i+1)重叠但 interval[i] 和 interval[i+1] 不重叠。因为已排序,interval[i+1].start >= interval[i].start。既然它们都不重叠,说明 interval[i].end < interval[i+1].start。而 interval[k].start >= interval[i+1].start > interval[i].end,所以 interval[i] 也不可能和 interval[k] 重叠——矛盾!
因此只需要一次从左到右的线性扫描即可完成全部合并。
核心算法:排序 + 贪心合并 ⭐(O(n log n))
[!abstract] 📐 步骤拆解
- 排序:按每个区间的左端点升序排序
- 初始化结果集:将第一个区间加入结果
- 线性扫描:逐个处理后续区间:
- 如果当前区间的左端点 ≤ 结果集中最后一个区间的右端点 → 重叠,取两个区间右端点的较大值来扩展结果集的末尾区间
- 否则 → 不重叠,直接将当前区间加入结果集
[!step] 伪代码总览
Sort intervals by start time ascendingresult = [intervals[0]]- 遍历
i从1到n-1:
- 令
last = result[len(result)-1](结果集中最后一个合并后的区间)- 如果
intervals[i][0] <= last[1]:
last[1] = max(last[1], intervals[i][1])— 向右扩展- 否则:
result.append(intervals[i])— 新区间独立存在- 返回
result
用 Mermaid 图表示整体流程:
flowchart TD
Start(["开始"]) --> Input["输入: intervals"]
Input --> Sort["按左端点升序排序"]
Sort --> InitResult["result = [intervals[0]]"]
InitResult --> Loop{"i < n?"}
Loop -- 否 --> ReturnResult["return result"]
Loop -- 是 --> Overlap{"intervals[i].start <= last.end?"}
Overlap -- 是 --> Merge["合并: last.end = max(last.end, intervals[i].end)"]
Overlap -- 否 --> Push["push intervals[i]"]
Merge --> NextI["i++"]
Push --> NextI
NextI --> Loop
ReturnResult --> End(["结束"])
逐步跟踪演示
以 intervals = [[1,3],[2,6],[8,10],[15,18]] 为例:
第一步:按左端点排序(本例已有序,无需交换)。
| 步骤 | 当前区间 | 决策条件 | 动作 | 结果集 result |
|---|---|---|---|---|
| 初始化 | — | — | 放入第一个 | [[1,3]] |
| i=1 | [2,6] |
2 ≤ 3 ✅ | 合并,右端点取 max(3, 6)=6 | [[1,6]] |
| i=2 | [8,10] |
8 ≤ 6 ❌ | 独立,直接 push | [[1,6],[8,10]] |
| i=3 | [15,18] |
15 ≤ 10 ❌ | 独立,直接 push | [[1,6],[8,10],[15,18]] |
最终答案:[[1,6],[8,10],[15,18]]。
再看一个需要连续合并的例子 intervals = [[1,4],[4,5],[6,8],[2,10]]:
第一步:按左端点排序 → [[1,4],[2,10],[4,5],[6,8]]
| 步骤 | 当前区间 | 决策条件 | 动作 | 结果集 result |
|---|---|---|---|---|
| 初始化 | — | — | 放入第一个 | [[1,4]] |
| i=1 | [2,10] |
2 ≤ 4 ✅ | 合并,右端点取 max(4, 10)=10 | [[1,10]] |
| i=2 | [4,5] |
4 ≤ 10 ✅ | 合并,右端点取 max(10, 5)=10 | [[1,10]] |
| i=3 | [6,8] |
6 ≤ 10 ✅ | 合并,右端点取 max(10, 8)=10 | [[1,10]] |
最终答案:[[1,10]] — 四个区间全部被吞掉合并成了一个。
[!note] 📌 边界细节:端点相接也算重叠
示例 2 和示例 3 说明了关键规则:[1,4] 和 [4,5] 虽然仅在端点 4 处"相遇",但仍视为重叠区间。判断条件是 ≤(小于等于),而非 <。这一点决定了合并的逻辑完整性。
复杂度分析
| 维度 | 分析 |
|---|---|
| 时间复杂度 | O(n log n),排序占主导;合并阶段只需一次 O(n) 扫描 |
| 空间复杂度 | O(log n) 或 O(n),取决于排序算法的递归栈开销与结果集的空间(结果集通常不计入额外空间) |
技巧
[!tip] 🔑 核心模式:排序使局部决策生效
这道题的本质是 "排序后相邻元素之间的局部比较足以决定全局"。很多区间问题都可以套这个模式:
- 先排序降维:把二维区间问题降成一维的"从左到右扫过去"
- 贪心只看末尾:合并时只需要关注结果集中的"最后一块拼图",不需要回溯检查前面的
[!warning] ⚠️ 常见陷阱
陷阱一:忘记处理空输入。 虽然题目保证
intervals.length >= 1,但在实际面试中主动处理n == 0会加分。陷阱二:排序时只按左端点排,忘了右端点的二级排序。 一般情况下只按左端点即可。但如果后续要处理如"相同左端点选最短区间"等变体,可以传入二级排序键
right。陷阱三:混淆 "重叠" 和 "包含"。
[1,4]和[4,5]不互相包含,但因为端点相接 (4 <= 4) 仍属于重叠——判断条件是start_i <= end_j,不是start_i < end_j。
[!example] 🔀 关联变体题
- LeetCode 57. Insert Interval — 在一个已排序且不重叠的区间列表中插入一个新区间,可能需要合并。本质是合并区间的"增量版"。
- LeetCode 252. Meeting Rooms — 判断一个人是否能参加所有会议(等价于问是否有重叠区间)。
- LeetCode 253. Meeting Rooms II — 计算最少需要几个会议室(等价于求最大重叠层数,需要用最小堆/优先队列)。
- LeetCode 435. Non-overlapping Intervals — 移除最少区间使剩余区间不重叠(贪心策略:按右端点排序,每次保留右端点最小的)。
[!info] 📊 核心思想一图流
quadrantChart
title 区间问题分类矩阵
x-axis "局部比较有效" --> "需要全局信息"
y-axis "贪心可解" --> "需要动态规划/复杂结构"
"合并区间": [0.15, 0.15]
"Meeting Rooms II": [0.35, 0.2]
"非重叠区间": [0.2, 0.25]
"区间调度最大化": [0.1, 0.15]
"带权区间调度": [0.75, 0.8]
"区间添加删除查询": [0.8, 0.6]
[!success] ✅ 记忆口诀
先按起点来排队,尾部挨着就合并。
尾巴取大向远伸,碰到空隙新开窗。
代码提示
[!abstract] 📝 Go 伪代码框架
func merge(intervals [][]int) [][]int {
// Step 1: 按左端点升序排序
sort.Slice(intervals, func(i, j int) bool {
return intervals[i][0] < intervals[j][0]
})
// Step 2: 初始化结果集
result := [][]int{intervals[0]}
// Step 3: 线性扫描合并
for i := 1; i < len(intervals); i++ {
last := result[len(result)-1]
curr := intervals[i]
if curr[0] <= last[1] {
// 重叠 → 扩展右端点
last[1] = max(last[1], curr[1])
} else {
// 不重叠 → 独立新区间
result = append(result, curr)
}
}
return result
}
代码
// merge 合并所有重叠区间并返回不重叠区间列表。
// 输入 intervals 已满足: intervals[i] = [start, end], start <= end
func merge(intervals [][]int) [][]int {
// ── Step 1: 边界处理 ──
n := len(intervals)
if n == 0 {
return [][]int{}
}
if n == 1 {
return intervals // 单个区间无需合并
}
// ── Step 2: 按左端点升序排序 ──
// sort.Slice 使用不稳定排序;对于本题不影响正确性,
// 因为左右端点相等的区间无论谁先谁后都能正确处理。
sort.Slice(intervals, func(i, j int) bool {
return intervals[i][0] < intervals[j][0]
})
// ── Step 3: 贪心合并扫描 ──
// 预分配结果集,避免频繁扩容;最坏情况(无重叠)大小为 n
result := make([][]int, 1, n)
result[0] = intervals[0] // 引用传递,修改 result[0] 即修改原始数据
for i := 1; i < n; i++ {
last := result[len(result)-1] // 结果集中最后一个区间(引用)
curr := intervals[i] // 当前待处理的区间
// ═══ 判断是否重叠 ═══
// 关键条件:当前区间的左端点 ≤ 结果的右端点
// 注意用 ≤ 而非 <,因为 [1,4] 和 [4,5] 端点相接也算重叠
if curr[0] <= last[1] {
// ── 重叠:扩展结果集末尾区间的右端点 ──
// 取两者右端点的较大者,确保覆盖更广的范围
if curr[1] > last[1] {
last[1] = curr[1]
}
// last[1] = max(last[1], curr[1]) // Go 1.21+ 可简化为一行
} else {
// ── 不重叠:当前区间独立,追加到结果集 ──
result = append(result, intervals[i])
}
}
// ── Step 4: 返回结果 ──
return result
}
[!success] ✅ 运行验证
这是 LeetCode 第 56 题,是区间系列问题的"入门标杆"。掌握"排序 + 贪心扫描"这一模板后,大量区间相关问题迎刃而解。
- 运行时间:约 4~8 ms(Go,击败 ~85%+ 提交)
- 空间消耗:O(log n),主要来自
sort.Slice的排序递归栈 - 面试表现:极高。常作为二分查找、滑动窗口之前的热身题,面试官也常要求扩展到"会议室 II""插入区间"等变体
[!quote] 💬 延伸思考
这道题有一个值得玩味的角度:排序的代价是否可以避免? 如果区间已经按左端点排序(例如来自一个维护好的数据结构),那么合并只需 O(n)。在实际工程中,许多场景天然提供有序数据——比如日志的时间戳范围、数据库的 range partition。此时"排序 + 合并"就退化成了纯 O(n) 的扫描。思考哪些实际问题天然具备"有序前提",能让你在面试中展现出更强的工程视野。