算法-线段树

一、引入

  我们经常会遇到需要一些维护一个序列的问题,例如,给定一个整数序列,每次操作会修改序列某个位置上的数,或是询问你序列中某个区间内所有数的和。用“暴力”算法,单点修改的复杂度为O(1),询问区间和的单次复杂度为O(区间长度)。用前缀和算法,询问区间和是O(1),但单点修改的复杂度为O(区间长度)。这类问题的m(询问次数)和n(区间长度)往往是10^5数量级的,但这两种算法都会失效。线段树就是用来处理类似这样,在序列上单点修改、区间询问等等的数据结构,相当于朴素算法O(n^2)的时间复杂度,线段树能在O(nloogn)的复杂度下解决问题。

二、概念

  线段树是一棵二叉树,线段树上每个结点对应的是序列的一段区间。如图所示:

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

  从这里可以看出,最后一层右n个结点,倒数第二层有n/2个结点,以此类推,线段树中的结点数是n+n/2+n/4+...+1=2n-1.

令线段树的高度为h,不难看出线段树h只有logn级别。当我们需要维护的序列长度为2的整数次幂时,线段树是一棵满二叉树。其他情况下,线段树h-1层是满二叉树,最后一层可能不满。

三、应用示例

  问题:给定序列a[0],a[1],a[2]...a[n-1]接下来有m次操作,可以单点修改,求区间和。

1、树的定义

  定义一个结点包括l,r,w,f。

struct node
{
    int l,r,w,f;//f为懒标记,可以不看。
}tree[N*4+5];

2、建树

void build(int k,int l,int r){//k为当前结点,l为当前左边界,r为当前右边界
    tree[k].l=l;tree[k].r=r;
    if(l==r){scanf("%d",&tree[k].w);return ;}//l==r是k为叶节点。
    int mid=l+r>>1;//l+r>>1=(l+r)/2
    build(k<<1,l,mid);//左儿子k<<1=k*2
    build(k<<1|1,mid+1,r);//右儿子k<<1|1=k*2+1
    tree[k].w=tree[k<<1].w+tree[k<<1|1].w; //统计结点值
}

标记下传(可以先不用了解)

void pushdown(int k){
    tree[k<<1].f+=tree[k].f;
    tree[k<<1|1].f+=tree[k].f;
    tree[k<<1].w+=tree[k].f*(tree[k<<1].r-tree[k<<1].l+1);
    tree[k<<1|1].w+=tree[k].f*(tree[k<<1|1].r-tree[k<<1|1].l+1);
    tree[k].f=0;    
}

4、询问区间和

int sum(int k,int x,int y){//x,y为询问区间
    int ans=0;
    if(tree[k].l>=x&&tree[k].r<=y){如果当前结点在x,y中。返回当前值。
        return tree[k].w;
    }
    if(tree[k].f)pushdown(k);//每次遍历到都要标记下传
    int mid=(tree[k].l+tree[k].r)>>1;
    if(x<=mid)ans+=sum(k<<1,x,y);//如果x<=mid说明左儿子有当前区间
    if(y>mid)ans+=sum(k<<1|1,x,y);//如果y>mid说明有儿子有当前区间
    return ans;
}

5、单点修改

void add(int k,int x,int v){//结点x加上
    if(tree[k].l==tree[k].r){//如果遍历到当前结点
        tree[k].w+=v;//当前结点加上v
        return ;
    }
    if(tree[k].f)pushdown(k);//标记下传
    int mid=tree[k].l+tree[k].r>>1;
    if(x<mid)add(k<<1,x,v);//如果x<mid那么在左儿子
    else add(k<<1|1,x,v);//否则在右儿子
    tree[k].w=tree[k<<1].w+tree[k<<1|1].w;//更新区间值
    return ;
}

6、区间修改

有时我们需要区间修改区间询问时,用到上面的操作已经无法完成我们的需求了。我们没法高效的完成区间修改,于是我们要引入一个东西叫懒标记,就是在每个结点上维护一个值f,表示每个结点所对应的区间内所有树都加上f,当遍历到这个结点时再把标记下传。

注意到根结点到叶节点[i,i]的路径上会经过所有包含i点的区间所对应的结点,并且路径上所有点也会包含i这个位置,因此我们将路径经过的f加起来就是位置i目前的值。而路径上只有O(logn)个点,因此复杂度可以承受。

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

void add_p(int k,int x,int y,int v){//区间x,y加上v
    if(tree[k].l>=x&&tree[k].r<=y){//如果包含当前区间加上l-r个v,然后标上标记
        tree[k].w+=(tree[k].r-tree[k].l+1)*v;
        tree[k].f+=v;
        return ;
    }
    if(tree[k].f)pushdown(k);//标记下传
    int mid=tree[k].l+tree[k].r>>1;
    if(x<=mid)add_p(k<<1,x,y,v);//左儿子
    if(y>mid)add_p(k<<1|1,x,y,v);//右儿子
    tree[k].w=tree[k<<1].w+tree[k<<1|1].w;//重新统计w
    return ;
}

7、例题

洛谷链接

#include<bits/stdc++.h>
using namespace std;
const int N=1000005,logN=20;
int x,y,n,m,Log[N],f[N][logN],a[N];
inline int read(){
    int ret=0,f=1;char ch=getchar();
    while(ch<'0'||ch>'9'){if(ch=='-')f=-f;ch=getchar();}
    while(ch<='9'&&ch>='0')ret=ret*10+ch-'0',ch=getchar();
    return ret*f;
}
int main(){
//     freopen("p.in","r",stdin);
//     freopen("p.out","w",stdout);
    n=read(),m=read();
    for(int i=1;i<=n;i++)a[i]=read();
    Log[0]=-1;
    for(int i=1;i<=n;i++)f[i][0]=a[i],Log[i]=Log[i>>1]+1;
    for(int j=1;j<=logN;j++)
    for(int i=1;i+(1<<j)-1<=n;i++){
        f[i][j]=min(f[i][j-1],f[i+(1<<j-1)][j-1]);
    }
    for(int i=1;i<=m;i++){
        x=read(),y=read();
        int s=Log[y-x+1];
        printf("%d ",min(f[x][s],f[y-(1<<s)+1][s]));
    }
    return 0;
}

void add(int k,int x,int v){if(tree[k].l==tree[k].r){tree[k].w+=v;return ;}if(tree[k].f)pushdown(k);int mid=tree[k].l+tree[k].r>>1;if(x<mid)add(k<<1,x,v);else add(k<<1|1,x,v);tree[k].w=tree[k<<1].w+tree[k<<1|1].w;return ;}

猜你喜欢

转载自www.cnblogs.com/booksBlog/p/11004989.html
今日推荐