【笔记】线段树
动态维护区间信息的一种数据结构,又称区间树。
相较于树状数组,线段树能维护的信息更全面也更灵活,缺点是常数较大,在数据量 \(10^6\) 及以上时易被卡常。
普通线段树
思想
线段树将每一个长度不为 \(1\) 的区间分成两半进行维护,分别是大区间的左子树和右子树,通过不断合并子树来求得区间信息。每个叶子结点就是原序列的每一项。
任意区间都易表示,如在这棵线段树中,\([2,5]\) 区间的表示即为:

线段树有以下性质:
- 树高为 \(O(\log n)\)。
- 任意区间都可以用线段树上不超过 \(O(\log n)\) 个结点表示。
- 进行修改时,最多只修改 \(O(\log n)\) 个结点。
- 设一个结点的编号为 \(i\),那么这个结点的左子树根节点编号为 \(2 \times i\),右子树根节点编号为 \(2 \times i+1\)。
若原序列长度为 \(n\),则对应线段树有 \(n\) 个叶子节点。由于除了叶子节点的点均有 \(2\) 个子节点,我们设这部分点数量为 \(k\),则有等式:
右侧为子节点数量,加上根节点。
等式即 \(k=n-1\),则线段树一共有 \(2n-1\) 个节点。
但叶子节点的空间并不是连续存储的,我们要开满最后一层所有的位置。因此一棵维护 \(n\) 个元素序列的线段树需要的存储空间为 \(4n-2\)。
建立
一个最基本的线段树节点要维护的信息有,其代表区间的左、右端点,及其区间信息。
struct Node{
int l,r;
int dat;
}tr[4*N];
建立线段树,我们从根节点开始递归,二分区间,直到叶子,记录每个节点的区间左、右端点。然后将叶子节点的信息设为原始序列的对应元素。
接着根据要维护的区间信息,从叶子节点开始向上合并区间,进而求得每个节点的初始信息。以下均以区间和为例。
void pushup(int p){
tr[p].dat=tr[2*p].dat+tr[2*p+1].dat;
}
void build(int p,int l,int r){
tr[p].l=l,tr[p].r=r;
if(l==r){//叶子
tr[p].dat=a[l];
return ;
}
int mid=(l+r)/2;
build(2*p,l,mid);
build(2*p+1,mid+1,r);
pushup(p);//合并区间信息
}
由此即可建立一棵线段树。
更新(单点)
从根节点出发向下递归,每次看目标节点在左子区间还是右子区间,决定向何处递归。到达叶子后修改此点,然后向上合并区间。
void update(int p,int x,int k){
if(tr[p].l==tr[p].r){
tr[p].dat+=k;//加 k
return ;
}
int mid=(tr[p].l+tr[p].r)/2;
if(x<=mid) update(2*p,x,k);//向左子区间递归
else update(2*p+1,x,k);//向右子区间递归
pushup(p);
}
查询(单点)
和单点更新一样的方法找到目标点,然后返回。
void query(int p,int x){
if(tr[p].l==tr[p].r&&tr[p].l==x){
return tr[p].dat;
}
int mid=(tr[p].l+tr[p].r)/2;
if(x<=mid) return query(2*p,x);
else return query(2*p+1,x);
}
查询(区间)
当然,如果只查询单点,那么我们维护的区间信息就完全没用了。
当一个区间完全被包含在查询区间时,它将产生贡献。否则,则应分割此区间,向下找到完全被包含的子区间。
int query(int p,int l,int r){
if(l<=tr[p].l&&tr[p].r<=r){//完全被包含
return tr[p].dat;
}
int mid=(tr[p].l+tr[p].r)/2,res=0;
if(l<=mid) res+=query(2*p,l,r);//左子区间有查询
if(r>mid) res+=query(2*p+1,l,r);//右子区间有查询
return res;
}
更新(区间)
如果只修改单点,那多数情况下就可以用树状数组代替。线段树支持高效的区间修改,比树状数组泛用性更高。
区间即为多个单点,但如果分别修改区间内每个单点,时间复杂度反而不如暴力维护。
这时候考虑优化。区间查询时我们只递归到完全被包含的区间,而不去触及每一个叶子节点。修改时也需要这么做。因为我们无法及时地修改每个点和区间的信息,我们就可以把它们应被修改的信息暂存在上方的区间中,以后用到时再去修改。
我们为每个节点多维护一个懒标记,代表此区间的所有子区间还有什么修改等待完成。在用到这些子区间时,我们再把懒标记往下传,去修改这些子区间。这样就能做到正确的时间复杂度。
此处修改为区间加。
struct Node{
int l,r;
int dat,laz;
}tr[4*N];
void pushdown(int p){
if(tr[p].laz){
tr[2*p].dat+=(tr[2*p].r-tr[2*p].l+1)*tr[p].laz;//修改子区间信息,并把懒标记向下传递,让其能修改自身的子区间
tr[2*p].laz+=tr[p].laz;
tr[2*p+1].dat+=(tr[2*p+1].r-tr[2*p+1].l+1)*tr[p].laz;
tr[2*p+1].laz+=tr[p].laz;
tr[p].laz=0;//修改完成,懒标记清空
}
}
void update(int p,int l,int r,int k){
if(l<=tr[p].l&&tr[p].r<=r){
tr[p].dat+=(tr[p].r-tr[p].l+1)*k;//修改区间信息
tr[p].laz+=k;
//此处用不到再往下的子区间,所以无需下传
return ;
}
pushdown(p);//子区间要被用到,下传
int mid=(tr[p].l+tr[p].r)/2,res=0;
if(l<=mid) update(2*p,l,r);
if(r>mid) update(2*p+1,l,r);
pushup(p);//修改子节点后合并区间
}
int query(int p,int l,int r){
if(l<=tr[p].l&&tr[p].r<=r){
return tr[p].dat;
}
int res=0,mid=(tr[p].l+tr[p].r)/2;
pushdown(p);//查询子区间,要先下传更新
if(l<=mid) res+=query(2*p,l,r);
if(r>mid) res+=query(2*p+1,l,r);
return res;
}
封装
我们一般将线段树用一个结构体封装,便于阅读与调试。
struct Node{
int l,r;
int dat,laz;
};
struct Segtr{
Node tr[4*N];
void pushup(int p){
tr[p].dat=tr[2*p].dat+tr[2*p+1].dat;
}
void pushdown(int p){
if(tr[p].laz){
tr[2*p].dat+=(tr[2*p].r-tr[2*p].l+1)*tr[p].laz;
tr[2*p].laz+=tr[p].laz;
tr[2*p+1].dat+=(tr[2*p+1].r-tr[2*p+1].l+1)*tr[p].laz;
tr[2*p+1].laz+=tr[p].laz;
tr[p].laz=0;
}
}
void build(int p,int l,int r){
tr[p].l=l,tr[p].r=r;
if(l==r){
tr[p].dat=a[l];
return ;
}
int mid=(l+r)/2;
build(2*p,l,mid);
build(2*p+1,mid+1,r);
pushup(p);
}
void update(int p,int l,int r,int k){
if(l<=tr[p].l&&tr[p].r<=r){
tr[p].dat+=(tr[p].r-tr[p].l+1)*k;
tr[p].laz+=k;
return ;
}
pushdown(p);
int mid=(tr[p].l+tr[p].r)/2;
if(l<=mid) update(2*p,l,r,k);
if(r>mid) update(2*p+1,l,r,k);
pushup(p);
}
int query(int p,int l,int r){
if(l<=tr[p].l&&tr[p].r<=r){
return tr[p].dat;
}
int res=0,mid=(tr[p].l+tr[p].r)/2;
pushdown(p);
if(l<=mid) res+=query(2*p,l,r);
if(r>mid) res+=query(2*p+1,l,r);
return res;
}
}seg;
标记永久化
在很特定的情况懒标记是不能下传的,因此我们有了标记永久化。顾名思义,即标记永远在一开始的节点上不下传。
这时候每个区间维护的信息就不是真实值了,真实值是从根到节点路径上的标记之和加上自己的信息。这时候就不能 pushup,在查询时再算上即可。
标记永久化常数更优但比较局限,仅在特定情况下可用。
void update(int p,int l,int r,int k){
tr[p].sum+=(min(r,tr[p].r)-max(tr[p].l,l)+1)*k;
if(l<=tr[p].l&&tr[p].r<=r){
tr[p].laz+=k;
return ;
}
int mid=(tr[p].l+tr[p].r)>>1;
if(l<=mid) update(2*p,l,r,k);
if(r>mid) update(2*p+1,l,r,k);
}
int query(int p,int l,int r,int L){
if(l<=tr[p].l&&tr[p].r<=r) return (tr[p].r-tr[p].l+1)*L+tr[p].sum;
L+=tr[p].laz;
int mid=(tr[p].l+tr[p].r)>>1,res=0;
if(l<=mid) res+=query(2*p,l,r,L);
if(r>mid) res+=query(2*p+1,l,r,L);
return res;
}
线段树二分
对于一些有单调性的查询,比如说 \(x\) 左侧第一个 \(\le a_x\) 的元素在哪里,或者是正序列前缀和第一个 \(\le s_x\) 的地方在哪等等,可以用二分求解,check 套上线段树查询。但是这样是 \(O(\log^2 n)\) 的,比较慢,有没有更好的做法?
注意到我们在线段树上查询时也是把区间二分,我们考虑在这一步就完成二分时的工作。
如果是某某左侧第一个,则要优先在左侧区间找右子区间,如果合法可以找到就直接返回,如果不合法就再去找左子区间。右侧第一个同理。递归到叶子结束。
前缀和这类就在递归右子区间给查询减掉左子区间和,这样就能单独在右子区间内查询。
于是就用 \(O(\log n)\) 的复杂度完成了这样的查询。
以一个找 \(<a_x\) 举例。
int binsch(int p,int l,int r,int x,int op){
if(tr[p].l>r||tr[p].r<l||tr[p].mina>x) return -1;
if(tr[p].l==tr[p].r) return tr[p].l;
pushdown(p);
int res=-1;
if(op==1){
if(tr[2*p].mina<=x) res=binsch(2*p,l,r,x,op);
if(res!=-1) return res;
else return binsch(2*p+1,l,r,x,op);
}else{
if(tr[2*p+1].mina<=x) res=binsch(2*p+1,l,r,x,op);
if(res!=-1) return res;
else return binsch(2*p,l,r,x,op);
}
}
简单变体线段树
区间加、乘线段树
Luogu P3373【模板】线段树 2
涉及区间加、区间乘两种操作,我们自然是要维护两个懒标记处理。此时要着重考虑两种更新操作叠加的运算顺序。
对于区间加,不会影响此前的任何操作,直接加即可。但对于区间乘,注意到此前所有的区间加操作相应的都会乘上这次操作,也就是说要同步修改区间加的懒标记。
然后在懒标记下传的时候,因为区间加懒标记已经在更新时同步乘过了,就需要先传乘再传加,这样加就不会被重复乘。
最后注意乘法懒标记初始为 \(1\)。
struct Node{
int l,r;
int dat,add,mul;
}tr[4*N];
void pushdown(int p){
if(tr[p].mul!=1){
tr[2*p].add=(tr[2*p].add*tr[p].mul)%mod;
tr[2*p].mul=(tr[2*p].mul*tr[p].mul)%mod;
tr[2*p].dat=(tr[2*p].dat*tr[p].mul)%mod;
tr[2*p+1].add=(tr[2*p+1].add*tr[p].mul)%mod;
tr[2*p+1].mul=(tr[2*p+1].mul*tr[p].mul)%mod;
tr[2*p+1].dat=(tr[2*p+1].dat*tr[p].mul)%mod;
tr[p].mul=1;
}
if(tr[p].add){
tr[2*p].dat=(tr[2*p].dat+(tr[2*p].r-tr[2*p].l+1)*tr[p].add%mod)%mod;
tr[2*p].add=(tr[2*p].add+tr[p].add)%mod;
tr[2*p+1].dat=(tr[2*p+1].dat+(tr[2*p+1].r-tr[2*p+1].l+1)*tr[p].add%mod)%mod;
tr[2*p+1].add=(tr[2*p+1].add+tr[p].add)%mod;
tr[p].add=0;
}
}
void updateA(int p,int l,int r,int op,int k){
if(l<=tr[p].l&&tr[p].r<=r){
if(op==1){
tr[p].dat=(tr[p].dat+(tr[p].r-tr[p].l+1)*k%mod)%mod;
tr[p].add=(tr[p].add+k)%mod;
}else{
tr[p].dat=(tr[p].dat*k)%mod;
tr[p].add=(tr[p].add*k)%mod;
tr[p].mul=(tr[p].mul*k)%mod;
}
return ;
}
pushdown(p);
int mid=(tr[p].l+tr[p].r)/2;
if(l<=mid) updateA(2*p,l,r,op,k);
if(r>mid) updateA(2*p+1,l,r,op,k);
pushup(p);
}
区间异或线段树
Luogu P2574 XOR的艺术
异或则是把区间内 \(1\) 变为 \(0\),\(0\) 变为 \(1\),\(0\) 和 \(1\) 总个数是区间大小。修改直接拿区间大小 \(-\) 区间和就可以了,懒标记改成异或。
Codeforces 242E XOR on Segment
考虑拆位,然后和上题一致。
区间加、推平线段树
Luogu P1253 扶苏的问题
区间推平是将区间赋为一个数,此前的加懒标记都将不算数。同样在下传时要先推平再加,避免重复推平。
注意推平懒标记的初始值应当为 INF。
void pushdown(int p){
if(tr[p].pus!=INF){
tr[2*p].dat=tr[p].pus;
tr[2*p].add=0;
tr[2*p].pus=tr[p].pus;
tr[2*p+1].dat=tr[p].pus;
tr[2*p+1].add=0;
tr[2*p+1].pus=tr[p].pus;
tr[p].pus=INF;
}
if(tr[p].add!=0){
tr[2*p].dat+=tr[p].add;
tr[2*p].add+=tr[p].add;
tr[2*p+1].dat+=tr[p].add;
tr[2*p+1].add+=tr[p].add;
tr[p].add=0;
}
}
void update(int p,int l,int r,int op,int k){
if(l<=tr[p].l&&tr[p].r<=r){
if(op==1){
tr[p].dat=k;
tr[p].add=0;
tr[p].pus=k;
}else{
tr[p].dat+=k;
tr[p].add+=k;
}
return ;
}
pushdown(p);
int mid=(tr[p].l+tr[p].r)/2;
if(l<=mid) update(2*p,l,r,op,k);
if(r>mid) update(2*p+1,l,r,op,k);
pushup(p);
}
根号线段树
Luogu P4145 上帝造题的七分钟 2 / 花神游历各国
因为我们无法实现维护区间和区间开根的数据结构,因此考虑发掘性质。
注意到所有的数均不超过 \(2^{64}\),也就是说最多开根 \(7\) 次就会变成 \(1\)。
于是在区间修改向下递归时,如果区间 \(\max=1\) 则不去修改,剩下的递归到叶子暴力修改即可。
void update(int p,int l,int r){
if(tr[p].l==tr[p].r){
tr[p].sum=sqrt(tr[p].sum);
tr[p].mxa=tr[p].sum;
return ;
}
int mid=(tr[p].l+tr[p].r)/2;
if(l<=mid&&tr[2*p].mxa>1) update(2*p,l,r);
if(r>mid&&tr[2*p+1].mxa>1) update(2*p+1,l,r);
pushup(p);
}
权值线段树
权值树为把值离散化后,在值域上开的线段树。
Luogu P3369 【模板】普通平衡树
区间信息表示值在区间内的元素数量。
插入删除直接在对应位置 \(+1-1\),查询多少个数比 \(x\) 小即查询前缀和。找排名、前驱、后继均为线段树二分,找到第一个前缀和 \(>=x\) 的位置、最后一个 \(<x\) 的位置、第一个 \(>x\) 的位置。
int getrank(int p,int x){
if(tr[p].l==tr[p].r) return tr[p].l;
if(x<=tr[2*p].dat) return getrank(2*p,x);
else return getrank(2*p+1,x-tr[2*p].dat);
}
int findpre(int p,int x){
if(tr[p].l==tr[p].r){
if(tr[p].l<x&&tr[p].dat) return tr[p].l;
else return 0;
}
int res=0,mid=(tr[p].l+tr[p].r)/2;
if(x>mid+1&&tr[2*p+1].dat) res=findpre(2*p+1,x);
if(res) return res;
else return findpre(2*p,x);
}
int findnxt(int p,int x){
if(tr[p].l==tr[p].r){
if(tr[p].l>x&&tr[p].dat) return tr[p].l;
else return 0;
}
int res=0,mid=(tr[p].l+tr[p].r)/2;
if(x<mid&&tr[2*p].dat) res=findnxt(2*p,x);
if(res) return res;
else return findnxt(2*p+1,x);
}
for(int i=1;i<=n;i++){
int t=lower_bound(d+1,d+1+lend,q[i].x)-d;
if(q[i].op==1) seg.update(1,t,1);
else if(q[i].op==2) seg.update(1,t,-1);
else if(q[i].op==3) cout<<seg.query(1,1,t-1)+1<<'\n';
else if(q[i].op==4) cout<<d[seg.getrank(1,q[i].x)]<<'\n';
else if(q[i].op==5) cout<<d[seg.findpre(1,t)]<<'\n';
else cout<<d[seg.findnxt(1,t)]<<'\n';
}
动态开点线段树
我们在普通线段树中,预先使用一个 build 函数将整个序列全部纳入,按照 \(2\times p\)、\(2\times p+1\) 的规则开点建树。但是对于区间长度特别大的情况,因为只有查询和更新到的点是有用的,我们就可以对线段树进行动态开点。
Luogu P13825【模板】线段树 1.5
struct Node{
int ls,rs;
int dat,laz;
};
struct Segtr{
Node tr[4*N];
void pushup(int p){
tr[p].dat=tr[tr[p].ls].dat+tr[tr[p].rs].dat;
}
void pushdown(int p,int l,int r,int mid){
if(tr[p].laz){
if(!tr[p].ls) tr[p].ls=++idx;
if(!tr[p].rs) tr[p].rs=++idx;
tr[tr[p].ls].dat+=(mid-l+1)*tr[p].laz;
tr[tr[p].ls].laz+=tr[p].laz;
tr[tr[p].rs].dat+=(r-mid)*tr[p].laz;
tr[tr[p].rs].laz+=tr[p].laz;
tr[p].laz=0;
}
}
void update(int &p,int l,int r,int ql,int qr,int k){
if(!p) p=++idx;
if(ql<=l&&r<=qr){
tr[p].dat+=(r-l+1)*k;
tr[p].laz+=k;
return ;
}
int mid=(l+r)/2;
pushdown(p,l,r,mid);
if(ql<=mid) update(tr[p].ls,l,mid,ql,qr,k);
if(qr>mid) update(tr[p].rs,mid+1,r,ql,qr,k);
pushup(p);
}
int query(int p,int l,int r,int ql,int qr){
if(ql<=l&&r<=qr) return tr[p].dat;
int val=0,mid=(l+r)/2;
pushdown(p,l,r,mid);
if(ql<=mid) val+=query(tr[p].ls,l,mid,ql,qr);
if(qr>mid) val+=query(tr[p].rs,mid+1,r,ql,qr);
return val;
}
}seg;
在修改过程中对 \(p\) 取地址,如果不存在就新建节点。由于不能建树,节点 \(l,r\) 要在递归中一并维护。
这个技巧在可持久化线段树中也一并会用到。
也可以标记永久化。
练习题
Luogu P1438 无聊的数列
注意到加一个等差数列相当于给差分数组区间加,上线段树维护。
Luogu P4513 小白逛公园 / SPOJ GSS3 - Can you answer these queries III
单点修改区间最大子段和。
考虑两个区间怎么合并。有可能是左区间或者右区间的最大子段和,也有可能是两个区间拼成的。
拼成的子段一定要连续,所以是左区间的最大后缀和 \(+\) 右区间的最大前缀和。
因此维护最大前缀和、最大后缀和、最大子段和。新区间的最大前缀和有可能是左区间和 \(+\) 右区间的最大前缀和,后缀和同理,所以还要维护区间和。
查询时以类似操作合并区间,可以写成 “全在左区间 - 全在右区间 - 跨过左右区间” 的分讨形式,避免初始化问题。
struct SegNode{
int l,r;
int pre,suf,sum,dat;
};
struct Segtr{
SegNode tr[4*N];
void pushup(int p){
tr[p].sum=tr[2*p].sum+tr[2*p+1].sum;
tr[p].pre=max(tr[2*p].pre,tr[2*p].sum+tr[2*p+1].pre);
tr[p].suf=max(tr[2*p+1].suf,tr[2*p+1].sum+tr[2*p].suf);
tr[p].dat=max(tr[2*p].suf+tr[2*p+1].pre,max(tr[2*p].dat,tr[2*p+1].dat));
}
void build(int p,int l,int r){
tr[p].l=l,tr[p].r=r;
if(l==r){
tr[p].sum=a[l];
tr[p].pre=tr[p].suf=tr[p].dat=max(0,a[l]);
return ;
}
int mid=(l+r)>>1;
build(2*p,l,mid);
build(2*p+1,mid+1,r);
pushup(p);
}
SegNode query(int p,int l,int r){
if(l<=tr[p].l&&tr[p].r<=r) return tr[p];
int mid=(tr[p].l+tr[p].r)>>1;
if(r<=mid) return query(2*p,l,r);
else if(l>mid) return query(2*p+1,l,r);
else{
SegNode res,ls,rs;
ls=query(2*p,l,r);
rs=query(2*p+1,l,r);
res.sum=ls.sum+rs.sum;
res.pre=max(ls.pre,ls.sum+rs.pre);
res.suf=max(rs.suf,rs.sum+ls.suf);
res.dat=max(ls.suf+rs.pre,max(ls.dat,rs.dat));
return res;
}
}
}seg;
Luogu P2572 [SCOI2010] 序列操作
和子段和很像,也是左区间后 \(+\) 右区间前合并。维护前缀 \(0/1\)、后缀 \(0/1\)、整段 \(0/1\)、区间和,还有懒标记。
比较难写。
Luogu P1471 方差
区间方差。
方差有个合并公式在高中数学必修二中介绍,设 \(\overline{x_1}\)、\(\overline{x_2}\)、\(\overline{x}\) 表示两个区间的平均数和大区间平均数,\(s_1^2\)、\(s_2^2\)、\(s^2\) 表示两个区间方差和大区间方差,\(n_1\)、\(n_2\)、\(n\) 表示两个区间元素数量和大区间元素数量,则有:
上线段树维护平均数和方差即可。
如果没有区间修改(Luogu P5142 区间方差)也可以展开方差计算公式然后维护区间和、区间平方和计算。

浙公网安备 33010602011771号