news 2026/9/29 22:18:14

区间合并:排序、贪心扫描与边界处理实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
区间合并:排序、贪心扫描与边界处理实战

1. 区间合并到底在解决什么问题

1.1 一堆时间轴糊在一起,就是区间的日常

先把话说白:区间合并(Merge Intervals)这件事,本质上是把一堆互相重叠、互相包含、互相挨着的"范围"揉成尽量少的几个大范围。输入是若干对数字[l, r],输出是一组互不重叠、按顺序排好的区间,并且要求覆盖的范围和原来一模一样——一个点不能多,一个点也不能少。

我第一次接触这玩意儿是在排会议室的场景里:手上有一张表,几十个部门分别报了"我们想用 9:30 到 11:00""我们想用 10:00 到 12:00",你作为行政要回答一个非常朴素的问题——"今天这间会议室到底有几个不可打断的占用大段?"如果答案是 3 段,那就能塞进去两段空闲时间做设备巡检;如果答案是 8 段,那今天别想干别的了。这就是最典型的区间合并需求。

它看起来简单到不像一道"算法题",但恰恰是这种看着简单的题,在面试和工程里翻车率极高。因为坑都藏在边界上:左端点相等怎么办、右端点相等算不算重叠、[1,2]和[2,3]到底该不该并、坐标到了 10 的 9 次方会不会溢出、输入是闭区间还是半开区间。这些东西题目里往往一句话带过,但写错一个符号就是全错。

1.2 四个真实场景,说明它不只是刷题

很多人觉得区间合并是"LeetCode 专属玩具",其实它趴在很多工程的角落里。

日程与资源排布。会议室、工位、充电桩、客服坐席,凡是"同一时刻只能被一个任务占用"的资源,都要先把碎片化的预约合并成连续占用段,再据此计算空闲窗口。空闲窗口的计算其实就是"合并结果的补集",这一步后面会专门讲。

内存与磁盘分配。内存分配器维护一张空闲块表,释放内存时会产生大量相邻的空闲块,如果不合并,就会碎片化到再也分配不出大块。这一块的实现方式和我们写的区间合并几乎是同一个骨架,只不过它们用的是平衡树或者空闲链表而不是数组排序。

网络地址段与权限范围。访问控制列表里经常出现多条互相包含的地址段,比如10.1.0.0/16和10.1.2.0/24,前者已经把后者吞掉了。规则匹配前先做一次合并,既能减少规则条目,也能避免"规则顺序不同导致结果不同"这种诡异 bug。

数据清洗与统计口径。埋点日志里同一次会话被切成了很多段,统计会话时长时要把间隔小于 30 秒的段并起来;基因组学里合并测序得到的区间;剪辑时间轴上去掉冻结帧后合并剩余片段。这些都是同一个套路。

1.3 合并到底"合"的是什么:三种重叠形态

判断两个区间能不能合并,只看一件事:它们的交集是否非空(或者按题目要求,是否"相邻即算连续")。具体有三种形态:

形态示例合并结果说明
部分重叠[1,4]、[3,7][1,7]最常见,左右端点各取极值
完全包含[1,10]、[3,5][1,10]必须用max保住大右端点,这里最容易翻车
端点相接[1,2]、[2,3]看题目口径闭区间通常算连续可并;半开区间则本来就不重叠

第三种形态是分水岭。如果题目说区间是实数的闭区间[l, r],那[1,2]和[2,3]在点 2 处共享一个点,可以合并;如果题目规定是半开区间[l, r),那[1,2)和[2,3)完全不相交,强行合并就错了。所以拿到题目第一件事,是确认区间口径,而不是急着敲代码。

提示:判断条件写成l <= curR还是l < curR,唯一依据就是区间口径。这个符号选错,样例可能还过,一交就 WA 一半。

2. 核心思路拆解:排序加一次扫描

2.1 为什么必须先排序:无序扫描会漏

假设不排序,直接两两比较能不能并?思路是有的——拿第一个区间去和后面所有区间比,能并就并,并完再回头重新扫一遍,直到某一轮没有任何合并发生。这个做法能出正确答案,但复杂度是 O(n²) 甚至 O(n³),几千个区间就卡住了。

排序的价值在于把"两两比较"降维成"只看相邻"。只要把所有区间按左端点从左到右排好,那么任意两个可能重叠的区间,在排序后的序列里一定是挨着出现(或者在更早的位置就已经并掉了)。于是你只需要维护"当前正在生长的这个合并区间",从左往右滑一遍,每一步只做一次判断。这就是排序带来的全部收益,也是它能从 O(n²) 降到 O(n log n) 的根本原因。

