洛谷P3932 浮游大陆的68号岛 —— 关于 一维前缀和 的进阶使用讨论
洛谷P3932 浮游大陆的68号岛
关于 一维前缀和 的进阶使用讨论
题目传送门:https://www.luogu.com.cn/problem/P3932
题目背景
浮游大陆的 \(68\) 号岛,位于浮游大陆的边境地带。平时很少有人造访。
岛上被浓厚的森林覆盖。

在这座边境地区不起眼的浮游岛上,建立着神秘的”兵器“管理仓库——妖精仓库。
题目描述
妖精仓库里生活着黄金妖精们,她们过着快乐,却随时准备着迎接死亡的生活。
换用更高尚的说法,是随时准备着为这个无药可救的世界献身。
然而孩子们的生活却总是无忧无虑的,幼体的黄金妖精们过着天真烂漫的生活,自然也无暇考虑什么拯救世界之类的重任。

有一天小妖精们又在做游戏。这个游戏是这样的。
妖精仓库的储物点可以看做在一个数轴上。每一个储物点会有一些东西,同时他们之间存在距离。
每次他们会选出一个小妖精,然后剩下的人找到区间\([l,r]\)储物点的所有东西,清点完毕之后问她,把这个区间内所有储物点的东西运到另外一个仓库的代价是多少?
比如储物点\(i\)有\(x\)个东西,要运到储物点\(j\),代价为
dist就是仓库间的距离。
当然啦,由于小妖精们不会算很大的数字,因此您的答案需要对19260817取模。

