目录

题目描述

LCR 074. 合并区间

题意分析

给一组闭区间,把所有有重叠的区间合并掉,返回一个互不重叠、恰好覆盖原输入所有点的区间集合。

「闭区间」这个前提决定了相接算不算重叠:[1, 4][4, 5] 共享端点 4,在闭区间语义下它们是连通的,必须合并成 [1, 5]。判定式里那个等号的取舍完全由这一点决定。

输入没有任何顺序保证,区间可以任意交错、包含、相离。而「合并」这件事本质上是在处理相邻关系——只有位置上挨着的区间才可能重叠——所以输入的无序性正是首要障碍。

约束里区间数可达 $10^4$,端点值可达 $10^4$。规模允许 $O(n \log n)$ 的排序,但不允许 $O(n^2)$ 的两两比较合并(那是 $10^8$ 次并且还要反复迭代直到稳定)。

边界上要注意:只有一个区间时直接返回它;完全包含的情况([1, 10][3, 5])合并后右端点不能被缩小;退化区间([5, 5])是合法输入;最后一个正在维护的区间必须记得写进答案。

解法:排序后扫描

核心思路

暴力做法是反复扫描区间集合,找到任意一对重叠的就合并成一个,直到某轮扫描下来一次合并都没发生。正确但代价高昂:单轮就是 $O(n^2)$ 的两两比较,而且可能需要多轮才收敛。

瓶颈在于「任意一对」的搜索完全没有方向感。合并是有传递性的(AB 重叠、BC 重叠,三者就该并成一个),暴力做法却在无序集合里盲目地找配对,同一段关系被反复检查。

观察的切入点是:如果所有区间按左端点升序排好,那么重叠一定只发生在相邻位置之间。理由是——扫描到某个区间时,前面已处理的区间左端点都不比它大;只要它的左端点没有超过「当前已合并段的右端点」,就必然与之相交;一旦超过,那么它后面的所有区间左端点更大,也一定不会再与前面这段相交。于是「找重叠」从全局搜索降级成一次线性扫描。

由此定下扫描时维护的状态:(st, ed) 表示当前正在生长的合并段,初值取排序后第一个区间。每读入一个新区间 (s, e):若 ed < s,说明新区间与当前段完全分离(注意 ed == s 时闭区间仍相接,不算分离),就把当前段封存进答案并用新区间开启下一段;否则两者相交,把当前段的右端点扩张为 max(ed, e)

max 而不是直接赋值为 e,是因为存在包含关系:[1, 10] 后面跟 [3, 5] 时,新区间的右端点更小,直接赋值会把已经覆盖到的 10 缩回 5。

维持的不变量是:扫描到位置 i 时,答案数组里的区间全部已经最终确定、互不重叠且都在 st 左侧;(st, ed) 是与 intervals[i..] 可能还有交集的唯一一段。循环结束后把最后这一段补进答案,就得到完整结果。

解题步骤

  • 先按左端点升序排序。这是整个算法成立的前提——只有有序才能保证「不相交就永远不再相交」,也才能把重叠判断局部化到相邻两项。
  • 用排序后的第一个区间初始化 sted,作为第一段的起点。题目保证至少有一个区间,所以直接取下标 0 是安全的。
  • 从第二个区间开始遍历,每次取出 (s, e) 与当前段比较。只与「当前段」比较而不与答案里的历史区间比较,是因为历史区间都已被证明不会再与后续区间相交。
  • 判定分离用 ed < s(严格小于)。写成 ed <= s 会把相接的 [1, 4][4, 5] 当成两段,与闭区间语义矛盾。
  • 分离时先把 (st, ed) 追加进答案,再把 sted 重置为新区间的两端。顺序不能颠倒,否则当前段会丢失。
  • 相交时只更新右端点为 max(ed, e),左端点 st 保持不变——排序保证了 st 已经是这一段中最小的左端点。
  • 循环结束后,把最后维护的 (st, ed) 追加进答案。这一步极易遗漏:最后一段永远不会经由「分离」分支被写入。

intervals = [[1, 3], [2, 6], [8, 10], [15, 18]] 走一遍:按左端点排序后顺序不变。初始化 st = 1ed = 3,答案为空。读入 [2, 6]ed = 3 不小于 s = 2,说明相交,右端点扩张为 max(3, 6) = 6,当前段变成 (1, 6)。读入 [8, 10]ed = 6 < 8,分离,把 (1, 6) 写进答案,当前段重置为 (8, 10)。读入 [15, 18]ed = 10 < 15,分离,把 (8, 10) 写进答案,当前段重置为 (15, 18)。循环结束,把最后的 (15, 18) 补进答案。最终返回 [[1, 6], [8, 10], [15, 18]]。若在此基础上再追加一个 [16, 17],读到它时 ed = 18 不小于 16,判定相交,右端点取 max(18, 17) = 18 保持不变——这正是 max 存在的意义。

代码实现

class Solution {
    public int[][] merge(int[][] intervals) {
        Arrays.sort(intervals, Comparator.comparingInt(a -> a[0]));
        int st = intervals[0][0], ed = intervals[0][1];
        List<int[]> answer = new ArrayList<>();
        for (int i = 1; i < intervals.length; ++i) {
            int s = intervals[i][0], e = intervals[i][1];
            if (ed < s) {
                answer.add(new int[] {st, ed});
                st = s;
                ed = e;
            } else {
                ed = Math.max(ed, e);
            }
        }
        answer.add(new int[] {st, ed});
        return answer.toArray(new int[answer.size()][]);
    }
}
func merge(intervals [][]int) [][]int {
    sort.Slice(intervals, func(i, j int) bool {
        return intervals[i][0] < intervals[j][0]
    })
    st, ed := intervals[0][0], intervals[0][1]
    var answer [][]int
    for _, e := range intervals[1:] {
        if ed < e[0] {
            answer = append(answer, []int{st, ed})
            st, ed = e[0], e[1]
        } else if ed < e[1] {
            ed = e[1]
        }
    }
    answer = append(answer, []int{st, ed})
    return answer
}

