高级数据结构:线段树(Lazy标记的两种处理方法)

目录

          线段树的使用

     1.线段树的建立

     2.区间询问

     3.单点修改

     4.区间修改

          例题及其补充


线段树的使用:

1.线段树的建立:

线段树的思想:

线段树是一种能维护区间性质的数据结构,最典型的应用就是维护区间最值与区间连续和。其功能与树状数组(不懂树状数组的可以先看我的树状数组的博客:高级数据结构:树状数组,懂了树状数组但是不知道树状数组的一些例如"区间修改,区间查询"等操作的也可看看)相比略有重合,但线段树的适用范围要更大,即能用树状数组解决的题一定能用线段树解决(除非是卡时间)。线段树的每个结点都对应着一个区间(或者叫线段,这也是线段树名称的由来),维护的值为该区间内的最优解,而得到全局最优解的核心思想实际上是分治,一个能用分治解决的问题一般就能用线段树来维护。

线段树一般能维护区间最值与区间和,下面举例说明线段树是如何维护这些信息的:

对如下一组数据,询问其区间的最小值与区间和:

2

4

6

8

10

3

5

 7

线段树的每个结点对应一段区间,它的左儿子对应区间[begin , mid],右儿子对应区间[mid+1 ,end]mid=(begin + end)/2),其存储的数据分别为该区间上的最小值于该区间上所有数的和。

我们将其存入线段树中,即可得到下面的树形结构(括号内表示对应的区间):

         

与树状数组的建立一样,线段树同样可以通过两种方式建立,首先是较方便的一种:直接通过修改操作建树(修改操作见下文),即对每个数据都执行一次修改操作,每次修改时间复杂度为logn,所以这样建树总时间复杂度为O(nlogn)。

for(int i=1; i<=n; ++i){
    cin >> x;
    modify(1, 1, n, i, x);
}

同样我们能更高效的建立一颗线段树,建树过程可以递归定义,每个结点只操作一次,所以时间复杂度仅为O(n):

const int INF = 0x7fffffff;
const int N = 100005;
//注意线段树的空间要开四倍
int st[4*N];       //存储区间最值
long long sum[4*N];//存储区间和
int num[N];

//建立一颗线段树
void build(int k, int l, int r) {
	if(l == r) {
		sum[k] = num[l];
		st[k] = num[l];
		return; 
	}
	int mid = (l + r) >> 1;
	build(k << 1, l, mid);            //建立左子树
	build(k << 1 | 1, mid + 1, r); //建立右子树
	sum[k] = sum[k << 1] + sum[k << 1 | 1];
	st[k] = min(st[k << 1], st[k << 1 | 1]);
}

关于线段树的空间要开4倍的问题,其实网上有很多探讨。这里简单的提及一下,我们知道线段树的结点数=2n-1,所以很大一部分人下意识的认为数组的下标不会超过结点数,即开2倍的空间就足够了,然而实际上我们在构建线段树时,并不是数组的每个下标都用上了,中间可能存在着没有使用浪费着的空间!例如,考虑这样一颗线段树,深度为h,它的h-1层是满二叉树,最后一层不满,请思考:最后一层的结点一定是按顺序接在h-1层的第一个结点上的吗?—— 很显然只有当最后一层的结点按顺序依次接在h-1层的第1、2···个结点上,此时数组的空间是全部用上了的,数组下标不会超过结点数。然而,最后一层的结点不一定会按顺序接上h-1层,比如最后一层只多出两个结点,而它们接在的是h-1层的最后一个结点上,此时数组的下标按照2k+1计算的话,中间跳过了大量的数组下标,将有大量的空间没有用上!事实上,此时的情况就为线段树空间的最坏情况,而这个下标的范围经计算=4n-5(具体计算可以去网上搜搜,有很多),因此,我们的空间为了应对最坏情况要开出4倍的大小。

2.区间询问:

函数query (k,begin,end,x,y):k为数组下标,(begin,end)为拆分区间,(x,y)为所求区间

注意区间的提取规则,按相同的方式拆分区间,并每次与所求区间相匹配:

1.若所求区间与拆分区间存在交集(但不完全包含),则继续拆分拆分区间;

2.若所求区间完全包含了拆分区间,(不继续递归)则直接返回该结点上的值;

3.区间最值:若所求区间与拆分区间没有交集,则返回一个不影响答案的极大值(求最大值就返回一个极小值);

   区间和:若所求区间与拆分区间没有交集,则返回0。

