刷LeetCode刷到区间题的时候,很多人第一反应是"这有什么难的",结果一写就错,一改就乱。标题写的是"Leetcode 130 合并区间 | 插入区间",我猜这里大概率是笔误,实际想说的应该是LeetCode 56合并区间和57插入区间这两道题。这两题在LeetCode热门100题里长期霸榜,周赛里也隔三差五换个包装出现,比如"合并重叠区间""会议室""扎气球"这些变体,核心模型全是同一个。这篇就把合并区间和插入区间从头到尾拆开揉碎,讲清楚排序为什么这样排、指针为什么这样走、边界条件到底有多少个坑,以及真正面试/工程里怎么用这套思想。
1. 区间题为什么值得花时间死磕:从LC周赛到工程实践
先说结论:区间题的考察密度非常高,而且它考的从来不是"你会不会写循环",而是你脑子里有没有"把问题抽象成区间模型"的习惯。LeetCode热题100里区间相关题目占了至少五六道,周赛430那一期的变体题我当时做过记录,本质上也在考区间合并和冲突判断。换句话说,这两道题不是刷完就扔的孤岛,它们是整个区间问题族的入口。
工程里的真实场景也特别多。比如后台系统要合并用户在线时长,一个用户今天登录了三次,每次登录时段有重叠,你要算出他实际在线多少小时;比如会议室的预订系统,要判断一个新会议能否插入到已有日程里;再比如网络运维要把一堆有交叠的IP段合并成一个大的网段。这些需求提取一下,全变成了一句话:给定一堆区间,合并它们,或者往有序区间列表里插入一个新的且保持无重叠。
所以我一直觉得,区间题是那种"刷一道顶十道"的题型。它的核心不是某个巧妙的算法,而是一套非常固定的思考流程:要不要排序、按什么排序、怎么处理相邻关系、边界条件怎么收尾。把这套流程练成肌肉记忆,后面遇到的变体题都能套。
2. 合并区间(LeetCode 56):排序贪心,一个指针搞定的事
2.1 核心思路:为什么必须先按左端点排序
合并区间的经典解法是排序+贪心。但很多人会问:为什么非要排序?不排序不行吗?
不排序,你每拿到一个新区间,都要和"当前合并结果"里的每一个区间比较并尝试合并,复杂度是O(n²),而且合并完以后可能还要反复回溯——因为你无法确定后面还有没有区间能和刚合并出来的新区间再次重叠。排序的意义在于,按左端点排序后,贪心策略有了一个确定的推进方向:后面的区间只可能从右边延伸出来,不会跑到当前合并区间的前面去。
打个比方:你有一堆有重叠的长条积木,想用尽量少的盒子把它们装起来。如果不排序,你得来回试错;如果按"每根积木的左端点位置"从前往后排好,那么当你把当前盒子扩展到能盖住的最远右端点时,就可以放心地看下一根积木,因为它不可能再往左伸出更长的部分。
2.2 代码走读:从排序到合并的完整实现
def merge(intervals): if not intervals: return [] # 按左端点排序,这是整个题的前提 intervals.sort(key=lambda x: x[0]) res = [intervals[0]] # 先取第一个区间作为当前合并目标 for l, r in intervals[1:]: # res[-1][1] 是当前合并区间的最右端点 if l <= res[-1][1]: # 有重叠,更新右端点(取较大值) res[-1][1] = max(res[-1][1], r) else: # 无重叠,直接开新区间 res.append([l, r]) return res这段代码很短,短到很多人背下来了,但背代码没用,要理解两个关键点。
第一,合并条件为什么是l <= res[-1][1]而不是<。因为区间相交包括"刚好端点相接"的情形。比如[1, 3]和[3, 5],它们有一个公共端点3,实际业务中这两个区间是连续的,可以合并成[1, 5]。如果你写了<,这个合并就被漏掉,结果注定错误。这个细节在LeetCode上属于边界测试点,在线面试的白板书写里更是高频翻车点。
第二,合并时右端点为什么用max而不是直接赋r。因为当前区间可能已经覆盖了很远的范围,比如res[-1]是[1, 10],现在来了个[2, 3],它完全在[1, 10]里面。如果直接res[-1][1] = r,右端点会从10被改小到3,后续区间的合并判断就全错了。这属于"看着代码没问题,一跑用例就炸"的典型低级错误,原因就是没有想清楚合并操作的本质是"取两个区间右端点的较大值",而不是"用新区间覆盖旧区间"。
2.3 排序细节:默认排序不一定对,key必须写清楚
Python里intervals.sort()也能跑通,因为默认按列表的字典序排序,先比左端点,再比右端点。但这里有个隐患:如果intervals是别的数据结构,比如自定义类实例,或者你后面要用其他语言(Java的二维数组默认排序行为就不一样),不显式指定key=lambda x: x[0],你就是在赌默认排序规则恰好符合需求。
更关键的是,按左端点排序后,只需要比较相邻区间,这个结论是贪心策略成立的基础。面试时把这句话说出来,比闷头写排序更有说服力:因为任意两个区间若相交,必然存在一条"相邻相交"的传播链,从左至右合并即可覆盖所有情况。
2.4 复杂度与空间优化
时间复杂度O(n log n),瓶颈在排序;空间复杂度如果不算输出结果就是O(log n)(排序栈),算上结果则是O(n)。工程里如果区间数量很大,可以考虑原地修改intervals来省内存,但不建议面试这么干,因为可读性差。
我在LC周赛430的签到题里就见过类似场景:给一堆形如[start, end]的区间,要求返回合并后的数量。当时不少人用了双重循环暴力比较,数据量一大直接超时。其实把合并逻辑写出来,最后返回len(res)就是答案。所以这个"骨架代码"值得放在笔记里反复看,它是很多变体题的地基。
3. 插入区间(LeetCode 57):在有序列表里放一个新区间
3.1 这题和合并区间的关系:插入本身就是"合并"的一种特殊形式
插入区间的题面是:有一个已经按左端点排序且无重叠的区间列表,给你一个新的区间,把它插进去,如果发生重叠就合并,最后返回新的无重叠列表。
很多人第一反应是:先把新区间append到列表里,然后调用一遍合并区间代码。这确实能通过,时间复杂度和合并区间一样是O(n log n)。但仔细看题面,输入的区间列表已经有序且不重叠了,你做的事情相当于"往有序数组里插一个元素",排序是多余的。
更优的做法是一趟线性扫描,因为原来列表已经有序,新区间只会"扰动"局部区域:它左边的部分完全不受影响,右边可能有若干区间要被合并,再右边又是完全不受影响。能把问题拆成三段,就说明这道题的本质是"定位"而非"排序"。
3.2 线性扫描的完整实现:三段式处理
def insert(intervals, newInterval): res = [] i = 0 n = len(intervals) # 第一阶段:把完全在新区间左边的区间直接收下 while i < n and intervals[i][1] < newInterval[0]: res.append(intervals[i]) i += 1 # 第二阶段:合并所有与新区间相交的区间 while i < n and intervals[i][0] <= newInterval[1]: newInterval[0] = min(newInterval[0], intervals[i][0]) newInterval[1] = max(newInterval[1], intervals[i][1]) i += 1 res.append(newInterval) # 第三阶段:把右边剩下的区间直接收下 while i < n: res.append(intervals[i]) i += 1 return res这里的三个while各司其职,非常清晰。第一个while判定条件是intervals[i][1] < newInterval[0],意思是当前区间整个都在新区间的左边,没有任何交集;第二个while判定条件是intervals[i][0] <= newInterval[1],意思是还有区间和"不断被扩展的新区间"相交,就一直合进去;第三个while就是把剩下的收尾。
有个容易漏的细节:第二阶段里,newInterval是被原地更新的。每合并一个区间,它的左端点可能变小,右端点可能变大,于是下一个区间是否相交的判断也要基于新边界,而不是原始newInterval的边界。我见过太多人在这里写了个单独的cur_left, cur_right变量用来存当前合并区间,其实直接复用newInterval是更简洁的写法,因为反正最后它就是要被放进结果里的。
3.3 二分查找优化:从O(n)到O(log n + n)
既然输入列表有序且无重叠,理论上是可以用二分先定位"新区间左边最后一个完全不相交的区间"的位置,这样前两个阶段可以更快。找位置用bisect,或者手写二分,代码如下:
import bisect def insert_binary(intervals, newInterval): n = len(intervals) # 找到第一个右端点 >= newInterval[0] 的位置 left = bisect.bisect_left([r for _, r in intervals], newInterval[0]) # 从left开始逐个合并,直到左端点 > newInterval[1] i = left while i < n and intervals[i][0] <= newInterval[1]: newInterval[0] = min(newInterval[0], intervals[i][0]) newInterval[1] = max(newInterval[1], intervals[i][1]) i += 1 return intervals[:left] + [newInterval] + intervals[i:]这里left = bisect.bisect_left([r for _, r in intervals], newInterval[0])的意思是:找到第一个"右端点不小于新区间左端点"的区间,它和它后面的区间才可能发生重叠。这样前面的intervals[:left]直接切走,不需要遍历。
这个写法时间复杂度可以描述成O(log n + m),m是需要合并的区间个数,最坏情况下仍为O(n),但常数小了很多。面试时能主动提"因为列表有序,可以先用二分压缩需要线性扫描的长度",是加分的点。
3.4 边界场景:区间完全被吞没的情况
有一种情况必须单独测:新区间完全被已有区间覆盖,比如intervals = [[1, 5]],newInterval = [2, 3]。跑上面的代码,第二阶段第一次合并后,newInterval还是[2, 3],但i已经走到头了,于是newInterval被append进去,结果变成[[1,5], [2,3]]。这就不对了,输出应该还是[[1,5]],因为[2,3]已经包含在[1,5]里。
问题出在第二阶段的条件和第三阶段的衔接上。如果newInterval被某个区间完全包含,其实这个新区间就应该"归并"到已有区间里,而不是作为独立元素append。我重新调整一下写法,更稳妥:
def insert(intervals, newInterval): res = [] i = 0 n = len(intervals) while i < n and intervals[i][1] < newInterval[0]: res.append(intervals[i]) i += 1 if i == n: # 新区间在最右边 res.append(newInterval) return res # 直接修改intervals[i],让重叠区间的合并结果落在它上面 while i < n and intervals[i][0] <= newInterval[1]: newInterval[0] = min(newInterval[0], intervals[i][0]) newInterval[1] = max(newInterval[1], intervals[i][1]) i += 1 res.append(newInterval) while i < n: res.append(intervals[i]) i += 1 return res其实上面的原始写法在[1,5]插入[2,3]时会输出[[1,5],[2,3]]吗?我们跑一遍:第一阶段intervals[0][1] = 5 < newInterval[0] = 2不成立,跳过;第二阶段intervals[0][0] = 1 <= newInterval[1] = 3成立,合并后newInterval = [1,5],i变为1;第三阶段没有剩余区间,append newInterval,得到[[1,5]]。所以原始写法在这个case下反而正确,因为合并后的newInterval被更新成了覆盖范围更大的区间。
真正需要警惕的是另一种情况:newInterval落在两个已有区间之间但和两者都不重叠,比如intervals = [[1,2],[5,6]],newInterval = [3,4]。跑代码:第一阶段收下[1,2];第二阶段intervals[1][0] = 5 <= newInterval[1] = 4不成立,循环退出;append newInterval;第三阶段收[5,6]。结果[[1,2],[3,4],[5,6]],正确。
所以代码的核心逻辑没有大坑,但如果你像我一样习惯用"跳过左右两边,处理中间"的模板,一定把覆盖更新的细节想清楚,线上笔试没有调试机会,边界用例自己先在草稿纸上过一遍。
4. 从合并到插入的通用套路:区间问题的内核是"相交判定"
4.1 把区间相交的四种情形一次搞清楚
我在面试模拟中问过很多人:两个区间[a, b]和[c, d]什么时候不相交?回答通常不完整。其实不相交只有两种情况:b < c(第一个在第二个左边)或者d < a(第一个在第二个右边)。因此相交的条件就是两个不相交条件的补集:
a <= d且c <= b
这也等价于max(a, c) <= min(b, d),意思是两个区间的左端点的较大值,不超过右端点的较小值。这个"交叉条件"在合并区间、插入区间、会议室问题里全都适用。我建议你把这个条件记住,遇到区间题第一件事就是套它。
4.2 区间题的通用解题步骤
从这两道题可以提炼出一个解题模板:
- 先看清楚输入区间是否有序、是否重叠。插入区间给你有序无重叠,那排序这步可以省。
- 如果无序,先按左端点排序。
- 用一个变量(或输出数组的最后一个元素)维护当前合并区间的左右端点。
- 逐个处理区间:相交则合并,不相交则开新区间。
- 最后统一处理收尾——合并区间题的循环结束后,别忘了把最后一个合并区间加入结果(上面的代码用"先放第一个进res,再依次合并"的方式规避了这个问题)。
前端时间我在LeetCode热门100题里看到"会议室"系列的讨论,很多题解的核心代码和上面几乎一模一样。比如会议室II要求判断最少需要多少个会议室,本质就是"区间重叠的最大深度",解法可以转换成排序后扫描端点:遇到开始时间+1,遇到结束时间-1,峰值就是答案。这和合并区间的排序贪心是同一套思考逻辑,只是统计维度不同。
4.3 引申变体:一道题带出一片题
评论区里常有人说"合并区间和插入区间没什么用,工作里又用不到",其实是因为只看了题面没看透模型。把它们拆开看:
- "给你一堆区间,返回合并后的区间列表" → 合并区间
- "给你一堆有序无重叠的区间,插入一个新区间,返回新列表" → 插入区间
- "给定一堆会议时间,判断一个人能否参加所有会议" → 排序后检查相邻重叠
- "给定一堆课程/会议时间,问最少需要几个教室" → 端点扫描求最大重叠数
- "在x轴上扎破所有气球,每支箭可以扎破重叠的气球,问最少几支箭" → 排序后贪心合并,只是把重叠区间的公共右端点不断收紧
每一道都是同一棵树上长出来的叶子。刷题最划算的方式从来不是追数量,而是把一个模型的来龙去脉搞透。我自己的刷题指南里对区间题的要求就一句话:合并区间能闭眼写出来,插入区间能讲清楚为什么不需要排序。做到这两点,上面的变体题基本都能推出来。
5. 实测与避坑:我在周赛和实际刷题中踩过的坑
5.1 区间为空、长度为1、负数区间
合并区间和插入区间都可能有空输入,必须开头就把if not intervals这种情况处理掉,这是基本功。但还有两个测试用例经常被忽略:
- 长度恰好为1:合并区间直接返回这个区间,插入区间要判断
newInterval插在它左边、内部、右边三种情况。 - 负数区间:比如
intervals = [[-10, -5], [-6, 0]],这两个区间其实相交(-6 <= -5),合并结果是[-10, 0]。有些人在纸上画图只画正半轴,写代码的时候能跑对,但推导分析时就容易漏掉。
5.2 排序稳定性与key函数的选择
合并区间选interval[0](左端点)排序,这一点确定后,如果左端点相同,排序后的顺序对结果有影响吗?比如[[1, 4], [1, 2]],无论[1,2]在前还是[1,4]在前,合并结果都是[1,4],所以稳定性不影响最终答案。这算是一个让人安心的结论。但插入区间题目明确说列表"未重叠且已排序",有的语言版本可能没严格保证左端点排序的稳定性,可幸逻辑上也不需要稳定。
我在实际工程中反而遇到过更隐蔽的问题:右端点排序的混淆。比如"最少箭数扎破气球"这道题,正确解法是按右端点升序排序,而不是左端点。如果你调用了合并区间的肌肉记忆按左端点排序,就会得出错误答案。所以每次拿到区间题,先问自己一句:这道题的关键决策是选左端点还是右端点排序?合并区间选左端点是为了贪心从左往右推进;扎气球选右端点是为了让每一箭尽量靠右、覆盖更多气球。排序端点不固定,这恰恰是区间题最考验理解深度的地方。
5.3 模拟面试复盘:三个必须先问的问题
我陪练过几次模拟面试,候选人拿到合并区间立刻低头写代码,我看着就着急。面试官在评价时往往不只看代码,还看"审题"动作。拿到区间题(尤其是插入区间),建议你张嘴就问三句话:
- 输入是否已经有序且无重叠?(决定要不要排序、以及能不能二分)
- 区间端点的开闭性如何?
[start, end]是闭区间还是半开区间?(LeetCode默认闭区间,但业务里可能不同,比如会议结束时间和下一场开始时间相同是否冲突) - 允许原地修改输入还是需要返回新数组?(影响空间复杂度和是否破坏数据)
问完这三个问题,你已经比80%的直接开写的人得分高了。因为它们都直接影响核心算法选择,而不只是装模作样的确认需求。
5.4 周赛430那一类的实战体会
上次LC周赛遇到一个区间合并的变形,题目要求把重叠区间合并后返回整个覆盖长度(不是区间列表)。本质上是在合并区间的同时累加长度,代码只需要在"开新区间"时把当前区间长度加上就行。很多人栽在没搞清楚"当前合并区间何时截止",一直在循环里重复累加。其实套路一样:按左端点排序,维护curL和curR,每次遇到不相交区间就把curR - curL加到答案里。这题我在本地一次跑过,因为合并区间的模板已经刻在脑子里了。
还有一次做"插入区间"的周赛题,题面稍微绕了一点:新区间可能和左右两边都重叠,形成一个大区间。这时候如果你套用线性扫描的三段式,第二阶段里更新newInterval时要连着"左边刚收入的结果"也检查一遍,否则可能出现res最后一个区间和新区间仍重叠但没合并的情况。我的建议是:遇到这类变体,第一步先老老实实把新区间append到列表里重新排序合并一遍,保证AC之后,再考虑优化成线性扫描。面试中先给正确解再给优化解,是比一口气写最优解更稳的策略。
6. 最后再分享一个从实践中来的区间题复习法
区间题是我刷题列表里"回看率"最高的一类,因为变体太多,一段时间不碰就会手生。我自己摸索出来一个复习办法很简单:把合并区间、插入区间、会议室、扎气球这四道题放在同一个备忘录里,每次复习只允许自己看"解题思路一句话"和"易错点",然后默写代码,跑样例。四道题跑完不超过二十分钟,但效果比单刷十道新题都好。
顺带说一个答题习惯方面的细节:写完代码一定要自己举一个"区间完全包含"的例子在草稿纸上走一遍循环。比如intervals = [[1, 10], [2, 3]],合并区间跑完应该输出[[1, 10]],如果写的是res[-1][1] = r这种非max更新,在这里就会当场露馅。一个简单的小用例,就能筛掉一大半常见的粗心错误。
区间题目看起来简单,实际要踩的坑一点都不少,希望这篇能帮你把从排序到合并、从插入到变体的这条线串起来,下次不管在周赛还是面试里遇到,都能稳稳地接住。