复杂度分析

  • 时间复杂度:$O(n \log n)$,排序占据主导;之后的扫描每个区间只被访问一次、做常数次比较与赋值,是 $O(n)$。
  • 空间复杂度:$O(n)$(不计返回值时为排序所需的 $O(\log n)$ 栈空间)。答案数组最坏情况下要装下全部 $n$ 个互不重叠的区间,这是输出本身的开销。

关键点总结

  • 区间类问题的第一反应是排序,因为排序把「任意两个是否重叠」这个全局关系压缩成「相邻两个是否重叠」的局部关系,复杂度立刻从平方级降到线性对数级。
  • 按左端点排序之后有一条强结论:一旦当前区间与已合并段分离,后续所有区间也必然分离。这条单调性是只维护一个「当前段」而不必回头的根本依据。
  • 右端点扩张必须取 max,因为包含关系会给出一个更小的右端点。任何「用新值直接覆盖累积状态」的写法,都要先确认新值一定不劣。
  • 闭区间与开区间的语义差别全部体现在判定式的等号上。先问清楚「端点相接算不算重叠」,再决定写 < 还是 <=
  • 「循环内只在遇到分界时结算,循环后必须补最后一段」是所有分段扫描类问题的共同结构,遗漏收尾是这类题最高频的错误。
  • 面试视角:主动说明为什么只按左端点排序就够了(右端点的顺序不影响正确性,因为扩张用的是 max),能显示你验证过算法而不是背下来的。
  • 面试视角:常见追问是 57 题「往已排好的区间集合里插入一个新区间」,答不必重新排序,扫描时分「在新区间左侧、与之重叠、在其右侧」三段处理,$O(n)$ 即可;再进一步的追问是「如何支持动态插入并随时查询」,答用有序集合(TreeMap)按左端点维护,每次插入只需局部合并。

易错点总结

  • 错误写法:忘记排序直接扫描。用例 [[2, 6], [1, 3]] → 第一段为 (2, 6),读到 [1, 3]ed = 6 不小于 s = 1 判定相交,右端点取 max(6, 3) = 6,输出 [[2, 6]],正确答案是 [[1, 6]],左端点 1 丢失。
  • 错误写法:分离判定写成 ed <= s。用例 [[1, 4], [4, 5]] → 两个相接的闭区间被判为分离,输出 [[1, 4], [4, 5]],正确答案是 [[1, 5]]
  • 错误写法:相交时直接令 ed = e。用例 [[1, 10], [3, 5]] → 右端点被缩回 5,输出 [[1, 5]],正确答案是 [[1, 10]],包含关系被破坏。
  • 错误写法:循环结束后忘记追加最后一段。用例 [[1, 3], [5, 7]] → 只有 (1, 3) 在遇到 [5, 7] 时被写入,输出 [[1, 3]],正确答案是 [[1, 3], [5, 7]]
  • 错误写法:分离时先重置 sted 再追加。用例 [[1, 3], [5, 7]] → 追加进答案的是刚被覆盖的新值 (5, 7),第一段 (1, 3) 永久丢失。
  • 错误写法:按右端点排序。用例 [[1, 10], [2, 3], [4, 5]] → 排序后变成 [[2,3],[4,5],[1,10]],扫描时 (2,3)(4,5) 被判为分离而各成一段,输出三段或错误合并,正确答案是 [[1, 10]];左端点序才是保证「分离即永久分离」的那个序。
  • 错误写法:相交时同时更新左端点为 min(st, s)。用例 [[1, 3], [2, 6]] → 结果虽然正确,但这个 min 掩盖了「排序已保证 st 最小」这一事实,一旦排序被误改成右端点序,错误就会被这行代码藏住而难以定位。
  • 错误写法:用 Arrays.sort(intervals) 不给比较器。用例 [[1, 3], [2, 6]] → 二维数组按引用无法自然排序,抛出类型转换异常或得到未定义顺序。
  • 错误写法:认为需要反复迭代直到不再发生合并。用例 [[1, 4], [2, 5], [3, 6]] → 排序后一次线性扫描即可得到 [[1, 6]];多轮迭代不但白费 $O(n^2)$ 的代价,还容易在中途修改数组时引入下标错乱。

相似题目

题目 难度 考察点
56. 合并区间 中等 与本题同题,是区间排序扫描最基础的样板
57. 插入区间 中等 输入已有序,无需排序,改为按左中右三段一次扫描完成插入
986. 区间列表的交集 中等 两个有序列表求交而非并,需双指针同步推进并按右端点决定谁前进
1288. 删除被覆盖区间 中等 判定的是包含而非相交,排序时右端点需降序作为第二关键字
253. 会议室 II 中等 求的是最大重叠层数而非合并结果,需要小根堆或差分扫描
252. 会议室 简单 只判断是否存在任意重叠,排序后相邻比较一次即可
759. 员工空闲时间 困难 先把多人区间合并再取补集,是本题结果之上的一层推导
763. 划分字母区间 中等 区间由字符的首末位置隐式给出,需要先构造区间再做同样的扫描