"The only person you are destined to become is the person you decide to be." - Ralph Waldo Emerson
1. 题目描述
2. 题目分析与解析
这个题目开始读题的时候是有点不好理解题意的,因此我先做个图让大家对于题意有更好更直观的理解再来分析题目。
比如对于这个测试用例:
实际上points就是气球的宽度,可视化如下:
而题目要求的就是引爆所有气球所必须射出的 最小 弓箭数 ,对于上述情况,那么就需要以下红色的两支箭:
其中虚线框就是表示两支箭的可以横向移动的范围。第一支箭从【2,6】都可以射出从而击破两个气球,第二支箭从【10,12】都可以射出击破剩下的两个气球。
2.1 思路一
可以看出本题的实质就是在找能够涵盖所有气球的最小交集个数。那么我们首先应该想到的是排序,经过排序后,然后遍历每一个区间,查看当前区间能够附带击破的气球的个数,比如如下图:
首先遍历第一个区间【1,6】,查看每一个位置能够附带击破的气球的个数,在数字1的位置,发现只能附带击破自己本身1个气球,在【2,4】发现能击破两个,但是在【5,6】(因为等于5就可以击破蓝色气球:满足 xstart ≤ x ≤ x``end
,则该气球会被 引爆 ),所以我们肯定得将箭设置成能击破最多个数气球的位置,也就是击破三个气球,然后移除这些已经被击破的气球,继续寻找下一个区间,以此循环,直到没有剩余的气球。
代码思路:
-
初始化两个变量,一个用来统计删除区间的位置,一个用来统计结果
-
按照区间的开始位置进行从小到大排序
-
从最小的区间开始,查看每一个数字射出箭能够击破的最大气球数以及击破气球的位置
-
也就是判断某个数字能否被某些区间所包含,即是否大于等于区间的最小值且小于等于区间的最大值
-
找到最多的能被包含的位置及相关区间,删除这些区间
-
-
从当前集合中取出下一个最小区间重复以上步骤直到集合为空
-
返回结果
但是理所当然的报了超时,因为我们尝试去遍历了每一个可能射出箭的位置:
2.2 思路二
根据上面的思路,其实我们的本质就是找每个区间和其余区间的最大交集个数。
而因为如果我们按照区间的开始位置从小到大进行排序,那么我们就需要根据下一个区间的开始位置是否小于等于当前遍历区间的结束位置,如下,假设现在遍历第一个蓝色区间:
我们发现绿色的开始位置小于蓝色气球的结束位置,所以我们就可以断定他们俩有交集(因为左端点已经经过排序了的)。然后再看下一个区间也就是黄色气球,发现还是有交集,那么我们是不是就可以断定这一支箭一定就可以一串三呢?
答案是否定的,虽然对于上述图中是可以从x=6射出一只箭来打破这三个气球,然后从剩余气球的第一个位置继续遍历,但是如果是如下的情况呢?
这时可以发现,虽然遍历的第一个蓝色气球和黄色绿色两个气球都相交,但是我们并不能用一只箭将他们全部射破,因为绿色和黄色并不像上面开始的图中给出的示例一样能够在 x=6
处相交。
所以此时要注意,在判断完毕第一个蓝色气球和绿色气球之后,我们需要把结束位置更新,更新为 6
,因为绿色气球必须要一只 x<=6
的箭来射穿它。
这时如果结束位置是6,我们再来判断黄色气球的开始位置和当前区间也就是 【1,6】
是否相交,发现是不可以的,就可以断定需要一只箭来射穿前两个气球。然后我们再从黄色气球开始向后按照之前的步骤继续发出箭。
所以核心点就是: 我们一定要把结束位置更新为走过的气球中的最小值
所以根据以上性质,我们可以给出以下代码思路:
代码思路:
-
对气球区间进行升序排序
-
遍历每一个区间,查看当前区间的下一个区间的开始位置是否小于等于当前区间的结束位置
-
如果满足,就将遍历的区间索引++,也就相当于移除了这个区间,然后更新右端点为两者的最小值。
-
-
如果走到某一个区间发现不满足上述条件,那么直接射出一只箭,然后遍历剩下的区间
小错误排查:
但是按照以上思路求解发现了一个问题,就是对于该测试用例:
发现解答错误,然后我debug了一下,发现按照从小到大排序是这样的:
通过排查了一番,找到了原因如下:
在排序时,我是用如下代码:
//1. 对气球区间进行升序排序Arrays.sort(points, (o1, o2) -> o1[0] - o2[0]);
该代码片段是使用一个自定义比较器对二维数组points
进行排序的Java代码。这个比较器是一个lambda表达式,它比较两个区间的开始点o1[0]
和o2[0]
。
这个比较器通过计算两个开始点的差值o1[0] - o2[0]
来决定排序顺序。在Java中,Comparator
接口期望的返回值为负数、零或正数,分别表示第一个参数小于、等于或大于第二个参数。
但是因为测试用例中整数值接近Integer
类型的极限,所以这个比较器的计算可能会导致整数溢出:
-
如果
o1[0]
是一个非常大的正数,而o2[0]
是一个负数,那么理论上o1[0]
应该大于o2[0]
。 -
但是如果
o1[0]
和o2[0]
的差值大于Integer.MAX_VALUE
(即2147483647
),那么计算结果将会溢出,导致返回一个负值。 -
这会使得排序函数错误地认为
o1[0]
小于o2[0]
。
为了避免整数溢出,那么应该使用Integer.compare()
方法来比较两个整数,它能正确处理溢出的情况。下面是修改后的代码:
Arrays.sort(points, (o1, o2) -> Integer.compare(o1[0], o2[0]));
使用Integer.compare()
方法可以确保即使是接近整数极限的值,比较操作也会正确执行,返回正确的排序顺序。
经过修改后完美解决!
2.3 思路三
在思路二中,实际上是对思路一的优化,减少了一些不必要的判断,思路二需要不断更新右边界来确保正确的射出箭。那么我们可不可以不更新右边界呢?
因为我们之所以不断更新右边界,就是想知道在哪个位置之前必须得射出一只箭,这是必须依赖于右边界的值的。那我们是不是可以根据右边界从小到大进行排序,以每一个当前区间的右边界作为指标,判断其余区间是否能和它相交?
这样其实每一个右边界就是一次极限
,必须射出一只箭的极限,因为如果到了右边界还不射出,那么当前这个气球就无法被击破了。所以根据以上思路,我们可以对右边界从小到大排序,得出以下:
代码思路:
假设给定的区间集合中的每个区间表示为一个点对[xstart, xend]
-
按照xend排序:首先将所有区间按照
xend
的值从小到大排序,这样可以确保我们每次选择的区间都是最先结束的。 -
选择区间:从排序后的区间列表中选择第一个区间,这个区间的
xend
将覆盖第一个坐标点。这将是我们选择的第一个区间。 -
覆盖剩余的点:然后遍历剩下的点,对于每个点,如果它没有被当前已经选择的区间集合中的任何一个区间覆盖,那么我们就选择下一个区间。我们选择的区间是排序后列表中第一个
xstart
小于或等于该点坐标的区间。 -
重复:重复上述过程,直到所有的点都被覆盖。
-
计数:在上述过程中,我们每选择一个区间就将计数器加一,最后的计数器的值就是最少需要的区间数。
3. 代码实现
3.1 思路一
3.2 思路二
3.3 思路三
4. 相关复杂度分析
解法一:
-
时间复杂度:O(n^2),最坏情况下,每个区间都会与每个箭进行比较。
-
空间复杂度:O(1),没有使用额外的空间,除了几个变量以外。
解法二:
-
时间复杂度:O(n log n),排序的时间复杂度是O(n log n),遍历区间的时间复杂度是O(n),因此总的时间复杂度是O(n log n)。
-
空间复杂度:O(1),排序可能需要O(log n)的空间复杂度(如果使用的是快速排序),但通常我们认为这是排序操作的内在空间使用,并且不会超过O(log n)。
解法三:
-
时间复杂度:O(n log n),与解法二类似,主要的时间消耗在于排序。
-
空间复杂度:O(1),同样的理由,主要空间消耗在排序的栈空间上,不会超过O(log n)。
需要注意的是,实际的时间复杂度还取决于使用的排序算法。
在Java中,Arrays.sort()
方法对于基本类型使用的是快速排序的变种,对于对象类型使用的是稳定的归并排序,归并排序的空间复杂度是O(n)。由于本题中排序的是int[][]
类型,即对象类型,所以如果严格来说,空间复杂度应为O(n)。但在面试或者实际工作中,如果不是特别强调,我们通常认为这是内置的排序函数的空间复杂度,而不将其计入算法的空间复杂度中。