线段树

线段树

线段树的基本结构:

  • 线段树中的每个节点都代表一个区间(可以理解为线段),每个节点维护的是父亲的区间二等分后的其中一个子区间
  • 线段树具有唯一的根节点,根节点维护的是整个区间,代表的区间是整个统计范围,比如有序列a[1]~a[N],那么根节点所代表的区间就是[1,N]。
  • 线段树的每个叶子节点都代表一个长度为1的元区间[x,x]
  • 对于 每个内部节点[l,r],它的左子节点是[l,mid],右子节点是[mid+1,r],其中mid=(l+r)/2向下取整
  • 当有n个元素时,对区间的操作可以在 O ( l o g n ) O(logn) O(logn)的时间内完成,空间复杂度为 O ( n ) O(n) O(n)

线段树的基本用途:

  • 维护区间信息
  • 合并区间信息
  • 对序列进行维护,支持查询和修改操作

对于一棵线段树来说,除去树的最后一层,整棵线段树一定是一棵完全二叉树,树的深度为 O ( l o g n ) O(logn) O(logn)。因此,我们可以按照与二叉堆类似的 "父子2倍"节点编号方法:

  • 根节点编号为1
  • 编号为x的节点的左子节点编号为u*2,右子节点编号 u × 2 + 1 u\times 2+1 u×2+1

我们可以用一个结构体数组来保存线段树。树的最后一层节点在数组中保存的位置是不连续的,直接空出数组中多余的位置即可。在理想的情况下, n n n个叶子节点的满二叉树有2n-1。为什么呢?由二叉树的性质可知, N = n 0 + n 1 + n 2 N=n_0+n_1+n_2 N=n0+n1+n2,再由 n 0 = n 2 + 1 n_0=n_2+1 n0=n2+1,因为是满二叉树,没有度为1的节点,所以 n 1 = 0 n_1=0 n1=0,因此 N = n 0 + n 0 − 1 = 2 n 0 − 1 N=n_0+n_0-1=2n_0-1 N=n0+n01=2n01。如果在上述存储方式下,最后一层产生了空余,由于倒数第二层是满的,设为n个节点,那么最坏情况下,每个节点会引出两个孩子节点,因此最后一层可能会有2n个节点。因此,总体有2n-1+2n=4n-1个节点。所以,保存线段树的结构体数组长度要不小于4N才能保证不会越界。

线段树和RMQ算法的区别

常用解决RMQ问题有ST算法,二者预处理时间都是O(NlogN),而且ST算法的单次查询操作是O(1),看起来比线段树好多了,但二者的区别在于线段树支持在线更新值,而ST算法不支持在线操作。这里也存在一个误区,刚学线段树的时候就以为线段树和树状数组差不多,用来处理RMQ问题和求和问题,但其实线段树的功能远远不止这些。不要带着线段树只是为了解决区间问题的数据结构。事实上,是线段树多用于解决区间问题,并不是线段树只能解决区间问题。首先,我们得先明白几件事情:每个结点存什么,结点下标是什么,如何建树。

下面以一个简单的区间最大值来阐述上面的三个概念。

在这里插入图片描述

对于A[1:6] = {1,8,6,4,3,5}来说,线段树如上所示,红色代表每个结点存储的区间,蓝色代表该区间最值。可以发现,每个叶子结点的值就是数组的值,每个非叶子结点的度都为二,且左右两个孩子分别存储父亲一半的区间。每个父亲的存储的值也就是两个孩子存储的值的最大值。

对于一个区间[l,r]来说,最重要的数据当然就是区间的左右端点l和r,但是大部分的情况我们并不会去存储这两个数值,而是通过递归的传参方式进行传递。这种方式用指针好实现,定义两个左右子树递归即可,但是指针表示过于繁琐,而且不方便各种操作,大部分的线段树都是使用数组进行表示,那这里怎么快速使用下标找到左右子树呢。

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

在这里插入图片描述

则每个结点下标如上所示,这里你可能会问,为什么最下一排的下标直接从9跳到了12,道理也很简单,中间其实是有两个空间的呀!!虽然没有使用,但是他已经开了两个空间,这也是为什么无优化的线段树建树需要 2 ∗ 2 k ( 2 k − 1 < n < 2 k ) 2*2k(2k-1 < n < 2k) 22k2k1<n<2k)空间,一般会开到4*n的空间防止RE。

仔细观察每个父亲和孩子下标的关系,有发现什么联系吗?不难发现,每个左子树的下标都是偶数,右子树的下标都是奇数且为左子树下标+1,而且不难发现以下规律:

  • l = fa*2 (左子树下标为父亲下标的两倍)
  • r = fa*2+1(右子树下标为父亲下标的两倍+1)

