线段树详解

· · 题解

背景:在普及水到一等,提高水到二等的NOIp2018结束后,滑稽的小宫不满足于简单的暴搜(用暴搜骗了至少150分),决定学习更高级的算法

线段树(水)

这是一种神奇的数据结构,用蒟蒻语说,就是“树状区间和”,即将一个二分过程表现出来。通过改变大区间的值,来实现短时区间计算

如图:
                   1 - 8
                 /       \
          1  -  4          5  -  8
         /      \        /       \
      1-2      3 - 4      5-6       7-8
    /    \     /   \     /   \     /   \
  1-1   2-2  3-3   4-4  5-5  6-6  7-7  8-8 

重点是,时间复杂度只有O(logn)!

(蒟蒻开始考虑自己已经多久没有见过logn的算法了)

不水了,进入正题

众所周知,拿数组存树是有“左右孩子定理”的。

因此通过某种高大上朴素的做法就可以轻易繁琐地求出左右孩子

先定义

#include<cstdio>  
#define left_son(x) (x<<1)//某种神秘方法
#define right_son(x) (x<<1|1)//某种神秘方法2
#define ll long long

ps:为了更好理解我用了很长的函数和数组名,密恐慎入

在线段树中,我们用到三个主要函数:建树、操作、查询,还有一个辅助函数“下推”。

我在这里用的是结构体做法(实际是因为不会动态开点),定义了一个结构体“st”(segment tree的简称),里面存有它的左端点、右端点、当前值和lazy标记。注意,空间范围最好开到n的4倍(前辈的经验)。

首先是建树操作,可以看到,我们能利用递归建树,每次二分就行了,事实上,线段树就是还原了一个二分的过程

二分代码:

//毫无作用的二分
void div(int l,int r){
    if(l==r)return;
    int mid=(l+r)/2;
    div(l,mid);
    div(mid+1,r);
}

如果想要把它变成建树操作,还需要加上这个位置的节点,以便赋值,这时我们就用那个神秘的找孩子函数实现:

拿二分改成建树函数代码:

void build(ll x,ll li,ll ri){
    st[x].left_point=li;//定义左孩子
    st[x].right_point=ri;//定义右孩子
    if(li==ri){//若相同,说明已经走到叶子结点
        st[x].sum=a[li];//只有叶子结点被真实赋值
        return;
    }
    //如果没到叶子结点,就继续二分
    ll mid=(li+ri)/2;
    build(left_son(x),li,mid);
    build(right_son(x),mid+1,ri);
    //二分完毕,在调用返回时把中间结点的值算出
    st[x].sum=st[left_son(x)].sum+st[right_son(x)].sum;
}

好了,先放出上面的那张图

//数字为区间端点下标
                   1 - 8
                 /       \
          1  -  4          5  -  8
         /     \         /       \
      1-2       3-4       5-6       7-8
    /    \     /   \     /   \     /   \
  1-1   2-2  3-3   4-4  5-5  6-6  7-7  8-8 

或者是

  |1              -                    16|
  |1      -      8|9         -         16|
  |1  -  4|5  -  8|9   -   12|13   -   16|
  |1-2|3-4|5-6|7-8|9-10|11-12|13-14|15-16|
  |1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|

那么如何修改元素呢?这里用加法举个例子。我继续使用二分,从根开始,往下分。题目中会给出要增加的区间,那么如果我们二分出了区间,就不用管了,因为这一部分不需要加上数。(比如我要在3-5区间加数,但是二分到了1-2区间,这时就要退出了,反正我也不加)但是如果是一部分有交集时怎么办(如加3-5,分到1-4)?这时就要有一些方法了。一言以蔽之:

继续分!

我们一直向下分,直到分到左右端点都在所需范围之中

void plus(ll x,ll li,ll ri,ll v){
    if(st[x].left_point>ri||st[x].right_point<li)return;//出了范围,跳出
    if(st[x].left_point>=li&&st[x].right_point<=ri){//都在范围中
        st[x].lazy+=v;//暂时先忽略
        st[x].sum+=(st[x].right_point-st[x].left_point+1)*v;//真实增值
        return;
    }
    push_down(x);//暂时忽略
    plus(left_son(x),li,ri,v);//二分
    plus(right_son(x),li,ri,v);//二分
    st[x].sum=st[left_son(x)].sum+st[right_son(x)].sum;//在递归调用返回时将父节点值改变
}

这样,可以提供一次添加服务的函数已经写完。但是,已修改节点的祖先修改了,而子孙没有修改,这该怎么办呢?

