Appearance
实战:用最少数量的箭引爆气球
LeetCode 452. 用最少数量的箭引爆气球 | 难度:中等
区间重叠的贪心应用,理解"找公共点"的思路。
题目描述
有一些球形气球,用二维数组 points 表示,其中 points[i] = [xstart, xend] 表示气球的水平直径。
一支弓箭可以在 x 轴某一点垂直射出,穿过所有 xstart <= x <= xend 的气球。
求引爆所有气球所需的最少弓箭数。
示例1:
输入:points = [[10,16],[2,8],[1,6],[7,12]]
输出:2
解释:
x=6 射爆 [2,8] 和 [1,6]
x=11 射爆 [10,16] 和 [7,12]示例2:
输入:points = [[1,2],[3,4],[5,6],[7,8]]
输出:4
解释:每个气球都需要一支箭示例3:
输入:points = [[1,2],[2,3],[3,4],[4,5]]
输出:2
解释:x=2 射爆 [1,2] 和 [2,3],x=4 射爆 [3,4] 和 [4,5]问题分析
问题转化
本质:将区间分成最少的组,使得每组内所有区间有公共点。
每组对应一支箭,箭射在公共点上可以引爆该组所有气球。
关键洞察
如果多个气球有公共区域,一支箭就能射爆它们。
问题变成:找到最少数量的"公共点组"。
贪心策略
思路
- 按右端点排序:优先处理结束早的气球
- 尽量复用箭:箭射在右端点,能覆盖最多重叠区间
为什么按右端点排序?
假设箭在位置 p 射出:
情况1:p < 区间右端点 right
[----right]
[-----] ← 这个可能射不到
情况2:p = 区间右端点 right
[----right] ← 一定能射到
[---...] ← 只要 start <= right 就能射到在右端点射箭,能覆盖所有起点 ≤ 右端点的气球。
代码实现
typescript
function findMinArrowShots(points: number[][]): number {
if (points.length === 0) return 0;
// 按结束位置(右端点)排序
points.sort((a, b) => a[1] - b[1]);
let arrows = 1; // 至少需要一支箭
let arrowPos = points[0][1]; // 第一支箭射在第一个区间的右端点
for (let i = 1; i < points.length; i++) {
// 如果当前气球的起始位置 > 箭的位置
// 说明当前箭射不到,需要新箭
if (points[i][0] > arrowPos) {
arrows++;
arrowPos = points[i][1]; // 新箭射在当前区间的右端点
}
// 否则当前箭可以同时射爆这个气球,不需要额外操作
}
return arrows;
}执行过程详解
以 points = [[10,16],[2,8],[1,6],[7,12]] 为例:
按右端点排序: [[1,6],[2,8],[7,12],[10,16]]
时间线可视化:
1------6
2--------8
7------12
10------16
| | | | |
1 5 10 15 20
处理过程:
初始: arrows=1, arrowPos=6(第一支箭射在6)
[2,8]: start=2 <= arrowPos=6
→ 当前箭(x=6)可以射爆,不需要新箭
[7,12]: start=7 > arrowPos=6
→ 需要新箭!arrows=2, arrowPos=12
[10,16]: start=10 <= arrowPos=12
→ 当前箭(x=12)可以射爆
结果: 2 支箭
第一支 x=6 射爆 [1,6] 和 [2,8]
第二支 x=12 射爆 [7,12] 和 [10,16]正确性证明
贪心选择性质
命题:在右端点射箭是最优选择。
证明: 设当前要处理的区间是 [start, end],箭射在位置 p。
- 如果
p > end,箭射不到这个气球,错误。 - 如果
p < end,虽然能射到当前气球,但后续区间[s', e']如果s' <= end && s' > p,就射不到了。 - 如果
p = end,能射到所有start' <= end的气球,覆盖范围最大。
结论:选择 p = end 是最优的。
最优子结构
已射箭决策不影响剩余气球的最优解。每次贪心选择后,剩余问题仍是同类型的子问题。
与"无重叠区间"的对比
| 题目 | LeetCode 435 无重叠区间 | LeetCode 452 射气球 |
|---|---|---|
| 问题 | 最少删除多少区间使无重叠 | 最少用多少箭射完 |
| 边界 | start >= end 不重叠 | start > end 需新箭 |
| 代码区别 | >= | > |
关键区别:气球在边界点也能被射爆(start <= x <= end)。
typescript
// 无重叠区间:[1,2] 和 [2,3] 不算重叠
if (points[i][0] >= arrowPos) { ... }
// 射气球:[1,2] 和 [2,3] 共享点 2,一箭可射
if (points[i][0] > arrowPos) { ... }复杂度分析
时间复杂度:O(n log n)
- 排序:O(n log n)
- 遍历:O(n)
空间复杂度:O(log n)
- 排序使用的栈空间
常见错误
错误1:按左端点排序
typescript
// ❌ 错误:按左端点排序会导致错过最优解
points.sort((a, b) => a[0] - b[0]);
// 例如:[[1,10],[2,3],[4,5]]
// 按左端点:[[1,10],[2,3],[4,5]]
// 如果箭射在10,只能射到[1,10]
// 需要3支箭
// ✅ 正确:按右端点排序
points.sort((a, b) => a[1] - b[1]);
// [[2,3],[4,5],[1,10]]
// 箭射在3,射爆[2,3]和[1,10]? 不对,[1,10]包含3
// 实际:3射爆[2,3],5射爆[4,5]和[1,10](1<=5<=10)
// 等等,还是3支...让我重新分析
// 实际上[[1,10],[2,3],[4,5]]按右端点排序:[[2,3],[4,5],[1,10]]
// arrowPos=3: [1,10] 的 start=1 <= 3 ✓
// 结果是2支箭,不是3支错误2:边界条件使用 >=
typescript
// ❌ 错误:边界点可以射爆,应该用 >
if (points[i][0] >= arrowPos) { ... }
// ✅ 正确
if (points[i][0] > arrowPos) { ... }错误3:忘记处理空数组
typescript
// ❌ 错误:空数组会导致访问越界
let arrowPos = points[0][1]; // points[0] 不存在
// ✅ 正确:先检查
if (points.length === 0) return 0;问题变体
变体1:返回射箭位置
typescript
function findArrowPositions(points: number[][]): number[] {
if (points.length === 0) return [];
points.sort((a, b) => a[1] - b[1]);
const positions: number[] = [points[0][1]];
let arrowPos = points[0][1];
for (let i = 1; i < points.length; i++) {
if (points[i][0] > arrowPos) {
arrowPos = points[i][1];
positions.push(arrowPos);
}
}
return positions;
}变体2:气球有不同分值,求最大得分
贪心不一定适用,可能需要动态规划。
相关题目
- LeetCode 435. 无重叠区间
- LeetCode 56. 合并区间
- LeetCode 253. 会议室 II
总结
射气球问题展示了区间贪心的经典模式:
- 按右端点排序:处理顺序决定了贪心策略的正确性
- 边界处理:
>vs>=是关键区别 - 贪心证明:右端点射箭覆盖范围最大
核心思想:尽量让每支箭射爆更多气球,而在右端点射箭就是实现这一目标的贪心选择。