LeetCode 1024. 视频拼接
题目描述
题意分析
手上有一堆片段,每个片段是一段闭区间
[start, end],可以随意挑选、允许重叠、允许不用。目标是让选中片段的并集完整盖住[0, time],并且用的片段数最少;盖不住就返回-1。有几个约束信号值得注意。第一,片段可以任意重叠,说明这不是「选出互不相交的一组」,而是「用尽量少的块铺满一条线段」,重叠是允许甚至必要的代价。第二,片段没有排序保证,起点终点都可能乱序,所以直接线性扫描原数组得不到有意义的推进顺序。第三,
time与坐标的上界都不大(百量级),既容忍 $O(n \log n)$ 也容忍按时间轴逐点处理。边界要想清楚三处:
time为0时什么都不用选,答案是0;没有任何片段以0为起点(或起点全部大于0)时立刻无解;覆盖到中途出现空隙,比如手上片段最远只到5而下一个片段从6开始,中间那个点没人盖,同样无解。注意区间是闭的,恰好首尾相接的两段[0,5]和[5,9]是能拼上的。
解法:排序后贪心扩展最远覆盖
核心思路
先想暴力:设
dp[i]为盖住[0, i]所需的最少片段数,对每个i枚举所有片段,若某片段[s, e]满足s < i <= e,就用dp[s] + 1更新dp[i]。这是对的,代价是 $O(n \cdot time)$。瓶颈在于,每个位置都把全部片段重新扫了一遍,而绝大多数片段在那一刻根本不可能被选中。观察一下最优解长什么样。把选中的片段按起点排好,它们必然形成一条链:第一段必须从
0或更左开始,后一段的起点必须落在前面已覆盖的范围内,否则中间断开。既然是链,就可以一节一节地定:已经确定了前k段、覆盖到位置curEnd,那么第k+1段只能从「起点不超过curEnd」的片段里挑。关键的交换论证是:在这批候选里挑终点最远的那个绝不会更差。假设最优解在这一步选了终点较近的片段,把它换成终点最远的那个,覆盖范围只增不减,后续所有可选片段的集合只会变大,因此片段总数不会增加。
不变量:进入每轮循环时,
curEnd是「用ans个片段能覆盖到的最远位置」,且区间[0, curEnd]已被完整覆盖;nextEnd是「所有起点不超过curEnd的片段中最大的终点」。 每轮把curEnd推进到nextEnd并令ans加一,直到curEnd >= time。若某轮nextEnd等于curEnd,说明可用片段全都无法越过curEnd,出现断层,无解。
解题步骤
- 把
clips按起点升序排序,起点相同时按终点降序。升序是为了让「起点不超过curEnd」的片段总是排在数组前缀里,从而能用一个只增不减的指针idx扫过去;终点降序只是让同起点中更优的排在前面,对正确性没有影响,但便于调试观察。- 初始化
curEnd = 0、nextEnd = 0、ans = 0、idx = 0。curEnd从0起步隐含了「位置0这个点本身不需要被谁盖住,真正要盖的是(0, time]」这一理解,也正因如此,只有终点严格大于0的片段才算有推进力。- 外层循环条件是
curEnd < time:还没盖到终点就继续选片段。- 内层把所有
clips[idx][0] <= curEnd的片段吃掉,用它们的终点更新nextEnd。idx全程不回退,因为一旦某个片段的起点满足了当前的curEnd,它对以后更大的curEnd也一定满足,重复检查纯属浪费。- 内层结束后若
nextEnd == curEnd,返回-1。这一步同时覆盖了两种失败情形:一是根本没有片段能起步(curEnd还是0而没人终点大于0),二是中途出现空洞。- 否则
ans加一、curEnd = nextEnd,表示真正敲定了一个片段。累加必须放在内层扫描之后,因为一轮扫描对应的是「从若干候选里最终挑一个」,而不是扫到几个就用几个。以
clips = [[0,2],[4,6],[8,10],[1,9],[1,5],[5,9]]、time = 10走一遍:排序后数组变成[0,2], [1,9], [1,5], [4,6], [5,9], [8,10]。初始curEnd = 0、nextEnd = 0、ans = 0、idx = 0。第一轮,[0,2]的起点0 <= 0被吃掉,nextEnd变2,idx到1;下一个起点是1,大于curEnd = 0,内层停。nextEnd = 2不等于0,于是ans = 1、curEnd = 2。第二轮,[1,9]起点1 <= 2,nextEnd更新为9,idx到2;[1,5]起点也满足,终点5小于9不更新,idx到3;[4,6]起点4 > 2,内层停。ans = 2、curEnd = 9。第三轮,[4,6]起点4 <= 9,终点6不更新,idx到4;[5,9]终点9不更新,idx到5;[8,10]起点8 <= 9,nextEnd更新为10,idx到6越界,内层停。ans = 3、curEnd = 10。此时curEnd >= time,外层退出,返回3,对应选中[0,2]、[1,9]、[8,10]。
代码实现
class Solution {
public int videoStitching(int[][] clips, int time) {
Arrays.sort(clips, (a, b) -> {
if (a[0] != b[0]) {
return a[0] - b[0];
}
return b[1] - a[1];
});
int ans = 0;
int idx = 0;
int curEnd = 0;
int nextEnd = 0;
while (curEnd < time) {
// 在当前可覆盖范围内,挑出能把右边界推得最远的片段。
while (idx < clips.length && clips[idx][0] <= curEnd) {
nextEnd = Math.max(nextEnd, clips[idx][1]);
idx++;
}
if (nextEnd == curEnd) {
return -1;
}
ans++;
curEnd = nextEnd;
}
return ans;
}
}
func videoStitching(clips [][]int, time int) int {
sort.Slice(clips, func(i, j int) bool {
if clips[i][0] != clips[j][0] {
return clips[i][0] < clips[j][0]
}
return clips[i][1] > clips[j][1]
})
ans := 0
idx := 0
curEnd := 0
nextEnd := 0
for curEnd < time {
// 在当前可覆盖范围内,挑出能把右边界推得最远的片段。
for idx < len(clips) && clips[idx][0] <= curEnd {
if clips[idx][1] > nextEnd {
nextEnd = clips[idx][1]
}
idx++
}
if nextEnd == curEnd {
return -1
}
ans++
curEnd = nextEnd
}
return ans
}
复杂度分析
- 时间复杂度:$O(n \log n)$,其中 $n$ 是片段数。排序是主导项;主体的双层循环里
idx单调递增且永不回退,所有内层迭代加起来只有 $O(n)$ 次,外层轮数不超过 $n$。- 空间复杂度:贪心扫描本身是 $O(1)$;计入标准库排序后,Java 对
int[][]这种对象数组排序最坏需要 $O(n)$ 辅助空间,Go 的原地内省排序需要 $O(\log n)$ 调用栈。
关键点总结
- 区间覆盖类贪心的模板是「双边界」:一个记录已经兑现的覆盖终点,一个记录当前候选能达到的最远终点,两者相等即为断层信号。这套结构可以原样迁移到跳跃游戏系列。
- 贪心成立与否要靠交换论证支撑,而不是靠直觉。这里的论证是「把最优解中的某一段替换成同批候选里终点最远的那段,覆盖范围单调不减」,能说清这句话,面试里的正确性质疑就化解了。
- 排序 + 单调指针的组合是消除重复扫描的常用手段。判断指针能否不回退,标准是「条件一旦满足就永远满足」,这里因为
curEnd单调不减,所以成立。- 闭区间的首尾相接算连通,开区间不算。动手前先确认区间语义,能避免大量边界纠纷。
- 面试视角:先给出 $O(n \cdot time)$ 的动态规划,再说明它的浪费在哪,最后升级到贪心,是这题的标准展示路线。如果面试官把坐标范围放大到 $10^9$,动态规划直接失效,贪心却仍然可行,这一点值得主动点出来。
- 面试视角:常见追问是「为什么不按终点排序」。按终点排序会丢掉「起点必须落在已覆盖范围内」这个约束的检查时机,导致选出的片段之间可能断开,可以现场举反例说明。
易错点总结
- 错误写法:把
ans++写进内层循环,扫到一个满足条件的片段就计一次数。用例[[0,2],[1,9],[1,5]]、time = 9→ 一轮里吃掉三个片段就加了三次,返回4而正确答案是2。- 错误写法:内层条件写成
clips[idx][0] < curEnd。用例[[0,5],[5,9]]、time = 9→ 起点恰好等于curEnd = 5的片段被排除,判定为断层返回-1,而闭区间下这两段是能接上的。- 错误写法:断层判断写成
if (nextEnd <= curEnd)之外的形式,比如漏掉这个判断直接推进。用例[[0,1],[6,8]]、time = 8→ 第二轮nextEnd仍是1,curEnd原地不动,外层条件永远为真,陷入死循环。- 错误写法:外层循环条件写成
curEnd <= time。用例[[0,4],[2,8]]、time = 8→ 覆盖到8后仍要再进一轮,此时已无片段可推进,误返回-1。- 错误写法:每轮把
nextEnd重置为0或重置为curEnd之后又不重新扫描已越过的片段。用例[[0,2],[1,9],[3,4]]、time = 9→ 之前累积的最远终点被抹掉,后面又因idx不回退取不到,结果误判无解。- 错误写法:不排序直接按原顺序用单调指针扫描。用例
[[1,5],[0,2],[4,6],[8,10],[1,9],[5,9]]、time = 10→ 第一轮遇到起点为1的片段就停下,起点为0的片段被永久跳过,直接返回-1。- 错误写法:认为「片段越长越好」,先按长度降序排序再依次贪心取。用例
[[0,1],[1,9],[0,8]]、time = 9→ 先取最长的[1,9]会让位置0到1这段无人覆盖,被迫多用片段甚至误判无解。- 错误写法:忽略
time为0的情形,在循环外无条件写ans = 1。用例任意片段、time = 0→ 返回1,而正确答案是不选任何片段的0。- 错误写法:把
idx每轮重置为0。用例片段数较多时 → 结果仍然正确,但复杂度退化成 $O(n^2)$,在面试里会被追问为什么不用单调指针。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 45. 跳跃游戏 II | 中等 | 同样的双边界贪心,区间由下标与步长隐式给出 |
| 55. 跳跃游戏 | 中等 | 只判可达性,无需统计使用次数 |
| 435. 无重叠区间 | 中等 | 目标是保留最多互不相交的区间,按终点排序 |
| 452. 用最少数量的箭引爆气球 | 中等 | 求区间的最少公共点数,用重叠交集收缩 |
| 1288. 删除被覆盖区间 | 中等 | 判定包含关系并计数,重点在同起点的排序技巧 |
| 757. 设置交集大小至少为2 | 困难 | 每个区间需贡献两个点,贪心需回看已选点 |