LeetCode 452. 用最少数量的箭引爆气球 —— 区间贪心经典:排序 + 扫描一箭穿心

LeetCode 452. 用最少数量的箭引爆气球 ------ 区间贪心经典:排序 + 扫描一箭穿心

区间问题是贪心算法的经典战场。今天的题目------用最少数量的箭引爆气球 (LeetCode 452),本质上是一个区间重叠计数问题:在数轴上给定一堆区间,问最少需要多少个"点"才能让每个区间至少包含一个点。

这个问题和「会议室 II」(LeetCode 253)、「无重叠区间」(LeetCode 435)并称为区间贪心三兄弟 ------它们共享同一个核心技巧:排序 + 扫描

本文将从三种思路入手:暴力枚举贪心(按右端点排序)贪心(按左端点排序 + 合并),彻底讲透区间重叠问题的通用套路。


问题描述

LeetCode 452. Minimum Number of Arrows to Burst Balloons(用最少数量的箭引爆气球)

有一些球形气球贴在一堵用 XY 平面表示的墙面上。墙面上的气球记录在整数数组 points,其中 points[i] = [xstart, xend] 表示水平直径在 xstartxend 之间的第 i 个气球。你不知道气球的确切 y 坐标。

弓箭可以沿着 x 轴从不同点 完全垂直地射出。在坐标 x 处射出一支箭,若 xstart <= x <= xend,则该气球会被引爆。可以射出的弓箭的数量没有限制。

给你一个数组 points,返回引爆所有气球所必须射出的最少弓箭数

示例:

css 复制代码
输入:points = [[10,16],[2,8],[1,6],[7,12]]
输出:2
解释:气球可以用 2 支箭来引爆:
  - 在 x = 6 处射箭,引爆 [2,8] 和 [1,6] 气球
  - 在 x = 11 处射箭,引爆 [10,16] 和 [7,12] 气球
lua 复制代码
输入:points = [[1,2],[3,4],[5,6],[7,8]]
输出:4
解释:每个气球都不重叠,需要 4 支箭。
css 复制代码
输入:points = [[1,2],[2,3],[3,4],[4,5]]
输出:2
解释:[1,2] 和 [2,3] 重叠(边界相交算重叠),[3,4] 和 [4,5] 重叠。

核心思想

从"引爆"到"重叠"

一支箭能引爆的气球,就是那些区间互相重叠的气球:

ini 复制代码
气球:  [1,6]  [2,8]  [7,12]  [10,16]

在数轴上:
1----6
  2--------8
            7----12
              10--------16

[1,6] 和 [2,8] 重叠 → 1 支箭(比如 x=6)可以同时引爆
[7,12] 和 [10,16] 重叠 → 1 支箭(比如 x=11)可以同时引爆

答案:2 支箭

关键问题: 怎么判断哪些气球可以被同一支箭引爆?

贪心策略

想象你站在数轴上,手里拿着箭。你想用最少的箭引爆所有气球,策略是什么?

每次都把箭射在"尽可能靠右"的位置------这样一支箭能覆盖更多的气球。

更精确地说:

  1. 按气球的右边界排序
  2. 射第一支箭在第一个气球的右边界处
  3. 跳过所有与这支箭"相交"的气球(即左边界 ≤ 箭的位置)
  4. 对下一个未被引爆的气球,重复步骤 2

为什么按右边界排序?

如果按左边界排序,箭可能射得太靠右,漏掉左边的气球。按右边界排序保证了:每支箭都在"当前能覆盖的气球中,最靠左的右边界"处射出------贪心地让每支箭覆盖尽可能多的气球

ini 复制代码
错误策略(按左边界):
  气球 [1,100], [2,3], [4,5]
  按左边界排序: [1,100], [2,3], [4,5]
  射在 x=100 → 只引爆 [1,100],还需要 2 支箭,共 3 支

正确策略(按右边界):
  按右边界排序: [2,3], [4,5], [1,100]
  射在 x=3 → 引爆 [2,3] 和 [1,100](因为 1 ≤ 3 ≤ 100)
  射在 x=5 → 引爆 [4,5]
  共 2 支箭 ✅

思路分析

解法一:暴力枚举法

最直观的想法:枚举所有可能的箭位置,找最少的箭数覆盖所有区间。

但箭的位置是连续的实数,直接枚举不可行。不过我们可以观察到:最优解中,每支箭的位置一定是某个气球的右边界(否则可以右移一点,覆盖更多气球)。

所以暴力法可以:枚举每个气球的右边界作为箭的位置,用集合覆盖的方式找最少箭数。但这需要指数级的搜索,不实用。

解法二:贪心法(按右端点排序)⭐ 推荐

核心观察:

arduino 复制代码
按右边界排序后,依次处理每个气球:
  - 如果当前气球已经被之前的箭引爆了(左边界 ≤ 上一支箭的位置)→ 跳过
  - 否则 → 需要一支新箭,射在当前气球的右边界处