排序键选左端点就够了,不需要同时排右端点。理由很直白:左端点决定了谁先被处理,而右端点的大小在处理过程中会用max动态修正。排右端点除了浪费比较开销,还会破坏左端点的自然顺序,反而添乱。

2.2 贪心扫描的不变量,用一句话说清

扫描过程中始终维护两个变量:curL和curR,代表"目前已经吃完的这一坨的最终左端点和已知最大右端点"。这个不变量的含义是:

[curL, curR]是当前这一坨从最左端出发能覆盖到的完整范围,左边不可能再有东西了,右边还可能有待扩展。

对每个新区间[l, r],只有两种命运:

  • 断开:l > curR,说明它和当前这一坨中间真的有空隙,没有任何点重叠。此时把[curL, curR]结算进结果数组,然后让curL = l、curR = r,开启新的一坨。
  • 粘连:l <= curR,说明有交集或者直接接上了。此时curL一定不需要改(因为排序保证l >= curL),只需要curR = max(curR, r)。那个max就是用来对付"完全包含"这种形态的——[1,10]后面跟一个[3,5],如果你写成curR = r,答案立刻变成[1,5],范围凭空少了一大截。

最后一步是几乎所有手写实现都会忘的:循环结束后,最后一坨[curL, curR]还没有被结算进结果数组,必须手动补上。这个 bug 的典型症状是"输入一个区间时输出空数组"或者"输入全部重叠时只少了一段"。

2.3 边界相等算不算重叠:一个符号定生死

我见过最多的翻车点就在这个符号上。把两种常见口径列清楚:

整数闭区间[l, r],即包含所有整数点。此时[1,2]和[2,3]共享整数点 2,应当合并。判断条件写l > curR才算断开,等价于l <= curR合并。

实数闭区间[l, r],即包含中间所有实数。同样在点 2 处相交,处理方式和上面一致。

半开区间[l, r)。[1,2)和[2,3)没有公共点,不能合并。判断条件要改成l >= curR才算断开,即l < curR才合并。

还有一种更刁钻的变体是"相邻即算连续",比如题目说"间隔小于等于 0 的段合并"(这是标准重叠),而有些业务说"间隔小于等于 5 分钟的段合并"。后者其实是在原区间上做了膨胀:把每个右端点加 5 再合并,然后再把右端点减回去。这招我用过好几次,比在扫描逻辑里塞一个阈值分支干净得多。

2.4 复杂度、数据结构与不变量校验

时间复杂度由排序主导,O(n log n);扫描本身是O(n)。空间复杂度取决于实现:如果新建结果数组,是O(n);如果允许原地覆盖输入数组(像 LeetCode 上那样把输入当草稿纸),可以做到O(1)额外空间。

数据结构的选择取决于使用模式:

  • 一次性批处理:数组加排序,最简单也最快,缓存友好。
  • 持续动态插入/删除:用有序映射(C++ 的std::map、Java 的TreeMap、Python 的sortedcontainers.SortedList),每次插入后在前后邻域做局部合并,单次操作O(log n)。
  • 高频查询最大重叠深度:扫描线加差分数组,或者线段树维护区间加与全局最大值。

我自己写业务代码时的默认选择是数组排序。除非插入频率明显高于查询频率,否则有序容器的常数因子和内存开销都不划算。

心得:写完后一定要在心里跑三个样例——全部分离、全部重叠、单区间。这三个能扛住,绝大多数边界就稳了。

3. 手把手写参考代码(四种语言)

3.1 C++ 版本:从暴力到 O(n log n)

先给一个最容易理解但会超时的暴力版本,目的是让你看清"排序到底省了什么":

// 暴力版:反复扫描,直到一轮没有合并发生 vector<vector<int>> mergeBrute(vector<vector<int>> a) { bool changed = true; while (changed) { changed = false; for (size_t i = 0; i < a.size() && !changed; ++i) for (size_t j = i + 1; j < a.size(); ++j) { if (a[i][0] <= a[j][1] && a[j][0] <= a[i][1]) { a[i][0] = min(a[i][0], a[j][0]); a[i][1] = max(a[i][1], a[j][1]); a.erase(a.begin() + j); changed = true; break; } } } sort(a.begin(), a.end()); return a; }

