线段树进阶操作
一、李超线段树
1、标记永久化
一言以蔽之:标记永久化就是不再下放标记,而是让标记永久地停留在线段树的节点上,统计答案时再考虑这些标记的影响
2、维护直线
我们以下面这道题为例子来进行讲解
[JSOI2008] Blue Mary开公司
题意:要求支持操作
Project:插入一条 \(y=kx+b\) 的直线,给定 \(k,b\)Query:求所有直线中与直线 \(x=t\) 的交点的纵坐标最大值是多少
我们首先建立一棵线段树,每个节点代表一个区间,且有一个标记记录一条直线
对于插入直线的操作:
-
如果当前区间还没有标记,我们将这个区间标记为当前直线
-
如果有标记,但插入的直线完全覆盖原先的直线,即替换掉原先的直线
-
如果插入的直线被原先的直线完全覆盖,即返回
-
剩下的情况就是插入的和原先的直线在区间内有交点。那么我们令 \(mid=(l+r)/2\),令与直线 \(x=mid\) 交点纵坐标更大的直线作为当前区间被标记的直线。然后递归交点所在的区间子树,继续修改即可
对于查询操作,类似标记永久化,找到所有覆盖了 \(x=t\) 的区间,考虑该区间的贡献即可
时间复杂度:\(O(n\log n)\)
code
#include<bits/stdc++.h>
using namespace std;
const int N=100010,T=50010;
const double eps=1e-12;
struct line
{
double k,b; //斜率和截距
int l,r;
bool flag; //标记
#define flag(x) tree[x].flag
}tree[4*T];
int n;
char op[10];
double calc(line a,int x) //通过x计算y
{
return (double)x*a.k+a.b;
}
void build(int p,int l,int r)
{
tree[p]=(line){0,0,1,50000,0};
if(l==r)
return;
int mid=(l+r)>>1;
build(p*2,l,mid);
build(p*2+1,mid+1,r);
}
void change(int p,int l,int r,line k)
{
if(k.l<=l && k.r>=r) //完全覆盖区间
{
if(!flag(p)) //没有标记
tree[p]=k,flag(p)=1;
else if(calc(k,l)-calc(tree[p],l)>eps && calc(k,r)-calc(tree[p],r)>eps) //有标记但插入的更优
tree[p]=k;
else if(calc(k,l)-calc(tree[p],l)>eps || calc(k,r)-calc(tree[p],r)>eps) //有交点
{
int mid=(l+r)>>1;
if(calc(k,mid)-calc(tree[p],mid)>eps) //令与x=mid交点更高的作为标记
swap(tree[p],k);
if(calc(k,l)-calc(tree[p],l)>eps) //递归交点的区间子树
change(p*2,l,mid,k);
else
change(p*2+1,mid+1,r,k);
}
}
else //未完全覆盖
{
int mid=(l+r)>>1;
if(k.l<=mid)
change(p*2,l,mid,k);
if(k.r>mid)
change(p*2+1,mid+1,r,k);
}
}
double ask(int p,int l,int r,int x) //标记永久化的查询需要不断递归直到一个点
{
if(l==r)
{
if(flag(p))
return calc(tree[p],x);
return -1e18;
}
int mid=(l+r)>>1;
double val=-1e18;
if(flag(p))
val=calc(tree[p],x); //当前点的标记
if(x<=mid) //递归子树
return max(val,ask(p*2,l,mid,x));
return max(val,ask(p*2+1,mid+1,r,x));
}
int main()
{
build(1,1,50000);
scanf("%d",&n);
for(int i=1; i<=n; i++)
{
scanf("%s",op);
if(op[0]=='P')
{
double s,p;
scanf("%lf%lf",&s,&p);
line now=(line){p,s-p,1,50000,1};
change(1,1,50000,now);
}
else
{
int t;
scanf("%d",&t);
double ans=ask(1,1,50000,t);
int anss=(int)(ans/100.0);
if(anss<0)
printf("0\n");
else
printf("%d\n",anss);
}
}
return 0;
}
[CEOI2017] Building Bridges
首先 \(O(n^2)\) 的 dp 很好想
设 \(f_i\) 表示连接第 \(1\) 根和第 \(i\) 根柱子的最小代价,答案即为 \(f_n\)
那么状态转移方程也是显然的:
\(f_i=\min\{f_j+(h_i-h_j)^2+\sum\limits_{k=j+1}^{i-1}w_k\}\)
现在我们令 \(w_i+=w_{i-1}\),即做一次前缀和,则有
\(f_i=\min\{f_j+(h_i-h_j)^2+w_{i-1}-w_j\}\)
考虑优化,将式子化简得:
\(f_i=h_i^2+w_{i-1}+\min\{f_j-2h_ih_j+h_j^2-w_j\}\)
我们令 \(k=-2h_j,\:b=f_j+h_j^2-w_j\),则后面的式子转化为一条直线 \(y=kh_i+b\),问题转化为求所有直线与 \(x=h_i\) 的交点的纵坐标的最小值,并插入当前自己所代表的直线
二、线段树合并
1、知识点
前置知识:动态开点线段树
线段树合并是一个递归的过程。我们合并两棵线段树时,用两个指针 \(p,q\) 从两个根节点出发,以递归的方式同步遍历两棵线段树。
-
若 \(p,q\) 之一为空,则以非空的那个作为合并的节点
-
若 \(p,q\) 均不为空,则递归合并两棵左子树和两棵右子树,然后删除节点 \(q\),以 \(p\) 为合并后的新节点,然后删除节点 \(q\),以 \(p\) 作为合并后的节点,更新信息
参考代码
int merge(int p,int q,int l,int r)
{
if(!p)
return q;
if(!q)
return p;
if(l==r)
{
dat(p)+=dat(q)
return p;
}
int mid=(l+r)>>1;
lc(p)=merge(lc(p),lc(q),l,mid);
rc(p)=merge(rc(p),rc(q),mid+1,r);
pushup(p);
return p;
}
时间复杂度 & 空间复杂度: \(O(n\log n)\)
2、一些习题
【模板】线段树合并 / [Vani有约会] 雨天的尾巴
根据套路,容易想到先找出 \(x,y\) 的最近公共祖先 \(lca\),之后进行树上差分。设 \(b[z]\) 为差分数组,对于每次操作,令 \(b[z][x]+1\),\(b[z][y]+1\),\(b[z][lca]-1\),\(b[z][fa[lca]]-1\) 即可。
现在考虑优化,我们可以对每一个点建立一棵权值线段树来替代差分数组,维护存放最多的救济粮的类型和数量,之后从根节点开始进行一次 \(\mathrm{dfs}\),对于 \(x\) 节点的所有儿子 \(y\),将它们的线段树合并起来,即完成差分数组最后的求前缀和的过程
code
#include<bits/stdc++.h>
using namespace std;
const int N=100010,M=100000;
struct SegmentTree
{
int val,ki;
int lc,rc;
#define lc(x) tree[x].lc
#define rc(x) tree[x].rc
#define val(x) tree[x].val
#define ki(x) tree[x].ki
}tree[80*N];
int n,m,t;
int dep[N],f[N][20],tot;
int rt[N],ans[N];
vector <int> g[N];
queue <int> q;
void bfs()
{
q.push(1);
dep[1]=1;
while(q.size())
{
int x=q.front(); q.pop();
for(int i=0; i<g[x].size(); i++)
{
int y=g[x][i];
if(dep[y])
continue;
dep[y]=dep[x]+1;
f[y][0]=x;
for(int j=1; j<=t; j++)
f[y][j]=f[f[y][j-1]][j-1];
q.push(y);
}
}
}
int LCA(int x,int y)
{
if(dep[x]>dep[y])
swap(x,y);
for(int i=t; i>=0; i--)
if(dep[f[y][i]]>=dep[x])
y=f[y][i];
if(x==y)
return x;
for(int i=t; i>=0; i--)
if(f[x][i]!=f[y][i])
x=f[x][i],y=f[y][i];
return f[x][0];
}
void pushup(int p)
{
if(val(lc(p))>=val(rc(p)))
val(p)=val(lc(p)),ki(p)=ki(lc(p));
else
val(p)=val(rc(p)),ki(p)=ki(rc(p));
}
void change(int &p,int l,int r,int pos,int v)
{
if(!p)
p=++tot;
if(l==r)
{
val(p)+=v;
ki(p)=pos;
return;
}
int mid=(l+r)>>1;
if(pos<=mid)
change(lc(p),l,mid,pos,v);
else
change(rc(p),mid+1,r,pos,v);
pushup(p);
}
int merge(int p,int q,int l,int r)
{
if(!p)
return q;
if(!q)
return p;
if(l==r)
{
val(p)+=val(q);
ki(p)=l;
return p;
}
int mid=(l+r)>>1;
lc(p)=merge(lc(p),lc(q),l,mid);
rc(p)=merge(rc(p),rc(q),mid+1,r);
pushup(p);
return p;
}
void dfs(int x,int fa)
{
for(int i=0; i<g[x].size(); i++)
{
int y=g[x][i];
if(y==fa)
continue;
dfs(y,x);
rt[x]=merge(rt[x],rt[y],1,M);
}
if(val(rt[x]))
ans[x]=ki(rt[x]);
}
int main()
{
scanf("%d%d",&n,&m);
for(int i=1; i<n; i++)
{
int x,y;
scanf("%d%d",&x,&y);
g[x].push_back(y);
g[y].push_back(x);
}
t=(log(n)/log(2))+1;
bfs();
for(int i=1; i<=m; i++)
{
int x,y,z;
scanf("%d%d%d",&x,&y,&z);
int lca=LCA(x,y);
change(rt[x],1,M,z,1);
change(rt[y],1,M,z,1);
change(rt[lca],1,M,z,-1);
change(rt[f[lca][0]],1,M,z,-1);
}
dfs(1,0);
for(int i=1; i<=n; i++)
printf("%d\n",ans[i]);
return 0;
}
CF600E Lomsat gelral
对每个节点建立一棵权值线段树,维护占主导地位的颜色的出现次数和编号和,再进行一次 \(\mathrm{dfs}\) 合并线段树即可
[HNOI2012] 永无乡
考虑用并查集去维护每座岛之间的连通性,并用连通块的祖先去代表整个连通块
对每座岛建立一棵权值线段树,每次查询操作在线段树上二分即可
对于建桥操作,合并两个连通块即可
[POI2011] ROT-Tree Rotations
对于节点 \(x\),设它的儿子子树分别为 \(y_1,y_2\),并且 \(y_1\) 在 \(y_2\) 左边。分析逆序对的来源:
-
在同一个 \(y\) 子树
-
在不同的 \(y\) 子树
对于节点 \(x\) 来说,要做的就是合并 \(y_1,y_2\) 并计算贡献。显然第 \(1\) 种来源已经在以 \(y_1,y_2\) 为根的子树中计算过了,所以我们只需通过合并操作计算来源 \(2\)
考虑对每个节点建立权值线段树,设值域区间内数字的个数为 \(sum\),那么逆序对个数就是 \(sum(rc(p))*sum(lc(q))\)
现在考虑交换子树的操作。显然交换子树的操作只会对来源2产生影响。那我们取 \(sum(lc(p))*sum(rc(q))\) 和 \(sum(rc(p))*sum(lc(q))\) 的最小值即可
CF208E Blood Cousins
直接找 \(a\) 的 \(p\) 级表亲是比较困难的,所以考虑先求出 \(a\) 的 \(p\) 级祖先 \(z\),将询问离线转化到 \(z\) 上,再求 \(z\) 子树内有多少个深度等于 \(dep[z]+p\) 的节点,记为 \(cnt\),那么该询问的答案就是 \(cnt-1\)
对于节点 \(x\),考虑如何求出其子树内有多少个深度为 \(dep[x]+p\) 的节点。我们可以以深度为下标建立权值线段树,查询时单点查询,然后不断合并即可。
具体实现时,在树的遍历时,可以开两个数组对询问进行处理。求 \(x\) 的 \(p\) 级祖先,可以树上倍增,也可以开一个栈,在遍历到节点 \(x\) 时入栈,返回时出栈,设当前栈顶下标为 \(t\),这样显然 \(x\) 的 \(p\) 级祖先就是 \(s[t-p]\)
一个小细节,该题的图不一定只有一课树,可能是多棵树,需要注意。
[Cnoi2019] 雪松果树
(上一题 Blood Cousins 的卡空间版)
大部分代码和上一题相同,这里主要讲如何优化空间
首先合并时,将子树按 \(size\) 从大到小合并,这样可以减少合并时的空间浪费
其次,合并完后,将无用的那个子树的空间回收起来,以后在动态开点时,优先从回收站里拿空间
最后,把 vector 换成链式前向星
code
#include<bits/stdc++.h>
using namespace std;
const int N=1000010,M=1000000;
struct SegmentTree
{
int lc,rc;
int sum;
#define lc(x) tree[x].lc
#define rc(x) tree[x].rc
#define sum(x) tree[x].sum
}tree[8*N];
struct node
{
int k,id,nxtt;
}qa[N],qb[N]; //qa,qb与上一题相同
int n,q,rt[N],tot,ans[N];
int dep[N],size[N],a[N],cnt;
int ha[N],tota,hb[N],totb;
int sa[N],ta,sb[N],tb; //sa是求k-祖先的栈,sb是回收空间用的
vector <int> g[N];
void adda(int x,int k,int id)
{
qa[++tota]=(node){k,id,ha[x]};
ha[x]=tota;
}
void addb(int x,int k,int id)
{
qb[++totb]=(node){k,id,hb[x]};
hb[x]=totb;
}
bool cmp(int x,int y)
{
return size[x]>size[y];
}
void pushup(int p)
{
sum(p)=sum(lc(p))+sum(rc(p));
}
void change(int &p,int l,int r,int pos,int v)
{
if(!p)
{
if(tb)
p=sb[tb--]; //优先拿回收站
else
p=++tot;
}
if(l==r)
{
sum(p)+=v;
return;
}
int mid=(l+r)>>1;
if(pos<=mid)
change(lc(p),l,mid,pos,v);
else
change(rc(p),mid+1,r,pos,v);
pushup(p);
}
int merge(int p,int q,int l,int r)
{
if(!p)
return q;
if(!q)
return p;
if(l==r)
{
sum(p)+=sum(q);
lc(q)=rc(q)=sum(q)=0; //回收
sb[++tb]=q;
return p;
}
int mid=(l+r)>>1;
lc(p)=merge(lc(p),lc(q),l,mid);
rc(p)=merge(rc(p),rc(q),mid+1,r);
pushup(p);
lc(q)=rc(q)=sum(q)=0;
sb[++tb]=q;
return p;
}
int ask(int p,int l,int r,int pos)
{
if(l==r)
return sum(p);
int mid=(l+r)>>1;
if(pos<=mid)
return ask(lc(p),l,mid,pos);
return ask(rc(p),mid+1,r,pos);
}
void solve(int x)
{
sa[++ta]=x;
for(int i=ha[x]; i; i=qa[i].nxtt)
{
int k=qa[i].k,id=qa[i].id;
if(ta>k)
addb(sa[ta-k],dep[x],id);
}
int l=cnt;
for(int i=0; i<g[x].size(); i++)
{
int y=g[x][i];
a[++cnt]=y;
}
int r=cnt;
sort(a+l+1,a+r+1,cmp); //按子树大小从大到小排序
for(int i=l+1; i<=r; i++)
{
int y=a[i];
solve(y);
rt[x]=merge(rt[x],rt[y],1,M);
}
change(rt[x],1,M,dep[x],1);
for(int i=hb[x]; i; i=qb[i].nxtt)
{
int k=qb[i].k,id=qb[i].id;
ans[id]=ask(rt[x],1,M,k);
}
ta--;
}
void dfs(int x,int fa)
{
dep[x]=dep[fa]+1;
size[x]=1;
for(int i=0; i<g[x].size(); i++)
{
int y=g[x][i];
dfs(y,x);
size[x]+=size[y];
}
}
int main()
{
scanf("%d%d",&n,&q);
for(int i=2; i<=n; i++)
{
int x;
scanf("%d",&x);
g[x].push_back(i);
}
dfs(1,0);
for(int i=1; i<=q; i++)
{
int x,k;
scanf("%d%d",&x,&k);
adda(x,k,i);
}
solve(1);
for(int i=1; i<=q; i++)
printf("%d ",max(ans[i]-1,0));
return 0;
}
[湖南集训] 更为厉害
因为 \(a,b\) 都是 \(c\) 的祖先,所以容易看出 \(a,b,c\) 在同一条链上
如果 \(b\) 在 \(a\) 的上方,显然答案就是 \((size[a]-1)\times \min(dep[a]-1,k)\)
如果 \(b\) 在 \(a\) 的下方,那么 \(a\) 子树内所有深度在 \([dep[a]+1,dep[a]+k]\) 范围内的点都可以作为 \(b\) 的候选项,此时 \(c\) 的数量就是 \(size[b]-1\)。因此我们可以以深度为下标建立权值线段树,求下标为 \([dep[a]+1,dep[a]+k]\) 内的 \(size-1\) 的和即可
CF570D Tree Requests
在每个节点同样以深度为下标建立线段树,存储该子树内深度相同的点所构成的字符集合
因为题目要求的是能否构成回文串。显然只要所有的字符都出现偶数次或只有一个字符出现次数为 \(1\) 即可,那么考虑将 \(26\) 个字母二进制压缩,每次 check 一下这个二进制数是否合法即可
CF246E Blood Cousins Return
容易想到用 map 给每个名字编号
对每个节点同样以深度为下标建立权值线段树,因为要去重计数,所以对线段树的每个叶子节点开一个 set,查询时返回 set 的大小即可
合并时,同 \(\text{雪松果树}\),将小的 set 合并到大的上
CF932F Escape Through Leaf
(李超线段树的合并)
考虑树形 \(\mathrm{dp}\),设 \(x\) 的儿子为 \(y\),\(f[x]\) 表示 \(x\) 跳到叶子节点费用的最小值。则显然
把 \(b_y\) 当作斜率,\(f[y]\) 当作截距,则变成李超线段树的模板,求平面内所有直线与直线 \(x=a_x\) 的交点的纵坐标最小值是多少
在合并时,对于表示相同区间的节点 \(p,q\)(\(p,q\) 非空),要做的就是把在 \(p\) 这里插入一条 \(tree[q]\) 的直线即可
code
#include<bits/stdc++.h>
#define LL long long
using namespace std;
const int N=100010,M=100000;
const LL INF=1e16;
struct line
{
LL k,b;
int lc,rc;
bool flag;
#define k(x) tree[x].k
#define b(x) tree[x].b
#define lc(x) tree[x].lc
#define rc(x) tree[x].rc
#define flag(x) tree[x].flag
}tree[20*N];
int n,a[N],b[N];
int rt[N],tot;
LL f[N];
vector <int> g[N];
LL calc(line a,int x)
{
return 1LL*a.k*x+a.b;
}
void change(int &p,int l,int r,line k)
{
if(!p)
p=++tot;
if(!flag(p))
k(p)=k.k,b(p)=k.b,flag(p)=1;
else if(calc(k,l)<calc(tree[p],l) && calc(k,r)<calc(tree[p],r))
k(p)=k.k,b(p)=k.b;
else if(calc(k,l)<calc(tree[p],l) || calc(k,r)<calc(tree[p],r))
{
int mid=(l+r)>>1;
if(calc(k,mid)<calc(tree[p],mid))
swap(k(p),k.k),swap(b(p),k.b);
if(calc(k,l)<calc(tree[p],l))
change(lc(p),l,mid,k);
else
change(rc(p),mid+1,r,k);
}
}
int merge(int p,int q,int l,int r)
{
if(!p)
return q;
if(!q)
return p;
change(p,l,r,tree[q]);
if(l==r)
return p;
int mid=(l+r)>>1;
lc(p)=merge(lc(p),lc(q),l,mid);
rc(p)=merge(rc(p),rc(q),mid+1,r);
return p;
}
LL ask(int p,int l,int r,int x)
{
if(!p)
return INF;
if(l==r)
{
if(flag(p))
return calc(tree[p],x);
return INF;
}
int mid=(l+r)>>1;
LL val=INF;
if(flag(p))
val=calc(tree[p],x);
if(x<=mid)
return min(val,ask(lc(p),l,mid,x));
return min(val,ask(rc(p),mid+1,r,x));
}
void dfs(int x,int fa)
{
for(int i=0; i<g[x].size(); i++)
{
int y=g[x][i];
if(y==fa)
continue;
dfs(y,x);
rt[x]=merge(rt[x],rt[y],-M,M);
}
f[x]=ask(rt[x],-M,M,a[x]);
if(f[x]==INF)
f[x]=0;
line now={(LL)b[x],f[x],0,0,1};
change(rt[x],-M,M,now);
}
int main()
{
scanf("%d",&n);
for(int i=1; i<=n; i++)
scanf("%d",&a[i]);
for(int i=1; i<=n; i++)
scanf("%d",&b[i]);
for(int i=1; i<n; i++)
{
int x,y;
scanf("%d%d",&x,&y);
g[x].push_back(y);
g[y].push_back(x);
}
dfs(1,0);
for(int i=1; i<=n; i++)
printf("%lld ",f[i]);
return 0;
}
三、势能线段树(Seg-beats)
有时候,题目中的操作可能让信息量趋于减小,此时可能产生均摊的做法。这时分析复杂度时,用势能来分析是不不错的方法
CF438D The Child and Sequence
题意:区间对一个数取模、单点修改、区间求和
后两个操作是线段树基本操作,主要考虑第一个
我们发现,当区间 \([l,r]\) 对 \(x\) 取模时,若 \([l,r]\) 的最大值小于 \(x\),那我们就不必操作。而每个数取模后至少会减小到原来的一半。所以我们想到可以暴力递归修改
来证明下复杂度为啥是对的。由上一段我们知道,一个数 \(k\) 最多进行 \(\log k\) 次有意义的取模。定义势能函数 \(\phi(x)=\log a_x\) 表示第 \(x\) 最多可以进行多少次有意义的取模,总势能 \(\phi\) 为所有叶子节点势能之和,一开始总势能 \(\leq n\log V\)。每次暴力取模会有 \(\mathcal{O}(\log n)\) 的复杂度,但会使势能减少 \(1\)。询问不会改变势能,一次单点修改操作最多使得势能增加 \(\log V\),所以总的时间复杂度不会超过 \(\mathcal{O}((n+m)\log V\log n)\)
code
#include<bits/stdc++.h>
#define LL long long
using namespace std;
const int N=1e5+10;
int n,m,a[N];
#define lc(p) p<<1
#define rc(p) p<<1|1
struct SegmentTree
{
int dat; LL sum;
#define dat(x) tree[x].dat
#define sum(x) tree[x].sum
}tree[N<<2];
void pushup(int p)
{
dat(p)=max(dat(lc(p)),dat(rc(p)));
sum(p)=sum(lc(p))+sum(rc(p));
}
void build(int p,int l,int r)
{
if(l==r)
{
dat(p)=sum(p)=a[l];
return;
}
int mid=(l+r)>>1;
build(lc(p),l,mid);
build(rc(p),mid+1,r);
pushup(p);
}
void cmod(int p,int l,int r,int ql,int qr,int x)
{
if(dat(p)<x)
return;
if(l==r)
{
dat(p)%=x; sum(p)%=x;
return;
}
int mid=(l+r)>>1;
if(ql<=mid)
cmod(lc(p),l,mid,ql,qr,x);
if(qr>mid)
cmod(rc(p),mid+1,r,ql,qr,x);
pushup(p);
}
void change(int p,int l,int r,int pos,int v)
{
if(l==r)
{
dat(p)=sum(p)=v;
return;
}
int mid=(l+r)>>1;
if(pos<=mid)
change(lc(p),l,mid,pos,v);
else
change(rc(p),mid+1,r,pos,v);
pushup(p);
}
LL ask(int p,int l,int r,int ql,int qr)
{
if(ql<=l && qr>=r)
return sum(p);
int mid=(l+r)>>1;
LL res=0;
if(ql<=mid)
res+=ask(lc(p),l,mid,ql,qr);
if(qr>mid)
res+=ask(rc(p),mid+1,r,ql,qr);
return res;
}
int main()
{
scanf("%d%d",&n,&m);
for(int i=1; i<=n; i++)
scanf("%d",&a[i]);
build(1,1,n);
while(m--)
{
int op,l,r,x,k;
scanf("%d",&op);
if(op==1)
{
scanf("%d%d",&l,&r);
printf("%lld\n",ask(1,1,n,l,r));
}
else if(op==2)
{
scanf("%d%d%d",&l,&r,&x);
cmod(1,1,n,l,r,x);
}
else
{
scanf("%d%d",&k,&x);
change(1,1,n,k,x);
}
}
return 0;
}
CF-gym-103107 And RMQ
题意:区间与、单点修改、区间求 \(\max\)
仍然是暴力修改。
我们发现,对区间 \([l,r]\) 进行修改时,若区间每一个数 \(\&\) 上 \(x\) 后都没有变化则无需修改。所以我们维护区间或的值,当它 \(\& \,x\) 没有变化则不操作
由于每次变化必然是某一位的 \(1\rightarrow 0\),所以可设势能函数 \(\phi(x)\) 表示 \(x\) 这个节点区间内的所有数的或和在二进制下 \(1\) 的个数。初始势能为 \(n\log V\),每次与操作都会使总势能最少减少 \(1\),单点修改操作最多使势能增加 \(\log n\log V\),因此总势能不会超过 \((n+m\log n)\log V\),时间复杂度 \(\mathcal{O}((n+m\log n)\log V)\)
Uoj#228.基础数据结构练习题
题意:区间加、区间开根、区间求和
先考虑这个问题的弱化版 P4145 上帝造题的七分钟2/花神游历各国
弱化版省去了区间加操作。我们发现对于一个数 \(x\),它在开根 \(\log \log x\) 次后就会变成 \(1\)。所以定义势能函数 \(\phi(x)=\log \log x\),势能总和为所有叶子节点的势能之和,为 \(n\log \log x\)。每次区间开根操作如果有大于 \(1\) 的数,就暴力递归,至少会使势能减少 \(1\),所以时间复杂度为 \(\mathcal{O}(n\log n\log \log V)\)
现在考虑这道题。我们引入一个典型的势能分析方法:容均摊
即:找出一种能概括信息量的“特征值”,证明其消长和时间消耗有关,最终通过求和得到复杂度。
在本题中,我们将每个节点的容定义为这个节点区间内的数的极差,记为 \(S\)
若 \(S\ne 0\),一次区间开根操作至少能使 \(S\) 开根
证明
设原来极差由 $x^2-y^2$ 产生,则新的极差为 $x-y$,而 $\sqrt{x^2-y^2}=\sqrt{(x-y)(x+y)}$,所以 $x-y<\sqrt{x^2-y^2}$设势能函数 \(\phi(x)=\log \log S_x\)(\(S_x\) 表示 \(x\) 所代表的区间的极差),每次开根操作都会使势能至少减少 \(1\),区间加会增加 \(\log n\log \log V\) 的势能,所以总的时间复杂度为 \(\mathcal{O}((n+m\log n)\log \log V)\)
然而这样是不严谨的,考虑向下取整带来的误差,如 \(3,4,3,4\) 开根号后变成 \(1,2,1,2\),极差并没有变化,意味着势能也没有减少,如果这时候区间加 \(2\) 的话又变成了 \(3,4,3,4\)。那我们每次区间开根都得暴力,复杂度退化
仔细思考可以发现向下取整带来的误差最多是 \(1\),所以产生这种情况的话极差只能是 \(1\),这时候两种数的变化量是相同的,我们可以直接转化成区间减法操作
CF1290E Cartesian Tree
笛卡尔树建树的过程可以看做是每次选取最大值然后进行分治。设 \(pre_i,nxt_i\) 表示 \(i\) 左边/右边第一个比它大的位置,则容易证明区间 \((pre_i,nxt_i)\) 都是 \(i\) 的子树,\(i\) 的子树大小即为 \(nxt_i-pre_i-1\)。令 \(k\) 从小到大增加,维护 \(pre_i,nxt_i\) 即可求出答案,下面以求解 \(nxt_i\) 为例(求解 \(pre_i\) 只要把原序列翻转即可)
每次加入一个数,都会令其左侧的 \(nxt\) 取 \(\min\),由于我们是不断“插入”新的数,空的位置不会计入下标,所以空的位置要记成 \(0\),右侧的所有 \(nxt\) 都要 \(+1\)。
于是我们转化成了下面的问题:
写一棵线段树,支持区间加、区间取 \(\min\)、区间求和
我们维护区间最大值 \(mx\),最大值个数 \(cmx\),区间严格次大值 \(mx_2\),区间和 \(sum\)
对于一个修改 \(a_i=\min(a_i,v)\) 来说
-
若 \(v\geq mx\),显然无影响
-
若 \(mx>y>mx_2\),则只会对最大值产生影响,利用 \(cmx\) 计算贡献并打下标记
-
若 \(y\leq mx_2\),则此次操作会影响到最大值以外的数,我们无法在当前节点处理,只能向深处 dfs,直到能处理为止
分析一下时间复杂度:
设节点的容为所表示区间的数的种类数,所有点的容的总和为 \(n\log n\)。
如果在 dfs 的过程中经过了该节点,则会将最大值与次大值合并使容至少减少 \(1\),所以 dfs 的复杂度就是 \(\mathcal{O}(n\log n)\)
总的时间复杂度应该是 \(\mathcal{O}((n+m)\log n)\)
下面考虑加入区间加操作
仍然维护上述四个变量 \(mx,cmx,mx_2,sum\)
把标记改进成 \((ad_1,v)\) 的形式,意义为先加上 \(ad_1\) 再对 \(v\) 取最小值
取 \(\min\) 时仍按照上述方法 dfs
根据论文中的证明,复杂度有上界 \(\mathcal{O}(n\log^2 n)\),实际近似于 \(\mathcal{O}(n \log n)\)
总结:我们通过暴力 dfs,将区间取 \(\min\) 转化为区间加法操作。在实现时,我们可以维护两套标记,一套对最大值生效,另一套对所有数(或其它数)生效
四、历史值问题
在维护序列 \(A\) 的同时维护序列 \(B\),\(B\) 一开始等于 \(A\)
- 历史最大值:每次操作后 \(B_i\leftarrow \max(B_i,A_i)\)
- 历史版本和:每次操作后 \(B_i\leftarrow B_i+A_i\)
1、历史最大值
基础操作:区间加、查询区间最大值、查询区间历史最大值
我们用标记来解决这类历史值问题。
在非历史值问题中,我们关注的是节点当下的标记是什么,所以我们直接合并标记。但在历史值问题中,我们还要考虑历史上推上来的标记的依次作用
先不合并标记,假设每个节点有一个队列存放着历史推上来的所有的标记。递归时将所有标记下推到儿子处,并清空队列
对于每个节点记录 \(dat,hdat\) 分别表示区间最大值、区间历史最大值。每次有一个区间加标记 \(ad\) 进来时,\(dat\leftarrow dat+ad\),然后 \(hdat\leftarrow \max(hdat,dat)\)
这样是正确的,但是我们根本无法存下所有的标记,所以我们要考虑如何概括一个队列所有的标记对当前节点的影响。
设 \(t[1\dots k]\) 表示推进来的加法标记,\(s[i]\) 为 \(t[i]\) 的前缀和。则打上第 \(i\) 个标记后,\(dat\) 的值为 \(dat+s[i]\),\(hdat\) 的值为 \(\max\{s[i]+dat\}=dat+\max\{s[i]\}\)。于是只需记录 \(\max\{s[i]\}\) 就可以知道这个队列标记的影响。记 \(ad\) 为加法标记,合并时直接求和,所以 \(ad\) 刚好等于 \(s[i]\)。记 \(had\) 为加法标记的历史最大值,则 \(had=\max\{s[i]\}\)。
现在考虑两个标记队列 \(t_1[1\dots p_1],t_2[1\dots p_2]\) 如何合并,设合并的结果为 \(t_3[1\dots p_1+p_2]\)
注意到 \(s_3[i]=\begin{cases}s_i[i]&1\leq i\leq p_1 \\ s_1[p_1]+s_2[i-p_1]&p_1<i\leq p_1+p_2 \end{cases}\)
我们需快速求出 \(\max\{s_3[i]\}=\max(\max\{s_1[j]\},s_1[p_1]+\max\{s_2[k]\})\),只需维护 \(s_1[p_1]\),这正是目前加法标记的值
具体地,每次 \(u\rightarrow v\) 下推时,令
hdat(v)=max(hdat(v),dat(v)+had(u));
had(v)=max(had(v),ad(v)+had(u));
dat(v)+=ad(u);
ad(v)+=ad(u);
ad(u)=had(u)=0;
P4314 CPU 监控
题意:区间加、区间覆盖、查询区间最大值、查询区间历史最大值
就是基础操作加上了赋值操作
对于线段树历史值问题,我们需要完整地考虑每个标记的历史影响
现在标记队列里有两种标记,加法标记和赋值标记,标记混杂不好处理
考虑赋值操作的影响,区间都变成一个数,那这之后的加法操作其实也可以等价成为赋值操作。那么标记队列就变成一个加法标记队列后面跟着一个赋值队列,加法标记用前文的方法处理
对于赋值操作 \(c[1\dots p]\),产生的历史最大值为 \(\max{c[i]}\),记录这个即可。
code
#include<bits/stdc++.h>
#define lc(p) p*2
#define rc(p) p*2+1
#define pii pair<int,int>
using namespace std;
const int N=100010,INF=(1<<31);
int n,m,a[N];
struct SegmentTree
{
int dat,hdat,ad,had,fu,hfu;
#define dat(x) tree[x].dat
#define hdat(x) tree[x].hdat
#define ad(x) tree[x].ad
#define had(x) tree[x].had
#define fu(x) tree[x].fu
#define hfu(x) tree[x].hfu
void add(int v,int mv)
{
hdat=max(hdat,dat+mv); dat+=v;
if(hfu!=-INF)
hfu=max(hfu,fu+mv),fu+=v;
else
had=max(had,ad+mv),ad+=v;
}
void cov(int v,int mv)
{
hdat=max(hdat,mv);
hfu=max(hfu,mv);
fu=dat=v;
}
}tree[4*N];
void pushup(int p)
{
dat(p)=max(dat(lc(p)),dat(rc(p)));
hdat(p)=max(hdat(lc(p)),hdat(rc(p)));
}
void spread(int p)
{
if(ad(p) || had(p)) //*
{
tree[lc(p)].add(ad(p),had(p));
tree[rc(p)].add(ad(p),had(p));
ad(p)=had(p)=0;
}
if(hfu(p)!=-INF)
{
tree[lc(p)].cov(fu(p),hfu(p));
tree[rc(p)].cov(fu(p),hfu(p));
fu(p)=hfu(p)=-INF;
}
}
void build(int p,int l,int r)
{
fu(p)=hfu(p)=-INF;
if(l==r)
{
dat(p)=hdat(p)=a[l];
return;
}
int mid=(l+r)>>1;
build(lc(p),l,mid);
build(rc(p),mid+1,r);
pushup(p);
}
void add(int p,int l,int r,int ql,int qr,int v)
{
if(ql<=l && qr>=r)
{
tree[p].add(v,max(v,0));
return;
}
spread(p);
int mid=(l+r)>>1;
if(ql<=mid)
add(lc(p),l,mid,ql,qr,v);
if(qr>mid)
add(rc(p),mid+1,r,ql,qr,v);
pushup(p);
}
void cov(int p,int l,int r,int ql,int qr,int v)
{
if(ql<=l && qr>=r)
{
tree[p].cov(v,v);
return;
}
spread(p);
int mid=(l+r)>>1;
if(ql<=mid)
cov(lc(p),l,mid,ql,qr,v);
if(qr>mid)
cov(rc(p),mid+1,r,ql,qr,v);
pushup(p);
}
pii ask(int p,int l,int r,int ql,int qr)
{
if(ql<=l && qr>=r)
return {dat(p),hdat(p)};
spread(p);
int mid=(l+r)>>1;
pii lans={-INF,-INF},rans={-INF,-INF};
if(ql<=mid)
lans=ask(lc(p),l,mid,ql,qr);
if(qr>mid)
rans=ask(rc(p),mid+1,r,ql,qr);
return {max(lans.first,rans.first),max(lans.second,rans.second)};
}
int main()
{
scanf("%d",&n);
for(int i=1; i<=n; i++)
scanf("%d",&a[i]);
build(1,1,n);
scanf("%d",&m);
for(int i=1; i<=m; i++)
{
char op[2]; int l,r,v;
scanf("%s%d%d",op,&l,&r);
if(op[0]=='Q')
printf("%d\n",ask(1,1,n,l,r).first);
else if(op[0]=='A')
printf("%d\n",ask(1,1,n,l,r).second);
else if(op[0]=='P')
scanf("%d",&v),add(1,1,n,l,r,v);
else
scanf("%d",&v),cov(1,1,n,l,r,v);
}
return 0;
}
P6242【模板】线段树 3
题意:区间加、区间求和、区间取 \(\rm min\),区间求最大值、区间求历史最大值
吉司机线段树!!!!!
维护两套标记,一套对最大值生效,另一套对其它数生效(历史最大值的标记合并讲究顺序,所以标记影响的对象不交才好维护)
一些注意点:
-
最大值标记下推时,要判断儿子是否含有相同的最大值。最大值的比较应在儿子中进行
-
下推标记时,若儿子的最大值不为区间最大值,要给儿子的最大值打上非最大值的加法标记
code
#include<bits/stdc++.h>
#define LL long long
using namespace std;
const int N=5e5+10;
const LL INF=1e9;
int n,m,a[N];
struct Ask{LL s,mx1,mx2;};
#define lc(p) p<<1
#define rc(p) p<<1|1
struct SegmentTree
{
int mx,cmx,mx2,hmx,ad1,had1,ad2,had2,len;
LL sum;
#define mx(x) tree[x].mx
#define cmx(x) tree[x].cmx
#define mx2(x) tree[x].mx2
#define hmx(x) tree[x].hmx
#define ad1(x) tree[x].ad1
#define had1(x) tree[x].had1
#define ad2(x) tree[x].ad2
#define had2(x) tree[x].had2
#define sum(x) tree[x].sum
#define len(x) tree[x].len
void add(int v1,int mv1,int v2,int mv2)
{
sum+=1LL*(len-cmx)*v1+1LL*cmx*v2;
hmx=max(hmx,mx+mv2);
had1=max(had1,ad1+mv1); ad1+=v1;
had2=max(had2,ad2+mv2); ad2+=v2;
mx+=v2; mx2+=v1;
}
}tree[N<<2];
void pushup(int p)
{
sum(p)=sum(lc(p))+sum(rc(p));
hmx(p)=max(hmx(lc(p)),hmx(rc(p)));
if(mx(lc(p))==mx(rc(p)))
mx(p)=mx(lc(p)),cmx(p)=cmx(lc(p))+cmx(rc(p)),mx2(p)=max(mx2(lc(p)),mx2(rc(p)));
else
{
int m1=max(mx(lc(p)),mx(rc(p))),m2=min(mx(lc(p)),mx(rc(p))),m3=max(mx2(lc(p)),mx2(rc(p)));
mx(p)=m1; cmx(p)=(m1==mx(lc(p))? cmx(lc(p)):cmx(rc(p)));
mx2(p)=max(m2,m3);
}
}
void spread(int p)
{
if(ad1(p) || had1(p) || ad2(p) || had2(p))
{
int mm=max(mx(lc(p)),mx(rc(p)));
if(mx(lc(p))==mm)
tree[lc(p)].add(ad1(p),had1(p),ad2(p),had2(p));
else
tree[lc(p)].add(ad1(p),had1(p),ad1(p),had1(p));
if(mx(rc(p))==mm)
tree[rc(p)].add(ad1(p),had1(p),ad2(p),had2(p));
else
tree[rc(p)].add(ad1(p),had1(p),ad1(p),had1(p));
ad1(p)=had1(p)=ad2(p)=had2(p)=0;
}
}
void build(int p,int l,int r)
{
len(p)=r-l+1;
if(l==r)
{
cin>>a[l];
mx(p)=hmx(p)=sum(p)=a[l];
cmx(p)=1; mx2(p)=-INF;
return;
}
int mid=(l+r)>>1;
build(lc(p),l,mid);
build(rc(p),mid+1,r);
pushup(p);
}
void add(int p,int l,int r,int ql,int qr,int v)
{
if(ql<=l && qr>=r)
{
tree[p].add(v,max(v,0),v,max(v,0));
return;
}
spread(p);
int mid=(l+r)>>1;
if(ql<=mid)
add(lc(p),l,mid,ql,qr,v);
if(qr>mid)
add(rc(p),mid+1,r,ql,qr,v);
pushup(p);
}
void change(int p,int l,int r,int ql,int qr,int v)
{
if(v>=mx(p))
return;
if(ql<=l && qr>=r && v>mx2(p))
{
tree[p].add(0,0,v-mx(p),v-mx(p));
return;
}
spread(p);
int mid=(l+r)>>1;
if(ql<=mid)
change(lc(p),l,mid,ql,qr,v);
if(qr>mid)
change(rc(p),mid+1,r,ql,qr,v);
pushup(p);
}
Ask ask(int p,int l,int r,int ql,int qr)
{
if(ql<=l && qr>=r)
return {sum(p),mx(p),hmx(p)};
spread(p);
int mid=(l+r)>>1;
Ask lval={0,-INF,-INF},rval={0,-INF,-INF};
if(ql<=mid)
lval=ask(lc(p),l,mid,ql,qr);
if(qr>mid)
rval=ask(rc(p),mid+1,r,ql,qr);
return {lval.s+rval.s,max(lval.mx1,rval.mx1),max(lval.mx2,rval.mx2)};
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(0); cout.tie(0);
cin>>n>>m;
build(1,1,n);
while(m--)
{
int op,l,r,x,k,v;
cin>>op>>l>>r;
if(op==1)
cin>>k,add(1,1,n,l,r,k);
else if(op==2)
cin>>v,change(1,1,n,l,r,v);
else if(op==3)
cout<<ask(1,1,n,l,r).s<<"\n";
else if(op==4)
cout<<ask(1,1,n,l,r).mx1<<"\n";
else
cout<<ask(1,1,n,l,r).mx2<<"\n";
}
return 0;
}
2、历史版本和
记原序列为 \(A\),版本和序列为 \(B\)。把更新 \(B\) 序列也看成一种标记,每次操作后给整棵树打一个
考虑一个加法标记和更新标记相互出现的队列 \(t[1\dots p]=\{v_1,v_2,upd,v_4\dots\}\),设当前节点区间和为 \(sum\),区间历史和为 \(hsum\),区间长度为 \(len\)
加法标记 \(ad\) 会使 \(sum\leftarrow sum+ad\),更新标记会使得 \(hsum\leftarrow hsum+sum\)
设 \(s[i]\) 表示前 \(i\) 个加法标记的和,则对 \(hsum\) 的总贡献为 \(\sum\limits_{i=1}^p[t[i]=upd]\left(sum+s[i]\times len\right)=sum\times\left(\sum\limits_{i=1}^p{\left[t[i]=upd\right]}\right)+len\times\left(\sum\limits_{i=1}^p{[t[i]=upd]s[i]}\right)\)
所以只需记录 \(\sum\limits_{i=1}^p{\left[t[i]=upd\right]}\) 和 \(\sum\limits_{i=1}^p{[t[i]=upd]s[i]}\) 即可。分别表示更新标记的总个数,记为 \(upd\)。以及更新标记打上时 \(s[i]\) 的和,即加法标记的历史版本和,记为 \(had\)
下面考虑两个标记队列 \(t_1[1\dots p_1],t_2[1\dots p_2]\) 如何合并,设合并的结果为 \(t_3[1\dots p_1+p_2]\)
于是再记录 \(s_1[p_1]\) 表示合并后的加法标记就可以实现标记队列的合并,记为 \(ad\)
具体地,每次 \(u\rightarrow v\) 下推时,令
hsum(v)+=sum(v)*upd(u)+len*had(p);
had(v)+=ad(v)*upd(u)+had(u);
sum(v)+=len*ad(u);
ad(v)+=ad(u);
upd(v)+=upd(u);
P3246 [HNOI2016] 序列
题意:给定一个区间,求所有子区间的最小值之和
设 \(f[l][r]\) 表示区间 \([l,r]\) 的最小值。往右侧加入一个元素时,可以用单调栈维护最小值,再用线段树做区间修改,就可以维护出 \(f\) 数组
将右端点理解成版本,对于一个询问 \([l,r]\),答案就是线段树上 \([l,r]\) 的历史版本和
code
#include<bits/stdc++.h>
#define lc(p) p*2
#define rc(p) p*2+1
#define LL long long
using namespace std;
const int N=100010;
int n,q,a[N],sta[N],top;
LL ans[N];
struct node{int l,id;};
vector <node> qq[N];
struct SegmentTree
{
LL ad,had,sum,hsum,upd;
int len;
#define ad(x) tree[x].ad
#define had(x) tree[x].had
#define sum(x) tree[x].sum
#define hsum(x) tree[x].hsum
#define upd(x) tree[x].upd
#define len(x) tree[x].len
void add(LL v,LL sv,LL uv)
{
hsum+=sum*uv+len*sv;
had+=ad*uv+sv;
upd+=uv;
sum+=v*len;
ad+=v;
}
}tree[4*N];
void pushup(int p)
{
sum(p)=sum(lc(p))+sum(rc(p));
hsum(p)=hsum(lc(p))+hsum(rc(p));
}
void spread(int p)
{
if(ad(p) || had(p) || upd(p))
{
tree[lc(p)].add(ad(p),had(p),upd(p));
tree[rc(p)].add(ad(p),had(p),upd(p));
ad(p)=had(p)=upd(p)=0;
}
}
void build(int p,int l,int r)
{
len(p)=r-l+1;
if(l==r)
return;
int mid=(l+r)>>1;
build(lc(p),l,mid);
build(rc(p),mid+1,r);
}
void add(int p,int l,int r,int ql,int qr,LL v)
{
if(ql<=l && qr>=r)
{
tree[p].add(v,0,0);
return;
}
spread(p);
int mid=(l+r)>>1;
if(ql<=mid)
add(lc(p),l,mid,ql,qr,v);
if(qr>mid)
add(rc(p),mid+1,r,ql,qr,v);
pushup(p);
}
LL ask(int p,int l,int r,int ql,int qr)
{
if(ql<=l && qr>=r)
return hsum(p);
spread(p);
int mid=(l+r)>>1; LL res=0;
if(ql<=mid)
res+=ask(lc(p),l,mid,ql,qr);
if(qr>mid)
res+=ask(rc(p),mid+1,r,ql,qr);
return res;
}
int main()
{
scanf("%d%d",&n,&q);
for(int i=1; i<=n; i++)
scanf("%d",&a[i]);
for(int i=1; i<=q; i++)
{
int l,r;
scanf("%d%d",&l,&r);
qq[r].push_back({l,i});
}
build(1,1,n);
for(int i=1; i<=n; i++)
{
while(top && a[sta[top]]>a[i])
{
add(1,1,n,sta[top-1]+1,sta[top],1LL*(a[i]-a[sta[top]]));
top--;
}
sta[++top]=i; add(1,1,n,i,i,a[i]);
tree[1].add(0,0,1);
for(auto x:qq[i])
ans[x.id]=ask(1,1,n,x.l,i);
}
for(int i=1; i<=q; i++)
printf("%lld\n",ans[i]);
return 0;
}
CF997E Good Subsegments
先咕着

浙公网安备 33010602011771号