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

11 KiB
Raw Blame History

tags, create time
tags create time
LeetCode
排序
贪心
中等
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 图表示整体流程:

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] 📊 核心思想一图流

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) 的扫描。思考哪些实际问题天然具备"有序前提",能让你在面试中展现出更强的工程视野。