前言
线段树,是数据结构皇冠上的明珠(我编的)。
它用途广泛,被一代代的oier应用,改进,优化。

本文介绍了线段树的基础知识,希望能帮到更多的oier。
在学习线段树前,默认你应该学会一下内容:
- 树和二叉树的基本知识(这你总得会吧)
- 二叉堆(主要是堆式储存)
- 离散化(其实并不需要)
- 会写代码
如果你不会,左转oiwiki,如果你会,那么继续读吧!
线段树的引入
举个例子,我们现在有一个序列,想维护一段子区间的和,该怎么办呢?
你或许会说,可以暴力!把这个区间的数加起来就行了。
那么如果这个子区间里有105个数呢?
前缀和?
如果强制在线呢?
如果在维护区间和的同时维护最大值、并且支持区间修改呢?
我们有很多种办法维护区间问题,比如树状数组,线段树,分块。其中,线段树是较通用且直观的一种数据结构。
基础线段树
线段树入门
首先,我们有一个序列。
{1,1,4,5,1,4}
我们利用二分的思想,用每一个节点表示一个区间,两个子节点表示左右两个子区间。

然后我们就可以在每个节点处维护一些信息。
注意:实际上,只有最下面一层的叶子节点才保存了实际的数字,其它的每个节点只保存着这个区间的信息(如区间和,区间最值等)
那么如何把子节点的信息传到父节点上呢?
我们要了解一个叫做pushup的操作。
1 2 3
| void pushup(int x){ tr[x].sum=tr[x*2].sum+tr[x*2+1].sum; }
|
这个操作的意思就是:节点表示的区间和等于两个子节点所表示的区间之和。即下图:

有了这个操作,我们就可以递归的求出每一个节点所表示的信息。

这个建立线段树的过程可以看作是预处理信息,把数组的信息转移到线段树的叶子节点上,时间复杂度大概是O(n)
事实上,还有另一种写法的线段树,不需要建树,但是需要O(nlogn)的时间复杂度插入数据,我们会在权值线段树部分介绍这种写法。
建树代码
1 2 3 4 5 6 7 8 9 10 11 12
| void build(int x,int l,int r){ tr[x].l=l,tr[x].r=r; if(l==r){ tr[x].sum=a[l]; return; } int mid=(l+r)/2; build(x*2,l,mid),build(x*2+1,mid+1,r); tr[x].sum=tr[x*2].sum+tr[x*2+1].sum; }
|
区间查询
线段树可以在O(logn)的时间复杂度下完成区间查询操作。
以刚刚的数列{1,1,4,5,1,4}为例。
此时如果询问[3,5]之间的区间和,我们该怎么办呢?

首先,如果直接查询[4,6]的区间和,我们肯定是会的,直接输出10就行。
查询[4,5]怎么办呢?
可以把[4,6]拆成[4,5]和[6,6],然后输出[4,5]的和。
那么[3,5]就可以表示为[3,3]和[4,5]。

所以无论我们查询多大的区间,都可以拆成一些(不超过logn)预处理过的子区间,把这些子区间的区间和加起来,就是答案。
区间查询代码
1 2 3 4 5 6 7 8 9
| int query(int x,int l,int r){ if(tr[x].l>=l&&tr[x].r>=r) return tr[x].sum; int mid=(tr[x].l+tr[x].r)/2; int sum=0; if(l<=mid) sum+=query(x*2,l,r); if(r>mid) sum+=query(x*2+1,l,r); return sum; }
|
单点修改
单点修改比较简单,不断递归,定位到要找的节点,修改即可。

单点修改代码
1 2 3 4 5 6 7 8 9 10 11
| void change(int now,int x,int k){ if(tr[now].l==tr[now].r){ tr[now].sum=k; return; } int mid=(tr[now].l+tr[now].r)/2; if(x<=mid) change(now*2,x,k); else change(now*2+1,x,k); tr[now].sum=tr[now*2].sum+tr[now*2+1].sum; }
|
线段树的存储
观察线段树,我们发现它是一个完全二叉树,可以用堆式储存法。
即把每个节点都存在一个数组里,因为是完全二叉树,所以两个子节点可以用2p,2p+1表示。
因为线段树大部分节点都不是用来存数字的,所以线段树所用的空间要比原数列的空间多很多,如图,只有红色的节点才是真正存数字的。

