无重叠区间:结束早的先占坑
删最少等于留最多。按结束时间排好,能接就接、接不上就丢掉——丢掉的个数就是答案。
问题等价于「保留最多互不重叠的区间」。按结束时间升序排序,贪心地保留「结束最早」且不与已保留区间重叠的区间。被跳过的区间就是要删除的。返回 len − kept。O(n log n)。
给定一堆闭区间,可以删掉其中若干个,使剩下的两两不重叠,求最少要删几个。端点相碰不算重叠:[1,2] 和 [2,3] 可以同时留下。
主例 [[1,2],[2,3],[3,4],[1,3]]。三条短的首尾相接,[1,3] 横跨在 [1,2] 和 [2,3] 上。删掉 [1,3] 就干净了,答案 1。演示场景保留 [1,2]、[2,3]、[3,4],删除数停在 1。
正面枚举「删哪几个」是子集问题。本文要回答:为什么翻成「留最多」之后,按结束时间排序就能一次扫完,以及主例里 [1,3] 为什么必须让路。
缺口是「给后面留空间」,不是「谁先开始」
第一直觉按开始时间贪心:谁开工早谁留下。主例里 [1,2] 和 [1,3] 同时从 1 出发,若留下更长的 [1,3],后面 [2,3] 立刻撞车,原本能留三条变成只能再拼 [3,4],一共两条,删除数变成 2,比最优多删一个。按长度删最长也不稳:有时该删的是短而卡在中间的那根。
缺的不是「冲突图上的最大独立集」这张全表,而是一个局部不变量:已经留下的区间里,最晚的结束时刻。下一个区间只要起点不早于这个时刻,就不会和已留的任何一段重叠——因为已留的都在它之前结束。问题从「删谁」收成「当前这段能不能接到已留的尾巴上」。
要让后面更容易接上,已留的尾巴应该尽量早。所以先按结束时间升序排序,结束早的先考虑。第一个区间一定留:它最早收工,占的未来最少。之后逐个看:起点 ≥ 当前 end 就留下,并把 end 改成它的终点;否则与已留的尾巴重叠,跳过,等价于删除。
用手走主例。按终点排序:[1,2] 终点 2,然后 [1,3] 与 [2,3] 终点都是 3,最后 [3,4] 终点 4。先留 [1,2],end=2。[1,3] 的起点 1 < 2,与已留段重叠,丢掉。演示第一帧标的就是这一刀。[2,3] 起点 2 ≥ 2,相碰合法,留下,end=3。[3,4] 起点 3 ≥ 3,留下。保留 3 个,删除 1 个。
为什么丢掉 [1,3] 安全?它和已经留下的 [1,2] 争同一段开头,而 [1,2] 更早结束。任何包含 [1,3] 的可行解,都可以把 [1,3] 换成 [1,2],后面空出 [2,3] 这一截,不会变差。这就是「结束早的更优」的交换论证。
代码不必真的存 kept 列表,计数即可:kept 从 1 起,能接上就加一并更新 end,最后返回 n-kept。空输入没有可删的,返回 0;只剩一个区间,删除数是 0。排序 O(n log n) 主导,扫描是线性。
为什么按结束排开始早不保证给后面留空;结束早才留空。主例若留下 [1,3],[2,3] 接不上,最优 3 条会少一条。相碰 start==end 算不重叠,判断必须用 >= 而不是 >。
Go:结束时间贪心
func eraseOverlapIntervals(intervals [][]int) int {sort.Slice(intervals, func(i, j int) bool {return intervals[i][1] < intervals[j][1]})kept, end := 1, intervals[0][1]for i := 1; i < len(intervals); i++ {if intervals[i][0] >= end {kept++; end = intervals[i][1]}}return len(intervals) - kept}
1按终点升序。主例 [1,2] 会排到最前,[1,3] 不会因为开工早而抢到第一名。
2第一段必留,end 记下它的终点。空切片要在排序前单独返回 0。
3start >= end 才留下。主例 [2,3] 的 2 等于 end 2,留下;[1,3] 的 1 小于 2,跳过。
4返回值是删除数,不是保留数。面试说反了会在主例上交 3。
总结
按结束排序,能接就留、接不上就删。主例删 [1,3],答案 1。
- 删最少等于留最多。按开始时间贪心会在主例上留下 [1,3],多删一个。
- end 是已留区间的尾巴。下一个 start >= end 才不重叠。
- LC452 射气球是同一套「结束早」;LC252 只判断有没有重叠,不删。