//查询答案:区间最值
int query(int k, int l, int r, int x, int y){
	if(y<l || x>r) return INF;           //规则3
	if(x <= l && y >= r) return st[k];   //规则2
	int mid = (l + r) >> 1;              //规则1   
	return min(query(k << 1, l, mid, x, y),query(k << 1 | 1, mid + 1, r, x, y));
}
//查询答案:区间和
long long query(int k, int l, int r, int x, int y){
	if(y<l || x>r) return 0;           
	if(x <= l && y >= r) return sum[k];   
	int mid = (l + r) >> 1;                 
	long long ans = 0;
	ans += query(k << 1, l, mid, x, y);
	ans += query(k << 1 | 1, mid + 1, r, x, y);
	return ans;
}

如果不理解以上规则,我们再来看看按上述流程是怎么得到最小值的:

假设现在要求[3,4]与[2,8]区间内的最小值(注意整个过程是递归的!要弄清执行顺序):

            

解释一下流程,以[3,4]为例:首先匹配区间[1,8],所求区间与拆分区间存在交集,执行规则1,继续拆分区间为[1,4]、[5,8]。匹配区间[1,4]所求区间与拆分区间存在交集,执行规则1,继续拆分区间为[1,2]、[3,4];同时匹配区间[5,8],所求区间与拆分区间没有交集,执行规则3,返回一个极大值。匹配区间[1,2],所求区间与拆分区间没有交集,执行规则3,返回一个极大值;同时匹配区间[3,4],所求区间完全包含了拆分区间,执行规则2,返回储存值6。递归结束,开始回溯:[3,4]区间返回值6要小于[1,2]区间返回值0x7fffffff,因此区间[1,4]的返回值为6,小于区间[5,8]的返回值0x7fffffff,于是函数的最终返回值就是6.

其实我们大可不必这么麻烦地分析,因为我们在建树的时候就已经知道且保存了[3,4]区间上的最小值,所以我们很明确结果就是6。不过这里我只是想说明一下整个查询函数的执行过程,对于那种没有直接保存了对应区间的询问(比如区间[2,8]),只要执行同样的分析就能弄懂它的原理了,建议读者自己试着分析下。

3.单点修改:

这个很简单,递归找到要修改的位置,直接修改,然后回溯时重新将不同区间维护的最值更新一下就行了。

//单点修改
void modify(int k, int l, int r, int x, int v){
	if(r<x || l>x) return;
	if(l == r && l == x){
		sum[k] += v;
		st[k] += v;
		return;
	}
	int mid = (l + r) >> 1;
	modify(k << 1 | 1, mid + 1, r, x, v );  //修改右子区间
	modify(k << 1, l, mid, x, v );            //修改左子区间
	//更新最值
	sum[k] = sum[k << 1] + sum[k << 1 | 1];
	st[k] = min( st[k << 1], st[k << 1 | 1] ); 
} 

4.区间修改:

和树状数组一样,我们分"区间修改,单点询问"和"区间修改,区间询问"讨论:

区间修改,单点询问

要进行区间修改,最简单的做法就是直接拆分区间为单点,然后修改,然后向上更新结点值,但是很明显这样做会非常慢,修改一个值的时间复杂度为logn,一次区间修改最坏就是nlogn,执行m次得到最终时间复杂度为O(mnlogn)!这样甚至不如直接暴力修改!

但现在我们换一个思路修改,考虑在单点修改的时候,我们不直接修改结点的值,而是在每个结点上单独维护一个值add,表示该结点要增加的值。这样我们在单点查询时,经过一个区间就将结果加上add值,直到查询到我们要找的单点,此时该单点的值就是该结点的值加上查询过程中累积的add值。这里的标记add事实上就是在线段树中常用到的Lazy懒标记。

过程比较好理解,这里演示一下:

最后的结果就是:8+4-2=10 。

区间修改,区间询问

在学习区间修改,区间询问前,我们先回顾一下已经掌握的两种修改情况:

  • 单点修改,区间询问:

操作:递归找到要修改的位置,直接修改,然后回溯时重新将不同区间维护的最值更新。

缺陷:不支持区间修改,原因是单次修改时间复杂度为logn,进行一次区间修改,最坏时间复杂度为nlogn,运行太慢!

  • 区间修改,单点询问:

操作:维护一个标记值,在查询时累加答案。

缺陷:不支持区间询问,原因是实际上这个操作完全没有更新维护值,区间查询的答案是不正确的!

分别了解了“单点修改,区间询问”与“区间修改,单点询问”的操作以及缺陷所在后,我们来正式思考如何实现“区间修改,区间询问”。首先,“区间修改,单点询问”维护标记值的操作无疑是很巧妙的,我们仍可以借鉴,所以现在应该考虑的是如何维护结点上的值。父结点是很好解决的,在回溯时更新即可,而对于子结点,却不是很好操作了,因为子结点的数目太多,一个一个去维护时间复杂度必然无法承受,所以现在问题的关键就在于如何维护子结点的信息。