线段树大概要开四倍的空间,具体可以看OIwiki上的分析。
例题1:单点修改,区间查询
洛谷P3374
已知一个数列,进行下面两种操作:
题目分析
相当于模板题,可以尝试着敲一遍,这里提供代码。
AC代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
| #include <bits/stdc++.h> using namespace std; const int N=1e6+10; struct node{ int sum,l,r; }tr[N*4]; int a[N]; inline void pushup(int x){ tr[x].sum=tr[x*2].sum+tr[x*2+1].sum; } void build(int x,int l,int r){ tr[x].l=l,tr[x].r=r; if(l==r){ tr[x].sum=a[l]; return; } int mid=(l+r)/2; build(x*2,l,mid),build(x*2+1,mid+1,r); pushup(x); } int query(int x,int l,int r){ if(tr[x].l>=l&&tr[x].r<=r) return tr[x].sum; int mid=(tr[x].l+tr[x].r)/2; int sum=0; if(l<=mid) sum+=query(x*2,l,r); if(r>mid) sum+=query(x*2+1,l,r); return sum; } void change(int now,int x,int k){ if(tr[now].l==tr[now].r){ tr[now].sum+=k; return; } int mid=(tr[now].l+tr[now].r)/2; if(x<=mid) change(now*2,x,k); else change(now*2+1,x,k); pushup(now); }
int n,q; int main(){ cin>>n>>q; for(int i=1;i<=n;i++) cin>>a[i]; build(1,1,n); while(q--){ int t,b,c; cin>>t>>b>>c; if(t==1) change(1,b,c); else cout<<query(1,b,c)<<endl; } }
|
习题
学会了线段树最基础的部分,就可以做一些习题了,将在博客的最后提供题解和代码。
- JSOI2008 最大数
线段树维护最大值的模板
- loj10123. Balanced Lineup
RMQ问题,可以试试用线段树做
懒标记
下面请思考,怎么才能做到线段树的区间修改呢?
如果直接把区间遍历一遍,依次修改,复杂度会达到无法接受的O(nlogn)。
那么怎么能让区间修改的复杂度变小呢?
我们需要引入一个叫做“懒标记”的东西。
懒标记也叫延迟标记,顾名思义,我们再修改这个区间的时候给这个区间打上一个标记,这样就可以做到区间修改的O(logn)的时间复杂度。
如图,如果要给[4,6]每个数都加上2,那么直接再代表着[4,6]区间的结点打上+2的标记就行了。

