Files

288 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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) 的扫描。思考哪些实际问题天然具备"有序前提",能让你在面试中展现出更强的工程视野。