具体证明也很简单,把线段树看成一个完全二叉树(空结点也当作使用)对于任意一个结点k来说,它所在此二叉树的 l o g 2 k log_2k log2k 层,则此层共有 2 l o g 2 k 2log_2k 2log2k个结点,同样对于k的左子树那层来说有 2 l o g 2 k + 1 2log_2k+1 2log2k+1个结点,则结点k和左子树间隔了 2 ∗ 2 l o g 2 k − k + 2 ∗ ( k − 2 l o g 2 k ) 2*2log_2k-k + 2*(k-2log_2k) 22log2kk+2(k2log2k)个结点,然后这就很简单就得到 k + 2 ∗ 2 l o g 2 k − k + 2 ∗ ( k − 2 l o g 2 k ) = 2 ∗ k k+2*2log_2k-k + 2*(k-2log_2k) = 2*k k+22log2kk+2(k2log2k)=2k的关系了吧,右子树也就等于左子树结点+1。

是不是觉得其实很简单,而且因为左子树都是偶数,所以我们常用位运算来寻找左右子树:

  • k<<1(结点k的左子树下标)
  • k<<1|1(结点k的右子树下标)

线段树的建树

struct SegmentTree{
    int l,r;
    int val;
}tr[4*N];
//更新函数,这里是实现最大值 ,同理可以变成,最小值,区间和等
void pushup(int u)
{
    tr[u].val=max(tr[u*2].val,tr[u*2+1].val);
}
//u为当前需要建立的结点,l为当前需要建立区间的左端点,r则为右端点
void build(int u,int l,int r)//递归方式建树 build(1,1,n);
{
    tr[u].l=l;
    tr[u].r=r;
    //左端点等于右端点,即为叶子节点,直接赋值即可
    if(l==r)
    {
        tr[u].val=a[l];	//此时l==r,也可以写成tr[u].val=a[r];
        return;		//回溯
    }
    int mid=(l+r)/2; //mid则为中间点,左儿子的结点区间为[l,mid],右儿子的结点区间为[mid+1,r]
    build(u*2,l,mid);	//递归构造左儿子结点
    build(u*2+1,mid+1,r);//递归构造右儿子结点
    //一般build后面都要跟着pushup,具体要看题意
    pushup(u);//更新父节点 从下往上传递信息
}

线段树的单点修改

如何实现单点修改,我们先不急看代码,还是对于上面那个线段树,假使我把a[3]+7,则更新后的线段树应该变成:

在这里插入图片描述

更新了a[3]后,则每个包含此值的结点都需要更新,那么有多少个结点需要更新呢?根据二叉树的性质,不难发现是log(k)个结点,这也正是为什么每次更新的时间复杂度为O(logN)。

单点修改时一条形如:"C x v"的指令,表示把A[x]的值修改为v。在线段树中,根节点(编号为1的节点)是执行各种指令的入口。我们需要从根节点出发,递归找到代表区间[x,x]的叶子节点,然后从下往上更新[x,x]以及它的所有祖先节点上保存的数据信息。

//更新函数,这里是实现最大值 ,同理可以变成,最小值,区间和等
void pushup(int u)
{
    tr[u].val=max(tr[u*2].val,tr[u*2+1].val);
}
//递归方式更新  
void modify(int u,int x,int v)	//u是当前节点,x是我们想要修改的节点的下标,v是修改的值
{
    //左端点等于右端点,即为叶子结点,直接修改为v即可
    if(tr[u].l==tr[u].r)
    {
        tr[u].val=v;
        return;	//回溯
    }
    //mid则为中间点,左儿子的结点区间为[l,mid],右儿子的结点区间为[mid+1,r]
    int mid=(tr[u].l+tr[u].r)/2;
    //如果需要更新的结点x在左子树区间
    if(x<=mid)
        modify(u*2,x,v);
    //如果需要更新的结点在右子树区间
    else
        modify(u*2,x,v);
    //从下往上更新信息
    pushup(u);
}

线段树的区间查询

我们知道线段树的每个结点存储的都是一段区间的信息 ,如果我们刚好要查询这个区间,那么则直接返回这个结点的信息即可,比如对于上面线段树,如果我直接查询[1,6]这个区间的最值,那么直接返回根节点信息返回13即可,但是一般我们不会凑巧刚好查询那些区间,比如现在我要查询[2,5]区间的最值,这时候该怎么办呢,我们来看看哪些区间是[2,5]的真子集

在这里插入图片描述