pushdown操作
再想一个问题,在给[4,6]区间打上懒标记后,我们如何查询[4,5]的值?
如果我们直接查询到[4,5]区间上,会发现根本就没有被加上过2。
为什么呢?
因为现在懒标记打在了[4,6]区间上。而他的子节点压根没被修改过!
所以我们需要把懒标记向下传递。
这就有了一个操作,叫做pushdown
,它可以把懒标记下传。
设想一下,如果我们要把懒标记下传,应该注意什么呢?
首先,要给子节点打上懒标记。
然后,我们要修改子节点上的值。
最后,不要忘记把这个节点的懒标记清空。
pushdown代码
1 2 3 4 5 6 7 8 9 10 11 12
| inline void pushudown(int x){ if(tr[x].add){ tr[2*x].add+=tr[x].add,tr[2*x+1].add+=tr[x].add; tr[2*x].sum+=tr[x].add*(tr[2*x].r-tr[2*x].l+1); tr[2*x+1].sum+=tr[x].add*(tr[2*x+1].r-tr[2*x+1].l+1); tr[x].add=0; } }
|
区间修改
学会了懒标记,应该可以很轻松地写出区间修改的代码了。
区间修改的操作很像区间查询,也是查找能够覆盖住的子区间,然后给它打上懒标记。
区间查询代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| void update(int now,int l,int r,int k){ if(l<=tr[now].l&&r>=tr[now].r){ tr[now].sum+=k*(tr[now].r-tr[now].l+1); tr[now].add+=k; } else{ pushudown(now); int mid=(tr[now].l+tr[now].r)/2; if(l<=mid) update(now*2,l,r,k); if(r>mid) update(now*2+1,l,r,k); pushup(now); } }
|
例题2:区间修改,区间查询
洛谷P3372
已知一个数列,你需要进行下面两种操作:
- 将某区间每一个数加上k。
- 求出某区间每一个数的和。
题目分析
应用到区间修改,需要注意的一点是,在区间查询时,也需要下传懒标记,这样才能查询到真实的值。
AC代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77
| #include <bits/stdc++.h> using namespace std; const int N=1e5+10; struct node{ int sum; int l,r; int add; }tr[N*4]; int a[N]; inline void pushup(int x){ tr[x].sum=tr[2*x].sum+tr[2*x+1].sum; } inline void pushudown(int x){ if(tr[x].add){ tr[2*x].add+=tr[x].add,tr[2*x+1].add+=tr[x].add; tr[2*x].sum+=tr[x].add*(tr[2*x].r-tr[2*x].l+1); tr[2*x+1].sum+=tr[x].add*(tr[2*x+1].r-tr[2*x+1].l+1); tr[x].add=0; } } void build(int x,int l,int r){ tr[x].l=l,tr[x].r=r,tr[x].add=0; if(l==r){ tr[x].sum=a[l]; return; } int mid=(l+r)/2; build(2*x,l,mid),build(2*x+1,mid+1,r); pushup(x); } int query(int x,int l,int r){ if(l<=tr[x].l&&r>=tr[x].r) return tr[x].sum; pushudown(x); int mid=(tr[x].l+tr[x].r)/2,sum=0; if(l<=mid) sum+=query(x*2,l,r); if(r>mid) sum+=query(x*2+1,l,r); return sum; } void update(int now,int l,int r,int k){ if(l<=tr[now].l&&r>=tr[now].r){ tr[now].sum+=k*(tr[now].r-tr[now].l+1); tr[now].add+=k; } else{ pushudown(now); int mid=(tr[now].l+tr[now].r)/2; if(l<=mid) update(now*2,l,r,k); if(r>mid) update(now*2+1,l,r,k); pushup(now); } } int n,q; int main(){ cin>>n>>q; for(int i=1;i<=n;i++) cin>>a[i]; build(1,1,n); while(q--){ int l,r,k,c; cin>>c>>l>>r; if(c==1){ cin>>k; update(1,l,r,k); } else cout<<query(1,l,r)<<endl; } return 0; }
|
例题3:较复杂的区间操作
洛谷P3373
已知一个数列,你需要进行下面三种操作:
-
将某区间每一个数乘上x。
-
将某区间每一个数加上x。
-
求出某区间每一个数的和。
题目分析
有些题要维护多个区间操作,这在pushdown
操作上就比较麻烦,比如这道题,要求维护区间加法和区间乘法,所以我们得维护两个懒标记。
那么我们该怎样安排懒标记的pushdown
顺序呢?
我们考虑先乘后加的维护顺序,假设两个懒标记分别是mul和add,那么这个数值就应该是mul×sum+add。
此时如果加上一个add,就会变成 mul×sum+add+add
如果乘上一个mul那就是mul×sum×mul+add×mul
这种方式便于计算,如果使用先加后乘的方式,就会比较麻烦甚至会出错。
AC代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76
| #include <bits/stdc++.h> using namespace std; const int N=1e5+10; struct node{ int l,r; int sum,add,mul; }tr[N*4]; int a[N]; int n,p,m; inline void pushup(int x){ tr[x].sum=(tr[2*x].sum+tr[2*x+1].sum)%p; } inline void eval(int x,int add,int mul){ tr[x].sum=(tr[x].sum*mul+add*(tr[x].r-tr[x].l+1))%p; tr[x].mul=(mul*tr[x].mul)%p; tr[x].add=(tr[x].add*mul+add)%p; }
inline void pushdown(int x){ eval(x*2,tr[x].add,tr[x].mul); eval(x*2+1,tr[x].add,tr[x].mul); tr[x].add=0,tr[x].mul=1; } void build(int x,int l,int r){ tr[x].l=l,tr[x].r=r; tr[x].add=0,tr[x].mul=1; if(l==r){ tr[x].sum=a[l]; return; } int mid=(l+r)/2; build(x*2,l,mid),build(x*2+1,mid+1,r); pushup(x); } void change(int x,int l,int r,int add,int mul){ if(l<=tr[x].l&&r>=tr[x].r) eval(x,add,mul); else{ pushdown(x); int mid=(tr[x].l+tr[x].r)/2; if(l<=mid) change(x*2,l,r,add,mul); if(r>mid) change(x*2+1,l,r,add,mul); pushup(x); } } int query(int x,int l,int r){ if(l<=tr[x].l&&r>=tr[x].r) return tr[x].sum; int sum=0; pushdown(x); int mid=(tr[x].l+tr[x].r)/2; if(l<=mid) sum+=query(x*2,l,r)%p; if(r>mid) sum+=query(x*2+1,l,r)%p; return sum; } int main(){ int t,g,c,ch; cin>>n>>m>>p; for(int i=1;i<=n;i++) cin>>a[i]; build(1,1,n); while(m--){ cin>>ch>>t>>g; if(ch==1){ cin>>c; change(1,t,g,0,c); } else if(ch==2){ cin>>c; change(1,t,g,c,1); } else cout<<query(1,t,g)%p<<endl; } return 0; }
|
标记永久化
其实,维护区间修改的方式有两种,一种是懒标记和标记下传,另一种叫做”标记永久化“。
标记永久化,就是不下传标记,在每次查询时把经过的标记累加起来,查询时加起来。

