Files
leetcode-go/普通数组/14-合并区间.md
T

288 lines
11 KiB
Markdown
Raw Normal View History

2026-05-14 23:30:50 +08:00
---
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<br/>≤ last.end?}
Overlap -- 是 --> Merge["合并: last.end = max(last.end,<br/>&nbsp;&nbsp;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) 的扫描。思考哪些实际问题天然具备"有序前提",能让你在面试中展现出更强的工程视野。