【Study】线段树

这几天终于认真搞懂了线段树,就赶紧生产了篇学习笔记来热热知识。(我真的好菜啊)

线段树呢,是一种很有用的数据结构(点头),对于一些区间查询、修改都能方便实现,还有好多好多的奇异功能(哇),也是学习一些高级du liu数据结构的基础,对于一名OIer还是 有认真学习的必要的。

线段树是一种二叉搜索树,什么叫做二叉搜索树,首先满足二叉树,每个结点度小于等于二,即每个结点最多有两颗子树,何为搜索,我们要知道,线段树的每个结点都存储了一个区间,也可以理解成一个线段,而搜索,就是在这些线段上进行搜索操作得到你想要的答案。

我们先来看看这么一个栗子:

这是一个比较简单的求区间最大值的线段树。每个节点红色部分是这个节点所维护的区间,蓝色部分是这个区间里的最大值,并且每个叶子结点的值就是原数组的值。

所以我们可以发现,每个非叶子结点的度都为二,且左右两个孩子分别存储父亲一半的区间。每个父亲的存储的值也就是两个孩子存储的值的最大值。

明白了上面这个道理,我们接下来就捋一捋每个节点如何来储存区间,还有如何快速找到非根节点的父亲和非叶子节点的儿子。

对于线段树的储值,我们一般不会去记录l和r的值,看一下上面的图,我们可以发现,根节点记录的是整个数组的最大值,它的两个儿子分别记录的是 1--3 和 4--6 的值,仔细看,你是不是想到什么?

没错,就是二分。当根节点记录的是 1--n 的值时,它的左儿子就记录的是区间 1--(1+n)/2 的值,右儿子就记录的是区间 (1+n)/2+1--n 的值,以此类推,根节点的左右儿子也是如此,就这样分到 l=r ,此时就是叶子节点了,也就可以将其赋值。

赋值之后呢,我们现在只给叶子节点赋值而已,那其他的节点呢?

所以,我们通常使用 递归 来实现整棵树的修改,只要不断地递归左右子树,直到 l=r 停止,返回的时候顺便进行修改即可。

好的,到这里,如果你捋清楚了如何进行整棵树的修改,那么我们接下来就来琢磨琢磨如何来给每个节点标一下下标。

下标可是很重要的。

对于上述线段树,我们增加绿色数字为每个结点的下标。

“ 1、2、3、4、5、6、7、8、9、12?!! ”

“ 怎么直接从 9 跳到了 12 ? ”

“ 噢!原来中间其实是有两个空间的呀! ”

虽然没有用到,但是一定要开,因为你不会知道这 5 这个节点到底是不是叶子节点,所以以防万一。这也是无优化线段树的缺点,空间好大,常M别怕

一般没有优化过的线段树,是需要 (2 * 2^{k} ( 2^{k-1} < n < 2^k)) 个空间的,所以一般会开到 4×n 的空间防止RE。

仔细观察上图,我们应该不难发现,左儿子的下标是父亲下标的两倍,而右儿子的下标就是左儿子的再加上1。

总结一下:

  • l = x × 2

  • r = x × 2 + 1

(x是父亲的下标,l是左儿子的下标,r是右儿子的下标)

具体证明也不太难,请大佬自行百度,这里不做细讲

一般,我们常用位运算来计算:

  • l = x << 1

  • r = x << 1 | 1

好了,讲了那么久,终于能开心地打一打代码了!

#define ls (x<<1)
#define rs (x<<1|1)
#define mid (l+r>>1)
#define MAXN 50010

int a[MAXN],t[MAXN<<2]; //a为原来区间,t为线段树

void pushup(int x) //更新函数,这里是实现最大值,同理可以变成最小值,区间和等
{
    t[x]=max(t[ls],t[rs]);
}

//递归方式建树 build(1,n,1);
void build(int l,int r,int x) //l表示当前区间的左端点,r表示当前区间的右端点,x表示当前结点的下标
{
    if(l==r) //左端点等于右端点,即为叶子节点,直接赋值即可
    {
    	t[x]=a[l];
    	return ;
    }
    build(l,mid,ls); //递归构造左儿子结点
    buile(mid+1,r,rs); //递归构造右儿子结点
    //这里的mid是l和r的中点,目的是为了将l--r分为两个区间
    pushup(x); //更新操作
}

