线段树详解(单点修改+区间修改和查询)

【...】

把之前做的线段树的理论总结做了一个整合和一点修改。

线段树不能只是会用板子而已,要理解并且熟练,不假思索分分钟就能敲出来才行。

单点修改+区间修改和查询 例题+代码

目录

【线段树】

【引入】

【概述】

【单点修改和查询】

【建树】

【单点修改】

【区间查询最小值】

【区间查询区间和】

 【区间修改和查询】

【延迟标记】

【标记下传】

【标记永久化】


【线段树】

【引入】

  • 线段树能够解决什么样的问题。

  线段树的适用范围很广,可以在线维护修改以及查询区间上的最值,求和。更可以扩充到二维线段树(矩阵树)和三维线段树(空间树)。对于一维线段树来说,每次更新以及查询的时间复杂度为O(logN)。

  • 线段树和其他RMQ算法的区别

  常用的解决RMQ问题有ST算法,二者预处理时间都是O(NlogN),而且ST算法的单次查询操作是O(1),看起来比线段树好多了,但二者的区别在于线段树支持在线更新值,而ST算法不支持在线操作。刚学线段树的时候就以为线段树和树状数组差不多,用来处理RMQ问题和求和问题,但其实线段树的功能远远不止这些,我们要熟练的理解线段这个概念才能更加深层次的理解线段树。

【概述】

线段树是一颗二叉树,线段树上每个结点对应的是序列的一段区间。

使用线段树可以快速的查找某一个节点在若干条线段中出现的次数,时间复杂度为O(logN)。而未优化的空间复杂度为2N,实际应用时一般还要开4N的数组以免越界,因此有时需要离散化让空间压缩。

如图所示,容易发现,根结点对应的是整个区间[1,n]。若一个结点对应的区间为[l,r],当l=r时,它是一个叶节点,没有左右儿子;否则它一定有两个儿子,令mid=(l+r)/2,则左儿子对应的区间为[l,mid],右儿子对应的区间为[mid+1,r]。

【单点修改和查询】

【应用示例】

下面通过一个例子介绍线段树的处理过程。

问题:给定序列a0,a1,a2...an-1,接下来有m次操作,操作有两种,给定i,x将ai修改为x,或是给定l,r,求区间[l,r]内序列的最小值。

【建树】

若为叶子结点,直接赋值;否则根据构造左右子树得到该结点的值。

void build(int k,int l,int r)   //k表示当前结点的编号,l,r为当前结点所代表的区间
{
    if(l==r)       //当前结点为叶子结点
    {
        mi[k]=v;      //对应区间的最小值为原序列中的对应值
        return;
    }
    int mid=(l+r)/2;
    build(k*2,l,mid);        //构造左子树
    build(k*2+1,mid+1,r);    //构造右子树
    mi[k]=min(mi[k*2],mi[k*2+1]); //自下向上更新
}

【单点修改】

更新ai的值时,需要对所有包含i这个位置的结点的值重新计算。

void change(int k,int l,int r,int x,int v) //x为原序列的位置,v为要改成的值
{
    if(r<x||l>x) return;          //当前区间与原序列的位置完全无交集
    if(l==r&&l==x)                //当前结点为对应的叶子结点
    {
        mi[k]=v;                  //修改叶子结点
        return;
    }
    int mid=(l+r)/2;
    change(k*2,l,mid,x,v);          //修改左子区间
    change(k*2+1,mid+1,r,x,v);      //修改右子区间
    mi[k]=min(mi[k*2],mi[k*2+1]);   //更新相关的值
}

【区间查询最小值】

查询区间与当前区间存在三种关系:

1.无交集。返回不影响答案的极大值即可。

2.询问区间完全包含当前区间。返回维护好的区间最小值即可。

3.其他情况。递归维护并返回左子区间和右子区间中的较小值。

int query_min(int k,int l,int r,int x,int y)  //l,r为当前区间,x,y为询问区间
{
    if(y<l||x>r) return inf;      //若与查询区间完全无交集,返回一个极大值
    if(x<=l&&r<=y) return mi[k];  //询问区间在当前区间,返回维护好的最小值
    int mid=(l+r)/2;
    //否则分别处理左子区间和右子区间
    return min(query_min(k*2,l,mid),query_min(k*2+1,mid+1,r)); 
}

【区间查询区间和】

int 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;
    int res;
    res=query(k<<1,l,mid,x,y);
    res+=query(k<<1|1,mid+1,r,x,y);
    return res;
}

【复杂度】

建树时只需要访问所有结点一次,所以复杂度为O(n)。区间查询为O(logn)。单点修改为O(logn)。

因此线段树的总复杂度级别为O(mlogn),m为操作次数。

【概述·续】

而未优化的空间复杂度为2*N,实际应用时一般还要开4*N的数组以免越界,因此有时需要离散化让空间压缩。

这是为什么呢?

