LeetCode每日一题2025-05-22
3362. 零数组变换 III M
给你一个长度为 n 的整数数组 nums 和一个二维数组 queries ,其中 queries[i] = [lᵢ, rᵢ] 。
每一个 queries[i] 表示对于 nums 的以下操作:
- 将
nums中下标在范围[lᵢ, rᵢ]之间的每一个元素 最多 减少 1 。 - 坐标范围内每一个元素减少的值相互 独立 。
零数组 指的是一个数组里所有元素都等于 0 。
请你返回 最多 可以从 queries 中删除多少个元素,使得 queries 中剩下的元素仍然能将 nums 变为一个 零数组 。如果无法将 nums 变为一个 零数组 ,返回 -1 。
示例 1:
输入: nums = [2,0,2], queries = [[0,2],[0,2],[1,1]]
输出: 1
解释:
删除
queries[2]后,nums仍然可以变为零数组。
- 对于
queries[0],将nums[0]和nums[2]减少 1 ,将nums[1]减少 0 。- 对于
queries[1],将nums[0]和nums[2]减少 1 ,将nums[1]减少 0 。
示例 2:
输入: nums = [1,1,1,1], queries = [[1,3],[0,2],[1,3],[1,2]]
输出: 2
解释:
可以删除
queries[2]和queries[3]。
示例 3:
输入: nums = [1,2,3,4], queries = [[0,3]]
输出: -1
解释:
nums无法通过queries变成零数组。
提示:
1 <= nums.length <= 10⁵0 <= nums[i] <= 10⁵1 <= queries.length <= 10⁵queries[i].length == 20 <= lᵢ <= rᵢ < nums.length
问题分析
给定一个长度为 的整数数组 nums 和一个包含 个查询的二维数组 queries,其中 queries[i] = [l_i, r_i] 表示对于 nums 执行一次操作:将下标在范围 之间的每个元素最多减少 (各位置减少值相互独立)。目标是从原来的 个查询中删除尽可能多的查询,使得剩余查询仍能将 nums 变成全 0 的“零数组”。如果无论如何也不能变成零数组,则返回 。
关键在于,“每次操作对区间内元素各自减少最多 1” 意味着,若某位置 在剩余操作中被覆盖了 次,则该位置至多减少 ,要达到最终 nums[i] = 0,就需要
此处每个查询只有两种选择:保留或删除。若保留,就会对它覆盖区间内的各个位置“增加 ”的覆盖次数;若删除,就相当于完全忽略它对覆盖次数的贡献。我们需要在确保对每个 ,剩余查询的覆盖次数 至少等于 的前提下,尽量 删除 也就是丢弃尽量多的区间。
换言之,若最少要保留 条区间才能覆盖所有位置的需求,那么最多能删除 条。若任何选择都无法满足某位置覆盖次数需求,则返回 。
算法思路
本文展示两种思路的实现代码,逻辑不同,但都遵循“贪心地选最需要的区间以满足每个位置的覆盖需求”这一核心原则。
方法一:按左端点分桶 + 差分数组 + 贪心选右端最大的可用区间
-
按左端点分桶
- 事先将所有查询
[l, r]按其左端点 归类:用start_at[l]存储所有左端为 的查询的右端 。这样在扫描数组位置 时,可以将所有新“激活”的区间(左端恰好是 )一次性加入到可选集合里。
- 事先将所有查询
-
差分数组维护“已选区间覆盖次数”
- 用一个长度为 的差分数组 ,配合一个变量 来维护“当前已保留的区间对当前位置 的覆盖次数”。矩阵含义如下:
- 当我们决定保留某个区间 时,就执行
这样在后续扫描时,位置 所被选中区间覆盖次数 可以通过累加前缀和来得到。
- 在扫描到位置 时,先用
(代码中累加 到 )以得到“已保留区间对 的覆盖次数”。如果 ,说明还需多选区间来满足位置 的需求。
- 当我们决定保留某个区间 时,就执行
- 用一个长度为 的差分数组 ,配合一个变量 来维护“当前已保留的区间对当前位置 的覆盖次数”。矩阵含义如下:
-
维护可选区间按右端最大化
- 用
availCount[r]统计当前“可选且未选”且右端恰好为 的区间数量;用intervals_by_r[r]存储这些区间的左端 。 - 同时维护一个指针
maxR,表示当前所有可选区间中最大的右端。当我们要为当前位置 额外“补覆盖”时,总是“贪心”地优先选取右端最大的区间(因为它能为之后尽可能多的位置提供覆盖机会),即不断从r = maxR的组里弹出一条区间 [l_{\text{top}},\,\maxR] 来保留。 - 每当保留一条 [l_{\text{top}},\,\maxR],我们会:
availCount[maxR] -= 1chosenCount += 1(记录已选区间数)- 更新差分:
diffCover[l_top] += 1,diffCover[maxR+1] -= 1,并即时currCover += 1因为该区间确实覆盖了当前 。
- 如果
availCount[maxR]变为 0,则将maxR不断向左移动至下一个availCount[r'] > 0为止(且保持 ),否则就说明没有可选区间能覆盖 ,直接返回 。
- 用
-
逐位扫描
- 从 到 ,先累加
diffCover[i]到currCover,再将左端恰好是 的所有区间“激活”到availCount和intervals_by_r,更新maxR。 - 如果此时
currCover < nums[i],就在当前可选中右端最大的maxR里不断选区间来增加覆盖,直到currCover >= nums[i]或者发现无法满足而返回 。 - 这样保证每个位置都至少被覆盖
nums[i]次。扫描结束后,若没有失败,则记录已保留区间数 ,答案即 。
- 从 到 ,先累加
-
数学公式
- 差分数组更新公式:
- 在扫描到位置 时,已选区间的覆盖次数:
- 需要满足:
- 差分数组更新公式:
方法二:按左端排序 + 最大堆 + 差分前缀
-
将查询按左端 升序排序
- 令
queries.sort(key=lambda x: x[0]),并用指针j遍历所有查询。当扫描到数组位置 时,顺便把所有 的区间逐一推入堆中。
- 令
-
用最大堆维护“已激活的区间按右端降序”
- 在Python里,我们用
heapq,但由于它是最小堆,我们将右端 $r` 取负数存入堆,实际效果是“堆顶”对应最大的 。 - 当扫描到 ,先将左端 的所有区间都
heappush(h, -r)。
- 在Python里,我们用
-
差分数组
diff记录“当前已选区间对后续位置的累积覆盖影响”- 初始化
diff = [0]*(n+1),并维护一个前缀和sum_d = 0。含义是:sum_d = \sum_{k=0}^{i-1} diff[k]。 - 当我们保留一条区间 时,执行:
并且在选区间时,立刻做
sum_d -= 1并计入已选区间计数k += 1,相当于该区间“直接覆盖了当前 ”,所以对 之后的所有位置在sum_d上预先扣了 1。总之,这里将覆盖量通过一维差分转换成后缀影响。
- 初始化
-
遍历数组
- 对于每个 ,先做
sum_d += diff[i]。此时sum_d代表“由之前保留的区间,在当前位置及其之后还能继续贡献的覆盖次数”与当前位置应有的需求-nums[i]做对比:- 因为我们维持
sum_d是负数时表示对当前位置仍需额外 的“补覆盖”。
- 因为我们维持
- 如果
sum_d <= -nums[i],说明当前已有保留区间的覆盖就足够满足nums[i],不需要额外选。 - 否则,就不断从堆中弹出右端最大的区间(即
-heap[0]最大),若弹出区间的右端 $r` ,就保留它:- 执行
diff[r+1] += 1(意味着在位置 开始,“覆盖贡献”减少 1), sum_d -= 1,k += 1。
- 执行
- 重复弹区间直到
sum_d <= -nums[i]或堆空或堆顶区间右端 (此时堆里所有剩余区间都无法覆盖当前 ,直接返回 )。 - 如果循环结束后仍然
sum_d > -nums[i],说明无法达到需求,返回 。
- 对于每个 ,先做
-
答案
- 如果整个 都顺利通过,记录保留的区间数 ,则最多能删除 。
时间复杂度
-
方法一
- 构建
start_at要 ,差分数组大小 。 - 遍历每个位置 时:
- 将左端恰为 的区间激活到
availCount和intervals_by_r,总共需要把 条区间分配一次,属于 。 - 在最坏情况下,每个位置都可能进行一次或多次“选区间”操作,总共最多选 条区间,,每次更新差分和
maxR都是 或者maxR左移 次的摊销。
- 将左端恰为 的区间激活到
- 因此总体时间复杂度 。
- 构建
-
方法二
- 先对查询排序 。
- 遍历 ,每步:
- 把左端 的查询压入堆,所有 条均只入一次堆,总共 。
- 弹堆操作最多也是 。
- 差分数组前缀 的累加是 。
- 因此总复杂度 。若 远大于 ,则主要开销在堆操作上。
代码分解
方法一
- 构建
start_at列表1
2
3start_at = [[] for _ in range(n)]
for idx, (l, r) in enumerate(queries):
start_at[l].append(r)
意味着 start_at[i] 内存储所有左端为 的查询的右端 r。
-
初始化辅助数组和变量
1
2
3
4
5
6diffCover = [0] * (n + 1) # 差分数组
currCover = 0 # 前缀和,表示当前位置已有覆盖
chosenCount = 0 # 已保留区间数
availCount = [0] * n # 右端为 r 的“可选未选”区间数量
intervals_by_r = [[] for _ in range(n)] # 记录右端 r 下的各左端 l
maxR = n - 1 # 当前可选区间的最大右端 -
从 i=0…n-1 扫描
-
累加差分:
currCover += diffCover[i]。 -
将所有
l=i的区间激活:1
2
3
4for r in start_at[i]:
availCount[r] += 1
intervals_by_r[r].append(i)
if r > maxR: maxR = r -
“剔除过期”并维护
maxR >= i && availCount[maxR] > 0。 -
若
currCover < nums[i],就要补选区间:1
2
3
4
5
6
7
8
9
10
11
12
13while currCover < nums[i]:
if maxR < i: return -1
# 取出右端 = maxR 的一个可选区间
l_top = intervals_by_r[maxR].pop()
availCount[maxR] -= 1
chosenCount += 1
currCover += 1
diffCover[l_top] += 1
diffCover[maxR + 1] -= 1
if availCount[maxR] == 0:
# maxR 不断左移
while maxR >= i and (availCount[maxR] == 0):
maxR -= 1 -
若循环结束时
currCover >= nums[i],位置 满足需求,继续下一个 。
-
-
返回结果
- 如果遍历结束都没出错,最少要保留
chosenCount条,最多可删除m - chosenCount。否则中途返回-1。
- 如果遍历结束都没出错,最少要保留
方法二
-
排序与初始化
1
2
3
4
5queries.sort(key=lambda x: x[0])
h = []
diff = [0] * (len(nums) + 1)
q, j = len(queries), 0
k = sum_d = 0h为堆(Python 默认最小堆,用-r保存便于每次弹出最大右端)。diff[i]存储差分:一旦保留区间 ,就对diff[r+1] += 1来表示“从 开始,后缀不再被此区间覆盖”。sum_d追踪前 的差分前缀和。
-
遍历 nums
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19for i, x in enumerate(nums):
sum_d += diff[i]
# 如果已有覆盖 sum_d <= -x,说明已满足 nums[i]
if sum_d <= -x:
continue
# 否则,把左端 <= i 的所有区间压堆
while j < q and queries[j][0] <= i:
heapq.heappush(h, -queries[j][1])
j += 1
# 弹堆,补选最大的右端区间
while sum_d > -x and h and -h[0] >= i:
r = -heapq.heappop(h)
diff[r + 1] += 1
sum_d -= 1
k += 1
# 若无法满足需求,则失败
if sum_d > -x:
return -1
return q - k- 对每个位置 ,先
sum_d += diff[i]。此时若sum_d <= -nums[i],说明此前已选的区间覆盖次数充足;否则进入补选。 - 在补选阶段,只要当前
sum_d > -nums[i],且堆顶区间的右端 ,就可以选取它:sum_d -= 1立刻反映“当前 被此区间覆盖”;diff[r+1] += 1表示“在 处去掉这条区间后续的覆盖贡献”。 - 若在此之后仍不能满足,则直接返回
-1。
- 对每个位置 ,先
代码实现
1 | from typing import List |
二
1 | import heapq |
