288 lines
11 KiB
Markdown
288 lines
11 KiB
Markdown
---
|
||
tags: ["LeetCode", "排序", "贪心", "中等"]
|
||
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^4`
|
||
- `intervals[i].length == 2`
|
||
- `0 <= 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] 📐 步骤拆解
|
||
|
||
1. **排序**:按每个区间的左端点升序排序
|
||
2. **初始化结果集**:将第一个区间加入结果
|
||
3. **线性扫描**:逐个处理后续区间:
|
||
- 如果当前区间的左端点 **≤** 结果集中最后一个区间的右端点 → **重叠**,取两个区间右端点的较大值来扩展结果集的末尾区间
|
||
- 否则 → **不重叠**,直接将当前区间加入结果集
|
||
|
||
> [!step] 伪代码总览
|
||
|
||
> 1. `Sort intervals by start time ascending`
|
||
> 2. `result = [intervals[0]]`
|
||
> 3. 遍历 `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])` — 新区间独立存在
|
||
> 4. 返回 `result`
|
||
|
||
用 Mermaid 图表示整体流程:
|
||
|
||
```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] 🔑 核心模式:排序使局部决策生效
|
||
|
||
这道题的本质是 **"排序后相邻元素之间的局部比较足以决定全局"**。很多区间问题都可以套这个模式:
|
||
|
||
1. **先排序降维**:把二维区间问题降成一维的"从左到右扫过去"
|
||
2. **贪心只看末尾**:合并时只需要关注结果集中的"最后一块拼图",不需要回溯检查前面的
|
||
|
||
> [!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] 📊 核心思想一图流
|
||
|
||
```mermaid
|
||
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 伪代码框架
|
||
|
||
```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
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 代码
|
||
|
||
```go
|
||
// 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) 的扫描。思考哪些实际问题天然具备"有序前提",能让你在面试中展现出更强的工程视野。
|