——问题A——
假设现在有长度为 n n n的序列 a a a,和 m m m个问题,
每一个问题包含一个区间的左端点和右端点,要求得出这个区间段所有元素的总和。
(其中 n < = 1 e 6 , m < = 1 e 6 n<=1e6,m<=1e6 n<=1e6,m<=1e6)
解决思路:
显然对于每一个提问都进行一次遍历累加必定会超时,最坏可达到 1 e 6 ∗ 1 e 6 1e6 * 1e6 1e6∗1e6。假设区间左端点为 l l l,右端点为 r r r,如果我们已经知道前 l − 1 l-1 l−1项的和 s u m [ l − 1 ] sum[l-1] sum[l−1]以及前 r r r项的和 s u m [ r ] sum[r] sum[r],那么答案即为 s u m [ r ] − s u m [ l − 1 ] sum[r]-sum[l-1] sum[r]−sum[l−1],这样的查询区间和操作的时间复杂度即为 O ( 1 ) O(1) O(1)。完成这一前置操作也十分简单,在读入数据时,就可以利用递推式 s u m [ i ] = s u m [ i − 1 ] + a [ i ] sum[i]=sum[i-1]+a[i] sum[i]=sum[i−1]+a[i],得出从第 1 1 1项到第 i i i项的和。这样的思想及其方法称为 前缀和。
——问题B——
假设现在有长度为 n n n的序列 a a a,和 m m m个问题,
每个问题给出一个区间的左端点和右端点,和一个 d d d,表示对这个区间的所有元素都加上 d d d。
m m m个问题结束后,输出这个序列。
(其中 n < = 1 e 6 , m < = 1 e 6 n<=1e6,m<=1e6 n<=1e6,m<=1e6)
解决思路:
暴力是万能的,超时是显然的。面对这个数据量,千万不能每次都老老实实的对每一个区间元素都加上 d d d。其实可以借助“导数”的知识轻松处理,对于一段要修改的区间,在区间头 l l l的位置放入一个标记 d d d,表示从这里开始每个元素加上 d d d;同时,在区间尾 r + 1 r+1 r+1的位置放入一个标记 − d -d −d,表示从这里开始后面的元素不用加 d d d了。这样的标记是可以叠加以及交叉的,当把 2 m 2m 2m个标记都打完后,最后从左到右依次遍历序列中每个元素,对于 i i i,修改为 a [ i ] + = s u m d [ i ] a[i]+=sum_d[i] a[i]+=sumd[i]。这样,区间修改的时间复杂度即为 O ( 1 ) O(1) O(1),这样的区间修改方法称为 差分。
——问题C——
假设现在有长度为 n n n的序列 a a a,和 m m m个问题,问题有两种:
1.给出一个区间的左端点和右端点,和一个 d d d,表示对这个区间的所有元素都加上 d d d。
2.给出一个区间的左端点和右端点,输出当前这段区间所有元素的总和。
(其中 n < = 1 e 6 , m < = 1 e 6 n<=1e6,m<=1e6 n<=1e6,m<=1e6)
解决思路:
介绍了上面的前缀和以及差分,不难发现,这两者是互不相容的操作,无法同时满足 O ( 1 ) O(1) O(1)的查询区间和以及 O ( 1 ) O(1) O(1)时间的区间修改。无论选择上面的哪种策略,都只能快速解决其中一个问题,而另一个问题仍然需要近乎暴力的手法解决,最终仍然会面临超时的风险。那么是否有一种兼顾快速查询与快速修改的方法,较为折中的方法呢?答案是肯定的,而且很容易想到,这个高效的而且折中的方法一定会与二分( l o g n logn logn)有关,它就是基于完美二叉树(Perfect Binary Tree) 实现的线段树。
线段是是擅长处理区间的数据结构,基于完美二叉树实现,区间操作可以在 O ( l o g n ) O(logn) O(logn)时间内完成,对于这棵树中的每一个结点,要么是叶子,要么是一颗有两个子结点的树。其结构如下:
算法的本质就是用时间换空间,或用空间换时间。存储这样一棵线段树,对于长度为 n n n的序列,需要至少 2 n 2n 2n的存储空间。通常情况下,为了防止操作时指针溢出,往往要开 4 n 4n 4n的存储空间。
——线段树的建立——
线段树可以用一个简单的一位数组实现,假设父节点为 i i i,它的两个子结点分别存储在 2 i 2i 2i和 2 i + 1 2i+1 2i+1的位置。对于一棵线段树,它的底层是原序列,上面的父节点可以根据题目需要填入合适的值。比如,我们可以用父节点存储它所指向区间的所有元素和,亦或是该区间的最小、最大元素。建立这些信息时,并不需要额外操作,在建立线段树时,即可一边回溯一边完成写入。
//常用父节点性质
int pushup_sum(int p)//父节点存储区间元素和
{
return tree[p]=tree[p<<1]+tree[p<<1|1];
//p为父节点,p<<1为左儿子,p<<1|1为右儿子
}
int pushup_min(int p)//父节点存储区间最小值
{
return tree[p]=min(tree[p<<1],tree[p<<1|1]);
}
int pushup_max(int p)//父节点存储区间最大值
{
return tree[p]=max(tree[p<<1],tree[p<<1|1]);
}
线段树的建立基于递归的思想,自上而下的把区间划分成单个元素,再自下而上的逐步回溯写入父节点的性质。
//线段树的建立
void build_tree(int p,int l,int r)
{
//递归到底层,该节点为叶节点,赋值,开始回溯
if(l==r)
{
tree[p]=a[l];
return;
}
//二分递归,建立二叉树;
int mid=(l+r)>>1;
build_tree(p<<1,l,mid);
build_tree(p<<1|1,mid+1,r);
//依据需要的性质写入父节点,上面提供了三种常用的性质
pushup_sum(p);
}
——区间问题1——
查询区间 [ l , r ] [l,r] [l,r]中所有元素的和。
根据线段树的结构,我们还是使用递归分块的思路自下而上逐步组成我们需要的元素和。
//查询区间和
int RMQ_sum(int lq,int rq,int l,int r,int p)
{
int ans=0;
//如果问题区间完全包含此时判断的区间[l,r]
if( lq<=l && r<=rq)
return tree[p];
//如果问题区间无法包含现在区间,比较问题区间与区间中点的关系,递归查询
int mid=(l+r)>>1;
if(lq<=mid)
ans+=RMQ_sum(lq,rq,l,mid,p<<1);
if(rq>mid)
ans+=RMQ_sum(lq,rq,mid+1,r,p<<1|1);
return ans;
}
——区间问题2——
查询区间 [ l , r ] [l,r] [l,r]中的最小/最大元素。
与上面的问题大同小异,修改一下回溯是更新条件即可。
//查询区间最大值或最小值
int RMQ_min(int lq,int rq,int l,int r,int p)
//void RMQ_max(int lq,int rq,int l,int r,int p)
{
int ans=0xffffff;
//int ans=-0xffffff;
//如果问题区间完全包含此时判断的区间[l,r]
if( lq<=l && r<=rq)
return tree[p];
//如果问题区间无法包含现在区间,比较问题区间与区间中点的关系,递归查询
int mid=(l+r)>>1;
if(lq<=mid)
ans=min(ans,RMQ_min(lq,rq,l,mid,p<<1));
//ans=max(ans,RMQ_max(lq,rq,l,mid,p<<1));
if(rq>mid)
ans=min(ans,RMQ_min(lq,rq,mid+1,r,p<<1|1));
//ans=max(ans,RMQ_max(lq,rq,mid+1,r,p<<1|1));
return ans;
}
——线段树中元素值的修改——
假设此时要对 [ l , r ] [l,r] [l,r]中的所有元素都加上一个 d d d,也只要利用父节点,一步一步向下传导即可。
//朴素的更新方法_sum或min
void update_sum(int lq,int rq,int l,int r,int p,int d)
// update_min(int lq,int rq,int l,int r,int p,int d)
{
//当前区间超出查询范围,则不做修改
if(rq<l || lq>r)
return;
//精确的找到单个元素,进行修改
if(lq<=l&&r<=rq&&l==r)
{
tree[p]+=d;
return;
}
//向下递归找到区间内元素,并更新
int mid=(l+r)>>1;
update_sum(lq,rq,l,mid,p<<1,d);
update_sum(lq,rq,mid+1,r,p<<1|1,d);
//回溯更新父节点信息
pushup_sum(p);
//pushup_min(p);
}
通过这段代码可以看出,每次修改元素的值,都需要精确地找到这个元素,找到一个元素并修改花费 O ( l o g n ) O(logn) O(logn)的时间,修改区间又需要对所有元素查询并修改一遍,最终复杂度为 O ( n l o g n ) O(nlogn) O(nlogn),这样的效率甚至还不如对数组进行简单的遍历操作,完全无法体现线段树的高效性。实际上,也没有必要精确找到所有元素,线段树的更新思想是区间整体修改的思想,对于一段连续的区间,只要在合适的深度进行整体操作即可。
不妨参考之前区间求和的思路,假设要得出 [ 6 , 10 ] [6,10] [6,10]的区间和,可以拆分为三个部分 [ 6 , 6 ] [6,6] [6,6]、 [ 7 , 9 ] [7,9] [7,9]、 [ 10 , 10 ] [10,10] [10,10],同样地,如果要对区间进行整体修改,也可以拆分成这三个部分。如果我们这时候维护的是区间最小值,父节点下的元素都加上 d d d,等价于父节点也加上 d d d,然后利用回溯,更新根节点;如果维护的是区间元素和,父节点点下元素加上 d d d,等价于父节点加上 d ∗ ( r − l + 1 ) d*(r-l+1) d∗(r−l+1)。这样的修改操作的复杂度约等于之前查询区间和或区间最小值的 O ( l o g n ) O(logn) O(logn)(还会带一点常数)。
这样操作虽然快,同时却面临另一个问题,如果现在有两段交叉区间要进行修改,利用上面的不完全更新法,两区间重叠元素会出错。
——lazytag——
线段树分块递归的好处就在于可以带着修改值 d d d不断往树的底层走,在每次区间修改时,不妨留下一个值为 d d d的标记,如果下一次这个区间需要往更深层挖掘,那就带着先前的标记 d d d更新完它下面的全部元素,再用 d ′ d' d′更新本次需要的元素。为了实现这一想法,我们引入一个新数组 l a z y t a g lazytag lazytag,它的空间大小等于线段树的大小。
//区间整体修改
void rev(int p,int l,int r,int d)
{
lazytag[p]+=d;
tree[p]+=d*(r-l+1);
}
//向下传导lazytag
void pushdown(int p,int l,int r)
{
int mid=(l+r)>>1;
rev(p<<1,l,mid,lazytag[p]);
rev(p<<1|1,mid+1,r,lazytag[p]);
lazytag[p]=0;
//注意此时父节点的标记已经被撤除,因为已经被传给两个儿子了
}
//更新
void update(int lq,int rq,int l,int r,int p,int d)
{
if(lq>r || rq<l)
return;
if(lq<=l&&r<=rq)
{
tree[p]+=d*(r-l+1);
lazytag[p]+=d;
return;
}
pushdown(p,l,r);
//先往下传导标记,再递归查找
int mid=(l+r)>>1;
update(lq,rq,l,mid,p<<1,d);
update(lq,rq,mid+1,r,p<<1|1,d);
pushup_sum(p);
//标记传导完后,回溯就不会出错了
}
同时,为了使区间查询也不至于出错,同时也要加上向下传导标记的操作
//引入lazytag后,区间查询也需要作出一定修改
int RMQ_sum(int lq,int rq,int l,int r,int p)
{
int ans=0;
if(lq<=l&&r<=rq)
return tree[p];
int mid=(l+r)>>1;
pushdown(p,l,r);
if(lq<=mid)
ans+=RMQ_sum(lq,rq,l,mid,p<<1);
if(rq>mid)
ans+=RMQ_sum(lq,rq,mid+1,r,p<<1|1);
return ans;
}
以引言中的问题C为例
#include
using namespace std;
int a[1000006];
int tree[4*1000006];
int lazytag[4*1000006];
int n,m;
inline void build_tree(int p,int l,int r)
{
if(l==r)
{
tree[p]=a[l];
return;
}
int mid=(l+r)>>1;
build_tree(p<<1,l,mid);
build_tree(p<<1|1,mid+1,r);
tree[p]=tree[p<<1]+tree[p<<1|1];
}
inline void rev(int p,int l,int r,int d)
{
lazytag[p]+=d;
tree[p]+=d*(r-l+1);
}
inline void update(int lq,int rq,int l,int r,int p,int d)
{
if(lq<=l&&r<=rq)
{
lazytag[p]+=d;
tree[p]+=d*(r-l+1);
return;
}
int mid=(l+r)>>1;
rev(p<<1,l,mid,lazytag[p]);
rev(p<<1|1,mid+1,r,lazytag[p]);
lazytag[p]=0;
if(lq<=mid) update(lq,rq,l,mid,p<<1,d);
if(rq>mid) update(lq,rq,mid+1,r,p<<1|1,d);
tree[p]=tree[p<<1]+tree[p<<1|1];
}
inline int RMQ_sum(int lq,int rq,int l,int r,int p)
{
int ans=0;
if(lq<=l&&r<=rq) return tree[p];
int mid=(l+r)>>1;
rev(p<<1,l,mid,lazytag[p]);
rev(p<<1|1,mid+1,r,lazytag[p]);
lazytag[p]=0;
if(lq<=mid) ans+=RMQ_sum(lq,rq,l,mid,p<<1);
if(rq>mid) ans+=RMQ_sum(lq,rq,mid+1,r,p<<1|1);
return ans;
}
int main()
{
cin>>n>>m;
for(int i=1;i<=n;++i)
cin>>a[i];
build_tree(1,1,n);
while(m--)
{
int num;
cin>>num;
if(num==1)
{
int lq,rq,d;
cin>>lq>>rq>>d;
update(lq,rq,1,n,1,d);
}
if(num==2)
{
int lq,rq;
cin>>lq>>rq;
cout<<RMQ_sum(lq,rq,1,n,1)<<endl;
}
}
return 0;
}