区间合并这个说法听起来有点学术,但你在日常写业务代码时大概率已经悄悄用过它了。日历里把一堆时间交叉的会议邀请合成几段空闲时间、日志系统把反复报错的连续时间段压成一条告警、内存管理里把相邻的碎片块拼回一整块可用空间,背后都是同一个动作:把一堆互相重叠的数值范围揉成互不相交的几段。它属于那种"套路极其固定、但细节极其容易翻车"的题型,面试里出现的频率高得离谱,工程里也真用得上。下面我按自己平时写题解和带新人的习惯,从场景、思路、逐步推演、代码实现到踩坑排查完整走一遍,Python、C++、Java 三个版本的参考代码都会给全,新手能跟着一行行敲出来,有基础的可以直接跳到第 4 节的例题和第 5 节的坑清单。
1. 区间合并到底在解决什么问题
1.1 从几个日常场景说起
先别急着看代码,我们把问题还原到真实场景里,你会瞬间明白它为什么值得单独拿出来讲。假设你在做一个会议室预订系统,前端把用户选中的时间片段传给你,格式是一串[开始时间, 结束时间],但这些片段可能是重叠的:有人先订了 9:00 到 11:00,另一个人又补订了 10:00 到 12:00,还有人选了 14:00 到 15:00。界面上的时间轴如果想画得干净,就必须先把 9:00-11:00 和 10:00-12:00 揉成 9:00-12:00,再和 14:00-15:00 并列展示。这个"揉"的动作,就是区间合并。
再举个更贴近后端的例子。你负责的告警系统每分钟采集一次服务状态,凡是响应时间超过阈值的时刻都会打上标记,最终你拿到的是若干个"连续异常"的时间区间。如果某个服务从 10:05 一直抖到 10:40,中间每次采集都异常,那么采集层可能给你几十个首尾相接的[10:05,10:06]、[10:06,10:07]……这种数据既冗余又难展示,合并成一条[10:05,10:40]才是人看的东西。还有一类更硬核的场景:在内存分配器里维护空闲块链表,释放内存后相邻的空闲块要合并成更大的块,否则碎片会越积越多,本质上也是区间合并,只是工程实现里还会加上"相邻即合并"的额外判定。你看,同一个算法骨架,换个外壳就能落到完全不同的业务里,这也是它值得吃透的原因。
1.2 暴力做法为什么行不通
面对"合并重叠区间"这个需求,最朴素的想法是:拿第一个区间,去和后面每一个比,能合并就更新,然后再拿第二个、第三个……写出来大概是一个双重循环。这个思路本身没错,结果也是对的,但代价是 O(n²)。当区间数量只有几十个时你感觉不到,一旦数据量上到几万、几十万条就彻底崩了——假设 n = 100000,双重循环就是 10^10 级别的基本操作,放到任何评测环境里都是秒超时的命。
问题的根源在于,暴力做法没有利用区间之间任何的结构信息。它每次都从头扫,做了大量重复比较。实际上,如果我们能让所有区间按照某种顺序站好队,那么"能不能合并"这个判断就可以在相邻元素之间一次性完成,完全不需要回头看更早的区间。这就是排序的价值,也是下一节要展开的核心。我经常跟新人说一句话:凡是涉及"范围""区间""线段"的题,先想想排序能带来什么,十有八九能把复杂度从 O(n²) 降到 O(n log n)。
1.3 问题的标准形式与输入输出约定
正式动手之前,必须把输入输出的约定钉死,否则你会在细节上反复返工。标准形式是这样的:给定一个区间集合,每个区间用两个数表示左右端点,比如[1,3]表示从 1 到 3 这一整段(通常是闭区间,也就是包含 1 和 3)。要求把所有相互重叠(哪怕只是端点相碰)的区间合并,输出一个按左端点升序排列、彼此不相交的区间列表。
举个具体的:输入[[1,3],[2,6],[8,10],[15,18]],输出应该是[[1,6],[8,10],[15,18]]。因为[1,3]和[2,6]有重叠,合起来是[1,6];[8,10]和前面的[1,6]之间隔着 6 到 8 的空隙,不重叠,单独保留;[15,18]同理。
这里有几个约定必须提前想清楚。第一,输出列表是否要求有序?绝大多数题目要求有序,而且是按左端点升序,这正好和你排序之后的结果一致,不需要额外操作。第二,空输入怎么办?返回空列表即可,别让它进排序逻辑报错。第三,单个区间怎么办?直接返回它本身。第四,端点相等算不算重叠?比如[1,4]和[4,7],绝大多数题目认为算重叠,合并成[1,7],因为闭区间里 4 这个点被两边都覆盖了。这最后一条是新手最容易翻车的地方,我后面会用整整一小节讲清楚。
2. 核心思路拆解:排序加一次扫描
2.1 排序为什么是绕不开的第一步
把区间按左端点从小到大排序,这个动作的本质是在给问题建立"单调性"。排序之前,区间的左右关系是混乱的,你可能遇到一个左端点很大、右端点很小的区间夹在中间,根本无法预测下一个区间会不会和当前处理的合并块重叠。排序之后,情况就完全不同了:遍历到第 i 个区间时,它的左端点一定不小于前面所有区间的左端点。这意味着,如果第 i 个区间能和前面某个合并块接上,那么它必然能和"当前正在维护的那个合并块"接上,而不需要回头检查更早的块。
用类比来说,这就像排队。一群人乱糟糟站在一起,你没法一眼看出谁和谁挨着;但如果让他们按身高从左到右排好,你就只需要盯着相邻两个人的身高差就行了。排序把"全局两两比较"降级成了"相邻比较",这是整个算法能做到 O(n log n) 的关键,也是它区别于暴力做法的根本原因。
2.2 为什么只和"上一个"比较就够了
这一条值得单独掰开讲,因为它涉及算法正确性,很多人代码写对了但说不出为什么。我们要证明的是:排序之后,遍历过程中只需把当前区间和"结果数组中最后一个区间"比较。
假设结果数组里最后一个区间是last,当前遍历到的区间是cur,由于排序保证cur的左端点不小于last的左端点(其实是不小于所有已处理区间的左端点,自然也不小于last的左端点)。此时判断重叠只看一件事:cur的左端点是否不超过last的右端点。如果不超过,说明两者在数轴上是有交集的,合并后的右端点取两者右端点的较大值;如果超过,说明cur整个落在last的右边,中间隔着一段空白,不可能再和前面的任何区间重叠,因为前面所有区间的右端点都不超过last的右端点(合并时我们一直取的是最大值,last的右端点已经是目前所有已合并区间的最大右端点)。
这个证明里最关键的一点是:last的右端点维护的是一段合并块的"最远右边界",而不是某一个原始区间的右端点。正因为它是当前合并块能延伸到的最远处,后面的区间只要左端点超过它,就绝对接不上。理解了这一点,你就不会写出"只比较相邻两个原始区间"这种错误逻辑了——那样会漏掉跨区间的大范围覆盖。
2.3 三种位置关系与合并判定
把两个区间扔到数轴上,按照排序后的顺序,只可能有三种关系。第一种是完全分离:cur的左端点大于last的右端点,比如last = [1,3],cur = [5,8],两者中间隔着 (3,5) 这段空白,不能合并,直接把这个cur作为新的合并块放进结果数组。第二种是部分重叠:cur的左端点落在last内部,但右端点超出,比如last = [1,5],cur = [3,8],合并结果是[1,8],左端点保持last的,右端点更新为cur的右端点。
第三种是包含关系:cur完全被last包住,比如last = [1,10],cur = [3,5],此时合并结果还是[1,10],右端点不能盲目换成 5,否则就把后面 5 到 10 的内容丢了。这就是为什么合并时右端点必须写成"两者取较大值"而不是"直接赋值为cur的右端点"。新手写代码时最容易在这里出错,因为前两种情况看起来都像是"把右端点更新成cur的右端点",只有遇到包含关系才会暴露问题。记住一句话:合并右端点永远取 max。
我把这三条判定整理成表格,写代码时对着抄就行。
| 关系类型 | 判定条件 | 处理动作 | 合并后区间 |
|---|---|---|---|
| 完全分离 | cur.left > last.right | 不入合并块,作为新区间追加 | last 不变,追加 cur |
| 部分重叠 | cur.left <= last.right 且 cur.right > last.right | 扩展右端点 | [last.left, cur.right] |
| 完全包含 | cur.left <= last.right 且 cur.right <= last.right | 不做任何修改 | last 保持不变 |
2.4 端点取舍:闭区间还是半开区间
这一节是我踩过坑之后专门加的。题目里说[1,3]和[3,6]要不要合并,答案取决于你的端点约定。如果是闭区间(包含端点),3 这个点被第一个区间覆盖了,也被第二个区间覆盖了,两者在 3 处相接,逻辑上应该连成一片,所以要合并成[1,6]。判断条件就是cur.left <= last.right,用的是小于等于。
如果是半开区间[1,3)表示 1 到 3 但不含 3,那么[1,3)和[3,6)在数轴上刚好首尾相接但不重叠,中间没有任何一个点被同时覆盖,严格来说不合并。这时候判断条件就变成了cur.left < last.right,用严格小于。
区分这两种情况在实际工程里不是抠字眼。举个时间调度的例子:一个任务占用 9:00 到 10:00,下一个从 10:00 开始,闭区间语义下它们是背靠背没有间隙的,可以直接拼成 9:00 到 11:00;但如果你的系统里 10:00 这一刻需要有一个"切换动作",那就必须把半开区间当作不重叠,否则你会丢失那个切换点。所以写代码前先跟需求方确认端点语义,别自己拍脑袋。下面所有代码我都按闭区间<=来写,这是刷题和面试的默认约定,实际项目里记得按需改。
3. 从零写出参考代码
3.1 数据结构与输入约定
代码实现的第一步是确定数据怎么存。最通用的做法是用一个二维数组或列表,每个元素是一个长度为 2 的数组[left, right]。Python 里就是List[List[int]],C++ 里是vector<vector<int>>或者更省内存的vector<pair<int,int>>,Java 里是int[][]或者List<int[]>。我在实际写题时更倾向vector<pair<int,int>>,因为 pair 的比较默认先比 first 再比 second,排序时连自定义比较器都省了。
坐标范围也需要留意。如果题目给的端点数值可能到 10^9 甚至更大,做加法、取长度的时候就要用 64 位整数,C++ 里用long long,Java 里用long,Python 天生大整数不用管。我见过有人统计覆盖总长度时用 int 相乘导致溢出,结果答案莫名其妙变成负数,调试半天才发现是类型问题。这种坑不值得踩第二次,养成看数据范围的习惯就行。
另外,输入可能是乱序的,也可能本身就有序,甚至可能包含重复区间。这三种情况算法框架都能处理,但如果你能从上下文判断出"输入本身已经有序",那可以省掉排序这一步,把复杂度压到 O(n)。比如从数据库里ORDER BY start_time查出来再合并,就属于这种场景。多问自己一句"数据是不是已经有序",往往能换来一波性能提升。
3.2 Python 版本逐行讲解
from typing import List def merge(intervals: List[List[int]]) -> List[List[int]]: # 特判:空数组直接返回,避免后续排序和下标访问出错 if not intervals: return [] # 第一步:按左端点升序排序 # 如果左端点相同,按右端点升序,这样包含关系会先被纳入 intervals.sort(key=lambda x: (x[0], x[1])) # 结果数组,先把排序后的第一个区间放进去作为初始合并块 merged = [intervals[0][:]] # 注意拷贝一份,别直接引用原数组 # 从第二个区间开始遍历 for i in range(1, len(intervals)): cur_left, cur_right = intervals[i] last = merged[-1] # 当前结果数组里最后一个合并块 if cur_left <= last[1]: # 有重叠(含端点相接),合并:右端点取较大值 last[1] = max(last[1], cur_right) else: # 完全分离,当前区间成为新的合并块 merged.append([cur_left, cur_right]) return merged逐行看,第一处细节是if not intervals的空数组特判。Python 里对空列表排序不会报错,但后面intervals[0]会越界,所以必须提前挡掉。第二处是排序的 key,我写成了(x[0], x[1])元组,意思是先按左端点排,左端点相同时按右端点排。这么做的好处是当出现[1,5]和[1,3]这种左端点相同的区间时,短的[1,3]排在前面,先被并入合并块,后面[1,5]来了直接走 max 逻辑更新右端点,逻辑干净。如果只按左端点排,顺序就依赖于排序算法的稳定性,虽然结果也对,但可读性差一些。
第三处是merged = [intervals[0][:]]里的[:]。这是浅拷贝,因为intervals[0]是个 list,如果不拷贝直接塞进去,后面last[1] = max(...)会同时改到原数组的元素。虽然在纯函数式解法里改原数组也无所谓,但一旦调用方还依赖原始数据就会出问题。养成拷贝的习惯,能省掉一类莫名其妙的 bug。
第四处也是最核心的:cur_left <= last[1]里用的是last[1]而不是merged[-1][1]的原始值,因为last是引用,前面的 max 更新会自动反映到它身上。这个引用语义在 Python 里很方便,但在 C++ 里如果你把vector的元素拷贝出来变成了值,就不会自动同步了,必须写回原位置,这是语言差异导致的一个常见坑,我在 3.3 的 C++ 版里会特意说明。
3.3 C++ 与 Java 版本参考
C++ 版本,我用vector<vector<int>>来写,这样更贴近主流题目接口。
#include <vector> #include <algorithm> using namespace std; class Solution { public: vector<vector<int>> merge(vector<vector<int>>& intervals) { vector<vector<int>> res; if (intervals.empty()) return res; // 按左端点升序,左端点相同按右端点升序 sort(intervals.begin(), intervals.end(), [](const vector<int>& a, const vector<int>& b) { if (a[0] != b[0]) return a[0] < b[0]; return a[1] < b[1]; }); res.push_back(intervals[0]); for (size_t i = 1; i < intervals.size(); ++i) { // 注意:这里必须取引用,否则修改不会写回 res vector<int>& last = res.back(); if (intervals[i][0] <= last[1]) { last[1] = max(last[1], intervals[i][1]); } else { res.push_back(intervals[i]); } } return res; } };这里我要重点强调的就是那句注释:vector<int>& last = res.back();必须用引用。我刚开始写这道题的时候,写成了vector<int> last = res.back();,结果每次合并都只改了last这个局部拷贝,res里的元素纹丝不动,输出结果和输入一模一样,排查了二十分钟。C++ 里 vector 的 back() 返回引用,你显式声明引用类型才会绑定过去,声明成值类型就触发拷贝。Java 和 Python 不存在这个问题,因为它们存的是对象引用。这个坑在 C++ 面试里出现的频率相当高,面试官甚至可能故意看你写不写&。
Java 版本。
import java.util.*; class Solution { public int[][] merge(int[][] intervals) { if (intervals == null || intervals.length == 0) return new int[0][]; // 按左端点升序排序 Arrays.sort(intervals, (a, b) -> a[0] != b[0] ? a[0] - b[0] : a[1] - b[1]); List<int[]> res = new ArrayList<>(); res.add(intervals[0]); for (int i = 1; i < intervals.length; i++) { int[] last = res.get(res.size() - 1); if (intervals[i][0] <= last[1]) { // 合并,右端点取较大值 last[1] = Math.max(last[1], intervals[i][1]); } else { res.add(intervals[i]); } } return res.toArray(new int[res.size()][]); } }Java 版本里res.get(res.size() - 1)拿到的是数组对象的引用,直接改last[1]就能改到res里存的那个数组,不存在 C++ 的拷贝问题。需要注意的是Arrays.sort里 lambda 的写法,a[0] - b[0]在坐标范围较小的时候没问题,但如果端点可能达到 int 的极值附近,相减会溢出,稳妥写法是用Integer.compare(a[0], b[0])。这个溢出坑也很典型,属于"范围一大就翻车"的类型。
3.4 复杂度分析与常数优化
时间复杂度由排序主导,是 O(n log n),后面的遍历是 O(n),总的就是 O(n log n)。空间复杂度是 O(log n) 到 O(n),取决于排序算法的实现和输出结果本身的大小。这个量级已经足够应付绝大多数场景,10^5 个区间跑起来是毫秒级的。
如果想再压常数,有几个方向。第一个是前面提到的:如果输入已经有序,直接跳过排序,变成 O(n)。第二个是减少中间对象的创建,比如 Python 里intervals[0][:]每次都会新建 list,可以改成merged = []然后统一走 append 逻辑,只在需要的时候创建,代码稍微绕一点但对象分配少一些。第三个是在 C++ 里把vector<vector<int>>换成vector<pair<int,int>>,pair 的内存布局更紧凑,缓存友好度更高,排序时的交换成本也更低,大数据量下能看出差别。这些优化在刷题时没必要都上,但如果你要把这段逻辑嵌到高频调用的服务里,就值得考虑。
4. 例题实战:三道递进式题目
4.1 基础例题:合并所有重叠区间
题目就是我前面一直在用的那个:给一组区间,合并所有重叠部分,返回互不相交的结果。
输入:[[1,3],[2,6],[8,10],[15,18]]输出:[[1,6],[8,10],[15,18]]
完整的推演过程我走一遍,你可以拿张纸跟着画。第一步排序,原数组已经有序了,保持不变。第二步把[1,3]放进结果。第三步看[2,6],它的左端点 2 小于等于结果最后一块的右端点 3,重叠,合并成[1,max(3,6)] = [1,6]。第四步看[8,10],左端点 8 大于当前合并块的右端点 6,分离,追加成[8,10]。第五步看[15,18],左端点 15 大于[8,10]的右端点 10,分离,追加。最终结果[[1,6],[8,10],[15,18]],和预期一致。
再换一组输入验证包含关系:[[1,4],[0,4]]。排序后变成[[0,4],[1,4]]。先放[0,4]。看[1,4],左端点 1 小于等于 4,重叠,右端点取 max(4,4)=4,结果还是[0,4]。正确,第二个区间被完全吸收。再看[[1,4],[2,3]],排序不变,先放[1,4],看[2,3],左端点 2 小于等于 4,合并,右端点 max(4,3)=4,结果[1,4]。如果这里把右端点直接赋成 3,就会错误地输出[1,3],把 3 到 4 这段丢了。这就是包含关系的典型陷阱。
4.2 进阶例题:插入区间
这道题换个方向考你:已经有一个排好序、互不重叠的区间列表,现在插入一个新的区间,要求合并后仍然保持有序且互不重叠。比如已有[[1,3],[6,9]],插入[2,5],结果是[[1,5],[6,9]]。
思路和基础题是同一个内核,但因为原列表已经有序,可以直接一次遍历分三段处理。第一段:所有右端点小于新区间左端点的区间,完全在左边,原样放进结果。第二段:所有和新区间有重叠的区间,把它们和新区间揉成一个更大的区间,具体做法是不断取左端点的最小值和右端点的最大值来扩展新区间。第三段:剩下的区间完全在右边,原样放进去。
拿刚才的例子走一遍。新区间[2,5]。第一段,[1,3]的右端点 3 不小于 2,不属于第一段,跳过。第二段,[1,3]的左端点 1 小于等于 5,和新区间重叠,扩展新区间为[min(2,1), max(5,3)] = [1,5];下一个[6,9]的左端点 6 大于 5,不重叠,第二段结束。第三段把[6,9]加进去。结果[[1,5],[6,9]]。
这道题有个容易忽略的边界:新区间可能和多个已有区间重叠,比如已有[[1,2],[3,10],[12,16]],插入[4,20]。第一段没有。第二段里[1,2]不重叠(2 小于 4),跳过;[3,10]重叠,扩展为[3,10]和[4,20]的并集[3,20];[12,16]的左端点 12 小于等于当前扩展后的右端点 20,继续重叠,扩展为[3,20]。最后结果[[1,2],[3,20]]。如果你在第二段只合并一次就跳出,就会漏掉[12,16],这是这道题最常见的错误。
4.3 变式例题:合并后统计覆盖总长度
工程里更常见的需求不是要合并后的区间列表,而是要所有区间覆盖的总长度。比如统计用户在线时长、磁盘被占用的总字节区间等等。做法是先合并,然后把每个合并块的右端点减左端点累加起来。
给个具体例子:[[1,3],[2,5],[8,10]],合并后是[[1,5],[8,10]],总长度是 (5-1) + (10-8) = 4 + 2 = 6。注意这里的长度计算依赖端点语义,如果区间是[1,3]表示覆盖了 1、2、3 三个整点,那长度其实是 3 不是 2,但如果是连续实数区间,长度就是 2。刷题时题目会明确说是长度还是点数,实际项目里你要根据业务定义清楚。我之前做过一个统计"连续在线天数"的需求,一开始按差值算少算了一天,后来才发现业务上要求的是含头含尾的天数,改成right - left + 1才对。这种语义差异必须在需求阶段就确认,代码写完了才改会很痛苦。
如果不需要输出区间列表,只是要总长度,还能省掉存储结果的数组,边扫描边累加,空间降到 O(1)(不含排序本身的开销)。代码就是在合并判定那里,一旦确认分离,就把当前合并块的长度加进总数,然后开启新块,循环结束后别忘了把最后一块补上。
4.4 变式例题:会议室数量与扫描线思路
再往外延一层,有一类题看起来不像区间合并,但内核相通:给一堆会议时间,问最少需要几间会议室。经典做法是把每个区间拆成两个事件点,左端点记为加一,右端点记为减一。把所有事件点按位置排序,位置相同时减号优先(因为一个会议 10 点结束,另一个 10 点开始,可以共用同一间),然后扫描一遍,维护当前正在进行的会议数量,记录下来出现的最大值就是答案。
这个扫描线思路和区间合并共享同一个直觉:把二维的区间问题投影到一维的数轴上,只关注每个位置发生的变化。区别在于区间合并关注的是"谁和谁连成一片",扫描线关注的是"某一时刻同时有多少个区间在活跃"。理解了这两个视角,你能解决相当一大批区间类问题,比如区间调度、区间选点、天际线问题等等。我在遇到区间题时的第一反应就是问自己:这题是要合并、要计数,还是要排序贪心?问清楚了再动手。
5. 常见坑与排查技巧
5.1 必踩的坑清单
下面这些坑我几乎每条都踩过,或者在看别人代码时见过。
- 忘记排序。最致命的错误,不排序直接遍历,结果会随输入顺序变化,有时候过测试有时不过,属于"玄学 bug"。第一件事就是确认有没有排序。
- 合并右端点忘记取 max。遇到包含关系时右端点会被错误地缩小,导致结果区间变短。记住永远是 max。
- 结果数组最后一块忘记更新。在 C++ 里表现为没写成引用导致改的是拷贝,在 Python 里如果用了不可变类型也可能出现类似问题。
- 端点判断用了严格小于。闭区间语义下
[1,3]和[3,5]应该合并,写<就漏合并了。先确认端点语义再决定用<还是<=。 - 空输入没特判。
intervals[0]直接越界,线上可能直接抛异常。 - 坐标溢出。计算长度或做端点比较时用了 32 位整数,大范围数据下结果错误。
- 排序比较器写反。升序写成了降序,导致合并逻辑完全失效,但程序不报错,只是答案离谱。
- 原地修改了输入数据。有些场景调用方还依赖原始数组,你在里面排序加修改,会造成难以追踪的副作用。
5.2 边界用例速查表
写完之后不要只测题目给的样例,下面这些边界一定要过一遍,我整理成表方便你对着测。
| 用例类型 | 输入示例 | 期望输出 | 考察点 |
|---|---|---|---|
| 空输入 | [] | [] | 特判是否到位 |
| 单区间 | [[1,5]] | [[1,5]] | 循环边界是否越界 |
| 全部重叠 | [[1,4],[2,5],[3,6]] | [[1,6]] | 多次合并是否正确 |
| 端点相接 | [[1,3],[3,6]] | [[1,6]] | 闭区间判定是否用对 |
| 完整包含 | [[1,10],[3,5]] | [[1,10]] | 右端点是否取 max |
| 完全相同 | [[2,4],[2,4]] | [[2,4]] | 重复区间处理 |
| 逆序输入 | [[5,8],[1,3]] | [[1,3],[5,8]] | 排序是否生效 |
| 零长度区间 | [[3,3],[3,3]] | [[3,3]] | 单点区间合并 |
| 相邻不重叠 | [[1,2],[3,4]] | [[1,2],[3,4]] | 不能误合并 |
| 坐标极值 | [[-10^9,0],[0,10^9]] | [[-10^9,10^9]] | 类型是否溢出 |
这十组用例基本能覆盖 95% 以上的边界情况,养成写完就过一遍的习惯,能挡掉大量低级错误。我自己在提交前一定会跑空输入、单元素、全部重叠、端点相接这四组,属于肌肉记忆了。
5.3 调试与对拍技巧
如果代码跑出来的结果不对,第一步是打印中间状态,把排序后的数组、每一步遍历时的last和cur都打出来,肉眼找哪一步判断出了偏差。第二步是定位到具体哪个用例,别在多个用例之间来回切换,会越调越乱。
更高效的做法是写一个暴力版本做对拍。暴力版本用 O(n²) 的思路:反复扫描结果数组,只要发现两个区间能合并就合并,直到一轮扫描没有任何变化为止。这个版本虽然慢,但逻辑足够直白,几乎不可能写错,非常适合当参照物。然后随机生成几千组小规模数据,两个版本逐个比对输出,一旦发现不一致就把那组数据记下来单独分析。这个方法帮我在很多区间题上快速定位过逻辑漏洞,尤其是那种"大部分用例都对、个别用例错"的情况,靠人眼盯根本找不到。
对拍脚本用 Python 写最省事,十几行就能搞定。随机数范围不要太大,坐标控制在 -20 到 20 之间,区间数量控制在 1 到 8 之间,这样能快速生成大量容易触发重叠、包含、相接等各种关系的用例。跑个几百轮没差异,基本就能确认逻辑正确了。
6. 一些延伸与个人体会
6.1 这套思路还能往哪走
区间合并的骨架能延伸出不少变体。如果区间是动态增删的,每次操作后都要查询合并结果,那就需要一个能维护有序集合的数据结构,把 O(n log n) 的离线处理升级成在线处理。如果区间带了权重,合并时不是简单取并集而是要累加权重的最大值,那就是带权区间合并,常见于收益计算类问题。如果是多维区间,比如矩形求并,同样的"排序加扫描"思路会升级成扫描线加线段树,难度上一个大台阶,但核心直觉还是一维版本的延伸。
还有一个很实用的变体是"合并后保留原区间编号",用于追溯每段合并结果是由哪些原始区间构成的。做法是在合并块里额外维护一个列表,记录来源下标,合并时把两个列表拼接。这个在日志聚合、事件归并的场景里特别有用,因为你不光要知道哪段时间出问题,还要知道具体是哪些告警项贡献了这段异常。
6.2 我自己踩过的几点体会
第一条体会是,先想清楚端点语义再写代码。我在这上面浪费的时间加起来可能超过一整周,都是因为一开始没跟需求确认清楚,写完才发现要改判定条件,改动又牵连到长度计算、边界展示等一堆地方。现在我的习惯是任何一个区间类需求,先把"闭区间还是半开区间""端点相接算不算重叠"这两个问题问明白,白纸黑字记下来。
第二条体会是,合并结果一定要验证右端点的单调性。合并后的区间列表一定是左端点严格递增、右端点也严格递增的。如果你跑出来的结果里出现了右端点不递增的情况,说明合并逻辑一定有 bug,通常就是忘记取 max 导致的。这个性质可以当做一个快速自检手段,写完顺手扫一遍确认单调性,比一条条测用例快得多。
第三条是,别急着上高级数据结构。很多区间问题看起来复杂,其实排序加一次遍历就够了,上来就想线段树、树状数组,反而把简单问题搞复杂,还容易写错。我的判断标准是:如果区间是静态的(一次性给定,不做增删),那大概率排序就够了;只有需要动态维护时才考虑上更重的结构。这个判断能帮你省下大量实现成本。
最后分享一个小技巧:在面试里写这道题时,写完主体逻辑后主动说一句"这里排序的比较器我按左端点优先、右端点次之来写,这样可以保证左端点相同的区间先处理短的,避免依赖排序稳定性"。这一句话能让面试官看出你对细节的把控,比闷头写完代码效果好得多。很多时候面试考察的不是你会不会写,而是你有没有想过那些边界和细节。