下面我们就来讲一下区间的查询修改和单点的修改。

区间修改,顾名思义,对某个区间的值进行更新。那么要如何更新呢,可以想一想,其实和建树是差不多的(点头)

(Three minutes later~)

好,其实我们可以发现,上面的建树,换个说法,就是对整个区间的更新,那么为了可以实现其中一部分区间的更新,我们需要在上面的建树代码中,当前节点往下跑的方向给一些限制。

举个例子,我们要将 [ 3 , 5 ] 这个区间的每一个值改为5,如下图。

其实我们可以发现,每次对于向下跑的方向,我们可以根据现在左右儿子管理的区间,里面有没有包含要修改的区间,有的话就可以朝着那个方向继续跑下去。

刚刚在 build 函数里面,有这么两个变量—> l和r,这两个变量的意思就是当前节点管理的区间范围,我们可以将最左(l)和最右(r)分别与要修改区间的左和右作比较就行了,到最后一定会跑到一个要修改的点上。

口胡大师当然是不行的,所以现在我们来动手操作一下:

#define ls (x<<1)
#define rs (x<<1|1)
#define mid (l+r>>1)
#define MAXN 50010

int a[MAXN],t[MAXN<<2]; 

void pushup(int x){t[x]=max(t[ls],t[rs]);}

//同样用递归的方式修改区间
void updata(int L,int R,int v,int l,int r,int x)
//L,R表示修改区间的左右端点,v表示要修改的数值
//l,r,x的含义见build
{
	if(l==r)
	{
		t[x]=v;//将第x个数修改为v
		return ;
	}
	if(L<=mid)
    //左儿子管理的区间有包括部分要修改的区间
		updata(L,R,l,mid,ls(x));
	if(R>mid)
    //右儿子管理的区间有包括部分要修改的区间
		updata(L,R,mid+1,r,rs(x));
	pushup(x);
}

如果还不太懂的话,可以自己画图模拟一下(汗)。

然后对于单点修改,其实也是差不多的。因为修改的只有一个点,所以,对于当前非叶子结点,如果左儿子管理的区间没有包括这个节点的话,就一定在右儿子那边。