一共有5个区间,而且我们可以发现[4,5]这个区间已经包含了两个子树的信息,所以我们需要查询的区间只有三个,分别是[2,2],[3,3],[4,5],到这里你能通过更新的思路想出来查询的思路吗? 我们还是从根节点开始往下递归,如果当前结点是要查询的区间的真子集,则返回这个结点的信息且不需要再往下递归了,这样从根节点往下递归,时间复杂度也是O(logN)。

区间查询时一条形如"Q l r"的指令,例如查询序列A在区间[L,R]上的最大值,即 m a x L ≤ i ≤ R ( A [ i ] ) max_L\leq i\leq R(A[i]) maxLiR(A[i])。我们只需要从根节点出发,递归执行以下过程:

  • 若待查询区间[L,R]已经完全覆盖了当前节点所代表的区间,即当前树中的这个节点所代表的区间[ t r [ u ] . l tr[u].l tr[u].l t r [ u ] . r tr[u].r tr[u].r] ⊆ [ L , R ] \subseteq [L,R] [LR],则立即回溯,并且该节点的val值为候选答案。
  • 若左子节点所代表的区间与待查询区间[L,R]有重叠部分,则递归访问左子节点。
  • 若右子节点所代表的区间与待查询区间[L,R]有重叠部分,则递归访问右子节点。
递归方式区间查询区间[L,R]
int query(int u,int L,int R)//[L,R]即为要查询的区间,u是当前节点

{
    //如果当前结点的区间真包含于要查询的区间内,则返回结点信息且不需要往下递归
    if(L<=tr[u&].l&tr[u].r<=R)
        return tr[u].val;
    int ans=-INF;	//返回值变量,根据具体线段树查询的什么而自定义
    int mid=(tr[u].l+tr[u].r)/2; //mid则为中间点,左儿子的结点区间为[l,mid],右儿子的结点区间为[mid+1,r]
    //如果左子树和需要查询的区间交集非空,即左子节点和需要查询的区间有重叠
    if(L<=mid)
        ans=max(ans,query(u*2,L,R));
     //如果右子树和需要查询的区间交集非空,即右子节点和需要查询的区间有重叠
    if(r>mid)
        ans=max(ans,query(u*2+1,L,R));
    return res;	 //返回当前结点得到的信息
}

线段树的区间修改

区间更新是指更新某个区间内的叶子节点的值,因为涉及到的叶子节点不止一个,而叶子节点会影响其非叶的父节点,那么回溯需要更新的非叶节点也会有很多,如果一次性更新完,操作的时间复杂度肯定不止 O (logn),例如当我们更新区间 [1,7] 内的值,就需要更新下图所示标红的所有节点:

在这里插入图片描述

为此引入线段树的延迟标记概念,也叫 lazy tag,这个标记一般用于处理线段树的区间更新。

延迟标记:节点结构体中新增一个标记,记录这个节点是否会进行某种修改,对于任意区间的修改,我们先按照区间查询的方式将其划分成线段树中的节点,然后修改这些节点的信息,并给这些节点打上标记。在修改和查询的时候,如果我们到了一个节点 P,并且要继续查看其子节点,那么我们就要看看节点 P 是否被标记,如果有,则需要按照其标记首先修改子节点的信息,并且给子节点都打上相同的标记,同时取消节点 P 的标记,这一操作称为标记下放,也叫 pushdown。举个栗子:

假设爷爷要给两个孙女压岁钱,所以爷爷就先把总的压岁钱给自己的儿子(女儿的爸爸),让儿子(女儿的爸爸)给女儿,但是儿子觉得自己的女儿还太小了,暂时用不到,于是就先保存着。突然有一天爷爷准备要询问孙女拿到压岁钱了没有,此时爸爸着急了,就赶紧把压岁钱给了女儿。

具体在modify函数中的操作就是,如果当前更新的区间为 [l,r],我走到节点 P 对应的区间是 [ t r [ u ] . l , t r [ u ] . r ] [tr[u].l,tr[u].r] [tr[u].l,tr[u].r],如果 [ t r [ u ] . l , t r [ u ] . r ] ⊆ [ l , r ] [tr[u].l,tr[u].r]\subseteq [l,r] [tr[u].l,tr[u].r][l,r],那就先更新当前节点 P,然后给 P 打上标记,P 的子节点就不管了,直接 return,如果以后进行查询或者更新操作的时候,发现当前节点有标记,才将标记下放。除了 pushdown,还需要 pushup,pushdown 的作用是将标记下放,而 pushup 的作用是从下往上更新根节点的信息,因为子节点值改变了,根节点也会变,所以必须要更新根节点的信息。

