P3372 题解
这篇题解说了什么?
这篇题解包括了三种解决 P3372 【模板】线段树 1 的方法:
-
线段树 (仅仅给出了代码)
-
平衡树
-
CDQ分治
首先声明:这篇题解所用的 有些 算法 并不是 解决这道题的正确算法。如果你想要看正确的 线段树 ,大可不必看这片题解。
蒟蒻的第一篇题解。
解法一: 线段树
这道题的正确解法就是线段树。
但是,我并不打算写线段树的具体构建方式,因为其他的题解讲的比我好得多。如果您打开题解就是为了学习线段树, 那也可以拿我的代码看看,毕竟注释写的还是蛮多的
这里贴出代码:
#include<iostream>
using namespace std;
const int N=100010;
typedef long long ll;
ll tree[N*4];
ll lazy[N*4];
int a[N];
#define lson ((root<<1))
#define rson ((root<<1)+1)
#define mid (l+r>>1)
void build(int root,int l,int r){// O(n) 建树
if(l==r){
tree[root]=a[l];//如果递归到了最底层,直接返回。
return;
}
build(lson,l,mid);//分别递归左右子树。
build(rson,mid+1,r);
tree[root]=tree[lson]+tree[rson];//重新计算这个节点所覆盖的元素的和。
}
void fun(int root,int l,int r,int v){//推标记
tree[root]+=(r-l+1)*v;
lazy[root]+=v;
}
void pushdown(int root,int l,int r){//放标记
fun(lson,l,mid,lazy[root]);
fun(rson,mid+1,r,lazy[root]);
lazy[root]=0;
}
void update(int root,int l,int r,int lr,int rr,int v){//更新
if(r<lr||rr<l){//如果现在这个节点所代表的线段完全不在更新的范围之内,直接返回。
return ;
}
if(lr<=l&&r<=rr){//如果现在这个节点所代表的线段完全在更新的范围之内,直接推标记。
fun(root,l,r,v);
return;
}
pushdown(root,l,r);//下放标记。
update(lson,l,mid,lr,rr,v);
update(rson,mid+1,r,lr,rr,v);
tree[root]=tree[lson]+tree[rson];//重新计算这个节点所覆盖的元素的和。
}
ll query(int root,int l,int r,int lr,int rr){//差不多和update函数一样。
if(r<lr||rr<l){//如果现在这个节点所代表的线段完全不在查询的范围之内,直接返回。
return 0;
}
if(lr<=l&&r<=rr){//如果现在这个节点所代表的线段完全在查询的范围之内,直接sum。
return tree[root];
}
pushdown(root,l,r);//下放标记
return (ll)query(lson,l,mid,lr,rr)+(ll)query(rson,mid+1,r,lr,rr);
}
int n,m;
int main(){
cin>>n>>m;
for(int i=1;i<=n;i++){
cin>>a[i];
}
build(1,1,n);
for(int a,b,c,d,i=1;i<=m;i++){
cin>>a;
if(a==1){
cin>>b>>c>>d;
update(1,1,n,b,c,d);
}else{
cin>>b>>c;
cout<<(ll)query(1,1,n,b,c)<<endl;
}
}
}
解释一下为什么建树是
根据主定理,
尽管这个东西可以做到
解法二:平衡树
平衡树所能维护的不仅仅是一种动态集合,它也可以用来解决某些序列区间问题。但是如果需要实现区间操作,普通的平衡树不能满足要求,最好使用 能够支持分裂和合并 的平衡树。
该解法前置知识:Splay 或是 fhq-Treap 。
这篇题解所采用的平衡树是 fhq-Treap 。它易于实现,常数也不错(虽然跟线段树比起来就差远了
不同于按照值来进行排序的平衡树,这棵平衡树是 按照下标进行排序 的。
下图演示了按照下标进行排序的平衡树和按照值进行排序的平衡树的区别。
这个地方应该挺好理解吧
那么,构建了一棵按照下标排序的平衡树之后我们该怎么办呢?
我们应该考虑去维护 每一个节点的子树的值之和 。
如果我们可以维护,那么我们在查询的时候,只需要将对应的子树分裂出来,然后查询这个被分裂出来的子树的根节点的 子树之和 即可。
实际上这里是很好维护的,我们只需要对于每一个节点维护
那么按照这个思路,我们也可以对于每一个节点维护一个 lazy tag 。
现在的问题就是怎么维护这个 lazy tag 。其实像 lazy tag 下放 。
这样的话,这道题的代码就不难写出来了。
平均时间复杂度:
之所以这里用的是“平均时间复杂度”,是因为 Treap 的时间复杂度依赖于随机出来数据的好坏。它是个随机化算法。 不过这其实不影响你在竞赛的时候写 Treap ,这是因为 Treap 因为随机出来数据不好而导致 TLE 的可能性微乎其微。
代码:
#include<iostream>
#include<cstdlib>
#define int long long
using namespace std;
const int N=1e6;
struct treap{
int v,l,r,p,s,lz,su;
}t[N];
#define v(x) t[x].v //代表这个节点的值。
#define ls(x) t[x].l //代表这个节点的左儿子。
#define rs(x) t[x].r //代表这个节点的右儿子。
#define p(x) t[x].p //代表这个节点的优先级
#define s(x) t[x].s //代表这个节点的子树的节点个数
#define lz(x) t[x].lz //lazy tag
#define su(x) t[x].su //sum
void pushup(int x,int v){//上推
if(x==0)return;
v(x)+=v;
su(x)+=s(x)*v;
lz(x)+=v;
}
void pushdown(int x){//下放
pushup(ls(x),lz(x));
pushup(rs(x),lz(x));
lz(x)=0;
}
int merge(int x,int y){//合并操作
if(x==0||y==0)
return x+y;
if(p(x)<p(y)){
pushdown(x);//这里是对于 lazy tag 的处理:合并、分裂之前先下放。
rs(x)=merge(rs(x),y);
s(x)=s(ls(x))+s(rs(x))+1;
su(x)=su(ls(x))+su(rs(x))+v(x);//这里是对于 sum 的处理:合并、分裂之后更新 sum
return x;
}else{
pushdown(y);//这里是对于 lazy tag 的处理:合并、分裂之前先下放。
ls(y)=merge(x,ls(y));
s(y)=s(ls(y))+s(rs(y))+1;
su(y)=su(ls(y))+su(rs(y))+v(y);
return y;
}
}
void split(int x,int& a,int& b,int siz){
if(x==0){
a=b=0;
return;
}
pushdown(x);//这里是对于 lazy tag 的处理:合并、分裂之前先下放。
if(s(ls(x))<siz){
a=x;
split(rs(x),rs(x),b,siz-s(ls(x))-1);
}else{
b=x;
split(ls(x),a,ls(x),siz);
}
s(x)=s(ls(x))+s(rs(x))+1;
su(x)=su(ls(x))+su(rs(x))+v(x);//这里是对于 sum 的处理:合并、分裂之后更新 sum
}
int cnt=0;
int create(int x){
su(++cnt)=x;
v(cnt)=x;
s(cnt)=1;
p(cnt)=rand()+rand();
return cnt;
}
int root;
int n,m;
signed main(){
cin>>n>>m;
for(int x,i=1;i<=n;i++){
cin>>x;
root=merge(root,create(x));//读入一个数,并直接将它合并到平衡树的最后一个位置去。
}
int a,b,c;
for(int ty,l,r,x,i=1;i<=m;i++){
cin>>ty;
if(ty==1){
cin>>l>>r>>x;
split(root,a,b,l-1);
split(b,b,c,r-l+1);//先将所修改的子树分裂出来
pushup(b,x);//打上个lazy tag
root=merge(merge(a,b),c);//合并回去
}else{
cin>>l>>r;
split(root,a,b,l-1);
split(b,b,c,r-l+1);//先将所修改的子树分裂出来
cout<<su(b)<<endl;//查一下sum
root=merge(merge(a,b),c);//合并回去
}
}
}
解法三:CDQ 分治
这才是这篇题解的独特之处!
前置知识:CDQ分治
其实不会 CDQ 分治也可以看这篇题解,毕竟 CDQ 分治只是一种思想,而不是一种具体的算法。
先将这个题差分化
如果我们来把一个修改拆开,变成两个修改,分别修改开头和末尾(也就是把一个原本是
我们不妨也把原本的数组也这样处理:对于每一个
既然修改都做到这样了,我们也可以对查询这么做:对于一个查询
探究一个查询的答案的构成。
现在我们来看一个查询
首先,如果有一个修改
我们现在已经搞明白了如果有一个修改和一个查询,那么这个修改什么条件下会对查询造成贡献以及造成贡献的量。现在我们将这个结论推向多个修改和一个查询。
我们先不妨设
如果现在有
分治统计答案
现在我们已经成功把这个题转化为了差分形式和搞明白了一个查询的答案的构成。但是知道这些有什么用吗?我们怎么用这些信息把答案算出来?
我们假设每一个询问和修改现在都被我们放在一个序列里,它们都有以下属性:
既然它们的属性基本一致,我们就叫它们节点好了。
我们重新改写题意:给出每一个节点的
好了,那么,现在又怎么做?
分治?
怎么分治?
我们考虑到,如果现在你正在处理一个区间
很明显,对于同一类节点之间造成的贡献,我们直接递归下去好了,我们需要考虑的只是不同类节点之间造成的贡献,而且,如果有贡献,一定是第一类节点对第二类节点造成贡献。
这个其实很显然:第一类节点的
于是这就变成了第一类节点单向对第二类节点造成贡献的问题。
那么怎么计算贡献呢?我们首先把两类节点都以
请注意:原来的贡献公式中,
-
创建两个变量
sum和all,分别表示已经处理的第一类节点的y 之值 和 已经处理的第一类节点的x 与y 的乘积之和。跳转至第 2 步。 -
如果第一类节点的第一个节点的
x 比 第二类节点的第一个节点的x 小,那么我们就先处理第一类节点的第一个节点,跳转至第 3 步;否则我们就先处理第二类节点的第一个节点,转至第 4 步。 -
将第一类第一个节点的
y 加入sum,将xy 加入all。将这个节点“删掉”,并返回到第二步。(让下一个节点变成第一个节点) -
按照公式
\text{contribution}=(x+1)\sum_{i=1}^{s}y_i-\sum_{i=1}^{s}x_iy_i=(x+1)sum + all 算出贡献,并将其加入到这个节点的总贡献中。将这个节点“删掉”,并返回到第二步。(让下一个节点变成第一个节点)
这里的“删掉”并不是真的删掉,只是为了方便说明原理而已。
我们可以保证,在这个算法运行完毕之后,每一个第二类的节点都已经加上了第一类节点所能造成的贡献。
现在,我已经将这整个算法的流程说明完毕。如果你在上面没有理解明白,可以到下面去看看具体实现。
下面是代码:
#include<iostream>
#include<algorithm>
using namespace std;
#define int long long
const int N=1e6;
int a[N];
struct query{//节点的结构体。
int time,x,y;
}p[N],temp[N];
int cnt=0;
void add(int x,int y){//添加一个节点
p[++cnt].time=cnt;
p[cnt].x=x;
p[cnt].y=y;
}
int ans[N];//a[x]用来存储t为x的节点所获得的总贡献。
pair<int,int> qa[N];
int n,m;
bool cmp(query a,query b){//一个按照 x 排序的 cmp 函数。
if(a.x!=b.x)
return a.x<b.x;
return a.time<b.time;
}
void cdq(int l,int r){//现在在处理区间 [l,r]
if(l==r)
return;//只有一个元素,直接返回
int mid=(l+r)>>1,x=l,y=mid+1;
for(int i=l;i<=r;i++) //先将区间 [l,r] 分为两类节点
if(p[i].time<=mid)
temp[x++]=p[i]; //这个节点属于第一类
else temp[y++]=p[i]; //这个节点属于第二类
for(int i=l;i<=r;i++)p[i]=temp[i];
cdq(l,mid); //分治第一类节点之间的贡献
cdq(mid+1,r); //分治第二类节点之间的贡献
sort(p+l,p+mid+1,cmp); //排序,使得它对于 x 有序。
sort(p+mid+1,p+r+1,cmp);
x=l,y=mid+1;int sum=0,all=0;
while(x<=mid||y<=r) //双指针算法。其中 x 代表第一类元素现在的下标,y 代表第二类元素现在的下标。
if(x<=mid&&(y>r||p[x].x<=p[y].x)) //如果我们要先处理第一类节点
sum+=p[x].x*p[x].y,all+=p[x].y,x++; //更新sum和all
else //否则处理第二类节点
ans[p[y].time]+=all*(p[y].x+1)-sum,y++; //计算贡献
}
signed main(){
cin>>n>>m;
for(int i=1;i<=n;i++)
cin>>a[i];
for(int i=1;i<=n;i++)
add(i,a[i]-a[i-1]);
int qm=0;
for(int ty,l,r,x,i=1;i<=m;i++){
cin>>ty;
if(ty==1){//是一个修改操作
cin>>l>>r>>x;
add(l,x);//进行差分
add(r+1,-x);
}else{
cin>>l>>r;
add(l-1,0);
qa[++qm].first=cnt;//存下来这个询问对应着哪两个询问,方便最后处理。
add(r,0);
qa[qm].second=cnt;
}
}
cdq(1,cnt);
for(int i=1;i<=qm;i++)
cout<<ans[qa[i].second]-ans[qa[i].first]<<endl;
}
复杂度分析和优化
那么这个算法我们写完了,它的时间复杂度是什么呢?
很遗憾,
我们先看
我们用
(其中那个
然后这个东西可以用主定理推出来
如果你没有学过主定理,正好可以学学(
复杂度分析我们说完了,那么怎么优化呢?
我们注意到,复杂度的瓶颈其实在快排上,我们只要把快排去掉,我们就可以实现
我们怎么不用快排呢?
你会发现,我们实际上所排序的东西,也就是下一层分治的区间,被分成了两部分,他们分别有序。
说人话就是假设你现在在处理区间
这比较不好描述,不过以下的代码描述了这个过程。
void cdq(int l,int r){
if(l==r)
return;
int mid=(l+r)>>1,x=l,y=mid+1;
for(int i=l;i<=r;i++)
if(p[i].time<=mid)
temp[x++]=p[i];
else temp[y++]=p[i];
for(int i=l;i<=r;i++)p[i]=temp[i];
cdq(l,mid);
cdq(mid+1,r);
x=l,y=mid+1;int sum=0,now=0,all=l;
//双指针算法
while(x<=mid||y<=r)
if(x<=mid&&(y>r||p[x].x<=p[y].x))
sum+=p[x].x*p[x].y,now+=p[x].y,temp[all++]=p[x++];
else ans[p[y].time]+=now*p[y].x-sum,temp[all++]=p[y++];
for(int i=l;i<=r;i++)
p[i]=temp[i];
}
(因为基本上只有这里有修改,所以无需把整个代码搬过来。)
如果你写过归并排序,你会发现这东西其实就是把双指针算法和归并排序的板子揉在了一块,或者说, 归并排序的合并实现就是双指针算法 。
在双指针算法执行完了之后,你会发现
那么,我们这么改了之后,复杂度变成了什么?
这样,这个算法的时间复杂度就和线段树一样了。
下面是完整代码:
#include<iostream>
#include<algorithm>
using namespace std;
#define int long long
const int N=1e6;
int a[N];
struct query{
int time,x,y;
}p[N],temp[N];
int cnt=0;
void add(int x,int y){
p[++cnt].time=cnt;
p[cnt].x=x;
p[cnt].y=y;
}
int ans[N];
pair<int,int> qa[N];
int n,m;
void cdq(int l,int r){
if(l==r)
return;
int mid=(l+r)>>1,x=l,y=mid+1;
for(int i=l;i<=r;i++)
if(p[i].time<=mid)
temp[x++]=p[i];
else temp[y++]=p[i];
for(int i=l;i<=r;i++)p[i]=temp[i];
cdq(l,mid);
cdq(mid+1,r);
x=l,y=mid+1;int sum=0,now=0,all=l;
while(x<=mid||y<=r)
if(x<=mid&&(y>r||p[x].x<=p[y].x))
sum+=p[x].x*p[x].y,now+=p[x].y,temp[all++]=p[x++];
else ans[p[y].time]+=now*p[y].x-sum,temp[all++]=p[y++];
for(int i=l;i<=r;i++)
p[i]=temp[i];
}
bool cmp(query a,query b){
if(a.x!=b.x)
return a.x<b.x;
return a.time<b.time;
}
signed main(){
cin>>n>>m;
for(int i=1;i<=n;i++)
cin>>a[i];
for(int i=1;i<=n;i++)
add(i,a[i]-a[i-1]);
int qm=0;
for(int ty,l,r,x,i=1;i<=m;i++){
cin>>ty;
if(ty==1){//update
cin>>l>>r>>x;
add(l,x);
add(r+1,-x);
}else{
cin>>l>>r;
add(l,0);
qa[++qm].first=cnt;
add(r+1,0);
qa[qm].second=cnt;
}
}
sort(p+1,p+1+cnt,cmp);
cdq(1,cnt);
for(int i=1;i<=qm;i++)
cout<<ans[qa[i].second]-ans[qa[i].first]<<endl;
}
完结撒花!(((
这篇题解难免会有一些错误,如果您发现了一些错误,欢迎在评论区指出,谢谢!
引用:
洛谷用户“这人太菜了”的洛谷日报博客:https://www.luogu.com.cn/blog/GJY-JURUO/master-theorem
Orz Orz %%%