这波超长福利篇~兄弟们
问题引入
在上上期文章中,我们讲了树状数组。这种线性与树形数据结构相结合的数据结构设计非常巧妙,可以在前缀和或差分基础上增加修改元素值的功能,从而解决掉一些看似 恶心 困难的综合性问题。但如果我说,树状数组的拓展性还不够强,不足以解决我们今天要提到的问题呢?
让我们先来看一道对于前缀和和差分的拓展问题。
区间修改&区间查询(分步版)
时间限制:1秒 空间限制:128MB
题目描述
给定一个由 n n n个元素组成的序列 a a a。
首先进行 t 1 t_1 t1次操作,对于每次操作给出三个数 l l l、 r r r和 x x x,表示将 a [ l ] a[l] a[l]至 a [ r ] a[r] a[r]区间内的元素加上 x x x。
接下来进行 t 2 t_2 t2次操作,对于每次操作给出三个数 l l l、 r r r和 x x x,表示查询 a [ l ] a[l] a[l]至 a [ r ] a[r] a[r]区间内的元素和。
数据范围
1 ≤ n , t 1 , t 2 , l , r ≤ 1 0 5 ; 1 ≤ a i , x ≤ 1 0 9 , 1\leq n,t_1,t_2,l,r\leq10^5;1\leq a_i,x\leq10^9, 1≤n,t1,t2,l,r≤105;1≤ai,x≤109,
此题是对于差分和前缀和的综合版。题目中前 t 1 t_1 t1次操作用到了差分,而后 t 2 t_2 t2次操作用到了前缀和。我们可以在修改操作前,先求出原数组的差分数组,然后按照差分的方法进行区间修改。修改完后求出差分数组的前缀和,即修改后的原数组。然后在查询操作前,求出修改后原数组的前缀和,再按照前缀和的方法进行区间查询即可。简单来说,求差分,再求查分的前缀和(原数组)的前缀和(前缀和数组)。
那如果这样呢?
区间修改&区间查询(综合版)
时间限制:1秒 空间限制:128MB
题目描述
给定一个由 n n n个元素组成的序列 a a a。
共需进行 t t t 次操作。操作类型分为以下两种:
- 修改操作:给出三个数 l l l、 r r r和 x x x,表示将 a [ l ] a[l] a[l]至 a [ r ] a[r] a[r]区间内的元素加上 x x x。
- 查询操作:给出三个数 l l l、 r r r和 x x x,表示查询 a [ l ] a[l] a[l]至 a [ r ] a[r] a[r]区间内的元素和。
两种操作的出现次序不定。
数据范围
1 ≤ n , t , l , r ≤ 1 0 5 ; 1 ≤ a i , x ≤ 1 0 9 , 1\leq n,t,l,r\leq10^5;1\leq a_i,x\leq10^9, 1≤n,t,l,r≤105;1≤ai,x≤109,
可以看到,当题目中两种操作出现的顺序被打乱混合后 可怜的差分和前缀和又一次被崩了,我们就不能像上边那样规规矩矩地先求差分再求前缀和了,因为两种方法都无法在规定时间内进行题目中的全部操作(时间复杂度
O
(
n
2
)
O(n^2)
O(n2))。那我们就需要一种综合了前缀和与差分功能的数据结构来解决。
其实如果用树状数组的话,此问题也是可以解决的。具体可以参照树状数组的功能模块写法,定义
a
a
a 存储修改前后的原数组,
d
d
d 存储差分树状数组,再额外加一个
s
u
m
sum
sum 数组存储差分前缀和数组的前缀和(即查询的答案),那么
s
u
m
[
n
]
=
∑
i
=
1
n
a
[
i
]
=
∑
i
=
1
n
∑
j
=
1
i
d
[
j
]
sum[n]=\sum_{i=1}^na[i]=\sum_{i=1}^n\sum_{j=1}^id[j]
sum[n]=i=1∑na[i]=i=1∑nj=1∑id[j]
根据式子,由于在求 sum[x] 时,d[1] 累加了 x 次(a[1]~a[x]中包含),d[2] 用了 x-1 次(a[2] ~ a[x]中包含)……d[n] 用了1次(a[n]包含)。我们就可以根据差分数组的累加次数简化一下式子,得到
s
u
m
[
n
]
=
∑
i
=
1
n
∑
j
=
1
i
d
[
j
]
=
∑
i
=
1
n
d
[
i
]
∗
(
n
−
i
+
1
)
sum[n]=\sum_{i=1}^n\sum_{j=1}^id[j]=\sum_{i=1}^nd[i]*(n-i+1)
sum[n]=i=1∑nj=1∑id[j]=i=1∑nd[i]∗(n−i+1)
这样就能省掉一层循环。接下来,只需要结合树状数组中的区间修改和区间查询就可以解决这个问题。
但是这个方法并不简单,一看就让人非常头疼,所以这方法不是很重要——当然,更重要的原因是,有另外一种树形结构,它和树状数组很像,但它是树状数组的进阶版,它可以更简单地解决这个问题和同类的其他延伸问题。它不能完全替代树状数组也许只是因为代码更难写(?),但它能够解决比树状数组更为复杂的问题。所以接下来让我们隆重请出今天的主角
线段树
线段树结构
想要学习线段树,首先要明白它的构造。线段树的构造还是比较简单的。
都是“树”字辈,线段树和树状数组一样,也是根据二叉树的构造来创建的,并且原数组中的单个元素也一样放在了每个叶子节点的位置上。不同的是,在构造时,我们是从根节点向下延伸的。
在线段树中,每个节点存储原数组中的一段区间。线段树的根节点存储的是整个原数组,而叶子节点是原数组中的每个元素。
对于每个节点,我们需要定义 l l l存储其代表区间的左端点, r r r存储其代表区间的右端点。每个节点存储的区间就是原数组中下标从 l l l到 r r r的一部分。对于节点的两个儿子,定义 m i d mid mid= ( l + r ) / 2 (l+r)/2 (l+r)/2,节点的左儿子代表的区间为 l l l~ m i d mid mid,而右儿子代表的区间为 m i d mid mid+ 1 1 1~ r r r。
重点:线段树根节点的 l l l= 1 1 1, r r r= n n n,而叶子节点的 l l l= r r r
一棵构建完成的线段树是这样的。在图中,我们用节点的宽度来区分其代表的序列的长度。
与树状数组不同的是,线段树完全使用了正常的二叉树结构,没有删去过渡节点,并且更加依赖树结构节点之间的关系;树状数组使用了二进制,也就是二的乘方思想,而线段树则涉及到了二分思想,两者恰好相反。
代码实现
我们在程序中创建一棵线段树,通常使用简单的线性数组存储,例如定义 f[] 数组为线段树,则 f[1] 为根节点,节点 f[i] 的左儿子是 f[*2] ,右儿子是 f[i*2+1] 。这样树中就不需要额外存储左右儿子了。
我们创建一个结构体来表示节点,结构体中包含节点表示区间的左端点和右端点。在使用线段树时,一个节点通常还会存储其表示序列的某些特定值(如最大值、最小值、元素和等,后文称为区间信息),因此还需要一个变量来存储这些信息。如,定义一棵求和的二叉树:
struct node{
int l,r;//需要左右端点
long long sum;//需要区间和,不开long long见祖宗
}f[100005];//线型数组存树结构
这样,一个存储线段树的数组 f[] 就完成了。但是其中还没有完整的树结构,因为我们没有规定节点的左右端点和 s u m sum sum 值。我们还需要一个函数用于创建线段树。
线段树操作-建树
一棵完整的线段树需要存储一个区间,以及实际问题需要的区间信息。大家可以参考上图线段树的结构,我们知道一个节点的两个子节点表示的区间,就是它自身表示的区间对半分。可是我们遇到一个问题:
- 我们初始时只知道根节点的区间范围,所以需要从根节点往下方延伸,才能知道每一个节点代表的区间。
- 我们初始时只知道叶子节点代表的区间信息,因为叶子节点只代表原数组中的一个元素,任何信息都等于该元素本身,其他节点可以通过将其两个子节点相加或相比较来得到区间信息。所以需要从叶子节点向上延伸,才能知道每一个节点的区间信息。
一上一下,在方向上看似矛盾,难道要分两步?其实不需要。因为有一种算法,它叫深搜。没错,在线段树建树时,我们可以利用深搜算法。利用递归,一探一回,往下探时确定范围,往上回时确定信息。
以这棵树为例。一开始,我们不知道除根节点外所有节点的信息。我们从 f[1] 开始,一直往左子树方向走,就可以把 f[2]、f[4]、f[8] 的区间范围都确定。f[8] 是叶子节点,因此到 f[8] 后,就能确定 f[8] 的区间信息,即 a[1] 。接下来返回 f[4] ,再往右子树 f[9] 走,确定区间信息后返回 f[4] 。此时 f[4] 的左右子节点都已经遍历完毕,也可以确定区间信息(即 a[1]+a[2])。
到此为止,我们已经确定了 f[4]、f[8]、f[9] 的全部信息。接下来从 f[4] 返回 f[2] 并遍历 f[2] 的右子树,以此类推,最后就能构建好整棵树。
代码实现
根据深搜的思想,我们使用递归来建树。定义一个函数,首先传进给定子树的根节点和其代表区间的左右端点。建树时将区间的左右两部分作为独立区间传给左右子树,递归完后将该节点存储的区间信息根据子树的信息进行更新。
void build(int root,int l,int r){
//f[]表示线段树,a[]表示原数组
f[root].l=l,f[root].r=r;//存储左右端点
if(l==r){
f[root].v=a[l];
return;//叶子节点记录区间信息并返回
}
//由于通常操作较多,线段树使用二进制运算小幅提升速度
int mid=(l+r)>>1;//求出中节点,(l+r)>>1等同于(l+r)/2
build(root<<1,l,mid);//递归左子树。root<<1等同于root*2
build(root<<1|1,mid+1,r);//递归右子树。root<<1|1等同于root*2+1
f[root].v=f[root<<1].v+f[root<<1|1].v;//更新区间信息(以求和为例)
}
线段树操作-查询
线段树单点查询
找到树上的对应叶子节点输出,或直接使用下面的区间查询代码即可。(这真不用我说了对吧)
线段树区间查询
在线段树中,如果要查询下面的几个区间的元素和,应该怎么办呢?
(3,4)(1,4)(5,6)(1,8)
很显然,它们都是线段树中某一个节点所代表的区间。因此如果要查询这些区间,只需要输出线段树中代表这些区间的对应节点输出就可以了。我们可以使用递归搜索来查找。
那如果是这几个区间呢?
(1,3)(2,5)(2,6)(3,8)
这些区间在线段树中都没有直接代表的节点。因为对于树中的节点,它们不是多几个数就是少几个数。因此查找时,不能直接找到一个节点输出。
事实上,我们可以根据区间与节点的包含关系来解题。从根节点开始,判断当前区间是否被目标区间完全包含,如果包含就返回这一段的区间和;否则,如果当前节点左子树包含目标区间,就查找左子树并累加,如果右子树包含目标区间,就查找右子树并累加,最后返回累加值。
例如,查找(2,5)。从根节点开始。
根节点的左右子树都包含(2,5)的一部分。所以左右子树都需要遍历。
f[2] 的左右子树依旧都包含(2,5),其中 f[4] 包含一部分,而 f[5] 完全包含,因此 f[4] 需查询,f[5] 直接返回;f[3] 的左子树包含(2,5)的一部分,右子树不包含,因此只查左子树 f[6]。
查询 f[4] 和 f[6] 时,我们发现 f[4] 的右子树、f[6] 的左子树包含目标区间。因此查找这两棵子树。由于两者都是叶子节点,所以查询到时直接返回。我们至此已得到了区间(2,5)的子段和,因此可以返回输出了。这就是区间查询的全过程。
代码实现
我们定义一个函数,传入当前子树的根节点和目标区间的左右端点。在主函数调用时,根节点为整棵线段树的根节点,左右端点分别为1和n。按照上述思维过程,先判断该区间是否完全被目标区间包含,是则返回,不是则递归查询包含部分目标区间的左右子树并累加,返回累加和。
int Find(int root,int l,int r){
if(l<=f[root].l&&r>=f[root].r) return f[root].v;//整个序列都包含在目标段里
int mid=(f[root].l+f[root].r)>>1,cnt=0;
if(l<=mid) cnt+=Find(root<<1,l,mid);//左子树还有,就累加左边
if(r>mid) cnt+=Find(root<<1|1,mid+1,r);//又子树还有,就累加右边
return cnt;//返回累加和
}
线段树操作-修改
单点修改
要改变线段树上一个单点的区间信息,不仅要改变它本身,还要改变所有包含它的节点的值。我们可以先深搜向下,找到叶子节点后改变值,再返回后更新路径上的值。此操作比较简单。
void update(int root,int d,int v){//节点d增加v
if(f[root].l==f[root].r){//到头了
f[root].v=v;//改完就走
return;
}
int mid=(f[root].l+f[root].r)>>1;
if(d<=mid) update(root<<1,l,mid);
else update(root<<1|1,mid+1,r);//二分思想向下查询,只需更新一个子树
f[root].v=f[root<<1].v+f[root<<1|1].v;//子树信息变动,需要更新区间信息
}
区间修改
接下来的这一步可以说是基础操作里面最为麻烦的一种了。以上的所有操作用树状数组也是可以完成的,但线段树较前者的优势就在于区间修改与其他操作的融合。
前面我们说过,如果修改了单点的值,其所有前驱节点的值都要改变。而如果改变了一个区间,影响还会更大。例如,在根节点存储8个元素的线段树中,将(1,2)区间内所有元素 (其实就俩) 增加2,则(1,2)、(1,4)、(1,8)所存储的区间信息都要增加
2
2
2*
2
2
2=
4
4
4。
这个例子还是简单的只涉及到一串点的操作。如果像上面区间查询时第二组 栗子 那样,影响了一片数据,操作起来才是真的难。
在区间修改的操作里,我们用到了一种标记——lazy(延迟标记,又称懒惰标记,简称懒标)。其原理是增加一个变量存储一个值,对于该节点的所有更改操作都只更改此变量,到下次查询时再经过一定计算累加到sum中。在高精度计算中,我们存储进位就运用了这种方法。
想要运用延迟标记,我们需要在表示线段树的结构体中,添加一个成员变量lazy,专门用来存储标记,还需要重写区间查询,不过建树的操作可以保持不变。
在搜索遍历中,如果一个区间完全包含要修改的目标区间,我们可以将该区间存储的元素和直接加上 增加的值×区间中元素个数,这样它仍然是更新完成后的区间和。
但是如果要用到该节点的后继节点,由于它们的值实际上并未改变,所以并不是正确的值。这时候lazy标记就出场了。我们改变完完全包含目标区间的节点后,将它的lazy标记增加区间要增加的值,表示 “这里改过,记得更新” 。但这样还不行,因为多次连续操作后lazy标记混合,无法确定区间范围。因此我们定义一个函数spread(int root)
,用于将节点 f[root] 的lazy标记下放。
在spread(int root)
函数中,我们分步骤讨论root节点的左右子树。每个子树要增加的值就是该子树的区间长度×要增加的值。更新后将该子树父节点的lazy值继承。
在修改函数中,如果目标区间完全包含当前节点所表示的区间,我们就更新该节点的值。否则分别更新包含在目标区间内的左右子树,回溯后更新该节点的值。
代码实现
struct node{
int l,r,lazy;//定义中增加lazy标记
long long sum;
}f[100005];
void spread(int root){//标记下放函数
long long la=f[root].lazy;
if(l!=0){
//更新左子树sum和lazy
f[root<<1].sum+=(f[root<<1].r-f[root<<1].l+1)*la,f[root<<1].lazy+=la;
//更新右子树sum和lazy
f[root<<1|1].sum+=(f[root<<1|1].r-f[root<<1|1].l+1)*la,f[root<<1|1].lazy+=la;
f[root].lazy=0;//清空父节点lazy
}
}
void update(int root,int l,int r,int p){//修改函数。现在到root了,要把a[l]到a[r]加上p
if(l<=f[root].l&&r>=f[root].r){//如果目标区间完全包含此区间
f[root].sum+=p*(f[root].r-f[root].l+1),f[root].lazy+=p;
return;
}
spread(root);//下放
int mid=(f[root].l+f[root].r)>>1;
if(l<=mid) update(root<<1,l,r,p);
if(r>mid) update(root<<1|1,l,r,p);//分治遍历包含目标区间的左右子树
f[root].sum=f[root<<1].sum+f[root<<1|1].sum;//累加更新
}
long long Find(int root,int l,int r){//查找函数
if(l<=f[root].l&&r>=f[root].r) return f[root].sum;
spread(root);//增加了lazy下放的步骤
long long cnt=0,mid=(f[root].l+f[root].r)>>1;
if(l<=mid) cnt+=Find(root<<1,l,r);
if(r>mid) cnt+=Find(root<<1|1,l,r);
return cnt;
}
PS:理论上如果题目中用到了区间修改,单点修改的函数就不需要了。