线段树在进行区间更新的时候,为了提高更新的效率,所以每次更新,只会更新到待更新区间 [ l , r ] [l,r] [l,r]完全覆盖线段树结点区间 [ t r [ u ] . l , t r [ u ] . r ] [tr[u].l,tr[u].r] [tr[u].l,tr[u].r]就停止了,这样就会导致被更新结点的子孙结点的区间得不到需要更新的信息,所以在被更新结点上打上一个标记,称为lazy-tag,等到下次访问这个结点的子结点时再将这个标记传递给子结点,所以也可以叫延迟标记。

也就是说递归更新的过程,更新到结点区间为需要更新的区间的真子集不再往下更新,下次若是遇到需要用这下面的结点的信息,再去更新这些结点,所以这样的话使得区间更新的操作和区间查询类似,复杂度为O(logN)。换言之,我们在执行 区间修改指令时,设节点p所维护的区间为 [ p l , p r ] [p_l,p_r] [pl,pr],设待查询区间为 [ L , R ] [L,R] [L,R],同样可以在 L ≤ p l ≤ p r ≤ R L\leq p_l\leq p_r\leq R LplprR的情况下就立即返回,只不过在回溯之前要想该节点p增加一个标记,该标记的含义是:该节点曾经被修改过,但是它的子节点还没有得到更新即延迟标记标识的是子节点等待更新的情况。因此一个节点被打上"延迟标记"的同时,它自身保存的信息应该已经被修改完毕了。如果在后续的指令中,需要从节点p想向下递归,我们需要先再检查节点p是否有了标记,若有标记,就根据标记信息更新p的两个子节点,同时把p的标记下放给它的两个子节点,然后清空p的标记(可以理解为爸爸钱给女儿后,爸爸就没有存款了)。

也就是说, 除了在修改指令中直接划分成的 O ( l o g N ) O(logN) O(logN)个节点之外,对任意节点的修改都延迟到“在后续操作中递归进入它的父节点时”再执行。这样一来,每条查询或修改指令的时间复杂度就都降低都了 O ( l o g N ) O(logN) O(logN)

struct NOde{
    int l,r;
    int val;
    int tag;
}tr[4*N];
//父节点把标记下放给左右节点
void pushdown(int u)
{
    Node &root=tr[u],&left,&right;	//root是父节点,left是左子节点,right是右子节点
    //如果父节点有标记	(可以理解爷爷把给孙女的压岁钱,给爸爸先存放了)
    if(root.tag!=0)
    {
        left.tag+=root.tag;//更新左子树的tag值  即爸爸把压岁钱下放给了女儿
        right.tag+=root.tag;//更新右子树的tag值  即爸爸把压岁钱下放给了女儿
        left.val+=root.tag;//左子树的最值加上tag值	即女儿的压岁钱又多了,多的这部分压岁钱来源于父亲给的tag
        right.val+=root.tag;//右子树的最值加上tag值	即女儿的压岁钱又多了,多的这部分压岁钱来源于父亲给的tag
        root.tag=0;	//爸爸把钱给女儿后,自己就没有存款了
    }
}
//递归更新区间 u是父节点,更新区间为[L,R],更新值为v
void modify(int u,int L,int R,int v)
{
    //如果当前结点的区间真包含于要更新的区间内
    if(L<=tr[u].l&&tr[u].r<=R)
    {
        tr[u].tag+=v;		//给当前u节点打上标记
        tr[u].val+=v;		//最大值加上v之后,此区间的最大值也肯定是加v
        return;
    }
    //重难点,查询tag标记,更新子树
    pushdown(u);	//下放延迟标记
    int mid=(tr[u].l+tr[u].r)2;
    //如果左子树和需要更新的区间交集非空
    if(L<=mid)
        modify(u*2,L,R,v);
    //如果右子树和需要更新的区间交集非空
    if(R>mid)
        modify(u*2+1,L,R,v);
    //更新父节点
    pushup(u);
}
//递归方式区间查询
int query(int u,int L,int R)
{
    if(L<=tr[u].l&&tr[u].r<=R)
        return tr[u];
    //爷爷开始来询问爸爸有没有把钱给女儿了,那么爸爸才想起来,所以爸爸要把钱给女儿了
    pushdown(u);/**每次都需要更新子树的tag标记*/
    int res = -INF;    //返回值变量,根据具体线段树查询的什么而自定义
    //mid则为中间点,左儿子的结点区间为[l,mid],右儿子的结点区间为[mid+1,r]
    int mid=(tr[u].l+tr[u].r)/2;
    //如果左子树和需要查询的区间交集非空,说明有部分信息在左子树中,递归查询左孩子区间
    if(L<=mid)
        res=max(res,query(u*2,L,R));
    //如果右子树和需要查询的区间交集非空,说明有部分信息在右子树中,递归查询右孩子区间
    if(R>mid)
        res=max(res,query(u*2+1,L,R));
    return res;//返回当前结点得到的信息
}

