在某些题目中,要求我们对数列的某些区间进行操作,例如求区间和或者区间内每个数加减某个数。一般来说,我们求区间和一般采用前缀和,时间复杂度是
O
(
1
)
O(1)
O(1)。而修改区间只能暴力修改,时间复杂度是
O
(
n
)
O(n)
O(n),当我们同时需要做这两件事的时候,每修改一次区间,前缀和也要随之改变,时间复杂度会变成
O
(
n
)
O(n)
O(n)。所以我们引入线段树这种数据结构来帮助我们对区间进行快速操作。
先看一道模板题:洛谷P3327【模板】线段树1
1 如何创建一颗线段树
首先我们要了解线段树的结构,线段树一定是一颗二叉树,每个结点代表了某段区间所有元素的和,例如
[
1
,
5
]
[1,5]
[1,5]代表了
∑
i
=
1
5
a
[
i
]
\sum^5_{i=1} a[i]
∑i=15a[i];而某个结点
[
l
,
r
]
[l,r]
[l,r]的子节点区间是
[
l
,
l
+
r
2
]
[l,\frac{l+r}{2}]
[l,2l+r](左子节点)和
[
l
+
r
2
+
1
,
r
]
[\frac{l+r}{2}+1,r]
[2l+r+1,r](右子节点)。
而每个结点都有一个编号
p
p
p,观察完全二叉树的规律可以知道,左子节点的编号一定是
p
∗
2
p*2
p∗2,而右子节点的编号一定是
p
∗
2
+
1
p*2+1
p∗2+1,我们用数组
t
r
e
e
tree
tree来存储该结点的值,即
t
r
e
e
[
p
]
=
∑
i
=
l
r
a
[
i
]
tree[p]=\sum^r_{i=l} a[i]
tree[p]=∑i=lra[i],所以一定也有
t
r
e
e
[
p
]
=
t
r
e
e
[
p
∗
2
]
+
t
r
e
e
[
p
∗
2
+
1
]
tree[p]=tree[p*2]+tree[p*2+1]
tree[p]=tree[p∗2]+tree[p∗2+1]。所以我们可以递归的创造一颗线段树,递归的边界是
l
=
r
l=r
l=r,也就是该结点代表的区间长度为
1
1
1,此时由
t
r
e
e
[
p
]
=
a
[
l
]
=
a
[
r
]
tree[p]=a[l]=a[r]
tree[p]=a[l]=a[r]
代码如下:
void build(int l,int r,int p){
if(l==r){
tree[p]=a[l];
return
}
int mid=(l+r)/2;
build(l,mid,p*2);//构造左子节点
build(mid+1,r,p*2+1);//构造右子节点
tree[p]=tree[p*2]+tree[p*2+1];
}
2 如何进行区间修改
区间修改是线段树真正节省时间复杂度的地方,我们并不需要真正修改数列中每一个数的值,我们的修改只是为了欺骗查询区间和。譬如我们现在有数列
a
[
1
]
到
a
[
5
]
,
假
设
值
全
为
1
a[1]到a[5],假设值全为1
a[1]到a[5],假设值全为1,我们显然可以构建如下树关系
[
1
,
5
]
(
1
)
→
[
1
,
3
]
(
2
)
,
[
4
,
5
]
(
3
)
[
1
,
3
]
(
2
)
→
[
1
,
2
]
(
4
)
,
[
3
,
3
]
(
5
)
[
4
,
5
]
(
3
)
→
[
4
,
4
]
(
6
)
,
[
5
,
5
]
(
7
)
[
1
,
2
]
(
4
)
→
[
1
,
1
]
(
8
)
,
[
2
,
2
]
(
9
)
(
p
)
为
结
点
编
号
[1,5](1)\rightarrow[1,3](2),[4,5](3)\\ [1,3](2)\rightarrow[1,2](4),[3,3](5)\\ [4,5](3)\rightarrow[4,4](6),[5,5](7)\\ [1,2](4)\rightarrow[1,1](8),[2,2](9)\\ (p)为结点编号
[1,5](1)→[1,3](2),[4,5](3)[1,3](2)→[1,2](4),[3,3](5)[4,5](3)→[4,4](6),[5,5](7)[1,2](4)→[1,1](8),[2,2](9)(p)为结点编号
我们现在要将
[
1
,
4
]
[1,4]
[1,4]内的所有值加上
1
1
1,我们想要查询的时候
[
1
,
4
]
[1,4]
[1,4]这个区间的元素确实表现出都加上
1
1
1的特证即可,而不是真的将
a
[
1
]
到
a
[
4
]
a[1]到a[4]
a[1]到a[4]都加
1
1
1,我们发现结点
3
3
3的区间包含在我们的目标区间内,且结点
3
3
3的长度为
3
−
1
+
1
=
3
3-1+1=3
3−1+1=3,所以只需把
t
r
e
e
[
3
]
+
=
3
∗
1
tree[3]+=3*1
tree[3]+=3∗1即可对
a
[
1
]
到
a
[
3
]
a[1]到a[3]
a[1]到a[3]一起修改,再效仿修改结点
6
6
6即可。所以我们在递归线段树的时候有三种区间,一种是该区间和目标区间无交集,这种区间什么都不需要操作,可以直接
r
e
t
u
r
n
return
return,一种区间包含在目标区间中,这时可以对整个区间进行操作,第三种情况是区间和目标区间有交集,但不被包含,则把该区间不断对半分,便可以变成前两种区间。
void update(int l,int r,int cl,int cr,int d,int p){//目前正在处理的区间是[cl,cr],目的是[l,r]上的元素加上d
if(cl>r||cr<l) return;//第一种区间
if(cl>l&&cr<r) tree[p]+=d*(cr-cl+1);//第二种区间
else{
int mid=(cl+cr)/2;
update(l,r,cl,mid,d,p*2);
update(l,r,mid+1,cr,d,p*2+1);
tree[p]=tree[p*2]+tree[p*2+1]; }
在查询时,可以效仿这种方法将区间分为三种(因为查询需要返回值,函数类型不再用void.
int query(int l,int r,int cl,int cr,int p){
if(cl>r||cr<l) return 0;//第一种区间
if(cl>l&&cr<r) return tree[p];//第二种区间
else{
int mid=(cl+cr)/2;
return query(l,r,cl,mid,p*2)+query(l,r,mid+1,cr,p*2+1;
}
我们马上会发现这种做法的问题所在,如上举例
[
1
,
5
]
(
1
)
→
[
1
,
3
]
(
2
)
,
[
4
,
5
]
(
3
)
[
1
,
3
]
(
2
)
→
[
1
,
2
]
(
4
)
,
[
3
,
3
]
(
5
)
[
4
,
5
]
(
3
)
→
[
4
,
4
]
(
6
)
,
[
5
,
5
]
(
7
)
[
1
,
2
]
(
4
)
→
[
1
,
1
]
(
8
)
,
[
2
,
2
]
(
9
)
(
p
)
为
结
点
编
号
[1,5](1)\rightarrow[1,3](2),[4,5](3)\\ [1,3](2)\rightarrow[1,2](4),[3,3](5)\\ [4,5](3)\rightarrow[4,4](6),[5,5](7)\\ [1,2](4)\rightarrow[1,1](8),[2,2](9)\\ (p)为结点编号
[1,5](1)→[1,3](2),[4,5](3)[1,3](2)→[1,2](4),[3,3](5)[4,5](3)→[4,4](6),[5,5](7)[1,2](4)→[1,1](8),[2,2](9)(p)为结点编号
我们现在要将
[
1
,
4
]
[1,4]
[1,4]内的所有值加上
1
1
1,我们想要查询的时候
[
1
,
4
]
[1,4]
[1,4]这个区间的元素确实表现出都加上
1
1
1的特证即可,而不是真的将
a
[
1
]
到
a
[
4
]
a[1]到a[4]
a[1]到a[4]都加
1
1
1,我们发现结点
3
3
3的区间包含在我们的目标区间内,且结点
3
3
3的长度为
3
−
1
+
1
=
3
3-1+1=3
3−1+1=3,所以只需把
t
r
e
e
[
3
]
+
=
3
∗
1
tree[3]+=3*1
tree[3]+=3∗1即可对
a
[
1
]
到
a
[
3
]
a[1]到a[3]
a[1]到a[3]一起修改,再效仿修改结点
6
6
6即可。如果我现在需要查询区间
[
1
,
2
]
[1,2]
[1,2]的值,我们发现这段区间根本没有被我们修改,我们整体修改完
[
1
,
3
]
[1,3]
[1,3]就停止了
u
p
d
a
t
e
update
update程序。为了解决这个问题,我们引入了延迟标记(懒标记)。
3 延迟标记
遇到上述问题,暴力的想法是将区间包含于
[
1
,
4
]
[1,4]
[1,4]的所有结点的值都改变,但这样显然时间复杂度比线性数组操作还大,我们引入延迟标记来解决问题,先看代码再讲解
void update(int l,int r,int cl,int cr,int d,int p){//目前正在处理的区间是[cl,cr],目的是[l,r]上的元素加上d
if(cl>r||cr<l) return;//第一种区间
if(cl>l&&cr<r){
tree[p]+=d*(cr-cl+1);//第二种区间
mark[p]=d;
}
else{
int mid=(cl+cr)/2;
mark[p*2]+=mark[p];
mark[p*2+1]+=mark[p];//懒标记的传递
tree[p*2]+=mark[p];
tree[p*2+1]+=mark[p];
mark[p]=0;//不需要懒标记维护
update(l,r,cl,mid,d,p*2);
update(l,r,mid+1,cr,d,p*2+1);
tree[p]=tree[p*2]+tree[p*2+1]; }
int query(int l,int r,int cl,int cr,int p){//查询区间从lr内所有元素的和
if(cl>r||cr<l){
return 0;
}
if(cl>=l&&cr<=r){
return tree[p];
}
else{
int mid=(cl+cr)/2;
mark[p*2]+=mark[p];
mark[p*2+1]+=mark[p];//懒标记的传递
tree[p*2]+=mark[p];
tree[p*2+1]+=mark[p];
mark[p]=0;//不需要懒标记维护
return query(l,r,cl,mid,p*2)+query(l,r,mid+1,cr,p*2+1);
}
}
我们来模拟一下延迟标记的作用,仍沿用上面那个例子
[
1
,
5
]
(
1
)
→
[
1
,
3
]
(
2
)
,
[
4
,
5
]
(
3
)
[
1
,
3
]
(
2
)
→
[
1
,
2
]
(
4
)
,
[
3
,
3
]
(
5
)
[
4
,
5
]
(
3
)
→
[
4
,
4
]
(
6
)
,
[
5
,
5
]
(
7
)
[
1
,
2
]
(
4
)
→
[
1
,
1
]
(
8
)
,
[
2
,
2
]
(
9
)
(
p
)
为
结
点
编
号
[1,5](1)\rightarrow[1,3](2),[4,5](3)\\ [1,3](2)\rightarrow[1,2](4),[3,3](5)\\ [4,5](3)\rightarrow[4,4](6),[5,5](7)\\ [1,2](4)\rightarrow[1,1](8),[2,2](9)\\ (p)为结点编号
[1,5](1)→[1,3](2),[4,5](3)[1,3](2)→[1,2](4),[3,3](5)[4,5](3)→[4,4](6),[5,5](7)[1,2](4)→[1,1](8),[2,2](9)(p)为结点编号当我们对
[
1
,
3
]
[1,3]
[1,3]区间进行区间修改时同时对其进行标记,
m
a
r
k
[
2
]
=
1
mark[2]=1
mark[2]=1它的意义是,标记
2
2
2结点上的所有元素
+
1
+1
+1,同时根据我们的程序,我们标记
m
a
r
k
[
6
]
=
1
,
m
a
r
k
[
7
]
=
1
mark[6]=1,mark[7]=1
mark[6]=1,mark[7]=1,当我们查询区间
[
1
,
2
]
[1,2]
[1,2]时,当我们目前区间是
[
1
,
3
]
[1,3]
[1,3]区间时,根据我们的程序我们会将延迟标记向下传递
m
a
r
k
[
4
]
=
1
,
m
a
r
k
[
5
]
=
1
mark[4]=1,mark[5]=1
mark[4]=1,mark[5]=1并将
t
r
e
e
[
4
]
tree[4]
tree[4]和
t
r
e
e
[
5
]
tree[5]
tree[5]的每一个元素加上
m
a
r
k
[
2
]
mark[2]
mark[2],同时清零该层标记
m
a
r
k
[
2
]
=
0
mark[2]=0
mark[2]=0,我们对
t
r
e
e
[
4
]
tree[4]
tree[4]的修改成功修改了
[
1
,
2
]
[1,2]
[1,2]的值,这就是延迟标记的作用,这一层的标记延迟作用于下一层,所以称之为延迟标记,我们在修改区间时用
m
a
r
k
mark
mark记着我们这个区间内的所有元素要加上一个数,而在我们查询的时候再将
m
a
r
k
mark
mark拿出来使用。