问题描述
设想一个区间上的问题,单点修改值,动态查询某区间的和
0.朴素算法
直接暴力来,区间上一个数一个数枚举求和(标题的“朴素”表达得很委婉)
假如查询次数是m,区间平均长度是n,那么时间复杂度达到了惊人的O(m*n)
这是不能接受的,因为O(1)修改,O(n)单次查询
为了在大数据规模下能够均衡,我们可以采用分治思想,
诞生了线段树(正如其名,是用在线段):O(log n)修改,O(log n)查询
接下来,你将看到的是线段树的进阶之路
1.单点修改,区间查询
线段树最初的样子,沿路修改,沿路合并区间查询,完全没有技术含量,直接上代码了
区间划分,回溯建树
建树即维护好区间和
//建树
void build(int i,int l,int r)
{
if(l>r) return;
//length=i>length? i:length; //记录树的编号范围,便于输出树的信息
if(l==r){
sum[i]=num[l];
return;
}
int mid=(l+r)/2;
build(i*2,l,mid);
build(i*2+1,mid+1,r);
sum[i]=sum[i*2]+sum[i*2+1];
}
单点修改,搜哪里“进入”哪里
//单点修改
void change(int i,int l,int r,int x,int k)
{
if(l>r) return;
if(l==r)
{
sum[i]+=k; //叶子节点单点加
return;
}
int mid=(l+r)/2;
if(x<=mid) change(i*2,l,mid,x,k);
else change(i*2+1,mid+1,r,x,k);
sum[i]=sum[i*2]+sum[i*2+1]; //回溯时更新
}
区间查询,查询哪里“进入”哪里“收集”起来
//区间查询
int query_sum(int i,int l,int r,int x,int y)
{
if(l>y||r<x) return 0;
if(l>=x&&r<=y) return sum[i];
int mid=(l+r)/2;
return query_sum(i*2,l,mid,x,y)+query_sum(i*2+1,mid+1,r,x,y);
}
2.区间修改,单点查询
区间修改假如暴力地使用m次单点修改,那么时间复杂度是不能接受的,为了节约时间,我们可以不必真正地全部修改区间的每一个元素,而是给路径上的某个祖先节点打标记,那样走这条路的时候就知道某个元素所在的区间曾经发生过区间加,这个标记我称为addtag
对于区间的修改,仿照区间的查询,并在全部包含在给定区间的节点打标记,然后立刻return,这一步是为了防止重复加
但是这样是有缺点的,只能单点查询,如果区间查询可能会存在一些问题,后面会马上讲到
//区间修改
void array_change(int i,int l,int r,int x,int y,int k)
{
if(l>y||r<x) return;
if(l>=x&&r<=y) {
addtag[i]+=k;
return;
}
int mid=(l+r)/2;
array_change(i*2,l,mid,x,y,k);
array_change(i*2+1,mid+1,r,x,y,k);
}
//单点查询
int query_num(int i,int l,int r,int x)
{
if(l>r) return 0;
if(l==r) return addtag[i]+sum[i];
int mid=(l+r)/2;
if(x<=mid) return addtag[i]+query_num(i*2,l,mid,x);
else return addtag[i]+query_num(i*2+1,mid+1,r,x);
}
3.区间修改,区间查询
上述代码在单点查询时是没有问题的,但是区间查询就会出现问题
如果我们和1中的区间查询一样,区间只找完全符合的区间纳入结果,那么可能会出现:一个节点打了addtag标记,但是我无法完美地使用这个节点(它表示的区间并不是完全在给定区间内),必须使用这个节点的子节点,而子节点是没有标记的,子节点是存在问题的,反过来,祖先节点也是存在问题的,当查询区间和时,如果祖先节点完美调用了,那么就不需要打了标记的子节点了,但是祖先节点没有打过这个标记,也会使结果错误
那么解决这个问题有两种方式(均是对标记的手段进行优化):标记下放和标记永久化
两种方法都维护了祖先节点的正确性,标记下放是靠回溯类似建树,标记永久化是沿路直接计算影响
不同之处在于标记下放的标记会下放,标记永久化不会下放
标记下放
标记下放的核心思想在于:时刻维护已走节点的正确性(包括当前所在的节点),未走的节点尽可能地懒惰不算以节约时间
核心原理: 区间修改时,每次使该节点sum值更新,然后打一个lazytag接着回溯
也就是说,对于每个点,如果有lazytag标记,那么它的值是完美的(更新过的),可以直接调用
只有我无法完美地调用这个节点时,不得已把之前偷懒的标记(lazytag)下放,此时子节点的sum值被修改正确,lazytag传给了子节点(甚至子节点也竭尽全力地偷懒),那么要记得把这个点的lazytag清空
由于区间修改和区间查询都会走树的路径,而且为了区间修改的最终结果是同一条路径上不会有多个lazytag,现在示例是区间加法,仅区间查询时下放效果相同,但是如果维护的是别的信息,很可能会致错,而且标记下放和区间修改、区间查询同步发生改变的是常数,不改变理论时间复杂度,所以我们选择区间修改、区间查询都下放标记
工具性的
//对节点加
void add(int i,int l,int r,int x)
{
lazytag[i]+=x;
sum[i]+=(r-l+1)*x;
}
//标记下放
void pushdown(int i,int l,int r)
{
if(!lazytag) return;
int mid=(l+r)/2;
add(i*2,l,mid,lazytag[i]);
add(i*2+1,mid+1,r,lazytag[i]);
lazytag[i]=0; //清空
}
区间修改
//区间修改
void array_change(int i,int l,int r,int x,int y,int k)
{
if(l>y||r<x) return;
if(l>=x&&r<=y) {
add(i,l,r,k);
return;
}
pushdown(i,l,r);
int mid=(l+r)/2;
array_change(i*2,l,mid,x,y,k);
array_change(i*2+1,mid+1,r,x,y,k);
sum[i]=sum[i*2]+sum[i*2+1];
}
区间查询
//区间查询
int query_sum(int i,int l,int r,int x,int y)
{
if(l>=x&&r<=y) return sum[i];
if(l>y||r<x) return 0;
pushdown(i,l,r);
int mid=(l+r)/2;
return query_sum(i*2,l,mid,x,y)+query_sum(i*2+1,mid+1,r,x,y);
}
标记永久化
区间修改时沿路直接计算对当前节点的影响,以维护修改节点的祖先节点的正确性
对于修改的节点,仍然打标记,但是标记就这样永久化,以后查询的时候计算影响即可
和“区间修改,单点查询”不同点在于,祖先节点的正确性得到了维护
区间修改
//区间修改
void array_change(int i,int l,int r,int x,int y,int k)
{
if(l>y||r<x) return;
if(l>=x&&r<=y) {
addtag[i]+=k;
return;
}
sum[i]+=(min(r,y)-max(l,x)+1)*k;
int mid=(l+r)/2;
array_change(i*2,l,mid,x,y,k);
array_change(i*2+1,mid+1,r,x,y,k);
}
区间查询
//区间查询
long long query_sum(int i,int l,int r,int x,int y)
{
if(l>=x&&r<=y) return sum[i]+(r-l+1)*addtag[i];
if(l>y||r<x) return 0;
int mid=(l+r)/2;
return query_sum(i*2,l,mid,x,y)+query_sum(i*2+1,mid+1,r,x,y)+(min(r,y)-max(x,l)+1)*addtag[i];
}