注意看pushdown这个函数,也就是当需要查询某个结点的子树时,需要用到这个函数,函数功能就是更新子树的tag值,可以理解为平时先把事情放着,等到哪天要检查的时候,就临时再去做,而且做也不是一次性做完,检查哪一部分它就只做这一部分。值得注意的是,使用了Lazy_tag后,我们再进行区间查询也需要改变。


注意点

"区间内的最大值"和"区间的累加和"的query函数形式是一样的,设为A;"区间内的最大公约数"和"最大连续子段和"的qeury函数形式是一样的,设为B。但是A和B的形式有所不同

对于A类,与B类的不同点

int query(int u,int L,int R)
{
    //此处省略
    //
    //
    int mid=(tr[u].l+tr[u].r)/2;
    if(L<=mid)//如果左子树和需要查询的区间交集非空,说明有部分信息在左子树中,递归查询左孩子区间
        //递归进入左子树  
    if(R>mid)//如果右子树和需要查询的区间交集非空,说明有部分信息在右子树中,递归查询右孩子区间
        //递归进入右子树
}

对于B类,与A类的不同点

Node query(int u,int L,int R)
{
     //此处省略
    //
    //
    int mid=(tr[u].l+tr[u].r)/2;
     //如果待查询区间完全在节点所维护区间的左区间
    if(R<=mid)
        return query(u*2,L,R);
     //如果待查询区间完全在节点所维护区间的右区间
    else if(L>mid)
        return query(u*2+1,L,R);
     //如果待查询区间横跨在节点所维护区间的左右区间
    else
    {
        	Node left=query(u*2,L,R);   //左子节点
            Node right=query(u*2+1,L,R);    //右子节点
            Node res;   //res是left和right的父节点
            pushup(res,left,right);
            return res;
    }
}

为什么会有这种写法的不同呢?

其实,这是根据问题的性质来决定的。其实A类和B类的最大不同在于 是否考虑终点对最终问题的影响。对于"区间的最大公约数"和"最大连续子段和",如果我们想要查询的区间是横跨左右子树的,那么我们就要分别去左右子树查询,然后再把左右子树的结果合并送给父节点。如果我们向A类一样写的话,return max(ask(p1,l,mid), ask(p2,mid+1,r)) 假设这个式子会返回区间[L,R]中的最大连续子段和,那么这句话的实际含义是只比较了p1的最大子段和 和 p2的最大子段和,漏掉了跨过mid的最大子段和。return max(ask(p1,l,mid), ask(p2,mid+1,r)) 假设这个式子会返回区间[L,R]中的最大公约数,那么这句话的实际含义是只比较了p1的最大公约数和p2的最大公约数,漏掉了跨过mid的最大公约数

但是对于A类来说,即便我们把查询区间[L,R]拆成了两部分,return max(ask(p1,l,mid), ask(p2,mid+1,r)),假设这个式子可以返回区间[L,R]中的最大值,那么这句话的实际含义是比较了p1的最大值和p2的最大值,即便忽略了跨过mid的最大值也不要紧,因为左右两边的最大值就已经可以确定整个区间[L,R]的最大值了,即使你加入了跨过mid的这种情况下的区间,那么假设最大值是左侧区间[L,mid],那么加入了跨过mid的这种情况下的区间[L,mid+t],那最大值不还是由左侧区间决定嘛。对于查询区间[L,R]内的累加和也是同样的道理。


单点修改函数和区间修改函数也是有不同点的:

单点修改函数:

void modify(int u,int x,int v)
{
    if(tr[u].l==x&&tr[u].r==x)
    {
        //此处省略
        return;
    }
    int mid=(tr[u].l+tr[u].r)/2;
    if(x<=mid)
        modify(u*2,x,v);
    else
        modfify(u*2+1,x,v);
    pushup(u);
}

区间修改函数

void modify(int u,int L,int R,int v)
{
    if(L<=tr[u].l&&tr[u].r<=R)
    {
        //此处省略;
        return;
    }
    pushdown(u);
    int mid=(tr[u].l+tr[u].r)/2;
    if(L<=mid)
        modify(u*2,L,R,v);
    if(R>mid)
        modify(u*2+1,L,R,v);
    pushup(u);
}

  • 3
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

卷心菜不卷Iris

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值