如果你刷过力扣热题100,会发现“合并区间”基本是绕不开的一道题。它在题库里的编号是56,热度常年不减,面试出镜率极高。很多同学觉得它简单,不就是排个序再扫一遍嘛,但真正到了面试或者周赛里,边界条件、重叠判断、原地修改这些细节一旦没想清楚,很容易翻车。这篇内容我就以这道题为切入点,把解题思路、代码实现、常见坑点以及它在热题100中的定位一次讲透,希望能给正在刷题或者准备面试的你一些参考。
1. 先说清楚:合并区间到底在考什么
1.1 题意回顾:热题100中的这道高频题
题目描述非常简洁:给定一个区间的集合,数组中每个元素是一个长度为2的数组,形如 [start, end],表示一个左闭右闭区间。如果两个区间之间存在重叠部分,就把它们合并成一个新区间。要求返回合并后的区间列表,并且最终结果里的区间不能重叠,还需要按每个区间的起始位置升序排列。
举个例子,输入是 [[1,3],[2,6],[8,10],[15,18]],因为 [1,3] 和 [2,6] 有交集,合并成 [1,6];而 [8,10] 和 [15,18] 之间没有任何交集,原样保留,所以输出是 [[1,6],[8,10],[15,18]]。
这个例子基本包含了题目的所有考点:判断重叠、执行合并、保持顺序。但要注意,题目给的输入列表不一定有序,换句话说,区间是乱序出现的,比如可能给你 [[2,6],[1,3],[15,18],[8,10]],这也是热题100里很多区间类题目共同的特点——输入无序,需要我们自己对数据先做预处理。
1.2 出题人真正想考察的是什么
我先说一个自己的判断:这道题表面上考的是“合并”,实际上考的是“排序 + 贪心 + 区间思维”。面试官在让候选人写这道题的时候,第一眼想看的往往不是你会不会调库,而是你能不能快速发现“排序是解决区间重叠问题的前置条件”。
很多无序区间问题,在排完序之后会瞬间变得清晰,这是区间类问题最核心的套路。排序之后,我们只需要依次比较当前区间的左端点和结果集最后一个区间的右端点,就能判断是否存在重叠,整个过程一次遍历搞定,复杂度为 O(n log n)。这个思想在热题100的其他题目里也反复出现,比如会议室问题、插入区间、用最少数量的箭引爆气球等,本质上都是同一套思想。所以说,合并区间是区间类题目的敲门砖,一点都不夸张。
2. 核心方法论:为什么排序是这题的第一性原理
2.1 区间重叠的条件与排序的关系
先回到一个基础问题上:两个左闭右闭区间 [a, b] 和 [c, d],什么时候算重叠?按集合论的定义,只要它们有公共点,即满足 c <= b 且 a <= d 就是重叠。但如果把两个区间按起点排好序,保证 a <= c,那么重叠条件可以进一步简化为 c <= b,也就是说,后一个区间的起点只要不超过前一个区间的终点,两个区间就一定有重叠部分。
这个简化看起来微不足道,却是整个算法的支点。没排序的时候,要两两比较区间,暴力做法是 O(n^2) 的双重循环,而且合并完还可能产生新的重叠,要反复循环;排序之后,线性扫描一次就能解决,从 O(n^2) 直接降到了排序的 O(n log n)。
我常给刷题的朋友打个比方:如果一堆人的生日散乱地写在纸条上,你要找出日期连续重叠的所有人,最常见的方式就是先把纸条按照日期从小到大排好,再一组一组看。排序让一个原本无序的二维问题降维成了一维的有序比较问题。
2.2 贪心思想在这里的落地
排序之后选择什么样的合并策略?这就要用到贪心了。我们把区间按起点从小到大排序,然后维护一个结果列表。遍历每个区间时,看当前区间能不能和结果列表里最后一个区间合并。
这里有一个关键点:结果列表里的最后一个区间,是所有已经处理过的区间中“最右边”的一个,因为后面的区间起点只会更靠后。所以只要当前区间的 start 小于等于结果列表中最后一个区间的 end,那就说明产生了重叠。此时需要做的不是新建区间,而是把结果列表最后一个区间的 end 更新为两者 end 的较大值。
为什么取较大值?因为虽然起点有序,但终点不一定有序。比如第一个区间是 [1,10],第二个区间是 [2,3],第二个被完全包裹在第一个里,合并后的终点仍然是 10,而不是 3。这是新手最容易出错的地方,后面我会专门展开讲。
如果当前区间的 start 大于结果列表最后一个区间的 end,说明它们完全分离,此时才需要把当前区间作为一个全新的区间加入结果列表。这样处理的逻辑,本质上是每一步都保留“当前合并后最靠右的终点”,贪心地让已合并区间尽可能地覆盖更多后续区间,最终达到合并所有重叠区间的最优结果。
2.3 复杂度分析:为什么排序是最优解的前置步骤
从复杂度角度看,排序是 O(n log n),一次遍历是 O(n),所以整体时间复杂度是 O(n log n)。空间复杂度方面,排序通常会消耗 O(log n) 的递归栈空间,如果不允许修改原数组,用来存储结果列表还需要 O(n) 的额外空间;如果允许原地修改输入数组,那空间上能省一点,但通常面试中不纠结这一点。
有同学问:能不能不排序直接做?我见过一些另类思路,比如用哈希表记录覆盖范围、用图论找连通分量,但都会把问题复杂化,而且时间复杂度并不会更优。在 n log n 已经是比较排序理论下界的情况下,排序是所有常规解法里最干净、最好写的。对于这道题,面试官期待看到的就是排序加一次遍历,而不是花里胡哨的黑科技。
3. 题解实现:合并区间常规解法三步走
3.1 第一步:边界处理与排序的细节
写代码之前,先把边界条件想清楚。常见的输入情况有三种:空数组、只有一个区间、有多个但完全无序。空数组直接返回空列表,这是 LeetCode 上必须处理的情况,否则后续访问会越界。只有一个区间的数组不需要合并,原样返回即可。
排序本身也有细节。Java 和 Python 里可以很方便地对二维数组做排序,但是要给比较器或者 key 参数指定排序依据,否则默认按字典序排,排序结果可能不符合预期。
Python 写法很简单,intervals.sort(key=lambda x: x[0])表示按每个区间的起始值排序;Java 需要使用Arrays.sort(intervals, (a, b) -> a[0] - b[0]);C++ 则常用sort(intervals.begin(), intervals.end()),因为vector<pair<int,int>>或者vector<vector<int>>默认会按第一个元素排序。
这里提醒新手:如果输入是int[][]且区间宽度固定为2,直接使用默认的字典序排序导致的问题不大,但为了语义清晰,最好显式指定按起点排序。这样代码的可读性更好,面试时也更容易讲清楚自己的思路。
3.2 第二步:一次遍历完成合并的核心逻辑
排序完成后,核心逻辑就非常简洁了。我们可以维护一个结果列表merged,先把排序后的第一个区间放进去,然后从第二个区间开始遍历。
每次取当前区间cur,同时观察结果列表最后一个区间last。如果cur[0] <= last[1],说明两个区间有重叠,那么我们执行合并:更新last[1] = max(last[1], cur[1]),注意这里必须取最大值,因为可能出现包含关系。如果cur[0] > last[1],说明当前区间和已有的所有区间都没有重叠(由于排序保证,后续也不可能和之前的其他区间重叠),直接merged.append(cur)。
这个遍历过程中有个隐含性质:因为结果列表里的区间已经按照起点排序,而且每次合并后我们都尽量把终点往右扩展,所以结果列表中的区间始终是有序且互不重叠的。确认了这一点,整个算法可以放心运行到最后。
3.3 第三步:代码落地(Python / C++ / Java 片段)
我先给出最常用的 Python 实现,这也是我在 LeetCode 上提交通过率最高的版本:
def merge(intervals): if not intervals: return [] intervals.sort(key=lambda x: x[0]) merged = [intervals[0]] for cur in intervals[1:]: last = merged[-1] if cur[0] <= last[1]: last[1] = max(last[1], cur[1]) else: merged.append(cur) return mergedC++ 实现可以这么写,这里使用的是vector<vector<int>>:
class Solution { public: vector<vector<int>> merge(vector<vector<int>>& intervals) { if (intervals.empty()) return {}; sort(intervals.begin(), intervals.end()); vector<vector<int>> merged; merged.push_back(intervals[0]); for (int i = 1; i < intervals.size(); ++i) { vector<int>& last = merged.back(); if (intervals[i][0] <= last[1]) { last[1] = max(last[1], intervals[i][1]); } else { merged.push_back(intervals[i]); } } return merged; } };注意这里的vector<int>& last,用的是引用,目的是直接修改merged中最后一个区间的终点。如果忘记加引用,修改的只是副本,会导致合并结果完全不生效。这一点是很多从 C++ 入门刷题的同学容易踩的坑。
Java 版本实现如下:
class Solution { public int[][] merge(int[][] intervals) { if (intervals.length == 0) return new int[0][2]; Arrays.sort(intervals, (a, b) -> a[0] - b[0]); List<int[]> merged = new ArrayList<>(); merged.add(intervals[0]); for (int i = 1; i < intervals.length; i++) { int[] last = merged.get(merged.size() - 1); if (intervals[i][0] <= last[1]) { last[1] = Math.max(last[1], intervals[i][1]); } else { merged.add(intervals[i]); } } return merged.toArray(new int[merged.size()][]); } }3.4 核心边界条件的检验清单
我在刷这道题时给自己列过一个边界测试清单,每次写完代码都会挨个过一遍,不建议跳步。
intervals = [],应该返回[],不能报数组越界。intervals = [[1,4]],只有单个区间,返回原数组。intervals = [[1,4],[2,3]],存在包含关系,合并结果是[[1,4]],检验的是end取最大值还是直接覆盖。intervals = [[1,4],[5,6]],恰好相邻但不重叠,因为5 > 4,合并结果应该是[[1,4],[5,6]]。intervals = [[1,4],[4,5]],起点等于终点,左闭右闭区间下有重叠,结果是[[1,5]]。intervals = [[1,4],[0,2],[3,5]],乱序输入,排序后合并,检验是否对原始无序数据有效。
把这些用例自己跑一遍,基本就能确定代码的正确性了。特别是第四种和第五种情况,很多人在写重叠条件的时候会用cur[0] < last[1],这里如果忽略了题目给出的区间是左闭右闭,一旦相邻相等就判断为不重叠,那结果就会出错。
4. 实操中的常见问题与调试实录
4.1 经典错因:为什么用 max 而不是直接赋值
遇到过不少同学在合并时写的是last[1] = cur[1],而不是last[1] = max(last[1], cur[1])。这种写法只在一种情况下正确,就是新区间的终点比旧区间大;一旦出现一个区间完全包裹另一个区间,比如[[1,10],[2,3]],合并的终点应该是 10,但错误写法会把它改成 3,导致结果出错。
为什么会有这种惯性思维?因为很多人脑子里想象的重叠都是“两个区间部分交叉,新的 end 肯定更大”。实际上区间之间存在三种位置关系:完全不重叠、部分重叠、完全覆盖。部分重叠时更新为更大的 end;完全覆盖时旧区间 end 本来就更大,不能缩小。
这个知识点我用一句话总结了:合并区间的 end,只可能变大或不变,绝无可能变小。掌握了这个规律,写max就是必然选择,而不是背写法。
4.2 经典错因:开区间/闭区间混淆引发的边界灾难
很多题解会告诉你判断条件是cur[0] <= last[1],但推导过程里用的是开区间或半开半闭的讨论,跟题目本身不完全一致。你要是只听结论不看区间定义,很可能在相邻区间[[1,4],[4,5]]上栽跟头。
在左闭右闭区间模型里,[1,4]和[4,5]在数字 4 处相交,所以它们必须合并成[1,5]。如果错误地使用cur[0] < last[1]作为重叠条件,就会认为它们不重叠,最终输出错误答案。反过来,在会议室问题中,因为会议结束时间和下一场开始时间通常不共享,判断条件可能不同,这要具体题目具体分析。所以刷题时第一步永远要先看清楚区间开闭定义,别想当然套模板。
4.3 关于“原地修改”和返回结构的困惑
我在代码交流区经常看到有人问:我不开新的 merged 列表,直接修改原数组行不行?理论上当然可以,比如排完序后,使用一个指针 idx 指向当前已合并区间的末尾,遍历时不断原地更新intervals[idx],最后只返回前 idx+1 个区间。但这样做会留下一个脏数据区域,如果用 Python 切片可以轻松截断,但在 C++ 或 Java 里需要额外处理数组长度。
个人建议:除非面试官明确要求 O(1) 额外空间或者不允许开辟新结构,否则没必要做这种优化。merged列表在整个算法过程中是必要的——因为它承担了“动态维护最后一个区间终点”的功能。用空间换代码清晰度,在这种题目里完全划算,面试官不会因为你多开一个结果数组就扣分。
4.4 性能与代码风格的细节建议
虽然这道题核心是排序加扫描,但在代码性能上仍有一些值得打磨的地方:
- 在 Python 里
intervals.sort()用的是 TimSort,对基本有序的数组非常快,所以先别自己写排序函数,标准库性能更好。 - Java 里如果担心
a[0] - b[0]溢出,可以用Integer.compare(a[0], b[0]),这道题区间范围一般到不了溢出边界,但这是个好习惯。 - 遍历时建议使用基于索引的循环而不是创建子数组切片,比如不要写
for cur in intervals[1:]:,因为切片会额外拷贝一份列表,增加空间开销;直接用for i in range(1, len(intervals))更规范。当然实际上对于时间要求不苛刻的 LeetCode 题目差别不大,但面试中主动提到这个点能加分。
注意:热题100里的很多题目,通不过往往不是算法有问题,而是代码风格和边界没处理好。学会在写完代码后主动说明“这段代码的边界处理在哪里、为什么这么写”,是面试官非常看重的工程素养。
5. 从热题100看合并区间的变形与面试追问
5.1 热题100里相关的区间类问题
力扣热题100是一个面向面试的精选题库,里面其实藏着多条和区间相关的暗线。合并区间这道题,往前关联的是第57题插入区间,往后关联的是第252题会议室、第253题会议室 II、第435题无重叠区间,以及第452题用最少数量的箭引爆气球。
插入区间和合并区间的关系非常紧密:给你一个已经按起点排好序且不重叠的区间列表,再插入一个新区间,要求最后依然有序且不重叠。解法就是一个变形的合并流程,核心逻辑依然是“找重叠区间、合并终点取最大”。你要是能先把合并区间吃透,插入区间基本就是多了一个二分查找定位插入位置的步骤,代码思路几乎一样。
无重叠区间则反过来了:给定一堆区间,问至少要移除多少个区间,才能让剩下的区间互不重叠。它的贪心策略是“按终点排序,每次保留终点最小的区间”,本质上也是区间覆盖问题的标准解法。这些题放在一起刷,你会发现自己对区间重叠模型的理解会快速上一个台阶。
5.2 面试官常见的追问与应对思路
面试官在合并区间之后特别喜欢追问几个变种,建议提前做好准备。
第一个追问:如果输入的区间有很多,而且不是一次性给你,而是以数据流的形式到达,怎么处理?这是一个在线算法问题。因为你无法预知未来区间,预处理排序就失效了,通常需要我们按到达顺序用有序结构动态插入,比如平衡树。每次插入新区间时,找和它有重叠关系的前驱与后继区间进行合并,整体复杂度是 O(log n) 每次插入。这个追问考的是对排序预处理不可用的敏感度。
第二个追问:如果区间是字符区间或者泛型区间,而不只是数字区间,能不能用同样的思路?其实只要区间端点定义了全序关系,按起点排序、终点取更大的思路依然成立,只是你没法直接用数组下标做比较,需要抽象出 Comparable 方法。这个追问往往是想考代码设计的抽象能力。
第三个追问:如果区间存在嵌套关系,例如多个区间都包含同一个小区间,你如何保证合并结果不遗漏也不过度合并?这就是回到算法自身正确性的证明上。你可以用数学归纳法按步推演,也可以画图把区间的排序后关系可视化。能把这个讲清楚的人,基本上说明是真正理解了这道题。
5.3 合并区间在真实业务场景的映射
聊完面试,我再说点实际的。合并区间并不只是刷题专用,它在真实业务里的映射非常广。最典型的是日程安排与会议管理,给定一批会议的起始时间,我们要合并出所有忙碌时间段,看哪些时段是空闲的。再比如在线广告投放系统的排期管理,多个订单可能覆盖同一时间段的广告位,系统需要把重叠的投放计划合并成一条记录,用于库存扣减和计费。
去年我在处理一个用户活跃时段统计的需求时,就把用户的登录日志按日期时间粒度切割成区间,然后需要对不同来源的登录记录做一次区间合并,才能算出每个用户单日在线时间的真实覆盖范围。当时用的就是先排序、再线性扫描合并的套路,虽然是业务代码而不是算法题,但核心逻辑完全一致。所以说,合并区间的思维确实能直接迁移到工程实践里,这也是这类题能常驻热题100的重要原因。
6. 备考点拨:如何把这道题在面试中讲到加分
6.1 讲思路的仪式感比炫技更重要
面试写算法题时,最忌讳拿过来就写。就算你已经见过这道题,也建议按“理解题目、澄清边界、提出思路、写代码、验证用例”的节奏来表现。对合并区间这道题,我建议的叙述思路是这样的:
先跟面试官确认区间是左闭右闭,然后分析暴力求解的复杂度,再说“如果先把区间按起点排序,重叠判断只在相邻区间之间进行,就能用一次扫描完成合并。于是整个算法的瓶颈落在排序上,总体复杂度 O(n log n)”。这种结构清楚、从问题出发推导解决路径的表达方式,比直接喊一句“用贪心”要有说服力得多。
6.2 代码写完后要主动自查什么
写完代码,不要干等面试官发问,主动拿例子过一遍是加分的表现。我会用题目自带的案例走一遍:排序后变成[[1,3],[2,6],[8,10],[15,18]];扫描到 [2,6] 时发现 2 小于等于 3,更新终点为 6;扫描到 [8,10] 时发现 8 大于 6,新增区间;扫描到 [15,18] 时发现 15 大于 10,新增区间,最后得到正确结果。
走完例子后,我还会特意提一个“易错点自查”:不会出现终点变小的情况,因为每次合并都用max更新;不会漏掉最右侧的区间,因为循环结束后 original 的最后一个区间要么被合并进结果,要么以独立区间入结果。这两点放到面试里说,会证明你有良好的算法敏感度。
6.3 给复习节奏的一点个人建议
刷题这事儿,我自己的体验是:不要追求刷完多少题,而是要追求把一类题真正吃透。合并区间作为一个经典代表,应该进入你的“二刷重点清单”。第一遍刷的时候可能只求 AC,第二遍建议你自己给自己讲一遍思路,能不能卡住不查资料写出来;第三遍再用它去串相邻的区间问题,尝试一题多解。
这道题选进热题100不是偶然——它难度适中,既能筛选出完全没有区间概念的新手,也能通过追问区分出只会背模板和真正理解贪心的人。如果你能把这道题讲透,热题100里面很多题目你都会觉得轻松一些。
我自己的体会是,区间题最重要的不是记住某种固定写法,而是培养一种条件反射:看到区间问题,先问自己,能不能排序?排序以后能不能简化重叠条件?如果答案是可以,就果断排序。这种直觉一旦建立起来,你会发现自己解很多区间相关题目的速度都会快起来。希望这篇内容能帮你在合并区间这道题上节省一些摸索时间,后面刷插入区间、会议室这些题,你会明显感觉顺手很多。