CF1814D Balancing Weapons
原题明显可以转化为:
给定一个长度为 n 的数组,初始为 pi。可以调整元素的值,但第 i 个元素必须是 ai 的 整数 倍,并且 必须严格大于 0(保证 pi 初始满足此要求)。求至少修改多少个元素,可以让数组的极差不超过 k。
首先观察到答案和数组内部顺序无关,即所给数列可看做可重集。这是很经典重要的信息,提示我们第一步可以向两个方向思考:
- 将数列排序。
- 对数列求出权值数组,在权值数组上考虑问题。
不过,我的做法并不需要做上面任意一步,但上面这个思维是必须要有的。本题有别的做法必须先对这个数组排序,因为排序后保留不改的元素是一段区间,这是一个非常良好的性质。
首先,修改一定有解。设 L 为所有 ai 的 lcm,我们一定可以将所有 pi 同时修改为 L(注意这里并没有规定 pi 的上界,即使 L 可能很大也无所谓)。那也就是说,修改 n 个元素一定可以达成目的。
接下来考虑修改少于 n 个元素的情况,这样以来一定存在一个元素没动。因此,考虑对这 n 个元素,依次钦定第 i 个元素不动考虑问题,就能把修改少于 n 个元素的情况全都考虑到。
接下来钦定第 x 个元素不动。那么必要条件是所有元素必须落入 [px−k,px+k]。当然,这个条件不充分,因为这样只能保证极差不超过 2k。下面,我们称这个区间为 S(x)。这个区间是建立在数组值域上的,而非下标。
在下面,区间 [l,r] 的长度被认为是 r−l,即一维坐标轴上点 l 到点 r 这条线段的长度,而非这个区间包含的整数数量 r−l+1。那么,若干个数落在长度为 k 的区间内,可以推得这些数的极差不超过 k。
为让所有元素极差不超过 k,我们期望所有元素落在 S(x) 内部的一个长度为 k 的小区间内。这样的小区间数量为 k,可以通式地表示为 [l,l+k],其中 px−k≤l≤px。
全局小区间的数量不超过 nk,这个量级可以承受。考虑依次钦定这 nk 个小区间中的每一个,计算将所有元素落入这个小区间的最小代价,然后再对求出的所有最小代价取最小值,作为全局最小代价(答案)。这样一定可以考虑到所有情况,因为任何一种少于 n 个元素的修改方式,都能在 nk 个小区间中找到一个小区间 P,使得这种修改方式会将所有元素落入 P 内。
关于 nk 这个量级是否可承受:注意到这里确实没有限制 ∑k,但是只要限制一个 ∑n 就已经足够了。设每组数据规模分别为 n1,n2,n3,… 和 k1,k2,k3,…,最终的代价有 n1k1+n2k2+n3k3+…≤K∑n,其中 K=max(ki),因此不用限制 ∑k。
nk 已经将近到极限了,所以计算每个区间最小代价的复杂度不能超过 log 了(可以均摊)。然而事实上这题卡 log 的常数所以我就讲讲均摊 Θ(1) 计算每个区间的方法吧。
枚举区间本身就已经需要区间长度的复杂度,直接爆炸了。所以关键就是找到区间和区间之间的关系加速计算,使得复杂度均摊,降低。
注意到,共 nk 个小区间大体是分成 n 组的,同一个 x 的 S(x) 内部的 k 个小区间是一组。不同组的区间之间基本无关,而同组区间关系密切:[px−k,px] 和 [px−k+1,px+1] 有 k−1 的长度完全重合,而 [px−k+1,px+1] 和 [px−k+2,px+2] 之间也有这种关系……得益于这种高度的重合性,用 双指针 加速计算同组内区间的答案:
维护两个指针 l=px−k,r=px,然后同步向右移动 l 和 r 恰 k 步,就扫描到了 S(x) 内部的这 k 个小区间。如果我们能做到这 2k 次指针移动中,每次移动都能快速更新辅助计算区间最小代价的信息(这里要求 Θ(1)),就能做到 Θ(k) 计算出 k 个小区间的答案,也即均摊 Θ(1)。
当然,这里需要提前知道 [px−k,px] 的信息作为初始状态。不过也可以这么理解:先令 l=px−k,r=l−1,也即一个空区间,空区间内部信息一般很平凡。然后再令 r 向右移动 k+1 步,使得信息慢慢更新到 [px−k,px],然后再让 l 和 r 同步向右。这样以来就是 3k 次指针移动,影响较小。虽然我最后实际代码中,是直接计算初始区间,然后直接令 l=px−k,r=px 的;但是这个思想可以一定程度简化问题,后面的讲解可以不再考虑初始区间,只考虑指针移动时区间信息的变化,以及怎么快速维护这样的信息变化。
接下来我们关心小区间最小代价的计算方法。显然,已经在小区间内部的 pi 无需修改,不在的必须修改,所以只要统计小区间内部 pi 的数量 c,代价就是 n−c。因此,我们应该维护小区间内部 pi 的数量 c。
那么双指针在移动时可以 Θ(1) 地快速更新 c 值吗?这不难做到。考虑记录 cnp(j)=∑i=1n[pi=j],这里我们只关心 j∈S(x),也即 cnp 看做长度为 2k+1 的数组(代码实际实现时下标手动平移到 0∼2k 即可)。这个记录复杂度是 Θ(n) 的。
注意虽然 S(x) 的长度是 2k,但是 S(x) 所含整数元素数量是 2k+1,所以 cnp 的长度(数组的长度指所含元素数)为 2k+1。
对于夹在两个双指针之间的区间 [l,r],其 c 值是 j=l∑rcnp(j)。这个信息用双指针维护非常简单:当 r 从 r′−1 移动到 r′ 时,c 自增 cnp(r′);l 从 l′ 移动到 l′+1 时,c 自减 cnp(l′)。
上面的做法比较简单,但有漏洞:上面我们直接默认任何一个不在小区间内的元素,都能落入当前小区间的内部。但这个命题并不正确,因为本题中元素的调整方法在正整数域离散——必须以 ai 为步长。举个例子,ai=5,小区间 [7,9],pi 无论如何调整都无法落入该小区间内。
如果出现了这种情况,小区间的 c 可以正常计算,但因为所有元素无法都落入该小区间内,该小区间应当是不合法的,这个小区间的 n−c 不应更新答案。所以,我们还需 快速维护一个区间是否合法。
一个小区间合法,等价于所有元素都存在一个正倍数点落在这个小区间中。
我们尝试标记所有元素的所有落在 S(x) 内部的正倍数点。对于 ai 的正倍数点 j∈S(x),我们记录 j 这个位置是 ai 的正倍数点。注意,一个位置可以是多个 ai 的正倍数点。
我们不妨调整 S(x) 的定义为 [max(px−k,1),px+k]。这样以来,如果一个数的倍数点落在这个区间内,它一定是正数。那么正数这个条件就不用强调了。同时本题不会涉及到 <1 的值,所以这样调整能保证不会丢掉不应该丢的情况,也同时能减少该大区间内长度为 k 的小区间的数量,有助于减少常数(本题卡常)。
这里先给出枚举且仅枚举到 t 在区间 [L,R] 内的倍数点的思路(这是个常用技能):
不小于 L 的最小 t 倍数点为 u=⌈tL⌉×t,不大于 R 的最大 t 倍数点为 v=⌊tR⌋×t。
从 u 到 v 按照 t 的步长跳跃即可枚举到所有 t 在 [L,R] 内的倍数点,并且无多余枚举。
考虑最坏的情况,ai=1。这样以来,S(x) 里的所有位置里,每个位置都是所有元素的倍数点。这样以来标记复杂度直接卡满到 2nk,我们要处理 n 组小区间,这样总复杂度直接 n2k 了,不可取。
这意味着若坚持这个做法,我们势必要放弃一些倍数点的记录。下面我给出两种思路,都需要发掘一些性质。
【思路一】
性质:如果存在 ai≤k,则任何一个在正整数域上连续的长度为 k 的小区间,都会存在一个 ai 的倍数点。
该性质可以被一个更强的性质推出:任何一个长度为 ai 的小区间内,每个数 modai 之后恰好构成 [0,ai−1] 的整数集。比较明显,不证明了。
因此,ai≤k 的元素不会影响任何一个小区间的合法性,只需检验是否所有 ai>k 的元素都存在倍数点落入小区间内。下面,我们称 ai>k 的元素为 重要元素。
这里又有特别好的性质:单个重要元素的倍数点在任何一个长度不超过 k 的小区间最多出现一次。假设它出现了,并且是 t,则 t+ai 和 t−ai 都不落在小区间内,其它的更不会。
这个性质又可以推出 单个重要元素的倍数点在任何 S(x) 最多出现两次,因为 S(x) 的长度不超过 2k,拆分成两个长度不超过 k 的小区间,用上面的性质证明即可。
于是,考虑预处理 cnm(j) 为 j 是多少个 重要元素 的倍数点(只关心 j∈S(x))。根据上面的讨论,这个预处理标记的过程复杂度是 2n。
小细节:因为单个重要元素 ai 的倍数点在 S(x) 最多出现两次,所以只要尝试标记【ai 的不小于 S(x) 左端点的最小倍数点】 以及【ai 的不超过 S(x) 右端点的最大倍数点】,就能将 ai 在 S(x) 内部的倍数点都标记好了。这里【尝试标记点 u】,意思是如果点 u 落在 S(x) 内就标记,否则忽略。
然后维护信息:当前区间内,存在 s 个重要元素的倍数点,这里需保证单个重要元素的多个倍数点只被计数一次。
因为长度为 k 的小区间内部,单个重要元素的倍数点并不会出现多次。因此,当 r−l=k 时,信息 s=j=l∑rcnm(j)。
维护同 cnp:在 r 从 r′−1 转移到 r′ 时,s 自增 cnm(r′)。l 从 l′ 转移到 l′+1 时,s 自减 cnm(l′)。
而一个区间合法,当且仅当 s 与重要元素的数量相等,该判定平凡。
那么本题就做完了。考虑复杂度。
处理每组区间,预处理 cnp 和 cnm,复杂度 Θ(n);双指针扫描复杂度 Θ(k)。共有 n 组区间,所以复杂度为 Θ(n2+nk)。
【思路二】
该思路的关键在于发掘:钦定 px 不动时,对于任意一个 pi,做以下三种策略之一不劣于做其它策略:
- 不调整。
- 调整到不大于 px 的最大 ai 倍数点 ui(如果未落入 S(x) 就忽略)。
- 调整到不小于 px 的最小 ai 倍数点 vi(如果未落入 S(x) 就忽略)。
证明:
考虑钦定 px 不动的策略,你钦定所有元素落入的小区间肯定是包含 px 的,左端点在 px 左侧,右端点在 px 右侧的一个区间。设它是 [l,l+k],即 l≤px,l+k≥px。
一个元素一旦被调整,就一定会产生 1 的代价,而与它调整的目标位置无关。
如果我们将 pi=ui−tai 调整到 ui−tai,它能落入 [l,l+k],那么有 ui−tai≥l,于是 ui≥l。又因为 ui≤px≤l+k,所以 ui 也一定能落入 [l,l+k]。因为 pi=ui−tai,所以将 pi 调整到 ui−tai 也会产生代价,不劣于将 pi 调整到 ui。
对于 vi,同理可证将 pi 调整到 vi+tai 不劣于 vi。ui 和 vi 之间一定不存在其它 ai 倍数点了,所以所有 pi 的调整方案都已被讨论,ui 和 vi 两种调整方案不劣于其它调整方案。
当然注意,如果 pi=ui−tai,它可能比 pi 调整到 ui 优秀,因为保持 pi 不动没有代价,调整到 ui 则有。因此,还有一种策略是不调整,也需要考虑。
综上,我们手动强化条件,任何一个 pi 都只能保持 pi,或者调到 ui,或者调到 vi。根据上面的讨论,这样的强化不会让原题的最优解发生变动,是可取的。如果这里 pi,ui,vi 三个点中,某些点不落在 S(x) 里,就直接忽略。这三个点之间可能有相同的点,可以去重一下。
这样以来,任何一个 ai 都只用标记最多三个倍数点,我们称之为 重要倍数点。
我们尝试沿用思路一的策略,设 cnm(j) 为 j 是多少个 ai 的某个重要倍数点。然后考虑维护双指针所夹区间内【有多少个元素,存在一个重要倍数点落在该区间】,记这个值为 s,则区间合法当且仅当 s=n。
然而,r−l=k 时,我们不保证 [l,r] 的答案 s=∑j=lrcnm(j),这是因为一个元素的重要倍数点可能落在长度为 k 的小区间内多次,性质并不像思路一那么美好。
不过,这个问题其实等价于经典的数颜色问题。我们考虑 n 种元素对应 n 个颜色,j 是 ai 的某个重要倍数点等价于 j 这个位置有 i 这种颜色(一个位置可以有多种颜色)。然后问一个区间内部有多少种颜色。
思路一保证了一种颜色只出现在目标区间一次,所以问题简化成对颜色数求和。但事实上,没有这个条件,数颜色种数这个问题用双指针维护也是相当经典简单的。
首先我们更改状态,cnm(j) 修改成一个集合,它的内容是 j 这个位置的所有颜色种类。然后我们在双指针维护时,新开一个数组 cnt(i),下标域 [1,n] 的正整数,代表第 i 种元素在当前双指针所夹区间内出现多少次。
然后进行如下维护:
- r 从 r′−1 转移到 r’ 时,将 r′ 处的所有颜色 i 进行如下操作:
- 如果 cnt(i)=0,则 s 自增 1。
- 将 cnt(i) 自增 1。
- l 从 l′ 转移到 l′+1 时,将 l′ 处的所有颜色 i 进行如下操作:
- 将 cnt(i) 自减 1。
- 如果 cnt(i)=0,将 s 自减 1。
上面的转移正确性比较明显。其复杂度虽然不能保证每次运动 Θ(1),但可以保证指针运动的全过程中,复杂度不会超过标记出的所有颜色数,也即 3k。均摊下来还是 Θ(1) 的。
那么这个做法也做完了,复杂度同理是 Θ(n2+nk)。
下面给出做法一的 AC 代码,最大 234 ms,无任何故意的卡常,还是比较优秀的。
/*
* @Author: crab-in-the-northeast
* @Date: 2023-07-11 11:17:47
* @Last Modified by: crab-in-the-northeast
* @Last Modified time: 2023-07-11 17:58:41
*/
#include <bits/stdc++.h>
#define int long long
inline int read() {
int x = 0;
bool f = true;
char ch = getchar();
for (; !isdigit(ch); ch = getchar())
if (ch == '-')
f = false;
for (; isdigit(ch); ch = getchar())
x = (x << 1) + (x << 3) + (ch ^ '0');
return f ? x : (~(x - 1));
}
inline bool gmi(int &a, int b) {
return b < a ? a = b, true : false;
}
const int N = 3005;
int a[N], p[N];
inline int solve() {
int n = read(), k = read();
int ans = n, cnt = 0;
for (int i = 1; i <= n; ++i) {
a[i] = read();
cnt += a[i] > k;
}
for (int i = 1; i <= n; ++i)
p[i] = read() * a[i];
for (int x = 1; x <= n; ++x) {
std :: vector <int> cnp((k << 1) + 1), cnm((k << 1) + 1);
// 注意这里一定要开成 k * 2 + 1
// 如果你平移到从 1 开始,那就得开成 k * 2 + 2
int L = std :: max(p[x] - k, 1LL), R = p[x] + k;
auto ins = [&](std :: vector <int>& v, int pos) {
if (pos >= L && pos <= R)
++v[pos - L];
};
for (int i = 1; i <= n; ++i) {
ins(cnp, p[i]);
int u = (L + a[i] - 1) / a[i] * a[i], v = R / a[i] * a[i];
if (a[i] > k) {
ins(cnm, u);
if (u != v)
ins(cnm, v);
}
}
int c = std :: accumulate(cnp.begin(), cnp.begin() + k + 1, 0LL);
int s = std :: accumulate(cnm.begin(), cnm.begin() + k + 1, 0LL);
for (int l = L, r = l + k; ;) {
if (s == cnt)
gmi(ans, n - c);
++r;
if (r > R)
break;
c += cnp[r - L] - cnp[l - L];
s += cnm[r - L] - cnm[l - L];
++l;
}
}
return ans;
}
signed main() {
int T = read();
while (T--)
printf("%lld\n", solve());
return 0;
}
如果您是从洛谷博客来这里的,觉得这篇题解解决了您的问题,写的不错的话,别忘了回到洛谷题解区,给我题解点个赞!
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 单线程的Redis速度为什么快?
· 展开说说关于C#中ORM框架的用法!
· Pantheons:用 TypeScript 打造主流大模型对话的一站式集成库
· SQL Server 2025 AI相关能力初探
· 为什么 退出登录 或 修改密码 无法使 token 失效