为什么射在右边界?
  → 右边界是能引爆当前气球的"最靠左"的位置
  → "最靠左"意味着能覆盖更多左边的气球?不对------
  → 实际上是"最靠右"的最优位置:射在右边界,能覆盖所有与当前气球重叠的、
    右边界更靠右的气球。

解法三:贪心法(按左端点排序 + 合并重叠区间)

另一种视角:把重叠的气球合并成一个"大区间",数一数最终有多少个不重叠的大区间,就需要多少支箭。

lua 复制代码
按左边界排序后:
  维护当前"重叠区域"的右边界 end
  - 如果下一个气球的左边界 ≤ end → 重叠,更新 end = min(end, 右边界)
  - 否则 → 新开一个重叠区域,箭数 +1

这本质上是合并区间的变体------只不过合并时取右边界较小值(而非较大值),因为我们关心的是"重叠区域"的交集。


代码实现

JavaScript 版本

方法一:贪心法(按右端点排序)⭐
javascript 复制代码
/**
 * @param {number[][]} points
 * @return {number}
 */
var findMinArrowShots = function(points) {
    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;
};
方法二:贪心法(按左端点排序 + 合并)
javascript 复制代码
var findMinArrowShots = function(points) {
    if (points.length === 0) return 0;

    // 按左边界排序
    points.sort((a, b) => a[0] - b[0]);

    let arrows = 1;
    let end = points[0][1]; // 当前重叠区域的右边界

    for (let i = 1; i < points.length; i++) {
        if (points[i][0] <= end) {
            // 重叠 → 更新重叠区域右边界(取较小值 = 交集)
            end = Math.min(end, points[i][1]);
        } else {
            // 不重叠 → 新增一支箭
            arrows++;
            end = points[i][1];
        }
    }

    return arrows;
};

Python 版本

方法一:贪心法(按右端点排序)⭐
python 复制代码
def findMinArrowShots(points: list[list[int]]) -> int:
    """方法一:贪心法(按右端点排序)"""
    if not points:
        return 0

    # 按右边界排序
    points.sort(key=lambda x: x[1])

    arrows = 1                # 至少需要一支箭
    arrow_pos = points[0][1]  # 第一支箭射在第一个气球的右边界

    for i in range(1, len(points)):
        # 如果当前气球的左边界 > 箭的位置 → 射不到,需要新箭
        if points[i][0] > arrow_pos:
            arrows += 1
            arrow_pos = points[i][1]  # 新箭射在当前气球的右边界
        # 否则:当前气球被已有箭引爆,跳过

    return arrows
方法二:贪心法(按左端点排序 + 合并)
python 复制代码
def findMinArrowShots(points: list[list[int]]) -> int:
    """方法二:贪心法(按左端点排序 + 合并重叠区间)"""
    if not points:
        return 0

    # 按左边界排序
    points.sort(key=lambda x: x[0])

    arrows = 1
    end = points[0][1]  # 当前重叠区域的右边界

    for i in range(1, len(points)):
        if points[i][0] <= end:
            # 重叠 → 更新重叠区域右边界(取交集)
            end = min(end, points[i][1])
        else:
            # 不重叠 → 新增一支箭
            arrows += 1
            end = points[i][1]

    return arrows

逐步推演

points = [[10,16],[2,8],[1,6],[7,12]] 为例(答案:2)。

方法一(按右端点排序)推演

ini 复制代码
排序前: [[10,16],[2,8],[1,6],[7,12]]
按右边界排序后: [[1,6],[2,8],[7,12],[10,16]]

初始化: arrows = 1, arrowPos = 6

=== i=1: 气球 [2,8] ===
  points[1][0] = 2 > arrowPos(6)? → 否
  2 ≤ 6,气球被箭引爆 ✅ 跳过
  arrows = 1, arrowPos = 6

=== i=2: 气球 [7,12] ===
  points[2][0] = 7 > arrowPos(6)? → 是!
  7 > 6,箭射不到,需要新箭 🏹
  arrows = 2, arrowPos = 12

=== i=3: 气球 [10,16] ===
  points[3][0] = 10 > arrowPos(12)? → 否
  10 ≤ 12,气球被箭引爆 ✅ 跳过
  arrows = 2, arrowPos = 12

结果:arrows = 2 ✅

验证:

ini 复制代码
第1支箭 x=6:
  [1,6]:  1 ≤ 6 ≤ 6   ✅ 引爆
  [2,8]:  2 ≤ 6 ≤ 8   ✅ 引爆
  [7,12]: 7 ≤ 6?       ❌ 射不到
  [10,16]: 10 ≤ 6?     ❌ 射不到

第2支箭 x=12:
  [7,12]:  7 ≤ 12 ≤ 12  ✅ 引爆
  [10,16]: 10 ≤ 12 ≤ 16 ✅ 引爆

两支箭引爆全部气球 ✅

