线段树
线段树是一个很优秀的树结构,较简单,功能多,可以维护复杂信息。 可以动态开点,可以打懒标记。
基本概念
线段树是基于分治思想的二叉树。
为了引入线段树,我们来看一个例子:
例 给你一个序列\(a\), 需要支持一下几种操作
1.查询区间\([l, r]\)上的值的和;
2.修改某一个位置上的值;
3.区间\([l, r]\)值\(+k\) ;
其实这是可以用前缀和来做的,但未免慢了些;前两个操作树状数组可以很简洁地完成,但第三个就有些麻烦了,而且效率也是不及线段树的。
类似于树状数组,线段树也是一个节点管理多个原数组的数的一些信息。
线段树的特点:
- 每个节点代表一个区间;
- 线段树具有唯一的根节点,代表区间是整个统计范围(可持久化除外,有多个);
- 线段树的每个叶子节点都代表一个长度为1的原区间\([x, x]\);
- 对于每个节点\([l, r]\) , 其左儿子是\([l, mid]\), 右儿子是\([mid + 1, r]\), 其中 \(mid = (l + r)/2\);
如图:
普通线段树的实现
线段树的存储
线段树是一种二叉树,所以我们可以采用父子二倍的方法来实现器存储。对于一个节点 \(p\) ,其左儿子是 \(p*2\) ,右儿子是 \(p*2+1\) 。
对于每个节点,都会存储一些信息,所以我们可以用一个结构体来存:
struct node{
int l, r;//这个节点所维护的区间
int dat;//这个节点所维护的区间的信息
}tree[4*maxn];
当然,也可以不用结构体,只要在调用相关函数时上传\([l, r]\) 信息即可。
我们可以看到,线段树数组开的空间是\(4*maxn\) ,这是为什么呢。首先我们可以从上图看到,对于整个区间长度是二的n次幂的区间,需要原数组二倍的空间(\(2n-1\)) ,而如果不是二的n次幂呢?那就会多一层,而多的那一层的所占空间是\(2n\) ,而且我们采用的父子二倍的方法,所以就算最后一层没有完全占用,依旧要开上它的空间。
建树
在输入原数组后,就可以先建树了,建完树后进行一些区间的维护也方便。
建树是从根节点开始的,层层向下递归,直到遇到叶子节点后保存叶子节点信息,再向上回溯维护信息。
void build(int p, int l, int r){
tree[p].l = l, tree[p].r = r;
if(l == r){ //遇到叶子节点
tree[p].dat = a[l];
return;
}
int mid = l + r >> 1;
build(p * 2, l, mid); //建左子树
build(p * 2 + 1, mid + 1, r); //建右子树
tree[p].dat = tree[p * 2].dat + tree[p * 2 + 1].dat; //维护信息
}
//调用:
build(1, 1, n);
单点修改与区间查询
区间修改
需要用到一个懒惰标记,这样就不用每次修改都把值传下去,而是在后续操作中遇到标记再下传。
#include <bits/stdc++.h>
#define maxn 100000
#define int long long
using namespace std;
int m,n;
#define ls k << 1
#define rs k << 1 | 1
#define mid (l + r >> 1)
int tree[maxn << 2], tag[maxn << 2];
void pushup(int k) {
tree[k] = tree[ls] + tree[rs];
}
void Pushup(int k) {
tree[k] = tree[ls] + tree[rs];
}
void Build(int k, int l, int r) {
if (l == r) {
cin >> tree[k];
return;
}
Build(ls, l, mid);
Build(rs, mid + 1, r);
pushup(k);
}
void Add(int k, int l, int r, int v) {
tag[k] += v;
tree[k] += 1ll * (r - l + 1) * v;
}
void pushdown(int k, int l, int r) {
if (!tag[k]) return;
Add(ls, l, mid, tag[k]);
Add(rs, mid + 1, r, tag[k]);
tag[k] = 0;
}
void Modify(int k, int l, int r, int x, int y, int v) {
if (l >= x && r <= y) return Add(k, l, r, v);
pushdown(k, l, r);
if (x <= mid) Modify(ls, l, mid, x, y, v);
if (mid < y) Modify(rs, mid + 1, r, x, y, v);
pushup(k);
}
int Query(int k, int l, int r, int x, int y) {
if (l >= x && r <= y) return tree[k];
pushdown(k, l, r);
int ret = 0;
if (x <= mid) ret += Query(ls, l, mid, x, y);
if (mid < y) ret += Query(rs, mid + 1, r, x, y);
return ret;
}
signed main() {
cin >> n >> m;
Build(1, 1, n);
for (int i = 1, opt, x, y, z; i <= m; i++) {
cin >> opt >> x >> y;
if (opt == 1) {
cin >> z;
Modify(1, 1, n, x, y, z);
}
if(opt == 2){
cout<<Query(1, 1, n, x, y)<<endl;
}
}
return 0;
}
区间乘法:需要维护两个懒惰标记,一个加一个乘,在下传时要注意顺序。
#include<bits/stdc++.h>
#define maxn 100005
#define int long long
using namespace std;
int n, m, mod;
int a[maxn];
struct node{
int l, r, tag1, tag2, sum;
}t[4*maxn];
void build(int p, int l, int r){
t[p].l = l, t[p].r = r, t[p].tag2 = 1;
if(l == r){
t[p].sum = a[l]%mod;
return;
}
int mid = l + r >> 1;
build(p*2, l, mid);
build(p*2+1, mid + 1, r);
t[p].sum = (t[p*2].sum + t[p*2+1].sum) %mod;
}
void spread(int p){
t[p*2].sum = (t[p].tag2 * t[p*2].sum + (t[p*2].r - t[p*2].l + 1) * t[p].tag1)%mod ;
t[p*2+1].sum = (t[p].tag2 * t[p*2+1].sum + (t[p].tag1 * (t[p*2+1].r - t[p*2+1].l + 1)) )%mod ;
t[p*2].tag2 = (t[p*2].tag2 * t[p].tag2)%mod ;
t[p*2+1].tag2 = (t[p*2+1].tag2 * t[p].tag2)%mod ;
t[p*2].tag1 = (t[p*2].tag1 * t[p].tag2 + t[p].tag1)%mod ;
t[p*2+1].tag1 = (t[p*2+1].tag1 * t[p].tag2 + t[p].tag1) %mod;
t[p].tag2 = 1;
t[p].tag1 = 0;
}
void add(int p, int x, int y, int k){
if(x <= t[p].l && y >= t[p].r){
t[p].sum = (t[p].sum + k * (t[p].r - t[p].l + 1)) % mod;
t[p].tag1 = (t[p].tag1 + k)%mod ;
return;
}
spread(p);
t[p].sum = (t[p*2].sum + t[p*2+1].sum) ;
int mid = t[p].l + t[p].r >> 1;
if(x <= mid) add(p*2, x, y, k);
if(y > mid) add(p*2+1, x, y, k);
t[p].sum = (t[p*2].sum + t[p*2+1].sum) ;
}
void mul(int p,int x,int y,int k){
if(t[p].l>=x && t[p].r<=y){
t[p].tag1 = (t[p].tag1 * k) ;
t[p].tag2 = (t[p].tag2 * k) ;
t[p].sum = (t[p].sum * k) ;
return ;
}
spread(p);
t[p].sum = t[p*2].sum + t[p*2+1].sum;
int mid = (t[p].l + t[p].r) >> 1;
if(x <= mid) mul(p*2, x, y, k);
if(mid < y) mul(p*2+1, x, y, k);
t[p].sum = (t[p*2].sum + t[p*2+1].sum)%mod;
}
int ask(int p, int x, int y){
if(t[p].l >= x && t[p].r <= y){
return t[p].sum;
}
spread(p);
int as=0;
int mid = (t[p].l+t[p].r)>>1;
if(x <= mid) as = (as + ask(p*2, x, y))%mod ;
if(mid < y) as = (as + ask(p*2+1, x, y))%mod ;
return as;
}
signed main(){
cin >> n >> m >> mod;
for(int i = 1; i <= n; i++) cin >> a[i];
build(1, 1, n);
for(int i = 1, opt, x, y, k; i <= m; i++){
cin >> opt;
if(opt == 1){
cin >> x >> y >> k;
mul(1, x, y, k);
}
if(opt == 2){
cin >> x >> y >> k;
add(1, x, y, k);
}
if(opt == 3){
cin >> x >>y;
cout << ask(1, x, y)%mod << endl;
}
}
return 0;
}
线段树应用
扫描线
权值线段树
所谓权值线段树就是在值域上建一棵线段树,当插入一个数时,其位置加一。
优化:动态开点
我们可以看到,在值域上开一棵线段树是非常危险的,这很可能会爆空间
可持久化线段树
可持久化线段树即为可保存历史版本的线段树。
在一个历史版本上修改时,只用新建被修改了的节点,没有修改的节点直接指向原来的历史版本就行。
根节点仍然是调用这棵树的入口,会有很多根节点,每一个根节点都是其所在的历史版本的入口。
可持久化数组
模板:P3919 【模板】可持久化线段树 1(可持久化数组)
#include <bits/stdc++.h>
#define maxn 1000012
using namespace std;
struct node{
int lc, rc, dat;
}t[30*maxn];
int root[maxn], tot;
int n, m, a[maxn], cnt;
inline int read(){
int f=1,x=0;char ch;
do{ch=getchar();if(ch=='-')f=-1;}while(ch<'0'||ch>'9');
do{x=x*10+ch-'0';ch=getchar();}while(ch>='0'&&ch<='9');
return f*x;
}
int build(int l, int r){
int p = ++tot;
if(l == r){
t[p].dat = a[l];
return p;
}
int mid = (l + r) >> 1;
t[p].lc = build(l, mid);
t[p].rc = build(mid + 1, r);
t[p].dat = max(t[t[p].lc].dat , t[t[p].rc].dat);
return p;
}
int change(int now, int l, int r, int x, int val){
int p = ++tot;
t[p] = t[now];
if(l == r){
t[p].dat = val;
return p;
}
int mid = (l + r) >> 1;
if(x <= mid) t[p].lc = change(t[now].lc, l, mid, x, val);
else t[p].rc = change(t[now].rc, mid+1, r, x, val);
t[p].dat = max(t[t[p].lc].dat , t[t[p].rc].dat);
return p;
}
int query(int p, int l, int r, int x){
if (l == r) return t[p].dat;
int mid = (l + r) >> 1;
if (x <= mid) return query(t[p].lc, l, mid, x);
else return query(t[p].rc, mid + 1, r, x);
}
signed main(){
n = read(), m = read();
for(int i = 1; i <= n; i++) a[i] = read();
root[0] = build(1, n);
for(int i = 1, now, opt, x, val; i <= m; i++){
now = read(), opt = read();
if(opt == 1){
x = read(), val = read();
root[i] = change(root[now], 1, n, x, val);
}
if(opt == 2){
x = read();
cout << query(root[now], 1, n, x) <<endl;
root[i] = root[now];
}
}
}
Tips
1.标记永久化
标记永久化就少了每次都上传下传,常数会小很多。
标记永久化的原理简单来说就是修改时一路更改被影响到的点,询问时则一路累加路上的标记,从而省去下传标记的操作。
//以下抄自标记永久化 - wozaixuexi - 博客园 (cnblogs.com)
3.0 说明
这里以区间修改区间求和的线段树为例。
线段树中编号为p的结点的值和标记分别为val[p]和mark[p]。
3.1 建树
标记永久化线段树的建树和标记不永久化线段树的建树没有什么区别,这里就不在赘述,直接上代码吧。
void Build(int p,int l,int r)
{
if(l==r) {scanf("%lld",&val[p]);return;}
int mid=(l+r)>>1;
Build(p<<1,l,mid);//递归建左子树
Build(p<<1|1,mid+1,r);//递归建右子树
val[p]=val[p<<1]+val[p<<1|1];//这里是要向上更新一下的
}
3.2 区间修改
0.设要将区间[xy]中的数都加上v。
1.一路走下去同时更新路上受此次修改影响的节点的值,即val[p]+=(y-x+1)*v。
2.当目前结点所代表的区间与待修改区间完全重合时,更新标记,返回,即mark[p]+=v;
void add(int p,int l,int r,int x,int y,long long v)
{
val[p]+=(y-x+1)*v;//更新该结点的权值
if(l==x&&r==y) {mark[p]+=v;return;}//更新标记
int mid=(l+r)>>1;
if(y<=mid) add(p<<1,l,mid,x,y,v);
else if(x>mid) add(p<<1|1,mid+1,r,x,y,v);
else add(p<<1,l,mid,x,mid,v),add(p<<1|1,mid+1,r,mid+1,y,v);
}
有人可能会问:标记更新后直接返回的话下面的结点不就没更新了吗?
慢慢来嘛,往下看就明白啦。
3.3 区间询问
0.设要要区间[x,y]中的数的总和。
1.一路走下去同时累加路上的标记,因为在修改操作中标记并没有下传,所以要这样子,即ad+=mark[p]。
2.当目前结点所代表的区间与待修改区间完全重合时,返回当前结点的值与累加下来的标记乘上询问区间长度的和,即return val[p]+(y-x+1)*ad。
int ask(int p,int l,int r,int x,int y,int ad)//ad为一路上累加的标记
{
if(l==x&&r==y) return val[p]+(y-x+1)*ad;
int mid=(l+r)>>1;
if(y<=mid) return ask(p<<1,l,mid,x,y,ad+mark[p]);
if(x>mid) return ask(p<<1|1,mid+1,r,x,y,ad+mark[p]);
return ask(p<<1,l,mid,x,mid,ad+mark[p])+ask(p<<1|1,mid+1,r,mid+1,y,ad+mark[p]);
}