这篇文章记录了区间调度问题之重叠区间、区间合并以及求区间交集。
解决区间问题的一般思路是先排序,再操作。关于排序方式的选择,不同的题型选择不同的排序方式:
- 对于重叠区间问题,往往是和贪心策略有关,因此根据右端点排序,维护
end
变量。-
- 用最少数量的箭引爆气球
-
- 无重叠区间(或求最多的无重叠区间个数)
-
- 对于合并区间问题,习惯来说就是从左至右依次合并,因此根据左端点排序,维护一个
res
数组,每次从res
中取最后一个区间的右端点作为比较的标准-
- 合并区间
-
- 划分字母区间
-
252. 会议室
给定一个会议时间安排的数组,每个会议时间都会包括开始和结束的时间 [[s1,e1],[s2,e2],…] (si < ei),请你判断一个人是否能够参加这里面的全部会议。
def canAttendMeetings(self, intervals):
"""
252. 会议室:判断是否能够参加所有的会议
"""
if len(intervals) <= 1:
return True
def getFirst(alist):
return alist[0]
intervals.sort(key=getFirst)
for i in range(1, len(intervals)):
if intervals[i][0] < intervals[i - 1][1]:
return False
return True
253. 会议室II
给定一个会议时间安排的数组,每个会议时间都会包括开始和结束的时间 [[s1,e1],[s2,e2],…] (si < ei),为避免会议冲突,同时要考虑充分利用会议室资源,请你计算至少需要多少间会议室,才能满足这些会议安排。
输入: [[0, 30],[5, 10],[15, 20]]
输出: 2
我们将所有区间在坐标轴上画好,然后用一个垂直于x轴的扫描线从左至右扫描,那么我们的目标就是求扫描线和区间最多的交点数。那么如何去求交点数目呢?
我们发现,每次遇到一个区间的左端点,交点数目就+1,每次遇到一个区间的右端点,交点数目就-1。所以我们对每个区间[left, right]
,保存为[left, 1]
和[right, -1]
,然后对所有点排序遍历,累加第二维的值作为最终结果。
def minMeetingRooms(self, intervals):
"""
253 会议室2:求出最少需要的会议室数目
扫描线的应用:求与x轴垂直的竖线和所有区间的最多交点
求交点的做法:遇到区间的左端点就+1,遇到右端点就-1
"""
if len(intervals) <= 1:
return len(intervals)
# 构建新列表,对于所有区间[left, right]都分割成两个:左端点[left, 1],右端点[right, -1]
tmp = []
for i in range(len(intervals)):
tmp.append([intervals[i][0], 1])
tmp.append([intervals[i][1], -1])
tmp.sort(key=lambda x: x[0])
res = 0
cur = 0
for i in range(len(tmp)):
cur += tmp[i][1]
res = max(res, cur)
return res
452. 用最少数量的箭引爆气球
给定每个气球的坐标,我们想找到使得所有气球全部被引爆,所需的弓箭的最小数量。
输入:
[[10,16], [2,8], [1,6], [7,12]]
输出:
2
解释:
对于该样例,我们可以在x = 6(射爆[2,8],[1,6]两个气球)和 x = 11(射爆另外两个气球)。
碰到区间题,首先在坐标轴上画出来所有区间,然后这道题要使得所需弓箭最少,实际上是让我们的每根扫描线与区间的交点数在大于等于1的情况下要最多。如下图,红色实线代表使得交点最多的扫描线,注意这边②③两条线,③比②更符合要求,因为当出现了新的区间(图中蓝色虚线),因为③更远,所以更可能经过新的区间,这就是体现了贪心策略——每根扫描线是能够经过当前所有区间的最远位置。
区间题一个很重要的步骤就是对区间排序:可以根据区间的左端点和右端点进行排序,排序之后再根据端点的大小比较进行选择操作。那么究竟如何排序呢?这道题两种排序方式都可以做,但是根据右端点排序会比较简便。
- 根据左端点排序
根据以上讨论,我们可以设置一个max_end
标记, 它表示:在遍历的过程中使用当前这只箭能够击穿所有气球的最远距离。这个最远距离,在每遍历一个新区间的时候,都会检查并更新:1)当新区间左端点在最远距离外,需要一只新的箭,更新最远距离为新区间的右端点。2)当新区间左端点在最远距离内,无需新的箭,更新最远距离为min(当前的最远距离,新区间的右端点)
def findMinArrowShots(self, points):
"""
452. 用最少数量的箭引爆气球:
贪心体现在当前这只箭的位置使能够射穿当前所有气球的最远位置 [1,5] [2,6] 那么箭最好射在5处,因为如果之后又来一个[4,7],那么还是可以射穿
这道题需要你体会按照左端点和右端点排序的差异
"""
if len(points) <= 1:
return len(points)
# 1. 按照左端点排序
points.sort(key=lambda x: x[0])
res = 1
max_end = points[0][1] # 代表在遍历的过程中使用当前这只箭能够击穿所有气球的最远离
for i in range(1, len(points)):
# 新的区间左端点比箭的最远距离还要远,需要新的箭
if points[i][0] > max_end:
res += 1
max_end = points[i][1] # 更新当前这只箭的最远距离
else: # 新的区间左端点在最远距离之内,不需要新的箭,但是要更新最远距离
max_end = min(max_end, points[i][1])
return res
- 根据右端点排序
可以看到,max_end
标记表示在遍历的过程中使用当前这只箭能够击穿所有气球的最远距离,这个标记需要跟新区间的右端点进行比较来决定如何更新。那么如果按照右端点排序的话,新区间的右端点一定是大于等于当前max_end
的,所以省略了比较操作。
def findMinArrowShots(self, points):
"""
452. 用最少数量的箭引爆气球:
贪心体现在当前这只箭的位置使能够射穿当前所有气球的最远位置 [1,5] [2,6] 那么箭最好射在5处,因为如果之后又来一个[4,7],那么还是可以射穿
这道题需要你体会按照左端点和右端点排序的差异
"""
if len(points) <= 1:
return len(points)
# 2. 按照右端点排序, 此时当新的区间左端点在最远距离之内,就不需要更新最远距离max_end
points.sort(key=lambda x: x[1])
res = 1
max_end = points[0][1] # 代表在遍历的过程中使用当前这只箭能够击穿所有气球的最远距离
for i in range(1, len(points)):
# 需要新的箭
if points[i][0] > max_end:
res += 1
max_end = points[i][1]
return res
435. 无重叠区间
给定一个区间的集合,找到需要移除区间的最小数量,使剩余区间互不重叠。
注意:
可以认为区间的终点总是大于它的起点。
区间 [1,2] 和 [2,3] 的边界相互“接触”,但没有相互重叠。
输入: [ [1,2], [2,3], [3,4], [1,3] ]
输出: 1
解释: 移除 [1,3] 后,剩下的区间没有重叠。
这道题和求最多的无重叠区间是一个意思。
同样,首先思考按照左端点还是右端点排序。因为每一步都要使得后续区间的选择范围大,所以同参加最多的会议
这个问题类似,按照右端点排序,结束时间越早,后续区间选择范围越大。
def eraseOverlapIntervals(self, intervals):
"""
435. 无重叠区间:找到需要移除区间的最小数量,使剩余区间互不重叠。
贪心体现在每一步都使得右端点最小,这样可以使后续的区间选择范围更大。类似于参加尽可能多的会议
"""
if len(intervals) <= 1:
return 0
# 按照右端点排序
intervals.sort(key=lambda x:x[1])
res = 0
end = intervals[0][1]
for i in range(1, len(intervals)):
if intervals[i][0] < end: # 有重叠
res += 1 # 删除数+1
else: # 无重叠,更新右边界
end = intervals[i][1]
return res
56. 合并区间
给出一个区间的集合,请合并所有重叠的区间。
输入: [[1,3],[2,6],[8,10],[15,18]]
输出: [[1,6],[8,10],[15,18]]
解释: 区间 [1,3] 和 [2,6] 重叠, 将它们合并为 [1,6].
首先按照左端点对区间排序,因为习惯来说我们就是从左到右合并的。然后维护一个结果数组res
,每次从res
取最后一个元素的右端点跟当前的区间左端点进行比较,决定是否合并。
def merge(self, intervals):
"""
56. 合并区间
"""
if len(intervals) <= 1:
return intervals
intervals.sort(key=lambda x:x[0])
# 先把第一个值加入res
res = [intervals[0]]
for i in range(1, len(intervals)):
# 再去遍历intervals,通过比较当前的第二维和res的第一维大小去决定是否要更新res的第二维
# 需要更新
if intervals[i][0] <= res[-1][1]:
res[-1] = [res[-1][0], max(res[-1][1], intervals[i][1])]
else:
# 不需要更新
res.append(intervals[i])
return res
57. 插入区间
给出一个无重叠的 ,按照区间起始端点排序的区间列表。
在列表中插入一个新的区间,你需要确保列表中的区间仍然有序且不重叠(如果有必要的话,可以合并区间)。
示例 1:
输入: intervals = [[1,3],[6,9]], newInterval = [2,5]
输出: [[1,5],[6,9]]
示例 2:
输入: intervals = [[1,2],[3,5],[6,7],[8,10],[12,16]], newInterval = [4,8]
输出: [[1,2],[3,10],[12,16]]
解释: 这是因为新的区间 [4,8] 与 [3,5],[6,7],[8,10] 重叠。
实际上还是合并区间,因为给出的原区间列表是排序好的且没有重叠区间,所以原区间列表中左端点小于等于新区间的左端点的区间全部可以1)首先全部存放到res
中,2)然后再把newinterval
合并到res
中,3)最后把区间列表中剩下的区间再合并到res
中。这边要注意2)中newinterval
合并到res
中的几种情况。
def insert(self, intervals, newInterval):
"""
57. 插入区间
在列表中插入一个新的区间,你需要确保列表中的区间仍然有序且不重叠
"""
if not intervals:
return [newInterval]
res = []
# 1. 首先把原区间列表中左端点小于等于新区间的左端点的区间全部存放到res中
for i in range(len(intervals)):
if intervals[i][0] > newInterval[0]:
break
res.append(intervals[i])
# 2. 把newinterval合并到res中
if not res: # res为空,说明newinterval左端点最小,直接进入res
res.append(newInterval)
elif res[-1][1] >= newInterval[0]: # newinterval和最后一个区间合并
res[-1] = [res[-1][0], max(res[-1][1], newInterval[1])]
else: # newinterval和最后一个区间没有交集,直接进入res
res.append(newInterval)
# 3. 继续将后续的区间压入到res中
while i < len(intervals):
if res[-1][1] >= intervals[i][0]:
res[-1] = [res[-1][0], max(res[-1][1], intervals[i][1])]
else:
res.append(intervals[i])
i += 1
return res
763. 划分字母区间
字符串 S 由小写字母组成。我们要把这个字符串划分为尽可能多的片段,同一个字母只会出现在其中的一个片段。返回一个表示每个字符串片段的长度的列表。
输入:S = “ababcbacadefegdehijhklij”
输出:[9,7,8]
解释:
划分结果为 “ababcbaca”, “defegde”, “hijhklij”。
每个字母最多出现在一个片段中。
像 “ababcbacadefegde”, “hijhklij” 的划分是错误的,因为划分的片段数较少。
每次遍历到一个字符,那么该字符最后一次出现的位置也必须得包括在当前这个区间中。所以对于每一个字符,该字符的第一次出现和最后一次出现的位置都必须包括在当前这个区间中。因此我们保存每个字符的第一次出现和最后一次出现的位置,作为一个区间的左端点和右端点。那么这个问题就转化成了区间合并的问题。最后只要输出每个合并区间的长度即可。
def partitionLabels(self, S):
"""
763. 划分字母区间
当遍历到一个字符,该字符的第一次出现和最后一次出现的位置都必须包括到这个区间中
用哈希表记录每个字符第一次出现和最后一次出现的位置,将问题转换为重叠区间的问题
"""
if not S:
return []
map = dict()
# 1. 计算每个字符的第一次出现和最后一次出现的位置
for i in range(len(S)):
if S[i] not in map:
map[S[i]] = [i, i] # 第一次出现的区间
else:
map[S[i]][1] = i # 更新第二维最后一次出现的位置
# 2. 保存到区间中
intervals = []
for _, value in map.items():
intervals.append(value)
# 3. 合并区间
intervals.sort(key=lambda x:x[0])
res = [intervals[0]] # 合并后的区间结果
for i in range(1, len(intervals)):
if res[-1][1] > intervals[i][0]:
res[-1] = [res[-1][0], max(res[-1][1], intervals[i][1])]
else:
res.append(intervals[i])
# 4. 求每个合并后区间的长度
final_res = []
for i in range(len(res)):
final_res.append(res[i][1] - res[i][0] + 1)
return final_res
986. 区间列表的交集
给定两个由一些 闭区间 组成的列表,每个区间列表都是成对不相交的,并且已经排序。
返回这两个区间列表的交集。
(形式上,闭区间 [a, b](其中 a <= b)表示实数 x 的集合,而 a <= x <= b。两个闭区间的交集是一组实数,要么为空集,要么为闭区间。例如,[1, 3] 和 [2, 4] 的交集为 [2, 3]。)
输入:A = [[0,2],[5,10],[13,23],[24,25]], B = [[1,5],[8,12],[15,24],[25,26]]
输出:[[1,2],[5,5],[8,10],[15,23],[24,24],[25,25]]
思路就和合并有序数组/链表类似,双指针。比较两个待选区间的右端点,来决定移动哪个指针(小的右端点移动指针)。
def intervalIntersection(self, A, B):
"""
986. 区间列表的交集
"""
i, j = 0, 0 # 区间的序号
res = []
while i < len(A) and j < len(B):
l = max(A[i][0], B[j][0])
r = min(A[i][1], B[j][1]) # 求i区间和j区间的左右
if l <= r:
res.append([l, r])
if A[i][1] <= B[j][1]: # 比较右端点,来决定移动哪个的区间
i += 1
else:
j += 1
return res