LeetCode每日一题2025-05-21
3356. 零数组变换 II M
给你一个长度为 n 的整数数组 nums 和一个二维数组 queries,其中 queries[i] = [lᵢ, rᵢ, valᵢ]。
每个 queries[i] 表示在 nums 上执行以下操作:
- 将
nums中[lᵢ, rᵢ]范围内的每个下标对应元素的值 最多 减少valᵢ。 - 每个下标的减少的数值可以独立选择。
零数组 是指所有元素都等于 0 的数组。
返回 k 可以取到的 最小非负 值,使得在 顺序 处理前 k 个查询后,nums 变成 零数组。如果不存在这样的 k,则返回 -1。
示例 1:
输入: nums = [2,0,2], queries = [[0,2,1],[0,2,1],[1,1,3]]
输出: 2
解释:
- 对于 i = 0(l = 0, r = 2, val = 1):
- 在下标
[0, 1, 2]处分别减少[1, 0, 1]。- 数组将变为
[1, 0, 1]。- 对于 i = 1(l = 0, r = 2, val = 1):
- 在下标
[0, 1, 2]处分别减少[1, 0, 1]。- 数组将变为
[0, 0, 0],这是一个零数组。因此,k的最小值为 2。
示例 2:
输入: nums = [4,3,2,1], queries = [[1,3,2],[0,2,1]]
输出: -1
解释:
- 对于 i = 0(l = 1, r = 3, val = 2):
- 在下标
[1, 2, 3]处分别减少[2, 2, 1]。- 数组将变为
[4, 1, 0, 0]。- 对于 i = 1(l = 0, r = 2, val = 1):
- 在下标
[0, 1, 2]处分别减少[1, 1, 0]。- 数组将变为
[3, 0, 0, 0],这不是一个零数组。
提示:
1 <= nums.length <= 10⁵0 <= nums[i] <= 5 * 10⁵1 <= queries.length <= 10⁵queries[i].length == 30 <= lᵢ <= rᵢ < nums.length1 <= valᵢ <= 5
问题分析
要找最小的 ,使得对 nums 执行前 条查询后,可以将数组完全凑成“零数组”。每条查询 表示在区间 内,各下标位置最多各自减少 ,每个位置可以独立选减少多少。
我们定义一个“剩余需求”数组 ,它表示下标 iii 要变成 0,还需要从查询里“累计扣除”多少。
初始时 。随着我们“依次”处理第 1 条、第 2 条、…、第 条查询,每执行一条查询,就要把它对区间 内的 统一**“减去”** (注意:如果该条查询本身要求的 大于 ,其实“最多”减 的含义对应我们在代码里直接减;因为之后我们会在 时“收集答案”并且把它标记为不再参与后续运算——详见下文)。
当且仅当某个位置 经历了若干次涵盖它的查询以后, 的值变得 ,就说明“对下标 而言,它已经完全被扣成 0 了”。我们记录下最先使得 “跨到 ”的那条查询的编号 ,记为 。如果直到所有 条查询都跑完,它仍未被扣到 ,则 (或用 表示“永远扣不成 0”)。
最后,只要所有下标都能在某个时刻“跨到 ”,那么整个数组才能凑成零。令
如果 ,则答案就是 ;否则(存在某个位置从未走到 ),答案返回 。
算法思路
区间减与“找最小非正下标”
“给 做一次 区间统一减 ”
“询问当前整个数组 的最小值,检查它是否 ;如果是,把那个位置取出来,记录答案,并把它从后续运算中剔除。”
要想在每执行一条查询之后,快速做到“把 整个区间都减掉 ”,并能检查是否有位置跨到 ,最常用的办法就是线段树(带懒标记)的“区间减一次,区间查询最小值”。具体思路如下:
我们把 维护在一棵线段树上,线段树的每个节点记录该区间当前的最小 rem 值(即 minv),同时支持“区间减去 ”的懒标记(节点上加一个 lazy 值表示要向下传递的减量)。
我们还需要一个额外操作:“如果整个区间的最小值 ,就找出一个下标 ,使得 。”
由于线段树根节点存储了全区间()的最小值,即 ,只要 ,就说明至少有一个叶子节点 满足 。
此时我们可以从根节点向下递归:
-
如果左子节点的 minv ,就优先进入左子树,否则进入右子树。
-
不断递归,直到定位到某个叶节点 ,它的
minv,这就是当前“最小rem”对应的下标。
找到 之后,我们将线段树中 设为一个很大的正数(比如 ,实际代码里用足够大的常数 )。这样,相当于把 从后续所有区间减法操作中移除,该下标不再参与后面的判 检查。
只要“线段树根节点 minv ”,就不断进行“找 ,将 置 ,并记录 ”操作;循环直到根节点最小值 ,然后进入下一条查询。
时间复杂度
-
线段树建树 。
-
先把 的那些下标提取一次,总共操作次数最多 次“找 + 单点置 ”,每次 ,合计 。
-
针对 条查询,每条:
- 区间减一次 。
- 紧接着最多有若干个新下标跨到 ,反复做“找 + 置 ” 若干次。
- 每个下标 只会从 变到 一次,因此所有“找 并置 ”操作总共不会超过 次,每次 。
-
所以总复杂度为:
- 建树
- 查找与置 总共
- 区间减 次
- 最终为
- 空间复杂度 ,主要用于 、 以及线段树节点(最多约 )。
代码分解
- 初始化
-
读入 ,。
-
用一个数组 ,大小 ,令 。
-
用一个大小为 的答案数组 ,全部初始化成 (或者 ,表示“还没被扣到 ”)。
-
提前把所有 且 的下标视作“在第 条查询就能扣到 ”:
-
构建一棵线段树,把每个叶子节点的初始值设成 。
-
线段树内置“最小值”信息,建完之后如果根节点 ,就说明某些下标初始就 。我们要在第 步(即不执行任何查询)就把它们提取:
1
2
3
4while tree_root.minv ≤ 0:
i = query_index_of_some_leaf_with_minv≤0()
ans[i] = 0
update_leaf(i, +INF) # 把 rem[i] 设为 +INF,等于“干掉它” -
这样,所有初始 的位置就有了 。
-
- 按查询编号 依次处理
对于当前处理到第 (1-based)的查询 ,执行:
-
在线段树上对区间 做“整体再减 ” (用懒标记方式)。
-
更新完毕后,检查线段树的根节点 是否 :
-
如果 ,说明这时候没有任何下标的 ,直接跳到下一条查询()。
-
否则,反复循环:
1
2
3
4while tree_root.minv ≤ 0:
i = 找到某个叶子下标 i,使得 rem[i] ≤ 0
ans[i] = idx
update_leaf(i, +INF) # 将该位置从后续考虑中剔除 -
上述循环结束代表此时所有还在树里的“rem”都已 ,可以处理下一条查询。
-
- 遍历结束后,计算最终答案
- 对 做一次遍历:如果有下标 永远都没被扣到 (即 保持初始的 ),说明它不可能凑成零,直接返回 。
- 否则,答案 。返回此 。
-
我们需要实现一个支持以下操作的线段树(0-based):
-
建树(build):把初始数组 的值装进去,构建出“每个区间的最小值”。
-
区间减 :在 这段,所有 都要再减去 。
- 通过在节点打“懒标记 ”让该节点下所有值都“虚拟地”减 ,并在需要时把懒标记下传给左右子节点。
-
询问全局最小 (query_min()):直接从根节点读出 。
-
找某个满足 的下标 :
- 如果当前根节点的 ,说明没有任何 的叶子,直接返回“无”。
- 否则从根节点开始,若左子节点的 就往左子树走;否则往右子树。直到到叶子,拿到具体下标 。
-
把一个下标 “标记为已完成”:在叶子层将 直接置 (足够大的正数),然后向上拉一次更新父节点的最小值。这样后续它就不会再被判 。
由于 ,线段树最多需要 个节点。所有操作的单次复杂度都是 。
代码实现
初始化时把 的下标提前提取
- 一开始建树完毕后,就把所有 的下标(也就是原始 已经是 )用“查询编号 ”直接记录在 ,然后在树里把 设为 (等价于把它从后续所有区间操作和“再查 ”中干掉)。
- 这样做的好处是:如果某个位置原本就是 ,就不需要等待任何真正的查询,它的答案就是 。
每条查询后“区间整体再减 ”
- 使用线段树带懒标记的区间减操作 ,单次复杂度 。
- 更新完毕以后,只需要检查树根的最小值 是否 。
- 如果 ,说明目前没有新的下标变得 ,直接进入下一条查询。
- 否则,循环“找一个 的叶子” ,把它的 设为当前查询编号 ,再在树里把 ,并向上更新维护最小值。循环直到树根最小值 。
最后的答案
- 只要所有 都在某个时刻跨到 ,它们的 就是一个落在 的整数。此时答案 。
- 如果有某个 始终保持 ,说明它从来没被扣到 ,返回 。
1 | class SegmentTree: |
算法思路2
-
差分+验证模型
-
对于某个固定的 k(只考虑前 k 条查询),要判断是否能把 nums 完全减为 0,需要检查每个位置 i 在这 k 条查询中,允许的“最大累计减量”之和是否 。
-
原因:每个查询在覆盖区间 [l, r] 内,对位置 i 可以最多减去 v;若该位置被多条查询覆盖,那么这些查询各自能提供的“最大减量额度”加起来,就形成了“可用的整体减量上限”。只有当前 k 条查询对位置 i 的“减量额度之和” nums[i],才能让它有机会被减到 0。
-
判定条件转化为:
定义数组 。
如果对所有 i 都有 cap[i] nums[i],则这 k 条查询可以通过合理分配将 nums 全部扣为 0。否则不能。
-
-
计算 cap[]——差分+前缀和
-
维护一个长度为 n+1 的差分数组 diff,初始化全为 0。
-
对于查询 [l, r, v](下标从 0 开始),做:
1
2diff[l] += v
diff[r+1] -= v -
所有前 k 条查询都更新后,对 diff 做一次前缀和,在线性时间 内得到每个位置 i 的 cap[i]。
-
最后只需检验:对每个 i,cap[i] nums[i] 吗?
-
-
二分查找
- 如果对于某个 k₀ 满足“前 k₀ 条查询可以凑出零数组”,则对于任何 ,cap[i] 只会更大,所以“可行性”是单调的。
- 可在 [0, m] 区间二分,找第一个可行的 k。若整个区间都不可,返回 -1。
-
实现思路
-
定义函数 check(k):判断前 k 条查询能否凑成零数组
a. 初始化长度为 n+1 的 diff 数组,全 0。
b. 对于 j=0…k-1,每条 :执行 ,。
c. 对 diff 做前缀和,得到 cap 数组长度为 n:cap[i] = 能够提供给位置 i 的累计减量额度。
d. 遍历 i=0…n-1,如果 cap[i] < nums[i],直接返回 False;否则全部通过返回 True。 -
在主函数,对 k 在 [0, m] 做二分:
1
2
3
4
5
6a. lo = 0, hi = m, answer = m+1。
b. while lo $\leq$ hi:
mid = (lo+hi)//2
if check(mid):answer = mid; hi = mid-1
else:lo = mid+1
c. 最终如果 answer $$\leq$$ m 则返回 answer,否则返回 -1。 -
允许 k = 0 进入 check(0):没有任何查询时,只有当 nums 全为 0 才判通过。
-
时间复杂度
- 一次 check(k):。
- 二分最多调用 次。
- 总复杂度:。
空间复杂度
- 差分数组 diff 长度 n+1,cap 长度 n,以及 zerolithx 变量,。
1 | from typing import List |
算法思路3
我们需要求出最小的 ,使得“前 条查询”能够同时满足
如果把
看作“在下标 处,前 条查询最多能提供的扣减总量”,那么题目要求找最小 使得。
这本质上等同于:对每个 ,我们单独去看“最早满足 的那个 ”;答案就是
(如果某个位置永远加不够,那整个答案就是 。)
-
贪心地求出每个 的思路
-
把下标 从 到 依次“排队”,
-
维护一个“差分数组”
cnt[0..n],它可以让我们以 的方式“把新的一条查询 加入到以后所有下标的可用额度里”:cnt[a] += v; cnt[b+1] -= v,- 然后每推进一个下标 时,只要做
s += cnt[i],就能得到“前面已经加入到差分结构的那些查询,在位置 处的总叠加额度”。
-
于是,如果当前
s < nums[i](说明已经加到差分里的查询还不够把nums[i]扣成 0),我们就继续把第 条、第 条……查询“投进来”——也就是执行1
2cnt[a_j] += v_j
cnt[b_j + 1] -= v_j
在差分里给它记号,然后如果这条新加的查询确实覆盖到了位置 (
if a_j <= i <= b_j),就s += v_j,因为此时“对 ”就立刻生效——把s补 。- 只要
s还小于nums[i],就再把下一条查询“投进来”,直到 或者(查完所有查询)为止。- 如果 而 ,说明“所有查询投完”都还是凑不够,直接
return -1。 - 否则在“退出 while”后,必然有 ,说明此时我们已经用了 条查询第一个时刻让 处达标。把这个“时刻”记下来,对更大的 来说,我们不会再减 ,因为我们要保证所有小于当前 的下标也都满足。在前面迭代过的所有 都已经被保证:它们的 “累计额度” “我们此刻用的 条查询的额度”,自然它们也都能凑够。
- 如果 而 ,说明“所有查询投完”都还是凑不够,直接
- 最终结束所有 后, 就是满足每个位置的需求()的 最小 查询数。
- 若有任何一个 iii 走到一半就发现 用光、又,就
return -1。 - 反之,最后返回的 恰好等同于 。
- 若有任何一个 iii 走到一半就发现 用光、又,就
-
时间复杂度
- 外层
for i in range(n)总共循环n次。 - 内部的
while j < m and s < nums[i]:- 每次循环体都会做一次
j += 1,因此j最多从 0 增加到m,循环体总共进不超过m次。 - 换句话说,所有
i累积在一起,j只会单调增长,从 0 到最多m,绝不会回退或重复。
- 每次循环体都会做一次
- 差分
cnt[a] += c; cnt[b+1] -= c;是 操作。 - 每进
for i一次,都先做s += cnt[i],也是 。 - 其余判断、下标范围判断、加法、比较都只是常数操作。
因此,总体的执行步骤:
- 外层走
n步(i = 0 … n-1)。 - 内层的
j累计至多走m步(从j=0一直加到不满足条件为止)。
没有任何嵌套导致 m 与 n 相乘,只是把这两段“线性扫描”交织在一起,最终耗时正好 。
代码实现
-
n = len(nums),m = len(queries)。 -
cnt是一个长度为n+1的「差分数组」,用于在 时间内模拟对任意区间 做一次 “所有下标各自减去 c” 的操作。-
当我们想对 区间整体去掉 “最多减 c” 这一额度时,就写
1
2cnt[a] += c
cnt[b+1] -= c -
然后在遍历到下标
i时,保留一个滚动变量s,先做s += cnt[i],此时s就相当于 “下标 i 处所有已加入(也就是j之前的)查询对 i 的累积可用额度之和”。
-
-
s: 表示“当前下标i被所有已用查询(前j条)覆盖时累计能减去的总量”。 -
j:代表「已经把queries[0], queries[1], …, queries[j-1]这j条查询全部用到差分cnt里了」。
从 i=0 开始,遍历每个位置 i,保证在走到下标 i 时,s(累积能减的额度)足以满足 nums[i]。如果 s < nums[i],就再从 queries[j] 开始把第 j 条、第 j+1 条……不断「添加到差分」直到让 s >= nums[i] 或者查询用完为止。一旦 s < nums[i] 并且 j == m(没有更多查询可加),就无法把 nums[i] 扣到 0,直接 return -1。如果所有 i 都能满足过,那么用到的查询数就是 j,返回它。
1 | class Solution: |
差分数组 cnt + 滚动和 s
- 当我们刚进入循环、到达
i时,用s += cnt[i]把“这一格的前缀差分”补上。 - 这样
s始终表示“所有已经加进cnt的查询,对i这个位置在累计能减的总量”——与我们要的 一一对应。
while j<m and s<nums[i] 里“如果 a≤i≤b 就 s += c”
- 这一步很重要:我们先在差分里做了
cnt[a]+=c; cnt[b+1]-=c,这保证了“从下标a开始”到“下标b结束”每个位置最终前缀和会多c。 - 但是此时指针还在
i上,如果a ≤ i ≤ b,说明这个新加的区间 立刻影响 到了位置i,所以s要马上加上那份c。 - 如果
i < a或者i > b,新加的区间对i还不生效(会在后面i推到a时再生效,具体表现为执行到i=a后的s += cnt[a]就扣上去了)。
一旦 s < nums[i] 且 j==m,直接返回 -1
- 因为此时所有
m条查询都加入到差分里,可是走到i时却依旧s < nums[i],说明在 所有查询 覆盖的累积额度下,nums[i]也凑不成 0,后面没办法了。
最小性保证:贪心不会跳过更优解
- 你需要最小的
j,使得每个位置的“累积额度”都足够。如果在某个i上,s < nums[i],你就一定要把queries[j]拿出来打一把,才能继续往前走——否则你根本没法保证这个i处的需求被满足。 - 你无法“跳过”某条查询,因为如果你不把它算在内,
s不可能「突然」跳到够用。 - 另一方面,一旦满足了
i,你也不会再撤回以前的查询——因为如果你撤回,就可能会让更小的下标变得不满足。但要想让 所有 下标都凑够,只能向右“多用”查询或者“用到恰好满足”为止。 - 因此你在 “遇到
i还不够就把查询往里扔” 这一步上,实际上是严格按下标顺序寻找每个i的最早 “凑够它的那条查询编号”。这样遍历完所有i,得到的j恰好就是最大的那一个“把i扣成 0 的查询编号”;也就是等同于 。