输入格式
第一行两个数表示\(n,m\)
第二行\(n-1\)个数,第\(i\)个数表示第\(i\)个储物点与第\(i+1\)个储物点的距离
第三行\(n\)个数,表示每个储物点的东西个数
之后\(m\)行每行三个数 \(x\ l\ r\)
表示查询要把区间\([l,r]\)储物点的物品全部运到储物点x的花费
输出格式
对于每个询问输出一个数表示答案
样例 #1
样例输入 #1
5 5
2 3 4 5
1 2 3 4 5
1 1 5
3 1 5
2 3 3
3 3 3
1 5 5
样例输出 #1
125
72
9
0
70
提示
对于30%的数据,\(n , m \le 1000\)
对于另外20%的数据,所有储物点间的距离都为1
对于另外20%的数据,所有储物点的物品数都为1
对于100%的数据 ,$ n , m \le 200000 ; a_i , b_i <= 2\cdot 10^9$
好习惯
- 观察数据范围,发现 \(a_{i}\) 和 \(b_{i}\) 的的最大值都为 \(2\cdot 10^9\),结合题目,注意到运算过程设计乘法运算,因此数据范围应当开到 \(long\ long\)
方法一:朴素算法
- 观察题目,我们可以画一个简单的数轴图,来方便我们直观地讨论问题:
- 我们规定上图的意义是:将从第 \(i\) 个仓库到第 \(j\) 个仓库的所有东西运到第 \(x\) 个仓库。并且在接下来的表述中,我们都用 \(a[i]\) 来表示仓库 \(i\) 的东西数量,并且用 \(d[i]\) 表示仓库 \(i\) 到 仓库 \(i+1\) 的距离。
- 根据题述公式,我们可以知道将仓库 \(i\) 的东西运到仓库 \(x\) 的代价是 $$a[i] \times dist(i,x)$$
- 因此对于每次查询,将区间 \([l,r]\) 的所有东西运到仓库 \(x\) 总花费是:
- 根据上述思路,我们很容易得到如下代码:
const long long mod=19260817;
long long cost=0; //cost用来统计总花费
for(int i=l;i<=r;i++)
{
long long dis=0; //dis用来记录本轮查询的总距离
for(int j=(i<x?i:x);j<=(i<x?x:i)-1;j++) //这里运用了一点三目运算符的小技巧,直接判断i是在x左侧还是右侧,j右边界-1是因为d[i]代表i到i+1的距离,因此只需要遍历到右边界-1的位置。
{
dis+=d[j];
dis%=mod;
}
cost+=((a[i]%mod)*(dis%mod))%mod;
cost%=mod;
}
- 上述代码非常形象地展示了一次查询的计算过程。但是我们观察循环的层数,抛开代码展示的两层不谈,最外层还有一层循环用来进行 \(m\) 次查询,所以我们很容易发现这样的做法复杂度在 \(O(n^3)\),是非常繁冗的。因此我们不得不做一些优化。
方法二:一维线性前缀和
预处理阶段:
- 观察上述公式:
- 我们已经知道,其中的 \(dist(i,x)\) 表示仓库 \(i\) 到 仓库 \(x\) 的距离,结合输入中已经给出了每一个仓库距离下一个仓库的距离,我们可以用前缀和的思想来快速得到 \(dist(i,x)\) 的值。
PS:以防你不知道 or 忘了什么是前缀和,这里简单的科普一下前缀和的思想:
高中学习数列求和的时候,我们知道,如果想求出一个数列中第 \(i\) 项到第 \(j\) 项的和,我们只需要做:\(S_{j}-S_{i-1}\),这样得到的答案就是从 \(a_{i}\) 一直累加到 \(a_{j}\) 的和。
- 是不是发现这个原理和 \(dist(i,x)\) 的计算很相似?没错,我们同样只需要在读入 \(d[i]\) 的时候用另外一个数组 \(dis[i]\) 做为 \(\sum_{j=1}^{i-1}d[j]\), 相当于第 \(i\) 个仓库距离第 \(1\) 个仓库的距离。
有的人可能疑问为什么 \(\sum_{j=1}^{i-1}d[j]\) 的上界是 \(i-1\),其实就是因为 \(d[i]\) 表示的是仓库 \(i\) 到仓库 \(i+1\) 的距离,所以我们如果想用 \(dis[i]\) 表示第 \(i\) 个仓库距离第 \(1\) 个仓库的距离的话只需要加到 \(d[i-1]\) 就够了。
- 根据以上分析,我们得到计算花费的新公式:
到这里,我们就已经能发现我们减少了一层循环,复杂度从 \(O(n^3)\) 进化到了 \(O(n^2)\) ,但是显然,从本体的数据范围来看,\(O(n^2)\) 的方法大概率还是要 \(TLE\) 的,因此我们不得不进行进一步处理,看能否再次减少一层循环,将复杂度进化到 \(O(n)\),因此我们对公式展开得到:
注意到右式第一项中 \(dis[x]\) 是常量,因此公式可以进一步简化为:
到这里,希望你能发现:在右式中,第一项的 \(\sum_{i=l}^r a[i]\) 同样符合前缀和的规律,因此我们可以在读入 \(a[i]\) 的时候用 \(S[i]\) 来存储 \(\sum_{j=1}^i a[j]\),这样我们只需计算 \(S[r]-S[l-1]\) 就能得到 \(\sum_{i=l}^r a[i]\) 的值。
同样的,我们观察右式第二项,由于 \(a[i]\) 和 \(dis[i]\) 的内部变量 \(i\) 是一致的,所以我们同样可以用前缀和来处理 \(\sum_{i=l}^r a[i] \cdot dis[i]\),只需要在读入 \(a[i]\) 的时候再用一个数组 \(pos[i]\) 存储 \(\sum_{j=1}^i a[j] \cdot dis[j]\) 的值。
可能有些细心的人会问:用 \(pos[i]\) 存储 \(\sum_{j=1}^i a[j] \cdot dis[j]\) 的时候,\(dis[j]\) 难道不需要用前缀和存吗?好吧,你可能忘记了 \(dis[j]\) 在读入 \(d[j]\) 的时候就已经存好了,这里直接拿出来用就行。
因此公式可以再次进行简化,并且得到最终核心公式:
- 至此,我们再一次减少了一层循环,现在对于每一次花费 \(cost\) 的查询,只需要进行复杂度为 \(O(1)\) 的处理即可得到答案,因此整个程序的复杂度进化到了 \(O(n)\),足以通过本题。
对于核心公式的形象理解:
- 细心的你也许发现了:经过这么多变换,我们得到的 \(cost\) 表达式的实际意义其实就是区间内的这些仓库从 \(1\) 搬到 \(x\) 的花费减去 从 \(1\) 搬到这些仓库对应的位置的花费,这样便得到了将 \([l,r]\) 搬到 \(x\) 的总花费,这像极了前缀和的思想。
正如上图所述,对于任意的仓库 \(i\) 都满足:
\(将其搬到x 的花费 = 将其从 1 搬到 x 的花费 - 将其从 1 搬到 i 的花费\)
暂时称上述关系式为:仓库花费公式。
所以将 \([l,r]\) 内所有的仓库的仓库花费公式都累加起来,便会得到我们预处理得到的核心公式!
Coding阶段:
- 我们观察处理预处理阶段还未简化的式子:
容易发现:如果 \(i\) 在 \(x\) 右侧,那么会出现 \(dis[x]-dis[i]\) 为负数的情况,而会出现 \(i\) 在 \(x\) 右侧的情况只有两种:
\(1.\) \(x\) 在 \([l,r]\) 内。
\(2.\) \(x\) 在 \([l,r]\) 左侧。
特别地,对于情况 \(2\),我们会发现计算得到的 \(cost\) 正好为正确答案的相反数,这是因为此时正确的 \(cost\) 计算式应为:
因此对于情况 \(2\),只需要取 \(cost\) 为核心公式的负数即可:
而对于最一般的情况 \(1\),我们可以考虑将 \([l,r]\) 分成 \(x\) 左右的两个区间 \([l_1,r_1]\) 和 \([l_2,r_2]\) ,然后分开计算再求和即可,对于左侧区间 \([l_1,r_1]\) 只需要带入一般的核心公式,对于右侧区间 \([l_2,r_2]\) 则需要带入核心公式的负数即可。
关于对答案取模:
-
题目中已经告诉我们:答案需要对19260817取模。
那么该怎样取模才能保证得到正确答案呢?其实并没有想象中的麻烦,只需要记住一个要领:
如果计算的过程只涉及到 加 减 乘 这三种运算,那就使劲模!
这是因为根据取模运算的性质:
\((a\pm b)\ mod\ p=(a\ mod\ p\pm b\ mod\ p)\ mod\ p\)
\((a\cdot b)\ mod\ p=(a\ mod\ p)\cdot(b\ mod\ p)\ mod\ p\)
我们发现,对于加减乘运算,对答案取模其实就是把运算过程的所有变量都狠狠模一遍,因此在写代码的时候也可以遵循这个原理,一个劲地模就可以了!
-
当然,计算答案的时候还要先加一个 \(mod\) 再取模,这样操作是为了防止出现负数取模的情况,可能这个不太好理解,但加上总归是保险的,比如这道题就必须加 \(mod\) 再取模,原因是前期又要取模又要做减法,很有可能出现这种情况:
本来是一个大的数减去小的数,但是大的数取模后要比小的数取模还小,这样就会出现负数。
所以计算 \(cost\) 的时候先加上几个 \(mod\) 变成正数,然后再取模,才能得到正解,具体应该是几个 \(mod\) 受很多因素影响,但是对于本题来说,只要你读入数据的时候就狠狠取模,那最后的 \(cost\) 只需要加上一个 \(mod\) 就够了。
最后,代码奉上:
#include <bits/stdc++.h>
using namespace std;
const long long mod=19260817;
long long n,m,x,l,r;
long long dis[200005];
long long pos[200005];
long long s[200005];
long long tmp; //由于最终核心公式和前缀和处理阶段都不需要存储下来每一个a[i]和d[i],所以我们这里直接用tmp当做输入变量,用于前缀和处理阶段
int main()
{
cin>>n>>m;
for(int i=2;i<=n;i++) //这里直接从dis[2]开始处理是因为其代表1到2的距离,显然1到1的距离为0,因此从2开始处理
{
cin>>tmp; //读入d[i]的值
dis[i]=((dis[i-1]%mod)+(tmp%mod))%mod;
}
//处理第一项
cin>>tmp;
s[1]=tmp; s[1]%=mod;
pos[1]=((tmp%mod)*(dis[1]%mod))%mod;
for(int i=2;i<=n;i++)
{
cin>>tmp; //读入a[i]的值
pos[i]=(pos[i-1]%mod+((tmp%mod)*(dis[i]%mod))%mod)%mod;
s[i]=((s[i-1]%mod)+(tmp%mod))%mod;
}
while(m--)
{
cin>>x>>l>>r;
if(x>l&&x<r)
{
long long l1=l;
long long r1=x-1;
long long l2=x+1;
long long r2=r;
long long cost=0;
cost+=((dis[x]*(s[r1]-s[l1-1])-(pos[r1]-pos[l1-1])+(pos[r2]-pos[l2-1])-dis[x]*(s[r2]-s[l2-1]))%mod+mod)%mod;
cout<<cost<<endl;
continue;
}
if(x>=r)
{
long long l1=l;
long long r1=r;
long long cost=0;
cost+=((dis[x]*(s[r1]-s[l1-1])-(pos[r1]-pos[l1-1]))%mod+mod)%mod;
cout<<cost<<endl;
continue;
}
if(x<=l)
{
long long l2=l;
long long r2=r;
long long cost=0;
cost+=(((pos[r2]-pos[l2-1])-dis[x]*(s[r2]-s[l2-1]))%mod+mod)%mod;
cout<<cost<<endl;
continue;
}
}
return 0;
}
毫无悬念:

至此,本题的所有测试点都得以通过,顺利AC.
总结:
- 本题对新手的思维能力和码力有较高的要求,也正是如此使得这道题成为了检测你扎实代码功底的一道优秀题目。
- 题目看似简洁易懂,实则危机四伏,稍有疏忽大意便会带着满屏 \(TLE\) 无功而返。因此这道题毫无疑问成为了送给初入算法学海的人的一份见面大礼:
算法的浪漫之处恰恰在于其透过表象看本质,抽丝剥茧,妙手回春。
老规矩,放一只赤色杀人魔镇楼(逃

浙公网安备 33010602011771号