通过做题目思考:为什么我们要学线段树呢?
这个博客是用结构体写的线段树:
下面的抛出两个问题一起思考一下:
问题1 :
给1000个正整数,编号1到1000,用a[1],a[2],…,a[1000]表示。
统计:1.编号从L到R的所有数之和为多少?
其中1<= L <= R <= 1000.
你很快就可以思考出解决方法:
- O(n)枚举搜索
- 前缀和O(1)。
问题2:
给出n个数,n<=1000000,和m个操作,每个操作可能有两种:
- 在某个位置加上一个数;
- 询问区间[l,r]的和,并输出。
O(n)枚举。(超时呀!!!)那怎么办呢?
这个时候也思考一下之前认知里有的解决问题的方式----前缀和
你也会发现动态修改是不能用静态的前缀和实现的。
那我们通过思考,可以知道我们所学习的知识对有些问题是无法解决的时候。(学过线段树或者数组数组的除外)于是,我们就需要学习新的算法了。一种强大的数据结构:线段树(树状数组也是可以做这几个问题的)
好,接下来,我们开始一步一步走向线段树:
一.详细讲解线段树。
线段树的基础操作主要有5个:
建树、单点查询、单点修改、区间查询、区间修改。
线段树,是一种二叉搜索树。它将一段区间划分为若干单位区间,每一个节点都储存着一个区间。虽然说是一个区间,但实际它储存的是一个区间的信息(数值)。
线段树基础思想:分块?二分?各有看法,但实际用起来就是二分。
-
每个节点的左孩子区间范围为[l,mid],右孩子为[mid+1,r]
-
对于结点k,左孩子结点为2k,右孩子为2k+1,这是完全二叉树性质。
如下图:
1.建树
建树的实现,思想就是分治,根本上也可以说是二分。
每个节点以结构体的方式存储(当然以后用数组比较好,节省代码量减少写代码的时间),结构体包含以下几个信息:
- 区间左端点、右端点;
- 这个区间要维护的信息(视实际情况而定,数目不等)
实现思路:
利用二分,从二叉树的根开始。
-
二分到每一个节点,给它左右端点确定范围。
-
如果是叶子节点,存储要维护的信息。(输入的初始数据。)
-
状态合并。将某个结点w的左右两个孩子值之和合并到w上。
可以看代码:
struct node//结构体
{
int l,r,w;//l,r分别表示区间左右端点,w表示区间和
}tree[15000];//数组开4倍
void build(int l,int r,int k)//k从1 开始
{
tree[k].l=l,tree[k].r=r;
if(l==r)//叶子节点
{
scanf("%d",&tree[k].w);
return;
}
int mid=(l+r)/2;
build(l,mid,2*k);//左孩子
build(mid+1,r,2*k+1);//右孩子
tree[k].w=tree[2*k].w+tree[2*k+1].w;//状态合并,此结点的w=两个孩子的w之和
}
最后要注意:
-
结构体要开4倍空间,看上面画的一个[1,10]的线段树就懂了。
-
自己写的时候不要漏了return语句,因为到了叶子节点不需要再继续递归了。
2.单点查询
单点,就是一个点进行查询。因为线段树的特殊性,这里的点,就是左右端点相等的时候的 “区间”,也可以说是叶子节点。
方式:二分查询:
-
如果当前枚举的点左右端点相等,即叶子节点,就是目标节点。
-
如果不是,利用二分思想。对查询位置为x,和当前结点区间范围为 [l,r],中点为mid。如果x<=mid,则递归它的左孩子,否则递归它的右孩子,那么最后一定可以得出答案。
我们仍然需要证明一下其正确性:
假如不是目标位置,由if—else语句对目标位置定位,逐步缩小目标范围,最后一定能只到达目标叶子节点。
再详细一点:
我们二分的情况有三种:
void ask(int k)
{
if(tree[k].l==tree[k].r) //当前结点的左右端点相等,是叶子节点,这就是答案
{
ans=tree[k].w;
return ;
}
int mid=(tree[k].l+tree[k].r)/2;
if(x<=mid) ask(k*2);//目标位置比中点靠左,递归左孩子
else ask(k*2+1);//反之,递归右孩子
}
3.单点修改
结合单点查询的原理,找到x的位置;根据建树状态合并的原理,修改每个结点的状态。
int ask(int k)
{
if(tree[k].l==tree[k].r)//二分到左右端点相等,也就是叶子节点。
{
tree[k].w+=y;
}
int mid=(tree[k].l+tree[k].r)/2;
if(x<=mid)
ask(2*k);
else
ask(2*k+1);
tree[k].w=tree[2*k].w+tree[2*k+1].w;
}
4.区间查询
mid=(l+r)/2;
y<=mid ,即 查询区间全在,当前区间的左子区间,往左孩子走
x>mid 即 查询区间全在,当前区间的右子区间,往右孩子走
否则,两个子区间都走
正确性分析
情况1,3不用说,对于情况2,最差情况是搜到叶子节点,此时一定满足情况1
void sum( int k)
{
if(tree[k].l<=x&&tree[k].r>=y)//找区间左右端点
{
ans+=tree[k].w;//找到目标
return ;
}
int mid=(tree[k].l+tree[k].r)/2;//二分
if(y<=mid)//查询区间全在当前区间的左子区间,往左孩子走.
sum(2*k);
if(x>mid)//查询区间全在当前区间的右子区间,往右孩子走.
sum(2*k+1);
}
5.区间修改
修改的时候只修改对查询有用的点。
对,这就是区间修改的关键思路。
为了实现这个,我们引入一个新的状态——懒标记。
Ⅱ 懒标记
(懒标记比较难理解,我尽力讲明白。。。。。。)
1、直观理解:“懒”标记,懒嘛!用到它才动,不用它就睡觉。
2、作用:存储到这个节点的修改信息,暂时不把修改信息传到子节点。就像家长扣零花钱,你用的时候才给你,不用不给你。
3. 实现思路(重点):
a.原结构体中增加新的变量,存储这个懒标记。
b.递归到这个节点时,只更新这个节点的状态,并把当前的更改值累积到标记中。注意是累积,可以这样理解:过年,很多个亲戚都给你压岁钱,但你暂时不用,所以都被你父母扣下了。
c.什么时候才用到这个懒标记?当需要递归这个节点的子节点时,标记下传给子节点。这里不必管用哪个子节点,两个都传下去。就像你如果还有妹妹,父母给你们零花钱时总不能偏心吧
d.下传操作:
3部分:
①当前节点的懒标记累积到子节点的懒标记中。
②修改子节点状态。在引例中,就是原状态+子节点区间点的个数*父节点传下来的懒标记。
这就有疑问了,既然父节点都把标记传下来了,为什么还要乘父节点的懒标记,乘自己的不行吗?
因为自己的标记可能是父节点多次传下来的累积,每次都乘自己的懒标记造成重复累积
③父节点懒标记清0。这个懒标记已经传下去了,不清0后面再用这个懒标记时会重复下传。就像你父母给了你5元钱,你不能说因为前几次给了你10元钱, 所以这次给了你15元,那你不就亏大了。
懒标记下穿代码:f为懒标记,其余变量与前面含义一致。
void down(int k)
{
tree[k*2].f+=tree[k].f;
tree[k*2+1].f+=tree[k].f;
tree[k*2].w+=tree[k].f*(tree[k*2].r-tree[k*2].l+1);
tree[k*2+1].w+=tree[k].f*(tree[k*2+1].r-tree[k*2+1].l+1);
tree[k].f=0;
}
完整的区间修改代码:
void add(int k)
{
if(tree[k].l>=a&&tree[k].r<=b)//当前区间全部对要修改的区间有用
{
tree[k].w+=(tree[k].r-tree[k].l+1)*x;//(r-l)+1区间点的总数
tree[k].f+=x;
return;
}
if(tree[k].f) down(k);//懒标记下传。只有不满足上面的if条件才执行,所以一定会用到当前节点的子节点
int m=(tree[k].l+tree[k].r)/2;
if(a<=m) add(k*2);
if(b>m) add(k*2+1);
tree[k].w=tree[k*2].w+tree[k*2+1].w;//更改区间状态
}
因为引入了懒标记,很多用不着的更改状态存了起来,这就会对区间查询、单点查询造成一定的影响。
所以在使用了懒标记的程序中,单点查询、区间查询也要像区间修改那样,对用得到的懒标记下传。其实就是加上一句if(tree[k].f) down(k),其余不变。
单点修改也需要下传懒标记
void ask(int k)//单点查询
{
if(tree[k].l==tree[k].r)
{
ans=tree[k].w;
return ;
}
if(tree[k].f) down(k);//懒标记下传,唯一需要更改的地方
int m=(tree[k].l+tree[k].r)/2;
if(x<=m) ask(k*2);
else ask(k*2+1);
}
void sum(int k)//区间查询
{
if(tree[k].l>=x&&tree[k].r<=y)
{
ans+=tree[k].w;
return;
}
if(tree[k].f) down(k)//懒标记下传,唯一需要更改的地方
int m=(tree[k].l+tree[k].r)/2;
if(x<=m) sum(k*2);
if(y>m) sum(k*2+1);
}
#include<cstdio>
using namespace std;
int n,p,a,b,m,x,y,ans;
struct node
{
int l,r,w,f;
}tree[400001];
inline void build(int k,int ll,int rr)//建树
{
tree[k].l=ll,tree[k].r=rr;
if(tree[k].l==tree[k].r)
{
scanf("%d",&tree[k].w);
return;
}
int m=(ll+rr)/2;
build(k*2,ll,m);
build(k*2+1,m+1,rr);
tree[k].w=tree[k*2].w+tree[k*2+1].w;
}
inline void down(int k)//标记下传
{
tree[k*2].f+=tree[k].f;
tree[k*2+1].f+=tree[k].f;
tree[k*2].w+=tree[k].f*(tree[k*2].r-tree[k*2].l+1);
tree[k*2+1].w+=tree[k].f*(tree[k*2+1].r-tree[k*2+1].l+1);
tree[k].f=0;
}
inline void ask_point(int k)//单点查询
{
if(tree[k].l==tree[k].r)
{
ans=tree[k].w;
return ;
}
if(tree[k].f) down(k);
int m=(tree[k].l+tree[k].r)/2;
if(x<=m) ask_point(k*2);
else ask_point(k*2+1);
}
inline void change_point(int k)//单点修改
{
if(tree[k].l==tree[k].r)
{
tree[k].w+=y;
return;
}
if(tree[k].f) down(k);
int m=(tree[k].l+tree[k].r)/2;
if(x<=m) change_point(k*2);
else change_point(k*2+1);
tree[k].w=tree[k*2].w+tree[k*2+1].w;
}
inline void ask_interval(int k)//区间查询
{
if(tree[k].l>=a&&tree[k].r<=b)
{
ans+=tree[k].w;
return;
}
if(tree[k].f) down(k);
int m=(tree[k].l+tree[k].r)/2;
if(a<=m) ask_interval(k*2);
if(b>m) ask_interval(k*2+1);
}
inline void change_interval(int k)//区间修改
{
if(tree[k].l>=a&&tree[k].r<=b)
{
tree[k].w+=(tree[k].r-tree[k].l+1)*y;
tree[k].f+=y;
return;
}
if(tree[k].f) down(k);
int m=(tree[k].l+tree[k].r)/2;
if(a<=m) change_interval(k*2);
if(b>m) change_interval(k*2+1);
tree[k].w=tree[k*2].w+tree[k*2+1].w;
}
int main()
{
scanf("%d",&n);//n个节点
build(1,1,n);//建树
scanf("%d",&m);//m种操作
for(int i=1;i<=m;i++)
{
scanf("%d",&p);
ans=0;
if(p==1)
{
scanf("%d",&x);
ask_point(1);//单点查询,输出第x个数
printf("%d",ans);
}
else if(p==2)
{
scanf("%d%d",&x,&y);
change_point(1);//单点修改
}
else if(p==3)
{
scanf("%d%d",&a,&b);//区间查询
ask_interval(1);
printf("%d\n",ans);
}
else
{
scanf("%d%d%d",&a,&b,&y);//区间修改
change_interval(1);
}
}
}
这个博客是用结构体写的线段树:
想看用数组写的可以去看这篇: