线段树详解
线段树的学习顺序。
单点修改+区间查询
区间修改+区间查询(懒惰标记)
区间合并( 最后的例题就是 )
扫描线,和区间修改很相似,但是没有懒惰标记,难理解一点
最后就是 主席树了,一个很大的不同就是 每个节点的左右儿子并不是简单的 rt<<1和rt<<1|1。
线段树 还是要多想想才能真正理解。这篇只有前两种线段树,区间合并不难,扫描线和主席树还要网上自己再找找。
最后给出一些例题。
由此看出,用线段树统计的东西,必须符合区间加法,否则,不可能通过分成的子区间来得到[L,R]的统计结果。
符合区间加法的例子:
数字之和——总数字之和 = 左区间数字之和 + 右区间数字之和
最大公因数(GCD)——总GCD = gcd( 左区间GCD , 右区间GCD );
最大值——总最大值=max(左区间最大值,右区间最大值)
不符合区间加法的例子:
众数——只知道左右区间的众数,没法求总区间的众数
01序列的最长连续零——只知道左右区间的最长连续零,没法知道总的最长连续零
初学者可以先看这篇文章: 线段树从零开始
(注:由于线段树的每个节点代表一个区间,以下叙述中不区分节点和区间,只是根据语境需要,选择合适的词)
线段树本质上是维护下标为1,2,..,n的n个按顺序排列的数的信息,所以,其实是“点树”,是维护n的点的信息,至于每个点的数据的含义可以有很多,
在对线段操作的线段树中,每个点代表一条线段,在用线段树维护数列信息的时候,每个点代表一个数,但本质上都是每个点代表一个数。以下,在讨论线段树的时候,区间[L,R]指的是下标从L到R的这(R-L+1)个数,而不是指一条连续的线段。只是有时候这些数代表实际上一条线段的统计结果而已。
线段树是将每个区间[L,R]分解成[L,M]和[M+1,R] (其中M=(L+R)/2 这里的除法是整数除法,即对结果下取整)直到 L==R 为止。
开始时是区间[1,n] ,通过递归来逐步分解,假设根的高度为1的话,树的最大高度为(n>1)。
线段树对于每个n的分解是唯一的,所以n相同的线段树结构相同,这也是实现可持久化线段树的基础。
下图展示了区间[1,13]的分解过程:
上图中,每个区间都是一个节点,每个节点存自己对应的区间的统计信息。
假设要修改[5]的值,可以发现,每层只有一个节点包含[5],所以修改了[5]之后,只需要每层更新一个节点就可以线段树每个节点的信息都是正确的,所以修改次数的最大值为层数。
复杂度O(log2(n))
线段树能快速进行区间查询的基础是下面的定理:
定理:n>=3时,一个[1,n]的线段树可以将[1,n]的任意子区间[L,R]分解为不超过个子区间。
这样,在查询[L,R]的统计值的时候,只需要访问不超过个节点,就可以获得[L,R]的统计信息,实现了O(log2(n))的区间查询。
下图是n=16 , L=2 , R=15 时的操作图,此图展示了达到最小上界的树的结构。
线段树的区间修改也是将区间分成子区间,但是要加一个标记,称作懒惰标记。
标记的含义:
本节点的统计信息已经根据标记更新过了,但是本节点的子节点仍需要进行更新。
即,如果要给一个区间的所有值都加上1,那么,实际上并没有给这个区间的所有值都加上1,而是打个标记,记下来,这个节点所包含的区间需要加1.打上标记后,要根据标记更新本节点的统计信息,比如,如果本节点维护的是区间和,而本节点包含5个数,那么,打上+1的标记之后,要给本节点维护的和+5。这是向下延迟修改,但是向上显示的信息是修改以后的信息,所以查询的时候可以得到正确的结果。有的标记之间会相互影响,所以比较简单的做法是,每递归到一个区间,首先下推标记(若本节点有标记,就下推标记),然后再打上新的标记,这样仍然每个区间操作的复杂度是O(log2(n))。
标记有相对标记和绝对标记之分:
相对标记是将区间的所有数+a之类的操作,标记之间可以共存,跟打标记的顺序无关(跟顺序无关才是重点)。
所以,可以在区间修改的时候不下推标记,留到查询的时候再下推。
注意:如果区间修改时不下推标记,那么PushUp函数中,必须考虑本节点的标记。
而如果所有操作都下推标记,那么PushUp函数可以不考虑本节点的标记,因为本节点的标记一定已经被下推了(也就是对本节点无效了)
绝对标记是将区间的所有数变成a之类的操作,打标记的顺序直接影响结果,
所以这种标记在区间修改的时候必须下推旧标记,不然会出错。
注意,有多个标记的时候,标记下推的顺序也很重要,错误的下推顺序可能会导致错误。
区间修改对应最基本的区间查询,比如:区间求和
为什么可以用懒惰标记?
当你在查询一个区间的时候,一定是从根节点遍历下来的,所以他的懒惰标记一定会被下推到你
要查询的区间,是不会错的。
线段树是用数组来模拟树形结构,对于每一个节点R ,左子节点为 2*R (一般写作R<<1)右子节点为 2*R+1(一般写作R<<1|1)
然后以1为根节点,所以,整体的统计信息是存在节点1中的。
这么表示的原因看下图就很明白了,左子树的节点标号都是根节点的两倍,右子树的节点标号都是左子树+1:
线段树需要的数组元素个数是:,一般都开4倍空间,比如: int A[n<<2];
以下以维护数列区间和的线段树为例,演示最基本的线段树代码。
#define maxn 100007 //元素总个数
#define ls l,m,rt<<1
#define rs m+1,r,rt<<1|1
int Sum[maxn<<2],Add[maxn<<2];//Sum求和,Add为懒惰标记
int A[maxn],n;//存原数组数据下标[1,n]
(1)建树:
//PushUp函数更新节点信息 ,这里是求和
void PushUp(int rt){Sum[rt]=Sum[rt<<1]+Sum[rt<<1|1];}
//Build函数建树
void Build(int l,int r,int rt){ //l,r表示当前节点区间,rt表示当前节点编号
if(l==r) {//若到达叶节点
Sum[rt]=A[l];//储存数组值
return;
}
int m=(l+r)>>1;
//左右递归
Build(l,m,rt<<1);
Build(m+1,r,rt<<1|1);
//更新信息
PushUp(rt);
}
(2)点修改:
假设A[L]+=C:
void Update(int L,int C,int l,int r,int rt){//l,r表示当前节点区间,rt表示当前节点编号
if(l==r){//到叶节点,修改
Sum[rt]+=C;
return;
}
int m=(l+r)>>1;
//根据条件判断往左子树调用还是往右
if(L <= m) Update(L,C,l,m,rt<<1);
else Update(L,C,m+1,r,rt<<1|1);
PushUp(rt);//子节点更新了,所以本节点也需要更新信息
}
(3)区间修改:
void Update(int L,int R,int C,int l,int r,int rt){//L,R表示操作区间,l,r表示当前节点区间,rt表示当前节点编号
if(L <= l && r <= R){//如果本区间完全在操作区间[L,R]以内
Sum[rt]+=C*(r-l+1);//更新数字和,向上保持正确
Add[rt]+=C;//增加Add标记,表示本区间的Sum正确,子区间的Sum仍需要根据Add的值来调整
return ;
}
int m=(l+r)>>1;
PushDown(rt,m-l+1,r-m);//下推标记
//这里判断左右子树跟[L,R]有无交集,有交集才递归
if(L <= m) Update(L,R,C,l,m,rt<<1);
if(R > m) Update(L,R,C,m+1,r,rt<<1|1);
PushUp(rt);//更新本节点信息
}
(4)区间查询:
询问A[L,R]的和
首先是下推标记的函数:
void PushDown(int rt,int ln,int rn){
//ln,rn为左子树,右子树的数字数量。
if(Add[rt]){
//下推标记
Add[rt<<1]+=Add[rt];
Add[rt<<1|1]+=Add[rt];
//修改子节点的Sum使之与对应的Add相对应
Sum[rt<<1]+=Add[rt]*ln;
Sum[rt<<1|1]+=Add[rt]*rn;
//清除本节点标记
Add[rt]=0;
}
}
然后是区间查询的函数:
int Query(int L,int R,int l,int r,int rt){//L,R表示操作区间,l,r表示当前节点区间,rt表示当前节点编号
if(L <= l && r <= R){
//在区间内,直接返回
return Sum[rt];
}
int m=(l+r)>>1;
//下推标记,否则Sum可能不正确
PushDown(rt,m-l+1,r-m);
//累计答案
int ANS=0;
if(L <= m) ANS+=Query(L,R,l,m,rt<<1);
if(R > m) ANS+=Query(L,R,m+1,r,rt<<1|1);
return ANS;
}
(5)函数调用:
//建树
Build(1,n,1);
//点修改
Update(L,C,1,n,1);
//区间修改
Update(L,R,C,1,n,1);
//区间查询
int ANS=Query(L,R,1,n,1);
题目:Codeforces 527C Glass Carving 题解
题意是给定一个矩形,不停地纵向或横向切割,问每次切割后,最大的矩形面积是多少。
最大矩形面积=最长的长*最宽的宽
这题,长宽都是10^5,所以,用01序列表示每个点是否被切割,然后,
最长的长就是长的最长连续0的数量+1
最长的宽就是宽的最长连续0的数量+1
于是用线段树维护最长连续零
问题转换成:
目标信息:区间最长连续零的个数
点信息:0 或 1
由于目标信息不符合区间加法,所以要扩充目标信息。
转换后的线段树结构:
区间信息:从左,右开始的最长连续零,本区间是否全零,本区间最长连续零。
点信息:0 或 1
然后还是那2个问题:
1.区间加法:
这里,一个区间的最长连续零,需要考虑3部分:
-(1):左子区间最长连续零
-(2):右子区间最长连续零
-(3):左右子区间拼起来,而在中间生成的连续零(可能长于两个子区间的最长连续零)
而中间拼起来的部分长度,其实是左区间从右开始的最长连续零+右区间从左开始的最长连续零。
所以每个节点需要多两个量,来存从左右开始的最长连续零。
然而,左开始的最长连续零分两种情况,
--(1):左区间不是全零,那么等于左区间的左最长连续零
--(2):左区间全零,那么等于左区间0的个数加上右区间的左最长连续零
于是,需要知道左区间是否全零,于是再多加一个变量。
最终,通过维护4个值,达到了维护区间最长连续零的效果。
2.点信息->区间信息 :
如果是0,那么 最长连续零=左最长连续零=右最长连续零=1 ,全零=true。
如果是1,那么 最长连续零=左最长连续零=右最长连续零=0, 全零=false。
至于修改和查询,有了区间加法之后,机械地写一下就好了。
由于这里其实只有对整个区间的查询,所以查询函数是不用写的,直接找根的统计信息就行了。
例题:
http://acm.hdu.edu.cn/showproblem.php?pid=1166
http://acm.hdu.edu.cn/showproblem.php?pid=1698
http://acm.pku.edu.cn/JudgeOnline/problem?id=2777
http://acm.pku.edu.cn/JudgeOnline/problem?id=2528
http://acm.pku.edu.cn/JudgeOnline/problem?id=3667
http://acm.pku.edu.cn/JudgeOnline/problem?id=2828
http://acm.zcmu.edu.cn/JudgeOnline/problem.php?id=1948
题解:https://blog.csdn.net/qq_41713256/article/details/80482763
http://hdu.hustoj.com/showproblem.php?pid=4578(懒惰标记下推顺序)
题解:https://blog.csdn.net/qq_41713256/article/details/80496874
区间合并:http://hdu.hustoj.com/showproblem.php?pid=3308
题解:https://blog.csdn.net/qq_41713256/article/details/80508611
这些题目一定要做完,,,,,,,