方法二(按左端点排序 + 合并)推演

ini 复制代码
排序前: [[10,16],[2,8],[1,6],[7,12]]
按左边界排序后: [[1,6],[2,8],[7,12],[10,16]]

初始化: arrows = 1, end = 6(第一个气球的右边界)

=== i=1: 气球 [2,8] ===
  points[1][0] = 2 ≤ end(6)? → 是
  重叠!更新 end = min(6, 8) = 6
  arrows = 1

=== i=2: 气球 [7,12] ===
  points[2][0] = 7 ≤ end(6)? → 否!
  不重叠!新增箭 🏹
  arrows = 2, end = 12

=== i=3: 气球 [10,16] ===
  points[3][0] = 10 ≤ end(12)? → 是
  重叠!更新 end = min(12, 16) = 12
  arrows = 2

结果:arrows = 2 ✅

边界情况推演:端点相切

points = [[1,2],[2,3],[3,4],[4,5]] 为例(答案:2)。

ini 复制代码
按右边界排序: [[1,2],[2,3],[3,4],[4,5]]

初始化: arrows = 1, arrowPos = 2

=== i=1: [2,3] ===
  2 > 2? → 否(等于不算大于)
  重叠 ✅ arrows = 1

=== i=2: [3,4] ===
  3 > 2? → 是!
  新箭 🏹 arrows = 2, arrowPos = 4

=== i=3: [4,5] ===
  4 > 4? → 否
  重叠 ✅ arrows = 2

结果:2 ✅

注意: 端点相切(如 [1,2][2,3] 在 x=2 处相交)算重叠!代码中用 > 而非 >= 来判断,正是为了处理这个边界。


复杂度分析

方法 时间复杂度 空间复杂度 优点 缺点
贪心(按右端点) O(n log n) O(1) 最简洁、最直觉 需要理解为什么按右端点排序
贪心(按左端点+合并) O(n log n) O(1) 和"合并区间"思路统一 取 min 的含义需要理解

举一反三

关联题目

题目 核心思想 与本题的关系
56. 合并区间 合并所有重叠区间 本题的"对偶"------合并 vs 计数
435. 无重叠区间 移除最少区间使不重叠 同样的贪心策略,去掉重叠区间
252. 会议室 判断能否参加所有会议 重叠判断的简化版
253. 会议室 II 最少需要多少会议室 本质和本题一样------重叠计数

区间贪心通用框架

markdown 复制代码
区间问题三步走:
1. 排序 → 按左端点 or 右端点(取决于问题)
2. 扫描 → 维护当前区间的"边界"
3. 判断 → 重叠?合并?计数?

本题:按右端点排序 → 扫描 → 重叠则跳过,不重叠则 +1
435:按右端点排序 → 扫描 → 重叠则移除,不重叠则保留
56:  按左端点排序 → 扫描 → 重叠则合并,不重叠则输出
253:按开始时间排序 → 扫描 → 用最小堆维护会议室结束时间

总结

题目 难度 核心思想 推荐解法
452 用最少数量的箭引爆气球 Medium 按右端点排序 + 贪心 按右端点排序贪心

一道题,三种理解:

  1. 按右端点排序:最直觉------每支箭射在能引爆当前气球的最靠左位置(右边界),最大化覆盖
  2. 按左端点排序+合并:和"合并区间"统一框架------重叠区域取交集(min),不重叠则新开一支箭
  3. 本质是区间重叠计数:和 253(会议室 II)、435(无重叠区间)同一类问题

区间贪心的核心口诀:

排序定方向,扫描做决策,重叠就合并/跳过,不重叠就 +1。

相关推荐
小小龙学IT1 小时前
Drizzle ORM:TypeScript 生态中冉冉升起的数据库工具链引言
javascript·数据库·typescript
winfredzhang2 小时前
用 Python + wxPython 做一个个人健康饮食管理工具:从记录三餐到综合生活建议
python·wxpython·deepseek·生活习惯管理
Zhang~Ling2 小时前
C++ 红黑树封装:myset和mymap的底层实现
开发语言·数据结构·c++·算法
ECT-OS-JiuHuaShan2 小时前
什么是对和错?——“有针对性定义域的逻辑值的真伪”:认识论终极追问的公理化裁决
数据库·人工智能·算法·机器学习·数学建模
旺王雪饼 www2 小时前
localStorage 和 sessionStorage区别与联系
服务器·前端·javascript
Irissgwe2 小时前
十、LangGraph能力详解:工作流的常见模式
python·langchain·ai编程·工作流·langgraph
এ慕ོ冬℘゜3 小时前
【双月日期范围选择器】博客(可直接交作业 / 上线)
前端·javascript·交互·jquery
Merlyn103 小时前
【栈】155. 最小栈
python·算法
SilentSamsara3 小时前
NumPy 进阶:广播机制、ufunc 与向量化计算的工程实践
开发语言·python·青少年编程·性能优化·numpy