Skip to content

实战:用最少数量的箭引爆气球

LeetCode 452. 用最少数量的箭引爆气球 | 难度:中等

区间重叠的贪心应用,理解"找公共点"的思路。

📎 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]

问题分析

问题转化

本质:将区间分成最少的组,使得每组内所有区间有公共点。

每组对应一支箭,箭射在公共点上可以引爆该组所有气球。

关键洞察

如果多个气球有公共区域,一支箭就能射爆它们。

问题变成:找到最少数量的"公共点组"。


贪心策略

思路

  1. 按右端点排序:优先处理结束早的气球
  2. 尽量复用箭:箭射在右端点,能覆盖最多重叠区间

为什么按右端点排序?

假设箭在位置 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

  1. 如果 p > end,箭射不到这个气球,错误。
  2. 如果 p < end,虽然能射到当前气球,但后续区间 [s', e'] 如果 s' <= end && s' > p,就射不到了。
  3. 如果 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

总结

射气球问题展示了区间贪心的经典模式:

  1. 按右端点排序:处理顺序决定了贪心策略的正确性
  2. 边界处理> vs >= 是关键区别
  3. 贪心证明:右端点射箭覆盖范围最大

核心思想:尽量让每支箭射爆更多气球,而在右端点射箭就是实现这一目标的贪心选择。

实战:用最少数量的箭引爆气球 has loaded