刚刷完这道题那阵子,我正好在协助一位朋友准备面试。他之前做后端开发,算法基础不算扎实,一看到“合并区间”这种题就慌,觉得要处理的情况太多了。我当时跟他说,这道题真没那么玄乎,本质上就是“先排序,再逛街”,把乱序的区间排成一条时间线,剩下的就是一个简单的判断逻辑。
今天这篇刷题日记,就把“合并区间”这道题完整拆一遍。不光给你能直接通过的写法,更会把背后的思考过程、边界条件、以及我实际踩过的坑都讲清楚。无论你是准备面试、复习数据结构,还是单纯想提升手感,这篇都能给你一些参考。
1. 题目回顾:到底要我们做什么
1.1 题面描述
题目本身非常短:给一个区间的集合,请合并所有重叠的区间。示例也很直白。比如输入[[1,3],[2,6],[8,10],[15,18]],因为[1,3]和[2,6]有重叠部分,合并之后变成[1,6],而[8,10]和[15,18]之间没有重叠,保持原样,所以最终输出[[1,3],[2,6],[8,10],[15,18]]合并后的结果是[[1,6],[8,10],[15,18]]。
1.2 隐藏的考点
看起来简单,但面试官在这道题里真正想看的,其实是两件事:第一,你懂不懂排序在这道题里的必要性和作用;第二,你处理边界情况够不够细心。很多人在“合并”那一步会写得很复杂,又是嵌套循环又是标记数组,实际用不到。这道题只要排序思路对了,代码量能控制在二十行上下。
1.3 适合谁看
如果你是第一次接触这道题,建议从头看一遍。如果你已经刷过,也能在后面的“常见坑”和“边界用例”里找到新的启发。网上很多题解直接给代码,但没说清楚为什么排序之后贪心就能成立,我会在下面重点解释这部分。
2. 核心思路:排序为什么是关键一步
2.1 不排序会怎样
先试想一下,如果不排序,直接从头到尾遍历并尝试合并,会发生什么?对于[[1,3],[8,10],[2,6]]这样的输入,当你看到[8,10]时,并不知道后面会出现一个能把它前面的[1,3]扩展成[1,6]的[2,6]。于是你不得不多次回看之前的区间,甚至要维护一个复杂的“已合并区间集合”,每次新加一个区间都要和集合里每一个已有区间比对。这样一来,最坏情况下时间复杂度会接近 O(n²),而且代码中要处理的判断条件会特别多,极易出错。
2.2 排序之后带来的变化
一旦我们按照每个区间的左端点升序排序,情况就完全不一样了。排序后,每个区间的起始位置都不会小于前一个区间的起始位置。这意味着,在遍历过程中,我们只需要关注“当前区间能不能接在已合并区间的后面”这一个问题。
举一个直观的例子。假设排序后的区间是[1,3]、[2,6]、[5,10]。从[1,3]开始,看[2,6]时,它的左端点 2 小于当前合并区间的右端点 3,说明有重叠,可以把右端点扩展到 6。现在合并区间接住了[1,6]。再看[5,10],它的左端点 5 小于等于 6,又重叠了,继续扩展成[1,10]。整个过程像是把区间一个接一个地“串”起来,不需要回头处理已合并区间,维护的始终只有一个“当前合并区间”。
2.3 排序的稳定性问题
可能有人会问:左端点相同怎么办?比如[[1,4],[1,3]]。这时候按左端点排序后,两者顺序其实无关紧要,因为合并时右端点会取最大值。所以排序函数不必额外处理左端点相同的情况,用默认排序即可。不过如果面试官恰好问到这个点,你能主动说一句“左端点相同时顺序不影响,因为我们会用最大值更新右边界”,会是一个加分项。
2.4 手写排序还是调用库函数
在面试中,直接用语言自带的排序函数就好,大部分场景不需要手写排序。比如 Python 里intervals.sort(key=lambda x: x[0]),Java 里Arrays.sort(intervals, (a, b) -> a[0] - b[0])。这里有一个小提醒:Java 的a[0] - b[0]写法在区间数值很大时存在整型溢出风险。虽然这道题的测试数据通常不会触发,但严谨一点的写法是用Integer.compare(a[0], b[0])。我在团队内部做代码评审时,会建议大家养成这个习惯,毕竟谁也不想在极端数据上翻车。
3. 合并逻辑:从模拟到代码实现
3.1 合并条件的确立
排序之后,我们把第一个区间先放进结果集,然后用一个变量记录“目前合并区间的右端点”。往后遍历时,只有两种情况:
- 当前区间的左端点小于等于记录中的右端点:说明重叠,合并。合并后的右端点取两者中的较大值。
- 当前区间的左端点大于记录中的右端点:说明没有重叠,把当前合并区间收尾,然后开启一个新的合并区间。
这里特别要注意“小于等于”的重叠判定。很多初学者会把条件写成“小于”,漏掉了相邻区间[1,2]和[2,3]应该合并的情况。题目描述中的“重叠”通常包含端点重合,所以必须是<=。
3.2 参考代码:Python 版
我习惯用 Python 先把思路快速验证一遍,逻辑清楚之后再用其他语言改写。代码如下:
def merge(intervals): if not intervals: return [] intervals.sort(key=lambda x: x[0]) merged = [] for interval in intervals: # 如果 merged 为空,或者当前区间与上一个合并区间不重叠 if not merged or merged[-1][1] < interval[0]: merged.append(interval) else: # 重叠时更新右端点 merged[-1][1] = max(merged[-1][1], interval[1]) return merged这段代码里,merged[-1]始终代表当前正在维护的合并区间。因为排序后的区间左端点不会变小,所以只需要调整右端点。max取较大值是为了处理[1,5]遇到[2,4]这种“大区间包小区间”的情况。
3.3 参考代码:Java 版
如果面试要用 Java,写法类似,只是语法上需要多注意一些细节:
public int[][] merge(int[][] intervals) { if (intervals.length == 0) { return new int[0][]; } Arrays.sort(intervals, (a, b) -> Integer.compare(a[0], b[0])); List<int[]> merged = new ArrayList<>(); for (int[] interval : intervals) { if (merged.isEmpty() || merged.get(merged.size() - 1)[1] < interval[0]) { merged.add(interval); } else { merged.get(merged.size() - 1)[1] = Math.max(merged.get(merged.size() - 1)[1], interval[1]); } } return merged.toArray(new int[merged.size()][]); }最后用merged.toArray(new int[0][])也行,但显式指定大小在某些 JDK 版本下性能略好。这里属于细枝末节,面试中写new int[merged.size()][]已经完全够用。
3.4 手动跑一个完整例子
我们来手动跟踪一遍最经典的输入:[[1,3],[2,6],[8,10],[15,18]]。
排序后结果不变。初始化merged = []。
- 读入
[1,3],merged为空,直接加入,merged = [[1,3]]。 - 读入
[2,6],当前最后一个合并区间右端点是 3,2 <= 3,重叠。更新右端点为max(3, 6) = 6,得到[[1,6]]。 - 读入
[8,10],8 > 6,不重叠。加入新区间,merged = [[1,6],[8,10]]。 - 读入
[15,18],15 > 10,不重叠。加入新区间,得到[[1,6],[8,10],[15,18]]。
整个过程就像在时间轴上推进,非常直观。
3.5 复杂度分析
排序的时间复杂度是 O(n log n),后续一次遍历是 O(n),所以整体是 O(n log n)。空间复杂度方面,如果用 Python 或 Java 的链表结构存储结果,额外空间是 O(n);如果允许修改原数组,有的写法可以做到 O(1) 额外空间(不算输出数组的话)。刷题时能够准确地说出这个复杂度分析,是基本功的体现。
4. 实操中的边界情况与常见陷阱
4.1 边界用例速查表
我整理了一份这个题的高频用例,建议你把这些 case 都手动跑一遍:
| 输入 | 预期输出 | 关键点 |
|---|---|---|
[] | [] | 空数组要直接处理,避免排序报错 |
[[1,4]] | [[1,4]] | 单区间直接返回 |
[[1,4],[2,3]] | [[1,4]] | 大区间包小区间 |
[[1,2],[2,3]] | [[1,3]] | 端点重合算重叠 |
[[1,4],[5,6]] | [[1,4],[5,6]] | 恰好不重叠 |
[[1,4],[0,4]] | [[0,4]] | 左端点更新 |
[[2,3],[1,6]] | [[1,6]] | 排序后左端点非递增是关键 |
4.2 我踩过的三个坑
第一个坑是开头忘了判空。intervals为空数组时,有些语言的排序函数会直接报错或返回奇怪结果。我一开始写 Python 时没加if not intervals,结果本地测试用例没覆盖到,提交之后被系统的隐藏用例打了个措手不及。从那以后,凡是处理数组的题,我写完核心逻辑后第一件事就是检查空输入。
第二个坑是合并时只更新右端点、没考虑到左端点也可能变化。你可能觉得排序之后左端点不会变小,但如果你的原数组没有被真正排序,或者排序函数写错了,就会出现左端点反而变小的情况。比如输入[[1,6],[0,4]],如果没排序,当前合并区间是[1,6],读入[0,4]时,0 <= 6,合并后如果只更新右端点,结果还是[1,6],但正确答案应该是[0,6]。解决办法很粗暴:一定先排序。只要你保证了排序,合并时就不需要再动左端点。
第三个坑是关于整型溢出的。我在某次模拟面试中用过 Java 的a[0] - b[0],当时面试官追问了一句“如果所有数字都是Integer.MAX_VALUE附近的值呢?”我第一反应觉得减法在 Java 里是允许的,但仔细一想确实可能溢出成负数,导致排序完全乱掉。虽然实际上测试数据一般到不了那个极端,但面试当场能意识到这个问题并改成Integer.compare,观感会不一样。
4.3 关于原数组能否修改
很多题解为了“省空间”,直接在原数组上写结果。这样确实可以把额外空间降到 O(1),但代价是破坏了输入数据。如果面试官没有特别说明可以修改入参,我建议还是新建一个结果集。理由很简单:工程化思维里,入参通常不应该被随意修改,否则调用方可能在后续逻辑中出现难以排查的 bug。面试过程中主动提一句“这里我会新建一个结果集合来避免修改原数组”,会让面试官觉得你不仅有算法能力,还有工程意识。
5. 从一道题到一类题:合并区间的变体与延伸
5.1 插入区间
一道非常常见的变体是:给定一组互不相交且已排序的区间,再给一个新的区间,让你插入进去并合并。这题看起来比“合并区间”多了一步插入,实际上思路一脉相承。你只需要找到新区间与所有旧区间重叠的范围,然后把它们合并成一个,再拼接剩余部分。理解了“合并区间”的贪心逻辑后,这种题就是小改。
我一开始做插入区间时,想着把所有区间放在一起重新排序再合并,虽然也能做,但复杂度多了一次 O(n log n)。后来意识到原输入已经有序,完全可以用一次遍历完成,复杂度降到 O(n)。这提醒我一个通用原则:题目给出的每一个条件,都可能是算法优化的线索。
5.2 无重叠区间
另一道经典题“无重叠区间”,问你最少需要移除多少个区间,才能让剩下的区间互不重叠。这题的核心思路是先按右端点排序,然后贪心地选尽可能多的不重叠区间。很多人做完“合并区间”后直接做这题,容易混淆“按左端点排序”和“按右端点排序”的用途。简单记法:合并区间关心的是“能连多远”,按左端点让区间从左往右展开,合并效率高;无重叠区间关心的是“给后面留多少空间”,按右端点排序能保证每次留下的区间结束得最早,给后续留出最大余地。
我强烈建议把“合并区间”“插入区间”“无重叠区间”打包刷掉,这样你对区间类问题的理解才是成体系的。面试时面试官从“合并区间”切入,大概率后续会追问类似变体。
5.3 区间分组问题
还有一类变体是:给一堆会议时间区间,问至少需要多少个会议室。这种题的常见解法是分别记录开始时间和结束时间,排序后用双指针扫描。它和“合并区间”的区别在于,合并要求算出并集,而会议室的题要求的是同时重叠的最大数量。两者都建立在排序思维之上,但一个用栈类似的“结果列表”,一个用两个指针同步推进。想清楚不同点,比多刷十道题还管用。
6. 实操总结与个人体会
6.1 这道题需要的核心能力
在我看来,“合并区间”其实考察了三个层次。第一层是基础的排序能力,包括排序方向和排序稳定性;第二层是线性扫描时的状态维护,也就是知道什么时候该开新区间、什么时候该扩展现有区间;第三层是边界意识,包括空数组、单元素、恰好相邻这些细节。把这三层都想通,代码怎么写都是次要的了。
6.2 我在实际项目中见到的相似场景
有一次在做某个数据处理模块时,遇到了一个真实的需求:一批时间段记录可能存在重叠,需要把它们合并成连续的时间段,用来统计每个用户的有效在线时长。那一刻我意识到,这类题不是只活在题库里的。数据清洗、日历合并、日志聚合、IP 地址段合并,到处都能遇到区间合并的影子。虽然实际工程里我会直接用现成的库函数,但懂了原理,排查异常时能更快定位问题。
6.3 最后一个小技巧
分享一个我刷题时常用的做法:每做完一道题,我会在本地维护一个冲突用例集合,把所有踩过的坑都记录进去。比如这题我会加入[[1,4],[0,2],[3,5]]这种交错重叠的用例,每次想改动写法时,先把这些用例跑一遍。刚开始觉得有点费时间,刷多了才发现,这比盲目刷新题性价比高得多。人在同一个坑里反复摔倒,才是最亏的。
顺带说一句,面试时如果时间充裕,主动写出边界测试用例并解释为什么这些用例有代表性,通常能给你带来不错的加分效果。