【算法解析】看懂线段树#1 线段树的构造认识与基本操作

这波超长福利篇~兄弟们

问题引入

上上期文章中,我们讲了树状数组。这种线性与树形数据结构相结合的数据结构设计非常巧妙,可以在前缀和或差分基础上增加修改元素值的功能,从而解决掉一些看似 恶心 困难的综合性问题。但如果我说,树状数组的拓展性还不够强,不足以解决我们今天要提到的问题呢?

让我们先来看一道对于前缀和和差分的拓展问题。

区间修改&区间查询(分步版)
时间限制: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, 1n,t1,t2,l,r105;1ai,x109,

此题是对于差分和前缀和的综合版。题目中前 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, 1n,t,l,r105;1ai,x109,

可以看到,当题目中两种操作出现的顺序被打乱混合后 可怜的差分和前缀和又一次被崩了,我们就不能像上边那样规规矩矩地先求差分再求前缀和了,因为两种方法都无法在规定时间内进行题目中的全部操作(时间复杂度 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=1na[i]=i=1nj=1id[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=1nj=1id[j]=i=1nd[i](ni+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:理论上如果题目中用到了区间修改,单点修改的函数就不需要了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值