(丢你代码(大雾

void updata(int i,int v,int l,int r,int x)
//i表示要修改的节点
{
	if(l==r)
	{
		t[x]=v;//将第x个数修改为v
		return ;
	}
	if(i<=mid)
		updata(i,v,l,mid,ls);
	else
		updata(i,v,mid+1,r,rs);
	pushup(x);
}

好的,现在我们已经完成了单点/区间修改,如果你已经完全理解了,那么对于接下来区间查询就不会太难理解了。
(单点修改的话直接找a数组查询,记得在修改的时候带上a数组qwq)

(话说哪个出题人那么无聊出单点修改)(大雾)

对于区间查询,其实和区间修改差不多,至少精髓是一样的(话说这不废话吗)

假如我们要查询 [ 2 , 5 ] 的最大值,就像下面这样

仔细瞧,说是一共要查询2、3、4、5这四个节点,其实我们只要查询 [ 2 , 2 ] 、 [ 3 , 3 ] 、[ 4 , 5 ] 这三个区间的最值,然后再来进行比较,就可以缩短一部分查询的时间,对于大数据的话就不止一点了(排开某些毒瘤数据、最坏情况),所以这也是线段树的其中一处优越。

大家要先把思路捋明了,代码也就清晰了(温馨提示(大雾

int query(int L,int R,int l,int r,int x)
//几个变量含义和updata的一样qwq
{
	if(L<=l&&r<=R) return t[x];
   //如果要查询的区间包括当前的区间,就直接返回当前区间的最大值
	ll ans=-19260817;
   //一般这里用long long,以防万一
	if(L<=mid)
		ans=max(ans,query(L,R,l,mid,ls));
	if(R>mid)
		ans=max(ans,query(L,R,mid+1,r,rs));
   //此处两个if和updata的用意一样
	return ans;
}

读到这,恭喜你,你已经掌握了最基本的线段树了(大佬orz)

然后你就迫不及待地去切题了。

P3372 【模板】线段树 1

嗯?模板?你怒打了一棵线段树,交了上去。

qwq

什么呀,怎么还有3个黑色的东西(大雾),然后你就仔细地调自己代码,看看有哪里写的不一样。

其实,线段树对于查询修改的复杂度都是 O(logN) , 但是上文提过,有些毒瘤出题人,弄了些最坏情况,把线段树的查询修改卡到 O(NlogN) (比如让你对所有区间进行修改),那么现在的话就比暴力还慢了,所以聪明的人们发明了 lazy_tag (懒标记),顾名思义,就是在更新区间的时候偷懒,如何偷懒呢,且听我一一道来。

通过上面对区间修改的讲解,我们可以发现,每次修改的时候总是要跑到叶子节点后才来进行修改,然而这样会比直接暴力扫区间还慢。我们是不是可以不用跑到叶子节点来修改区间呢?

当我们从根节点往下跑的时候,如果这个非叶子节点管理的区间包含在修改区间的范围内时,我们可以直接对这个节点进行修改,然后开一个数组标记修改的值,这个数组称为lazy数组,我们已经将这部分区间修改,接着就可以直接返回了。

如果当我们需要更下面的节点怎么办?我们只修改了下面节点的父节点,并没有将下面的节点更新。此时我们刚刚开的那个lazy数组就起作用了。

我们可以利用父节点的lazy值,来修改子节点的值,然后将子节点的lazy值改为父节点的lazy值,接着把父节点的lazy值清零就行了。

(说白了就是将lazy下传)

好了,如果现在已经理解lazy_tag的作用和实现方法的话,就来coding一下qwq

(这里我们以刚才的模板为例子qwq)

void pushdown(ll i,ll k)//将lazy值下传
//i表示当前节点,k表示i节点管理区间长度
{
	if(lazy[i])//如果有lazy值的话
	{
		lazy[ls(i)]+=lazy[i];
		lazy[rs(i)]+=lazy[i];
        //将lazy值赋给左右子节点
		t[ls(i)]+=(k-(k>>1))*lazy[i];
        //因为左子节点里面有(k-k/2)个叶子节点,每个叶子节点都要加上lazy值
		t[rs(i)]+=(k>>1)*lazy[i];
        //同上
		lazy[i]=0;
        //修改完之后就要把lazy值归0,否则会重复计算
	}
}

因为这是在修改是的lazy,所以updata也要相对应进行修改。

void updata(int L,int R,int v,int l,int r,int x)
//L,R表示修改区间的左右端点,v表示要修改的数值
{
	if(L<=l&&r<=R)//如果要修改的区间包含当前区间
	{
		lazy[i]+=v;
		t[i]+=v*(r-l+1);
        //思路和pushdown一样
        return ;
	}
    pushdown(x,r-l+1);//因为下面的节点可能之前没有修改过,所以我们要pushdown一下
	if(L<=mid)
    //左儿子管理的区间有包括部分要修改的区间
		updata(L,R,v,l,mid,ls(x));
	if(R>mid)
    //右儿子管理的区间有包括部分要修改的区间
		updata(L,R,v,,mid+1,r,rs(x));
	pushup(x);
}

同样的,query也要进行相对应的修改

int query(int L,int R,int l,int r,int x)
{
	if(L<=l&&r<=R) return t[x];
    pushdown(x,r-l+1);//和updata同样,查询之前先更新
	ll ans=-19260817;
	if(L<=mid)
		ans=max(ans,query(L,R,l,mid,ls));
	if(R>mid)
		ans=max(ans,query(L,R,mid+1,r,rs));
	return ans;
}

恭喜你,到这里你就已经掌握一个线段树的基本写法了,现在你就可以开心切题了!

P3372 【模板】线段树 1

P2357 守墓人

P3372 【模板】线段树 2

线段树只是一种较基础的数据结构,对于一些关于线段树的 高级算法,等以后有时间再讲吧qwq(


写在最后:

害,这次的文章咕了好久,咕了差不多大半年的时间了(sta-> 3-29 / fin-> 8-02),剩下的lazy_tag还是在jzsc的闲余时间完善的,才发现我差不多都完干净了qwq。其实我写这篇博客主要是为了给以后自己复习用 (翻译:我太菜了),如果说,这篇博客可以帮助到想学线段树的大佬的话,我是非常高兴的,我将一些较难懂的点给简单化详细化 (话说好像没什么难懂的点(大雾 ),所以对于像我一样的蒟蒻还是挺容易理解的qwq

参考资料:线段树详解 - Xenny

原文地址:https://www.cnblogs.com/zhangjiayang/p/13463702.html