有人说,可以从已修改节点向下递归,把下面节点都修改,但是这样时间会到达nlogn,得不偿失。

于是,lazy标记应运而生。lazy是用来标识当前区间每一个节点需要加上的值,当再进行操作时经过某一点,就可以把这一点的lazy值往下推,一直到叶子节点。时间复杂度仅为O(1)。

void push_down(ll x){
    st[left_son(x)].sum+=(st[left_son(x)].right_point-st[left_son(x)].left_point+1)*st[x].lazy;
    //将当前节点的左孩子的sum值加上当前节点的lazy乘上左孩子的区间长度
    st[right_son(x)].sum+=(st[right_son(x)].right_point-st[right_son(x)].left_point+1)*st[x].lazy;
    //将当前节点的右孩子的sum值加上当前节点的lazy乘上右孩子的区间长度
    st[left_son(x)].lazy+=st[x].lazy;
    //将lazy标记向左下推
    st[right_son(x)].lazy+=st[x].lazy;
    //将lazy标记向右下推
    st[x].lazy=0;
    //已经加完,归零
}

如图:

这是lazy标记下推

这是sum值下推

所以我们利用lazy标记就解决了复杂度太高的问题,一句话总结,就是:在需要向下搜索时再把值向下推,不用就不推。

以此类推,当我们需要查找时,就用一样的道理

ll find(ll x,ll li,ll ri){
    if(st[x].left_point>ri||st[x].right_point<li)return 0;
    //如果搜索出范围了,就停止
    if(st[x].left_point>=li&&st[x].right_point<=ri){
        return st[x].sum;//边界,搜索区间完全在范围内,返回sum值
    }
    push_down(x);//别忘了在向下查找之前下推
    return find(left_son(x),li,ri)+find(right_son(x),li,ri);//二分搜索
}

至此,整个线段树我们就写完了(省略主函数)

线段树是我自学习OI以来遇到的第一个高级算法,心里还是很激动的,就写了这篇题解来记录它(也是我目前写过的最长的题解了)

完整代码(P3372)

#include<cstdio>
#define left_son(x) (x<<1)
#define right_son(x) (x<<1|1)
#define ll long long
struct segment_tree{
    ll left_point,right_point,sum,lazy;
}st[400010];
ll n,m,a[100010];
void build(ll x,ll li,ll ri){
    st[x].left_point=li;
    st[x].right_point=ri;
    if(li==ri){
        st[x].sum=a[li];
        return;
    }
    ll mid=(li+ri)/2;
    build(left_son(x),li,mid);
    build(right_son(x),mid+1,ri);
    st[x].sum=st[left_son(x)].sum+st[right_son(x)].sum;
}
void push_down(ll x){
    st[left_son(x)].sum+=(st[left_son(x)].right_point-st[left_son(x)].left_point+1)*st[x].lazy;
    st[right_son(x)].sum+=(st[right_son(x)].right_point-st[right_son(x)].left_point+1)*st[x].lazy;
    st[left_son(x)].lazy+=st[x].lazy;
    st[right_son(x)].lazy+=st[x].lazy;
    st[x].lazy=0;
}
void plus(ll x,ll li,ll ri,ll v){
    if(st[x].left_point>ri||st[x].right_point<li)return;
    if(st[x].left_point>=li&&st[x].right_point<=ri){
        st[x].lazy+=v;
        st[x].sum+=(st[x].right_point-st[x].left_point+1)*v;
        return;
    }
    push_down(x);
    plus(left_son(x),li,ri,v);
    plus(right_son(x),li,ri,v);
    st[x].sum=st[left_son(x)].sum+st[right_son(x)].sum;
}
ll find(ll x,ll li,ll ri){
    if(st[x].left_point>ri||st[x].right_point<li)return 0;
    if(st[x].left_point>=li&&st[x].right_point<=ri){
        return st[x].sum;
    }
    push_down(x);
    return find(left_son(x),li,ri)+find(right_son(x),li,ri);
}
int main(){
    scanf("%lld%lld",&n,&m);
    for(int i=1;i<=n;i++){
        scanf("%lld",&a[i]);
    }
    build(1,1,n);
    for(int i=1;i<=m;i++){
        ll sign,x,y,k;
        scanf("%lld",&sign);
        if(sign==1){
            scanf("%lld%lld%lld",&x,&y,&k);
            plus(1,x,y,k);
        }else{
            scanf("%lld%lld",&x,&y);
            printf("%lld\n",find(1,x,y));
        }
    }
    return 0;
}