如图,打上标记的节点用绿色表示,查询路线(橙色)经过的就累加。
标记永久化代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| const int N=1e4+10; struct node{ int sum,add; int l,r; }tr[N*4]; int a[N]; void build(int x,int l,int r){ tr[x].l=l,tr[x].r=r; if(l==r){ tr[x].sum=a[l],tr[x].add=0; return; } int mid=(l+r)/2; build(x*2,l,mid),build(x*2+1,mid+1,r); tr[x].sum=tr[x*2].sum+tr[x*2+1].sum; } void update(int x,int l,int r,int k){ tr[x].sum+=(min(tr[x].r,r)-max(tr[x].l,l)+1)*k; if(tr[x].l>=l&&tr[x].r<=r){ tr[x].add+=k; return; } int mid=(tr[x].l+tr[x].r)/2; if(l<=mid) update(x*2,l,r,k); if(r>mid) update(x*2+1,l,r,k); } int query(int x,int l,int r,int add){ if(tr[x].l>=l&&tr[x].r<=r){ int s=(tr[x].r-tr[x].l+1)*add; return tr[x].sum+s; } add+=tr[x].add; int mid=(tr[x].l+tr[x].r)/2,sum=0; if(l<=mid) sum+=query(x*2,l,r,add); if(r>mid) sum+=query(x*2+1,l,r,add); return sum; }
|
标记永久化应用很多,比如可持久化线段树中的区间修改,树套树中第二维的修改。
习题
这里给出一些习题,按照难度排序。
- AHOI2009 维护序列
与例题3差不多
- 洛谷P1253 扶苏的问题
稍微复杂的懒标记维护
- 洛谷P5142 区间方差
需要一定的数学推导能力
- P4145 花神游历各国
想一想如何优化?
- P1471 方差
3题的加强版,较难
- P6327 区间加区间sin和
需要一些高中的数学知识
后记
后面本来是有内容的,为了方便阅读,我把后面的内容单独拿出来组了三篇博客。
如果你想继续学习线段树有关内容,可以看
权值线段树
zkw线段树
可持久化线段树(主席树)