正解版本把上面的循环彻底删掉,只留一趟扫描:

vector<vector<int>> merge(vector<vector<int>>& intervals) { if (intervals.empty()) return {}; sort(intervals.begin(), intervals.end(), [](const vector<int>& a, const vector<int>& b) { return a[0] < b[0]; // 只按左端点,右端点不参与 }); vector<vector<int>> res; int curL = intervals[0][0], curR = intervals[0][1]; for (size_t i = 1; i < intervals.size(); ++i) { int l = intervals[i][0], r = intervals[i][1]; if (l > curR) { // 严格大于才断开 res.push_back({curL, curR}); curL = l; curR = r; } else { curR = max(curR, r); // 这里必须取 max } } res.push_back({curL, curR}); // 补上最后一坨 return res; }

三个细节值得单独说。第一,比较器里绝对不要写a[0] <= b[0],std::sort要求严格弱序,用<=在某些实现下会直接越界崩溃,这个坑我在早期踩过一次,调试了半天才发现是比较器的锅。第二,res.push_back({curL, curR})里用花括号初始化,比先造一个临时vector再 push 少一次拷贝。第三,如果题目允许修改输入,可以把结果直接写回intervals[write++],把额外空间压到O(1)。

3.2 Python 版本:简洁写法与性能陷阱

Python 写起来最短,但有几个陷阱:

def merge(intervals): if not intervals: return [] intervals.sort(key=lambda x: x[0]) res = [] cur_l, cur_r = intervals[0] for l, r in intervals[1:]: if l > cur_r: res.append([cur_l, cur_r]) cur_l, cur_r = l, r elif r > cur_r: cur_r = r res.append([cur_l, cur_r]) return res

第一个陷阱是排序方式。intervals.sort()不加key会按元组的字典序排,也就是左端点相同时右端点也参与比较。这在纯合并题里不影响结果,但在"删除被覆盖区间"那类题里会直接改变答案,因为那道题需要左端点相同的区间按右端点降序排。养成显式写key的习惯,能避免大量隐性错误。

第二个陷阱是intervals[1:]这个切片。它复制了一份列表,区间数量到百万级时会明显吃内存。写成索引循环for i in range(1, len(intervals))就没这个问题,代价是代码长一点。

第三个陷阱是元组解包的误用。cur_l, cur_r = intervals[0]要求每个元素恰好两个值,如果输入偶尔混进了长度不对的列表,会抛ValueError。做数据清洗的时候,我一般会在前面加一道校验,把长度不为 2 的条目先剔出去,比在循环里做 try 干净。

3.3 Java 与 Go:比较器和切片的两点差异

Java 的坑集中在排序。int[][]不能用Arrays.sort(intervals)直接按第一列排(那样是按行字典序),必须传比较器。而且比较器里要用Integer.compare(a[0], b[0])而不是a[0] - b[0],因为相减在极端值下会溢出,导致排序结果错乱——这个 bug 特别隐蔽,只有坐标接近Integer.MIN_VALUE或MAX_VALUE时才暴露。

public int[][] merge(int[][] intervals) { if (intervals == null || intervals.length == 0) return new int[0][]; Arrays.sort(intervals, (a, b) -> Integer.compare(a[0], b[0])); List<int[]> res = new ArrayList<>(); int curL = intervals[0][0], curR = intervals[0][1]; for (int i = 1; i < intervals.length; i++) { if (intervals[i][0] > curR) { res.add(new int[]{curL, curR}); curL = intervals[i][0]; curR = intervals[i][1]; } else { curR = Math.max(curR, intervals[i][1]); } } res.add(new int[]{curL, curR}); return res.toArray(new int[res.size()][]); }

Go 这边sort.Slice用闭包,逻辑很直观,但要注意append的扩容语义:如果结果切片是从已有的切片派生出来的(比如res := intervals[:0]),后续 append 会覆盖原数组内容。做原地合并时这是优点,做纯函数时就是隐患,得用make单独分配。

func merge(intervals [][]int) [][]int { if len(intervals) == 0 { return [][]int{} } sort.Slice(intervals, func(i, j int) bool { return intervals[i][0] < intervals[j][0] }) res := make([][]int, 0, len(intervals)) curL, curR := intervals[0][0], intervals[0][1] for i := 1; i < len(intervals); i++ { l, r := intervals[i][0], intervals[i][1] if l > curR { res = append(res, []int{curL, curR}) curL, curR = l, r } else if r > curR { curR = r } } return append(res, []int{curL, curR}) }

3.4 用测试用例自检:边界样例清单

写完代码别急着提交,先把下面这组用例过一遍。我把它们按"能杀死哪类 bug"归类:

用例期望输出专门用来抓什么
[][]空输入未处理
[[1,4]][[1,4]]忘记补最后一坨
[[1,4],[4,5]][[1,5]]端点相接的判断符号
[[1,10],[2,3]][[1,10]]忘记取max
[[1,2],[3,4]]原样无重叠时误合并
[[5,6],[1,2],[3,4]]三个独立区间排序缺失或排序键错误
[[1,4],[0,4]][[0,4]]左端点被覆盖时curL的处理

其中[[1,4],[0,4]]这条特别值得一提。合并后左端点应该是 0 而不是 1。因为我们按左端点排序,[0,4]会排在前面,所以标准算法里curL天然是 0,不需要额外取 min。但如果你用了原地去重的写法,或者在动态插入场景里没排序,就很容易忘记更新左端点。这也是我建议"批处理一律先排序"的另一个理由:排序帮你把curL的更新问题消掉了。

4. 六个高频变体与例题思路分析

4.1 变体一:插入区间,三段式最稳

插入区间(LeetCode 57)是合并的直接扩展:给一个已排好序且互不重叠的区间列表,再插入一个新区间,要求保持有序不重叠。标准解法是把整个过程切成三段:

  1. 左边干净段:所有右端点小于新区间左端点的区间,它们和新区间完全无关,原样输出。
  2. 中间合并段:所有左端点小于等于新区间右端点的区间,它们和新区间有交集,不断把新区间左右端点扩张。
  3. 右边干净段:剩下的全部原样输出。
vector<vector<int>> insert(vector<vector<int>>& a, vector<int>& t) { vector<vector<int>> res; int i = 0, n = a.size(); while (i < n && a[i][1] < t[0]) res.push_back(a[i++]); // 左边 while (i < n && a[i][0] <= t[1]) { // 中间 t[0] = min(t[0], a[i][0]); t[1] = max(t[1], a[i][1]); ++i; } res.push_back(t); while (i < n) res.push_back(a[i++]); // 右边 return res; }

为什么不用"插入再整体合并"?因为那样是O(n log n),而三段式利用了输入本身有序的特性,做到O(n)。数据量大时差距明显。

4.2 变体二:无重叠区间与最少箭数,换个排序键

无重叠区间(LeetCode 435)问的是"最少删掉几个区间,让剩下的互不重叠"。最少删 = 总数 − 最多能留下几个不重叠的。这里的关键是:贪心要按右端点排序,而不是左端点。每次选右端点最小的、且和上一个选择不冲突的区间,这样给后面留的空间最大。

最少箭数(LeetCode 452)的骨架几乎一模一样:一支箭能穿过一组互相重叠的区间,问最少几支箭。答案就是最大不重叠区间数——注意这里的判断条件是start > lastEnd才需要新箭,因为[1,2]和[2,3]可以被同一支箭在点 2 处穿过,和闭区间的合并口径保持一致。

这两个题放在一起讲,是因为它们共享同一个思维:按右端点排序 + 贪心选择。什么时候用左端点排序,什么时候用右端点排序?我的判断标准是:问题要求"输出合并后的区间列表",用左端点;问题要求"最大化留下的数量/最小化资源数",用右端点。

4.3 变体三:合并总长度与最大重叠深度,扫描线上场

有时候不需要具体区间,只要一个数。比如"所有区间合并后的总长度"和"同时存在的最大区间数量"。

总长度可以在合并过程中直接累加:每结算一坨,就把curR - curL加进答案(整数闭区间的长度是curR - curL + 1,实数区间是curR - curL,别搞混)。

最大重叠深度用扫描线更合适。把每个区间拆成两个事件:左端点+1,右端点-1,排序后扫一遍,累加过程中出现的最大值就是深度。闭区间和半开区间在这里的差别体现在同坐标事件的先后顺序上:

def max_overlap(intervals): events = [] for l, r in intervals: events.append((l, 1)) events.append((r, -1)) # 闭区间:同一坐标先处理 +1,让相接的两个区间也算重叠 events.sort(key=lambda e: (e[0], -e[1])) cur = best = 0 for _, delta in events: cur += delta best = max(best, cur) return best

半开区间的写法只要把排序键改成(e[0], e[1])——同一坐标先处理-1,让[1,2)结束后[2,3)才开始。这个顺序差别看起来只有一行,却是扫描线题最常见的失分点。

4.4 变体四:区间交集与区间列表交集

单对区间求交集很直接:lo = max(l1, l2)、hi = min(r1, r2),lo <= hi则有交集。但"两个区间列表的交集"(LeetCode 986)就有意思了,因为两个列表各自的区间都是有序不重叠的,可以用双指针线性扫。

思路是:每次比较两个当前区间,先算出交集放进结果,然后把右端点较小的那个指针往前推。这一步的道理是,右端点小的那个区间已经不可能再和后面任何区间产生交集的"新内容"了,因为后面所有区间的右端点都更大。这个腾挪逻辑和归并排序的 merge 步骤神似。

vector<vector<int>> intervalIntersection(vector<vector<int>>& A, vector<vector<int>>& B) { vector<vector<int>> res; int i = 0, j = 0; while (i < A.size() && j < B.size()) { int lo = max(A[i][0], B[j][0]); int hi = min(A[i][1], B[j][1]); if (lo <= hi) res.push_back({lo, hi}); if (A[i][1] < B[j][1]) ++i; else ++j; } return res; }

4.5 变体五:区间补集与区间删除

补集就是"合并结果的反面",计算空闲窗口、可预约时段都要用。做法是:先合并,再从域的左端点开始,把合并结果之间的缝隙抠出来。

def complement(intervals, domain_l, domain_r): merged = merge(intervals) res = [] cur = domain_l for l, r in merged: if l > cur: res.append([cur, l - 1]) # 整数闭区间,缝隙到 l-1 cur = max(cur, r + 1) if cur <= domain_r: res.append([cur, domain_r]) return res

两个坑。第一,l - 1和r + 1只适用于整数闭区间,如果是实数区间,缝隙就是[cur, l],不需要加减一。第二,merged里可能出现在域外的大区间,所以cur = max(cur, r + 1)里的max不能省,否则cur会被一个更小的r + 1拽回去,产生错误的重复缝隙。

区间删除是补集的对偶操作:把要删的区间补齐,然后和大区间求交、取差。批量删除时,先合并所有删除项,性能会好不少。

4.6 变体六:删除被覆盖区间与划分字母区间

删除被覆盖区间(LeetCode 1288)有个漂亮的排序技巧:左端点升序,右端点降序。为什么?因为左端点相同的区间里,右端点最大的那个一定覆盖其他所有同左端点的区间。排完之后从左往右扫,维护已见的maxR,只要当前右端点> maxR就说明它没被覆盖,计数加一;否则就是被覆盖,跳过。

int removeCoveredIntervals(vector<vector<int>>& a) { sort(a.begin(), a.end(), [](const vector<int>& x, const vector<int>& y) { if (x[0] != y[0]) return x[0] < y[0]; return x[1] > y[1]; // 同左端点,右端点降序 }); int cnt = 0, maxR = -1; for (auto& iv : a) { if (iv[1] > maxR) { ++cnt; maxR = iv[1]; } } return cnt; }

划分字母区间(LeetCode 763)看起来毫不相干,其实是"每个字母的首次出现到末次出现构成一个区间",然后对这些区间做合并,合并后有几坨就分几段。这就是我特别喜欢这道题的原因——它把"构造区间"和"合并区间"两个步骤串起来了,非常适合作为检验理解的综合练习。

5. 常见错误与排查技巧实录

5.1 排序比较函数的三个暗雷

比较器看着最不起眼,出问题却最难查,因为错误往往是"偶尔错一次"。

暗雷一:非严格弱序。写return a[0] <= b[0],当两个区间左端点相同时比较器返回 true,而反过来的比较也返回 true,破坏了"相等元素互不大于"的约定。std::sort在数据量大时会走内省排序,一旦触发就可能在数组边界外乱跳,表现为随机崩溃或者结果错乱。修法就是老老实实用<。

暗雷二:相减溢出。return a[0] - b[0];在坐标接近整型极值时结果溢出,符号翻转,排序结果完全错。用Integer.compare或显式的比较分支。

暗雷三:排序键不完整。1288 那道题如果只按左端点排,同左端点的顺序不确定,导致maxR的判断随机出错。凡是题目对"同键元素"有额外要求,排序键里必须显式带上第二个维度。

心得:写完比较器,心里默念一遍"如果两个元素完全相同,它返回什么"。答案必须是 false。

5.2 最后一个区间被吃掉:一个高频低级错误

这个 bug 的形态极其固定:循环里只处理了[0, n-2]到[1, n-1]的转移,最后一坨忘了push。测试用例用单个区间,结果是空数组;用全部重叠的区间,结果少一段。修法也简单,循环结束后补一行。

但还有更隐蔽的变体。如果你用的是"当发现断开时结算"的写法,那么最后一坨永远不会触发"断开"分支,所以必须手动补。如果你用的是"每轮先扩张再结算"的写法,就要检查结算是否重复。我自己的习惯是固定用第一种写法,因为逻辑最好验证。

5.3 原地修改与引用共享的隐蔽 bug

为了压空间,很多人会原地把合并结果写回输入数组。这本身没问题,但要小心两件事。一是在写回之前不能再用旧数据,比如你一边读intervals[i]一边往intervals[write]写,而write <= i恒成立,所以读的位置永远在写的位置之后,是安全的;但如果你的循环顺序是倒着来的,就完蛋了。二是调用方可能还在用这个数组,Python 和 Go 里的切片/引用共享语义会让"被调用函数悄悄改了入参",这种 bug 在跨模块调用时很难定位。

我的做法是:除非题面或性能明确要求原地,一律返回新数组。可读性和安全性远比那点内存值钱。

5.4 数据类型、溢出与精度

坐标范围到 10 的 9 次方、数量到 10 的 5 次方时,长度累加可能突破 32 位整数上限。求总长度、求面积覆盖这类题,累加变量一定要用 64 位整型。C++ 用long long,Java 用long,Go 用int64,Python 天然任意精度不用管,但要注意它慢。

浮点坐标是另一个雷区。用double判断区间是否重叠,端点相等时容易因为精度问题判错。稳妥做法是把浮点坐标放大成整数(比如时间精确到毫秒就乘 1000 转成整数),全程用整数运算,需要输出时再除回去。

5.5 排查速查表

症状最可能的原因快速验证方法
单区间输入返回空漏补最后一坨用[[1,4]]跑
包含关系的结果变短忘了curR = max(curR, r)用[[1,10],[2,3]]跑
端点相接的没合并判断条件用了>=用[[1,4],[4,5]]跑
结果顺序乱排序键写错或没排序检查比较器
大规模随机崩溃比较器非严格弱序加assert或换<
长度累加为负数32 位整型溢出换 64 位整型
最大深度偶尔少 1扫描线同坐标事件顺序反了检查排序键的第二项

6. 工程化落地与性能调优

6.1 流式与超大规模区间怎么处理

数据量大到内存放不下时,数组排序就行不通了。可行的路子有三条。

外部排序。把区间按左端点分成若干块,每块内部排好序写临时文件,然后做 k 路归并,归并的同时做合并,一次只持有 k 个区间在内存里。这套做法和数据库的排序归并连接是一个思路。

分桶后局部合并。如果坐标范围已知且不算太大,可以按坐标分桶,每个桶内独立合并,再处理桶边界的粘连。坐标范围到 10 的 9 次方就不适用了,适合坐标比较密集的场景。

预合并 + 有序结构。如果数据是分批到达的,可以维护一个有序容器,每次插入后在邻居附近做局部合并。Python 里没有内置的有序容器,bisect加列表的插入是O(n),数据大时改用sortedcontainers或者干脆自己写跳表。

6.2 线段树、有序集合的取舍

需求一旦变成"动态增删区间 + 随时查询被覆盖的总长度",单纯的合并就力不从心了。这时候要上带懒标记的线段树,支持区间赋值、区间加和全局求和。坐标范围大时先做离散化,把端点压缩到2n个刻度上,线段树的规模就降到可接受范围。

有序集合适合"插入区间时自动合并重叠邻居"的场景,典型实现是维护一个start -> end的映射,插入[l, r]时先找到所有左端点落在[l, r]范围内的条目,把它们和[l, r]一起并成一个大的,再删旧插新。这套逻辑在内存分配器和区间调度器里非常常见。

我的建议是:能排序解决的绝不上线段树。线段树的代码量和调试成本是排序版本的五到十倍,只有确实需要在线查询时才值得。

6.3 一份可复用的合并模板

最后给你一份我自己用了很久的 C++ 模板,把常见需求都做成可选项,改一个枚举就切换行为:

struct Interval { long long l, r; }; enum class Touch { Strict, Adjacent }; // 相接是否算连接 vector<Interval> mergeIntervals(vector<Interval> a, Touch mode = Touch::Adjacent) { if (a.empty()) return {}; sort(a.begin(), a.end(), [](const Interval& x, const Interval& y) { return x.l < y.l; }); vector<Interval> res; long long curL = a[0].l, curR = a[0].r; for (size_t i = 1; i < a.size(); ++i) { bool connected = (mode == Touch::Adjacent) ? (a[i].l <= curR) : (a[i].l < curR); if (!connected) { res.push_back({curL, curR}); curL = a[i].l; curR = a[i].r; } else { curR = max(curR, a[i].r); } } res.push_back({curL, curR}); return res; }

把它包一层,还能顺手算出总长度、区间个数和最大深度:

struct MergeStat { vector<Interval> merged; long long totalLen; // 实数长度;整数长度请自行 +1 int segments; }; MergeStat analyze(vector<Interval> a, Touch mode = Touch::Adjacent) { auto m = mergeIntervals(std::move(a), mode); long long len = 0; for (auto& iv : m) len += iv.r - iv.l; return {m, len, (int)m.size()}; }

这套封装上了生产环境之后,我发现最大的收益不是性能,而是口径统一:所有调用方都通过Touch明确表达自己的相接语义,再也不会出现"A 模块认为相接算连接、B 模块认为不算"导致统计口径对不上的问题。这类问题在多人协作的项目里比算法本身的 bug 更难查——因为每一段代码单独看都是对的。

最后分享一个我自己的小习惯:凡是要写合并逻辑的地方,先花两分钟用纸把测试用例画成数轴上的线段图,把端点的开闭、相接的处理、域外的过滤在图上标一遍。画完再动手,比写完再调省的时间多得多。区间合并这类题的难点从来不在代码长度,而在你脑子里那张图是否清晰。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/29 22:17:15

179、MLIR的Profiling(性能分析)与Timing(计时)Pass

MLIR的Profiling(性能分析)与Timing(计时)Pass 上周帮团队调一个AI推理引擎的算子性能问题,模型跑在自研NPU上,某个卷积算子的延迟比预期高了3倍。常规手段——插桩、打印时间戳、甚至用perf去抓——都试了,结果发现瓶颈不在计算本身,而在MLIR编译后的IR调度上。那个调…

作者头像 李华
网站建设 2026/9/29 22:15:18

广东芯片封装选型实录:空洞率从18%压到4.6%

上个月去东莞拜访一位做电动工具控制器多年的老熟人&#xff0c;他的团队去年走完了一个芯片封装项目&#xff0c;从工程批到客户认证一次通过。这顿下午茶喝得不亏&#xff0c;我把整个项目从头到尾替他复盘了一遍&#xff0c;细节做了脱敏&#xff0c;数据都是实打实的。 项目…

作者头像 李华
网站建设 2026/9/29 22:13:55

新手AI的入门必知

1. 引言 随着 AI 生态的共建&#xff0c;AI 早已从问答知识库发展成了“全能助理”。无论是创意发展、内容生成&#xff0c;还是日常办公、代码编写&#xff0c;AI 都在扮演越来越重要的角色。然而&#xff0c;AI 入门看似简单&#xff0c;实则学问不少——从模型选择、提示词设…

作者头像 李华
网站建设 2026/9/29 22:13:30

武汉奥迪动力受限顿挫挂挡异常?从发动机到变速箱这样查

武汉奥迪车主碰到加不起油门、起步顿挫、挂不上挡&#xff0c;最常听到一句话&#xff1a;"变速箱坏了&#xff0c;得大修。"这话多数时候下早了。毛病可能在发动机&#xff0c;可能在变速箱&#xff0c;也可能只是一段线束接触不良、一个程序版本没更新——不检查就…

作者头像 李华
网站建设 2026/9/29 22:13:26

2026企业AI办公工具选型全指南:能力评估框架与行业全景盘点

不少企业在调研AI办公工具的初期&#xff0c;很容易陷入几个典型的选型误区&#xff1a;把不同产品的功能列表拉出来逐项比对&#xff0c;谁覆盖的功能点多就优先纳入候选池&#xff1b;单纯对比不同产品的订阅成本&#xff0c;把价格作为决策的核心权重&#xff1b;或是直接参…

作者头像 李华