如下图所示,最底下一排的下标直接从9跳到了12,为什么呢,因为中间其实是有两个空间的呀~虽然没有使用,但是他已经开了两个空间,这就是为什么未优化的线段树建树需要2*2k(2k-1 < n < 2k)个空间,一般会开到4*n的空间防止越界。

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

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

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

  所以我们常用位运算来寻找左右子树

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

 【区间修改和查询】

有时我们需要解决的不只是单点修改、区间询问,而是区间修改、区间询问

下面我将以区间内所有数同时加上一个值,以及单点查询某位置的值为例,来考虑若是区间修改、单点查询应该如何解决。

【延迟标记】

考虑在每个结点上维护一个值add,表示这个结点所对应的区间内的所有数都加上了add。区间修改时像之前处理区间询问那样,将区间拆成许多子区间,并在线段树对应的结点上修改。这相当于是在结点处打上了一个标记,并且它的值不会被子结点重复出现。

之前修改时没有选择将所有位置的值马上更新,而是将修改对值的影响记录在根到叶子路径上的某结点处,等到询问某结点时,将一整条路径上所有对这个位置值产生的影响加起来,得到了要求的结果,巧妙地优化了时间复杂度。

//区间修改+单点询问
int query(int k,int l,int r,int p)
{
    if(l==r) return add[k];
    int mid=l+r>>1;
    if(p<=mid) return query(k<<1,l,mid,p)+add[k];
    else return query(k<<1|1,mid+1,r,p)+add[k];
    //根结点到叶子结点[p,p]的路径上所有点的add之和就是答案
}
//区间[x,y]内所有数加上v
void modify(int k,int l,int r,int x,int y,int v)
{
    if(l>y||r<x) return;
    if(l>=x&&r<=y)
    {
        add[k]+=v;
        return;
    }
    int mid=l+r>>1;
    modify(k<<1,l,mid,x,y,v);
    modify(k<<1+1,mid+1,r,x,y,v);
}

再回到我们区间修改、区间查询的问题。我们考虑将单点修改、区间查询与区间修改、单点查询的方法结合起来,即我们对线段树每个结点维护一个标记值add,表示区间内所有数加上了add,并且维护区间内所有数的和sum,这样修改与询问时,我们都能将区间拆为O(logn)个小区间并在对应的结点上进行处理。

常用的方法有两种,1.维护时新增一个标记下传操作2.标记永久化,他们的核心都是对标记进行处理,我们称这个标记为Lazy-Tag(懒标记)。

【标记下传】

当我们需要用到这些子结点的信息时再进行更新。具体来说就是当我们要从某个结点递归下去时,将当前结点的add值下传,更新两个子结点的add与sum值,并将当前结点的add值清零。

void Add(int k,int l,int r,int v)        //给区间[l,r]所有数加上v
{
    add[k]+=v;          //打标记
    sum[k]+=(r-l+1)*v;  //维护区间和
    return;
}
void pushdown(int k,int l,int r,int mid) //标记下传
{
    if(add[k]==0) return;         //没有标记则不用考虑
    Add(k<<1,l,mid,add[k]);       //下传到左子树
    Add(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)  //给定区间[x,y]所有数加上v
{
    if(l>=x&&r<=y) return Add(k,l,r,v);
    int mid=l+r>>1;
    pushdown(k,l,r,mid);          //到达每一个结点都要下传标记
    if(x<=mid) modify(k<<1,l,mid,x,y,v);
    if(mid<y) modify(k<<1|1,mid+1,r,x,y,v);
    sum[k]=sum[k<<1]+sum[k<<1|1];     //下传后更新sum
}
int query(int k,int l,int r,int x,int y)          //询问区间[x,y]的和
{
    if(l>=x&&r<=y) return sum[k];
    int mid=l+r>>1,res=0;
    pushdown(k,l,r,mid);       //下传标记
    if(x<=mid) res+=query(k<<1,l,mid,x,y);
    if(mid<y) res+=query(k<<1|1,mid+1,r,x,y);
    return res;
}

【标记永久化】

在询问过程中计算每个遇到的结点对当前询问的影响。

这种标记永久化的写法在树套树以及可持久化数据结构中较为方便。

void modify(int k,int l,int r,int x,int y,int v)
{
    if(l>=x&&r<=y)
    {
        add[k]+=v;
        return;
    }
    sum[k]+=(min(r,y)-max(l,x)+1)*v;  //计算子结点对当前结点的影响
    int mid=l+r>>1;
    if(x<=mid) modify(k<<1,l,mid,x,y,v);
    if(mid<y) modify(k<<1|1,mid+1,r,x,y,v);
}
int query(int k,int l,int r,int x,int y)
{
    if(l>=x&&r<=y) return sum[k]+(r-l+1)*add[k];
    int mid=l+r>>1;
    int res=(min(r,y)-max(l,x)+1)*add[k];   //计算标记影响
    if(x<=mid) res+=query(k<<1,l,mid,x,y);  //计算左右区间的贡献
    if(mid<y) res+=query(k<<1|1,mid+1,r,x,y);
    return res;
}

  • 27
    点赞
  • 66
    收藏
    觉得还不错? 一键收藏
  • 5
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值