特别浅的浅谈线段树
Segment_Tree
线段树好题大赏
定义
线段树是一种二叉搜索树,线段树的每个结点都存储了一个区间,也可以理解成一个线段。
用处
维护区间信息。线段树可以在 \(O(\log n)\) 的时间复杂度内实现单点修改,区间修改,区间查询等操作。
最典型的,也是最简单的就是 区间加 和 区间求和。
以 此题 为例,就代表最简单的线段树操作了。
树的储存
线段树比较直观的理解方式是看图:
(比较懒,直接从网上找了一个,我也不知道来自哪个 dalao 的博客)
这个图中每个节点有三个数:上边是点的编号,左边是区间的左端点,右边是区间的右端点。
个人习惯用结构体:
struct Seg_Tree{
int le;//区间左端点
int ri;//区间右端点
int val;//区间维护的值,图中未展示
int tag;//懒标记,之后会提到
}T[inf*4];
虽然图中的线段树不是每个节点都画了出来,但那些节点确确实实存在,因此线段树大概需要所维护的数组大小的 4 倍左右。
建树
对于线段树来说,每个节点 \(i\) 的左儿子的编号都是 \(2\times i\),右儿子的编号都是 \(2\times i+1\),同时区间大小对半分(见上图)。
通俗的理解一下,1 号节点代表整个区间,2 号节点代表左半个区间,3 号节点代表右半个区间。
这个代表可以是区间最值,区间和,区间 GCD 等题目要求维护的东西。
那以此题来说,就是区间和了。
建树的时候,先递归到叶子,将原数组的数分别赋到对应的叶节点,然后回溯时再将左右两个子树的权值加回来。
代码实现:
void build(int i,int l,int r)
{
T[i].le=l;T[i].ri=r;
if(l==r)
{
T[i].val=a[l];
return;
}
int mid=(l+r)>>1;
build(i*2,l,mid);
build(i*2+1,mid+1,r);
T[i].val=T[i*2].val+T[i*2+1].val;
}
区间求和
基本思路就是若覆盖则返回,否则递归到下一层合适的区间再返回。
举个例子,还是由上图,手模一下查询区间 \([3,5]\) 的和。
首先进入 1 号节点。
1 号节点太大,\([3,5]\) 无法将其覆盖,那么就找一号节点的两个子节点:2 号节点和 3 号节点。
2 号节点与 \([3,5]\) 有交集,3 号节点与 \([3,5]\) 并无交集,就递归到 2 号节点。同理,再递归到 4 号节点和 5 号节点。
这时注意 5 号节点。5 号节点不仅与 \([3,5]\) 有交集,而且能被其覆盖,那么这时就可以直接返回 5 号节点的权值,并在回溯时累加到答案中。
Code:
int ask(int i,int l,int r)
{
if(l<=T[i].le&&T[i].ri<=r)
return T[i].val;
int mid=(T[i].le+T[i].ri)>>1,ans=0;
if(l<=mid)ans+=ask(i<<1,l,r);
if(mid<r)ans+=ask(i<<1|1,l,r);
return ans;
}
部分初学者可能会在递归传参的时候将区间写错(别问我怎么知道的),记住这句话:查询的区间不能变。
区间加
基本的思路和区间求和相似,同样是找交集。
但是每次都将所覆盖的区间全部更新的话复杂度是 \(O(n\log n)\),而暴力的时间复杂度是 \(O(n)\) 的,多个 \(\log\)。
如果想要我们的线段树比暴力快的话,我们需要引入一个 懒惰标记 ,顾名思义,就是偷懒。此时的单次修改时间复杂度就成了 \(O(\log n)\)。
因为每次修改不一定会马上被查询。比如上图中我更新 \([6,9]\),查询 \([3,5]\),那么这次的更新对下次的询问没有任何影响。我们就用懒标将这次更新储存下来,来表示 这个区间的每个元素都有 tag
还没加上 。
然后查询或者再次更新的时候,就将这个懒标下放到两个儿子上。
代码:
void update(int i,int l,int r,int k)
{
if(l<=T[i].le&&T[i].ri<=r)
{
T[i].val+=k*(T[i].ri-T[i].le+1);
T[i].tag+=k;
return;
}
if(T[i].tag)pushdown(i);
int mid=(T[i].l+T[i].r)>>1;
if(l<=mid)update(i<<1,l,r,k);
if(mid<r)update(i<<1|1,l,r,k);
T[i].val=T[i<<1].val+T[i<<1|1].val;
}
pushdown
即为:
void pushdown(int i)
{
T[i<<1].val+=T[i].tag*(T[i<<1].ri-T[i<<1].le+1);
T[i<<1|1].val+=T[i].tag*(T[i<<1|1].ri-T[i<<1|1].le+1);
T[i<<1].tag+=T[i].tag;
T[i<<1|1].tag+=T[i].tag;
T[i].tag=0;
}
那么区间求和也需要加上这个 pushdown
:
int ask(int i,int l,int r)
{
if(l<=T[i].le&&T[i].ri<=r)
return T[i].val;
if(T[i].tag)pushdown(i);
int mid=(T[i].le+T[i].ri)>>1,ans=0;
if(l<=mid)ans+=ask(i<<1,l,r);
if(mid<r)ans+=ask(i<<1|1,l,r);
return ans;
}
这里也有一个易混淆的点:懒标表示的是当前区间已经维护但其子区间仍未维护。注意区分。
完整代码
由于不是同一历史时期写的,可能码风会有不同,等有空了在维护吧。
const int inf=1e5+7;
int n,m,a[inf];
struct Seg_Tree{
int le,ri;
int val,tag;
}T[inf<<2];
void build(int i,int l,int r)
{
T[i].le=l;T[i].ri=r;
if(l==r)
{
T[i].val=a[l];
return;
}
int mid=(l+r)>>1;
build(i<<1,l,mid);
build(i<<1|1,mid+1,r);
T[i].val=T[i<<1].val+T[i<<1|1].val;
}
void pushdown(int i)
{
T[i<<1].val+=(T[i<<1].ri-T[i<<1].le+1)*T[i].tag;
T[i<<1|1].val+=(T[i<<1|1].ri-T[i<<1|1].le+1)*T[i].tag;
T[i<<1].tag+=T[i].tag;
T[i<<1|1].tag+=T[i].tag;
T[i].tag=0;
}
void update(int i,int l,int r,int k)
{
if(l<=T[i].le&&T[i].ri<=r)
{
T[i].val+=(T[i].ri-T[i].le+1)*k;
T[i].tag+=k;
return;
}
if(T[i].tag)pushdown(i);
int mid=(T[i].le+T[i].ri)>>1;
if(l<=mid)update(i<<1,l,r,k);
if(mid<r)update(i<<1|1,l,r,k);
T[i].val=T[i<<1].val+T[i<<1|1].val;
}
int ask(int i,int l,int r)
{
if(l<=T[i].le&&T[i].ri<=r)
return T[i].val;
if(T[i].tag)pushdown(i);
int mid=(T[i].le+T[i].ri)>>1,ans=0;
if(l<=mid)ans+=ask(i<<1,l,r);
if(mid<r)ans+=ask(i<<1|1,l,r);
return ans;
}
signed main()
{
n=re();m=re();
for(int i=1;i<=n;i++)
a[i]=re();
build(1,1,n);
for(int i=1;i<=m;i++)
{
int op=re(),l=re(),r=re();
if(op==1)update(1,l,r,re());
else wr(ask(1,l,r)),putchar('\n');
}
return 0;
}
习题
除了区间求和,动态区间 RMQ 问题也经常用线段树求解。
因为求和与 RMQ 都具有区间可加性,即知道两个子区间的和/最值就可以直接求出整个区间的和/最值。
不过对于静态区间 RMQ 问题,一个更快(指常数更小)的算法是 ST 表。
Code
const int inf=1e5+7;
int n,m,a[inf];
struct Seg_Tree{
int le,ri,minn;
}T[inf<<2];
void build(int i,int l,int r)
{
T[i].le=l,T[i].ri=r;
if(l==r)
{
T[i].minn=a[l];
return;
}
int mid=(l+r)>>1;
build(i<<1,l,mid);
build(i<<1|1,mid+1,r);
T[i].minn=min(T[i<<1].minn,T[i<<1|1].minn);
}
int ask(int i,int l,int r)
{
if(l<=T[i].le&&T[i].ri<=r)
return T[i].minn;
int mid=(T[i].le+T[i].ri)>>1,ans=2147483647;
if(l<=mid)ans=min(ans,ask(i<<1,l,r));
if(mid<r)ans=min(ans,ask(i<<1|1,l,r));
return ans;
}
int main()
{
n=re();m=re();
for(int i=1;i<=n;i++)
a[i]=re();
build(1,1,n);
for(int i=1;i<=m;i++)
{
int l=re(),r=re();
wr(ask(1,l,r)),putchar(' ');
}
return 0;
}
区间取反操作,只是在 tag
上有些小动作,其他部分基本没有什么区别。
用 tag
表示这个区间有没有被翻转,显然翻转两次的区间和未翻转的区间相同,那么每次异或 1 就可以了。
Code
const int inf=2e5+7;
int n,m,a[inf];
struct Seg_Tree{
int le,ri,siz;
int val,tag;
}T[inf<<2];
void build(int i,int l,int r)
{
T[i].le=l,T[i].ri=r;
T[i].siz=r-l+1;
if(l==r)
{
T[i].val=a[l];
return;
}
int mid=(l+r)>>1;
build(i<<1,l,mid);
build(i<<1|1,mid+1,r);
T[i].val=T[i<<1].val+T[i<<1|1].val;
}
void pushdown(int i)
{
T[i<<1].val=T[i<<1].siz-T[i<<1].val;
T[i<<1|1].val=T[i<<1|1].siz-T[i<<1|1].val;
T[i<<1].tag^=1,T[i<<1|1].tag^=1;
T[i].tag=0;
}
void update(int i,int l,int r)
{
if(l<=T[i].le&&T[i].ri<=r)
{
T[i].val=T[i].siz-T[i].val;
T[i].tag^=1;
return;
}
if(T[i].tag)pushdown(i);
int mid=(T[i].le+T[i].ri)>>1;
if(l<=mid)update(i<<1,l,r);
if(mid<r)update(i<<1|1,l,r);
T[i].val=T[i<<1].val+T[i<<1|1].val;
}
int ask(int i,int l,int r)
{
if(l<=T[i].le&&T[i].ri<=r)
return T[i].val;
if(T[i].tag)pushdown(i);
int mid=(T[i].le+T[i].ri)>>1,ans=0;
if(l<=mid)ans+=ask(i<<1,l,r);
if(mid<r)ans+=ask(i<<1|1,l,r);
return ans;
}
int main()
{
n=re();m=re();
for(int i=1;i<=n;i++)
scanf("%1d",&a[i]);
build(1,1,n);
for(int i=1;i<=m;i++)
{
int op=re(),l=re(),r=re();
if(op)wr(ask(1,l,r)),putchar('\n');
else update(1,l,r);
}
return 0;
}
状压线段树,建议先了解部分位运算知识。
通过观察可以发现,颜色数并不多,可以用一个 int
存下来。那么区间的颜色数就是两个子区间的颜色数之并集,就是两个数取或即可。
Code
const int inf=1e5+7;
int n,m,k;
struct Seg_Tree{
int le,ri;
int val,tag;
}T[inf<<2];
void build(int i,int l,int r)
{
T[i].le=l,T[i].ri=r;
T[i].val=2;
if(l==r)return;
int mid=(l+r)>>1;
build(i<<1,l,mid);
build(i<<1|1,mid+1,r);
}
void pushdown(int i)
{
T[i<<1].val=T[i<<1|1].val=1<<T[i].tag;
T[i<<1].tag=T[i<<1|1].tag=T[i].tag;
T[i].tag=0;
}
void assign(int i,int l,int r,int k)
{
if(l<=T[i].le&&T[i].ri<=r)
{
T[i].val=1<<k;
T[i].tag=k;
return;
}
if(T[i].tag)pushdown(i);
int mid=(T[i].le+T[i].ri)>>1;
if(l<=mid)assign(i<<1,l,r,k);
if(mid<r)assign(i<<1|1,l,r,k);
T[i].val=T[i<<1].val|T[i<<1|1].val;
}
int ask(int i,int l,int r)
{
if(l<=T[i].le&&T[i].ri<=r)
return T[i].val;
if(T[i].tag)pushdown(i);
int mid=(T[i].le+T[i].ri)>>1,ans=0;
if(l<=mid)ans|=ask(i<<1,l,r);
if(mid<r)ans|=ask(i<<1|1,l,r);
return ans;
}
int main()
{
n=re();k=re();m=re();
build(1,1,n);
for(int i=1;i<=m;i++)
{
char op[10]="";scanf("%s",op);
int l=re(),r=re();
if(l>r)l^=r^=l^=r;
if(op[0]=='C')assign(1,l,r,re());
else
{
int ls=ask(1,l,r),ans=0;
while(ls)
{
if(ls&1)ans++;
ls>>=1;
}
wr(ans),putchar('\n');
}
}
return 0;
}
小清新线段树,就是在线段树上加剪枝。
可以发现,就算是 \(10^{12}\),在开 \(6\) 次平方之后也变成了 \(1\)。
而且 \(\sqrt1=1\),那么如果这个区间已经全是 \(1\) 了,就可以不操作,否则暴力单点修改整个区间。
const int inf=1e5+7;
int n,m,a[inf];
struct Seg_Tree{
int le,ri;
int val,siz;
}T[inf<<2];
void build(int i,int l,int r)
{
T[i].le=l,T[i].ri=r;
T[i].siz=r-l+1;
if(l==r)
{
T[i].val=a[l];
return;
}
int mid=(l+r)>>1;
build(i<<1,l,mid);
build(i<<1|1,mid+1,r);
T[i].val=T[i<<1].val+T[i<<1|1].val;
}
void update(int i,int l,int r)
{
if(T[i].le==T[i].ri)
{
T[i].val=sqrt(T[i].val);
return;
}
if(T[i].val==T[i].siz)return;
int mid=(T[i].le+T[i].ri)>>1;
if(l<=mid)update(i<<1,l,r);
if(mid<r)update(i<<1|1,l,r);
T[i].val=T[i<<1].val+T[i<<1|1].val;
}
int ask(int i,int l,int r)
{
if(l<=T[i].le&&T[i].ri<=r)
return T[i].val;
int mid=(T[i].le+T[i].ri)>>1,ans=0;
if(l<=mid)ans+=ask(i<<1,l,r);
if(mid<r)ans+=ask(i<<1|1,l,r);
return ans;
}
signed main()
{
n=re();
for(int i=1;i<=n;i++)
a[i]=re();
build(1,1,n);
m=re();
for(int i=1;i<=m;i++)
{
int op=re(),l=re(),r=re();
if(l>r)l^=r^=l^=r;
if(op)wr(ask(1,l,r)),putchar('\n');
else update(1,l,r);
}
return 0;
}