题目要求
给你一个气球数组 points。
每个气球是一个水平区间:
[start, end]
如果一支箭射在横坐标 x 上,并且:
start <= x <= end
那么这支箭可以射爆这个气球。
一支箭会一直向上飞,所以同一个 x 上能覆盖到的所有气球都会被射爆。
题目要求返回:
射爆所有气球需要的最少箭数
输入输出
- 输入:
points: List[List[int]] - 输出:
int - 每个气球是一个区间
[start, end] - 箭的位置
x可以落在端点上 - 不需要返回每支箭的位置,只需要返回最少箭数
示例
输入:points = [[10,16],[2,8],[1,6],[7,12]]
输出:2
一种射法是:
x = 6 射爆 [2,8] 和 [1,6]
x = 11 射爆 [10,16] 和 [7,12]
再看两个边界例子:
输入:points = [[1,2],[3,4],[5,6],[7,8]]
输出:4
这些气球互不相交,每个气球都需要一支箭。
输入:points = [[1,2],[2,3],[3,4],[4,5]]
输出:2
因为箭可以射在端点上,所以:
x = 2 可以射爆 [1,2] 和 [2,3]
x = 4 可以射爆 [3,4] 和 [4,5]
约束
1 <= points.length <= 10^5points[i].length == 2-2^31 <= start < end <= 2^31 - 1
Step 1:一支箭到底能覆盖哪些气球?
先看一个很小的问题:
[1,2] 和 [2,3]
这两个气球能不能用一支箭射爆?
可以。
因为箭可以射在端点上:
x = 2
同时满足:
1 <= 2 <= 2
2 <= 2 <= 3
当前 baseline 是:
一个气球需要一支箭。
这个 baseline 的 break 是:
示例 1 有 4 个气球,但答案是 2。说明一支箭不一定只服务一个气球。
真正要问的是:
哪些气球有共同的 x 位置?
只要一组气球的区间存在公共交集,就可以用一支箭射爆这一组。
例如:
[1,6] 和 [2,8]
公共交集是:
[2,6]
在这个范围内任意选一个 x,都能射爆它们。
现在这一版能做到:
- 知道一支箭对应的是一组有公共交集的区间。
- 知道端点相接也可以共享一支箭。
- 能解释为什么箭数可能小于气球数量。
它还缺:
- 一个能把所有气球分成若干组的可运行方法。
Step 2:先写共同交集扫描 baseline
现在 baseline 是:
一支箭可以覆盖一组有公共交集的气球。
break 是:
我们还不能对任意
points算出最少箭数。
先写一个正确但状态稍重的版本。
把气球按起点排序:
points.sort(key=lambda p: p[0])
扫描时维护当前这一组气球的共同可射区间:
[left, right]
当读到一个新区间 [start, end]:
- 如果
start <= right,说明它和当前组仍有交集。 - 共同可射区间收缩成
[max(left, start), min(right, end)]。 - 如果
start > right,说明当前组已经无法覆盖它,必须新开一支箭。
代码:
from typing import List
def arrows_by_intersection(points: List[List[int]]) -> int:
points.sort(key=lambda p: p[0])
arrows = 1
left, right = points[0]
for start, end in points[1:]:
if start <= right:
left = max(left, start)
right = min(right, end)
else:
arrows += 1
left, right = start, end
return arrows
用示例检查:
points = [[10,16],[2,8],[1,6],[7,12]]
按起点排序:
[[1,6],[2,8],[7,12],[10,16]]
扫描过程:
当前组 [1,6]
读到 [2,8],仍有交集,公共可射区间变成 [2,6]
读到 [7,12],7 > 6,必须新开一支箭,当前组变成 [7,12]
读到 [10,16],仍有交集,公共可射区间变成 [10,12]
一共需要 2 支箭。
现在这一版能做到:
- 正确统计需要多少个有公共交集的气球组。
- 能解释每一支箭覆盖的是哪个区间组。
- 能跑通题目的核心示例。
它还缺:
- 状态里维护了
left和right两端,但最终判断新开一箭时真正关键的是右边界。
Step 3:把共同交集压缩成 arrow_pos
现在 baseline 是:
维护当前组的共同可射区间 [left, right]。
break 是:
这个 baseline 正确,但状态还可以更小。判断下一个气球能不能被当前箭覆盖,只需要知道当前箭最晚能射在哪里。
在一组气球里,最危险的是结束位置最早的气球。
如果当前组里最早结束的位置是:
right
那么当前箭的位置不能超过 right。
为了给后续气球留下机会,直接把箭放在这个最早结束位置:
arrow_pos = right
这和 435 里的“保留结束更早的区间”是同一类贪心压力:
越早冻结右边界,越不会错过当前必须覆盖的区间。
于是可以先按结束位置排序。
每次遇到一个气球 [start, end]:
- 如果
start <= arrow_pos,当前箭能射爆它。 - 如果
start > arrow_pos,当前箭射不到它,必须新开一支箭,并把新箭放在end。
注意这里新开箭的条件是:
start > arrow_pos
不是:
start >= arrow_pos
因为箭可以射在端点上。
看边界例子:
points = [[1,2],[2,3],[3,4],[4,5]]
如果第一支箭放在:
arrow_pos = 2
那么 [2,3] 的 start = 2,仍然能被这支箭射爆。
所以 [1,2] 和 [2,3] 共享一支箭。
现在这一版能做到:
- 把共同交集的右边界压缩成
arrow_pos。 - 知道按结束位置排序的原因。
- 知道什么时候必须新开一支箭。
它还缺:
- 完整的 LeetCode 函数和可运行检查。
Step 4:用 arrow_pos 统计最少箭数
现在 baseline 是:
按结束位置排序,把当前箭放在当前组最早结束的位置。
break 是:
还没有把这个规则组装成 LeetCode 要的
findMinArrowShots。
完整代码:
from typing import List
class Solution:
def findMinArrowShots(self, points: List[List[int]]) -> int:
points.sort(key=lambda p: p[1])
arrows = 1
arrow_pos = points[0][1]
for start, end in points[1:]:
if start > arrow_pos:
arrows += 1
arrow_pos = end
return arrows
用示例走一遍:
points = [[10,16],[2,8],[1,6],[7,12]]
按结束位置排序:
[[1,6],[2,8],[7,12],[10,16]]
扫描:
第一支箭放在 6
[2,8] 的 start = 2 <= 6,被当前箭覆盖
[7,12] 的 start = 7 > 6,新开第二支箭,放在 12
[10,16] 的 start = 10 <= 12,被第二支箭覆盖
最后需要 2 支箭。
现在这一版能做到:
- 用最少状态统计箭数。
- 正确处理端点相接。
- 满足 LeetCode 的函数签名。
正确性直觉
当前箭要覆盖当前组里的所有气球。
只要当前组里有一个气球最早结束在 arrow_pos,这支箭就不能放到 arrow_pos 右边。
所以把箭放在 arrow_pos 是安全且最宽松的选择:
它不会错过当前最早结束的气球,
同时尽可能给后面还没处理的气球留下覆盖机会。
按结束位置排序后,每次选择当前最早结束的气球来决定箭的位置。
如果后面的气球起点仍然不超过 arrow_pos,它就被当前箭顺带覆盖。
如果后面的气球起点已经大于 arrow_pos,当前箭无论放在哪里都不可能同时覆盖它和当前组,因此必须新开一支箭。
复杂度
排序需要:
O(n log n)
扫描一次:
O(n)
总时间复杂度:
O(n log n)
额外只用了 arrows 和 arrow_pos:
O(1)
常见错误
1. 把 start == arrow_pos 当成需要新箭
这题的覆盖条件是:
start <= x <= end
所以如果:
start == arrow_pos
当前箭仍然可以射爆这个气球。
新开箭条件必须是:
start > arrow_pos
2. 一开始按起点排序后直接套最终写法
按起点排序适合写共同交集 baseline。
最终贪心写法更适合按结束位置排序,因为 arrow_pos 来自当前最早结束的气球。
如果排序依据和状态含义对不上,很容易写出看似能过部分样例、但证明不清楚的代码。
3. 忘记题目保证至少一个气球
约束里有:
1 <= points.length
所以 LeetCode 版本里可以直接初始化:
arrows = 1
arrow_pos = points[0][1]
不需要额外处理空数组。
可以直接运行的检查
from typing import List
class Solution:
def findMinArrowShots(self, points: List[List[int]]) -> int:
points.sort(key=lambda p: p[1])
arrows = 1
arrow_pos = points[0][1]
for start, end in points[1:]:
if start > arrow_pos:
arrows += 1
arrow_pos = end
return arrows
def check() -> None:
s = Solution()
assert s.findMinArrowShots([[10, 16], [2, 8], [1, 6], [7, 12]]) == 2
assert s.findMinArrowShots([[1, 2], [3, 4], [5, 6], [7, 8]]) == 4
assert s.findMinArrowShots([[1, 2], [2, 3], [3, 4], [4, 5]]) == 2
assert s.findMinArrowShots([[1, 2]]) == 1
check()
小结
452 的贪心点是:
每一支箭放在当前组最早结束的位置。
扫描 invariant 是:
当前箭放在
arrow_pos,它能覆盖当前已经归入这一组的所有气球;如果下一个气球的start > arrow_pos,当前箭组已经结束,必须新开一支箭。
和 435 的关系是:
- 435:保留尽量多的不重叠区间。
- 452:用尽量少的点覆盖所有区间。
两题都在训练同一个区间贪心直觉:
按结束位置排序,优先处理最早结束的约束。