下面给出了两种解决方案:

  • 标记下传

标记下传的思想在于,当且仅当我需要用到该结点的时候,我才去更新它的信息,否则不去管它,以此来降低时间复杂度,为此我们在修改或者查询过程中如果遇到标记,就处理标记在本结点上的影响,然后将标记传递到它的子结点上。

//给[l,r]区间内的数+v,add[k]为标记数组
void update(int k, int l, int r, int v){
	add[k] += v;               //打标记
	sum[k] += (long long)(r - l + 1) * v; //维护区间和
	st[k] += v;                //维护区间最小值
}
//标记下传函数
void pushdown(int k, int l, int r, int mid){
	if(add[k] == 0) return;                //无标记则不管
	update(k << 1, l, mid, add[k]);        //下传到左子树
	update(k << 1 | 1, mid + 1, r, add[k]);//下传到右子树
	add[k] = 0;                            //清空当前标记
}
//区间修改
void modify(int k, int l, int r, int x, int y, int v){
	if(y < l || x > r) return;
	if(l >= x && r<= y) return update(k, l, r, v);//当前区间被完全覆盖,就打上标记
	int mid = (l + r) >> 1;
	pushdown(k, l, r, mid);                 //多次修改时,遇到之前的标记就将其下传
	modify(k << 1, l, mid, x, y, v);        //修改左子树
	modify(k << 1 | 1, mid + 1, r, x, y, v);//修改右子树
	//注意维护当前结点值
	sum[k] = sum[k << 1] + sum[k << 1 | 1];
}
//区间查询:区间和
long long query(int k, int l, int r, int x, int y) {
	if (y < l || x > r) return 0;
	if (l >= x && r <= y) return sum[k];
	int mid = (l + r) >> 1;
	pushdown(k, l, r, mid); //遇到标记就将其下传
	long long ans = 0;
	ans += query(k << 1, l, mid, x, y);
	ans += query(k << 1 | 1, mid + 1, r, x, y);
	return ans;
}
//区间查询:区间最值
int query(int k, int l, int r, int x, int y){
	if(y<l || x>r) return INF;           
	if (l >= x && r <= y) return st[k];   
	int mid = (l + r) >> 1;
	pushdown(k, l, r, mid); //遇到标记就将其下传             
	return min(query(k << 1, l, mid, x, y),query(k << 1 | 1, mid + 1, r, x, y));
}
  • 标记永久化

标记永久化不移动标记的位置,而是在修改操作时对每个结点都维护好子结点对其的影响,在查询操作时维护每个遇到的标记对当前询问的影响。(稍微说一下,标记永久化似乎不好维护区间最值,具体来说是不好维护子结点的影响,区间和能部分增加,但是区间最值则不能。但是或许只是因为我水平的原因实现不了,要是有哪位大佬来打脸,我会修正)

void modify(int k, int l, int r, int x, int y, int v){
	if(y < l || x > r) return;
	if(l >= x && r <= y) {
		add[k] += v;
		return;
	} 
	sum[k] += (long long)(min(r, y) - max(l, x) + 1) * v; //维护子结点的影响
	int mid = (l + r) >> 1;
	modify( k << 1, l, mid, x, y, v );
	modify((k << 1) + 1, mid + 1, r, x, y, v);
}
long long query(int k,int l, int r, int x, int y){
	if(y < l || x > r) return 0;
	//需要维护本结点上的标记的影响 
	if(l >= x && r <= y) return sum[k] + (long long)(r - l + 1) * add[k];
	int mid = (l + r) >> 1;
	//父结点的标记也要累加进来 
	long long ans = (long long)(min(r, y) - max(l, x) + 1) * add[k];
	ans += query(k << 1 ,l ,mid ,x ,y);
	ans += query((k << 1) +1 ,mid + 1 ,r ,x ,y);
	return ans;
}

例题及其补充:

1.区间和(https://ac.nowcoder.com/acm/contest/967/A

线段树维护区间和的模板题,可以来检测一下自己模板的正确性(也可以用树状数组来做)。

2.最大数(https://ac.nowcoder.com/acm/contest/967/C

线段树维护区间最值的模板,同样可以来检测一下模板正确性。

3.A Simple Problem with Intergers(https://ac.nowcoder.com/acm/contest/967/B

"区间修改,区间查询"的模板题,也是套模板就行了,至于用哪种方法可以自己选择(也能用树状数组来做,属于树状数组的扩展应用,详情参见我的树状数组博客,链接在本博客开头)。

4.维护序列(https://ac.nowcoder.com/acm/contest/967/E

本题在区间修改上加大了难度,增加了区间乘操作。但本题的修改操作仍然是容易实现的:sum[k]*=v 就能维护区间乘了,没有特别困难的地方。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值