算法学习模板——线段树(上)

        万里之行,始于足下。本博客总结近期学习到的部分数据结构模板,以便于日后查询使用。作者水平有限,难免存在疏漏不足,恳请诸位看官斧正。倘若我的文章可以帮到你,十分荣幸。当然,笔者以后会对本文更新优化。本次内容的灵感来自于最近暑期训练对线段树内容的回顾。

目录

1.引经据礼——何谓线段树?

2.抽丝剥茧——且听我逐层道来

(1)区间节点的表示

(2)线段树的建树

(3)lazy_tag

(4)区间查询&区间修改

(5)总体模板代码

3.长虑顾后——关于线段树的一些问题的讨论

(1)线段树的单点修改

(2)线段树多lazy_tag的后效性

定义节点

init_lazy函数

cal_lazy函数

union_lazy函数

4.来日正长——接下来我要学习的


1.引经据礼——何谓线段树?

       学习过树状数组的同学知道,树状数组很能够方便快捷地实现区间的前缀和查询与单点修改。但是它的本质仍然是前缀和,所以它依赖了前缀和的“前缀可减性”,单点修改操作具有一定的局限性。例如,如何维护区间最大值、最小值?而且,如果我们想更方便一点,一次修改一个区间的数呢?例如:洛谷-P3372 【模板】线段树 1

      于是就有了今天的主角,线段树。线段树是一种二叉搜索树,与区间树相似,它将一个区间对半划分成一些单元区间,每个单元区间对应线段树中的一个叶结点。使用线段树可以快速的查找某一个节点在若干条线段中出现的次数,时间复杂度为O(logN)

线段树示意图(来源:百度百科) 

       愚以为,线段树的核心思想是对于一个区间[l,r],我们可以令mid=(l+r)/2,将它分成[l,mid]和[mid+1,r]两个部分,如此分而治之,维护、查询完之后再如此递归处理它的子区间,之后不要忘记通过新的子区间来向上更新这个区间自己的信息。

2.抽丝剥茧——且听我逐层道来

(1)区间节点的表示

        我们采用一个结构体来存储线段树的一个节点,也就是节点代表的这一段区间的数据。

struct node{
    int l,r;//节点表示区间的左右端点
    ll sum;//区间和
    ll lazy;//lazy_tag
};

       其中,sum是[l,r]这个区间的数据之和(因为之前我们给出的问题是快速查询区间和)。当然,如果我们需要查询其他的信息的话也需要在这里加上,比如区间乘积之类的。而lazy呢?这个关乎我们如何高效进行线段树区间修改的,后文且听我细细道来。材料有了,那我们就可以着手建立数据结构了。

(2)线段树的建树

       线段树的建树主要采用了递归的思想。在我们在建树函数中输入线段树的根节点信息后,对半分区间,递归构建它的左右子节点。

       问题来了:请思考,编号为root的二叉树左右子节点的编号是啥?

       数据结构知识告诉我们,在二叉树中为了便于管理,某个编号root的节点,左子节点编号为root*2,右子节点编号为root*2+1(当然,我们还可以使用位运算用root<<1表示root*2),具体原理这里不做赘述。

void build(int root,int l,int r){
        t[root].l=l;
        t[root].r=r;//区间左右端点
        init_lazy(root);//初始化节点lazy_tag
        if(l!=r){//非叶子结点,表示一段数据
            int mid=(l+r)>>1;//值相当于(l+r)/2
            int ch=root<<1;//子节点,值相当于root*2
            build(ch,l,mid);
            build(ch+1,mid+1,r);//递归建立左右子树
            update(root);//根据新的左右子树更新当前节点
        }else{
            t[root].sum=a[l];//叶子结点,我们直接赋节点值就好了
        }
    }

(3)lazy_tag

       试想一下:在修改某个区间数的时候,如果我们对每个叶子节点逐一修改,是不是会很麻烦,违背了我们一开始想高效区间维护的初衷?我们会发现,在修改的区间中,有些子区间可能从头到尾我们查询操作都没理过它,那么,我们可以“偷懒”,只是向下更新部分叶子节点的值,而其他的区间节点仅仅只是记录我们需要修改的值,并不向下更新子节点的信息,一直到我们需要用到它,再让它向下传递,更新子节点的sum和lazy的值。

       例如:我们需要让[2,8]这个区间的数都加上1 。覆盖了[2,2]、[3,4]和[5,8]这三个区间、那么我们就让这些被覆盖的区间节点lazy_tag加1,至于他们的子节点嘛,先等等看,不着急更新([2,2]这个是叶子节点,其实我们可以直接把修改的值给加上了)。当然,修改了这些区间节点,也别忘了向上更新他们的父节点信息(红色斜线标记)。

       之后,我们需要查询区间[5,6]的信息,但是这里的信息还是旧的,所以这个时候我们就需要用到这个区间,我们将它的父节点的lazy_tag向下传递到左右子节点。同时也需要依据lazy_tag和父节点区间长度更新父节点,也就是更新为父节点真实的信息,并重置父节点lazy_tag值。这样我们要查询的区间就可以根据先祖节点记录的lazy_tag值来计算它的真实信息了。

        至于其他的区间如[7,8]嘛?哈哈,如果没有用到就没必要更新了,这样就省去了好多不必要操作,降低了时间复杂度。相关操作如下:

void init_lazy(int root){//初始化lazy_tag
        t[root].lazy=0;
    }
    void union_lazy(int fa,int ch){//子节点接受父节点lazy_tag值
        t[ch].lazy+=t[fa].lazy;
    }
    void cal_lazy(int root){//通过区间lazy_tag计算区间真实值
        t[root].sum+=(t[root].r-t[root].l+1)*t[root].lazy;
    }
    void push_down(int root){//向下传递lazy_tag
        if(t[root].lazy){
            cal_lazy(root);//通过区间lazy_tag计算区间真实值
            if(t[root].r!=t[root].l){//非叶子节点向下传递tag
                int ch=root<<1;
                union_lazy(root,ch);
                union_lazy(root,ch+1);
            }
            init_lazy(root);//重置初始化节点lazy_tag
        }
    }
    void update(int root){//向上更新父节点值
        int ch=root<<1;
        push_down(ch);
        push_down(ch+1);//维护左右子节点
        t[root].sum=t[ch].sum+t[ch+1].sum;
    }

        这里为了使结构更加清晰可能函数有些冗余,为了效率我们可以直接用代码替换。init_lazy、union_lazy和cal_lazy函数在之后拓展中较为复杂的线段树问题中可以更加便利地拓展。

(4)区间查询&区间修改

       区间查询与修改操作的思路与代码是类似的。如上一节所述,我们递归修改/查询需要操作的区间的子区间,当然不要忘了向下传递lazy_tag。如果当前区间正好与需要操作的区间相同,那我们就可以很愉快地完成任务了。不然,我们需要将当前区间一分为二,到它的左右子节点递归精确查找到我们想要的区间。如果是修改的话,需要在最后向上更新父节点。

void change(int root,int l,int r,ll data){//修改操作
        push_down(root);//向下传递lazy_tag
        if(l==t[root].l&&r==t[root].r){//当前节点区间刚好是需要查找的区间
            t[root].lazy+=data;//修改数据
            return;
        }
        int mid=(t[root].l+t[root].r)>>1;//区间一分为二
        int ch=root<<1;
        if(r<=mid) change(ch,l,r,data);//查找区间在左半段
        else if(l>mid) change(ch+1,l,r,data);//查找区间在右半段
        else{//查找区间横跨当前节点区间中点,左右都有
            change(ch,l,mid,data);
            change(ch+1,mid+1,r,data);
        }
        update(root);//向上更新父节点
    }
    ll query(int root,int l,int r){//查询操作(当前例题为区间和)
        push_down(root);//向下传递lazy_tag
        if(l==t[root].l&&r==t[root].r){//当前节点区间刚好是需要查找的区间
            return t[root].sum;
        }
        int mid=(t[root].l+t[root].r)>>1;//区间一分为二
        int ch=root<<1;
        if(r<=mid) return query(ch,l,r);//查找区间在左半段
        else if(l>mid) return query(ch+1,l,r);//查找区间在右半段
        else return query(ch,l,mid)+query(ch+1,mid+1,r);
        //查找区间横跨当前节点区间中点,左右都有
    }

(5)总体模板代码

        好,综上所述,我们已经分析了线段树最基础的结构了,那么它的例题代码就是如此。

#include <bits/stdc++.h>
#define endl '\n'
#define ll long long
using namespace std;

template<class T>inline void read(T &res){//快读
    char c;T flag=1;
    while((c=getchar())<'0'||c>'9')if(c=='-')flag=-1;res=c-'0';
    while((c=getchar())>='0'&&c<='9')res=res*10+c-'0';res*=flag;
}

const int maxn=1e5+7;
ll a[maxn];

struct node{
    int l,r;//节点表示区间的左右端点
    ll sum;//区间和
    ll lazy;//lazy_tag
};
struct SegmentTree{
    node t[maxn<<2];
    void init_lazy(int root){//初始化lazy_tag
        t[root].lazy=0;
    }
    void union_lazy(int fa,int ch){//子节点接受父节点lazy_tag值
        t[ch].lazy+=t[fa].lazy;
    }
    void cal_lazy(int root){//通过区间lazy_tag计算区间真实值
        t[root].sum+=(t[root].r-t[root].l+1)*t[root].lazy;
    }
    void push_down(int root){//向下传递lazy_tag
        if(t[root].lazy){
            cal_lazy(root);//通过区间lazy_tag计算区间真实值
            if(t[root].r!=t[root].l){//非叶子节点向下传递tag
                int ch=root<<1;
                union_lazy(root,ch);
                union_lazy(root,ch+1);
            }
            init_lazy(root);//重置初始化节点lazy_tag
        }
    }
    void update(int root){//向上更新父节点值
        int ch=root<<1;
        push_down(ch);
        push_down(ch+1);//维护左右子节点
        t[root].sum=t[ch].sum+t[ch+1].sum;
    }
    void build(int root,int l,int r){
        t[root].l=l;
        t[root].r=r;//区间左右端点
        init_lazy(root);//初始化节点lazy_tag
        if(l!=r){//非叶子结点,表示一段数据
            int mid=(l+r)>>1;//值相当于(l+r)/2
            int ch=root<<1;//子节点,值相当于root*2
            build(ch,l,mid);
            build(ch+1,mid+1,r);//递归建立左右子树
            update(root);//根据新的左右子树更新当前节点
        }else{
            t[root].sum=a[l];//叶子结点,我们直接赋节点值就好了
        }
    }
    void change(int root,int l,int r,ll data){//修改操作
        push_down(root);//向下传递lazy_tag
        if(l==t[root].l&&r==t[root].r){//当前节点区间刚好是需要查找的区间
            t[root].lazy+=data;//修改数据
            return;
        }
        int mid=(t[root].l+t[root].r)>>1;//区间一分为二
        int ch=root<<1;
        if(r<=mid) change(ch,l,r,data);//查找区间在左半段
        else if(l>mid) change(ch+1,l,r,data);//查找区间在右半段
        else{//查找区间横跨当前节点区间中点,左右都有
            change(ch,l,mid,data);
            change(ch+1,mid+1,r,data);
        }
        update(root);//向上更新父节点
    }
    ll query(int root,int l,int r){//查询操作(当前例题为区间和)
        push_down(root);//向下传递lazy_tag
        if(l==t[root].l&&r==t[root].r){//当前节点区间刚好是需要查找的区间
            return t[root].sum;
        }
        int mid=(t[root].l+t[root].r)>>1;//区间一分为二
        int ch=root<<1;
        if(r<=mid) return query(ch,l,r);//查找区间在左半段
        else if(l>mid) return query(ch+1,l,r);//查找区间在右半段
        else return query(ch,l,mid)+query(ch+1,mid+1,r);
        //查找区间横跨当前节点区间中点,左右都有
    }
};

SegmentTree st;//线段树
int n,m,ins,x,y;
ll k;

int main(){
    //ios::sync_with_stdio(false);
    //cin.tie(0),cout.tie(0);
    read(n), read(m);//快读读入n,m
    for(int i=1;i<=n;i++) read(a[i]);
    st.build(1,1,n);//建树
    for(int i=1;i<=m;i++){
        read(ins);//根据相应的标记执行查询/修改操作
        if(ins==1){
            read(x), read(y), read(k);
            st.change(1,x,y,k);
        }else if(ins==2){
            read(x), read(y);
            cout<<st.query(1,x,y)<<endl;
        }
    }
    return 0;
}

        这里我受到牛客竞赛四系智乃的启发,采用了这一种比较“面向对象”的线段树模式,结构比较清晰合理,也易于勘误debug。

3.长虑顾后——关于线段树的一些问题的讨论

(1)线段树的单点修改

        线段树功能如此强大,那我们怎么对它进行单点修改呢?我们知道数据的单点对应着线段树的叶子结点,那么我们需要在建树的时候使用一个数组记录映射关系,即每个单点在数据(例如数组)中的序号到线段树节点编号的映射,这样我们就可以找到节点数组需要修改的位置。在修改完成之后,我们需要向上更新它所有的先祖节点,根据我们之前了解的二叉树的性质,设叶子节点编号为n,那我们就用while(n>>1)(或者说是while(n/2))对它的先祖节点遍历更新。实现代码如下:

void change(int x,int y){
        int ind=mp[x];//获得映射关系
        t[ind].sum+=y;
        while(ind){
            ind>>=1;
            update(ind);//循环更新先祖节点
        }
    }

(2)线段树多lazy_tag的后效性

        在一些题目中,我们需要考虑的操作可能不止一种,那么我们就自然而然的想到了加入多个lazy_tag,例如牛客竞赛-数据结构,我们需要对区间进行加/乘两种不同的修改操作,设置了两个lazy_tag。但是这可能会给我们的push_down操作带来一定的麻烦:

        第一步,[5,8]区间乘3,那么3号节点的乘lazy_tag改为3。第二步,[5,6]区间加1,那么6号节点的加lazy_tag改为1。问题来了:在push_down操作中,[5,6]区间的值是先加1,之后才乘3,和我们期望的顺序相反。3(x+1)和3x+1是不同的,所以在此类多lazy_tag问题中我们需要合理处理lazy_tag的后效性。

        这个时候我们之前提到过的在模板例题中看似冗余的init_lazy、union_lazy和cal_lazy函数派上了用场。

定义节点

struct node{
    ll sum,square;//区间和与区间平方和
    ll add,mul;//加、乘lazy_tag
    int l,r;
};

init_lazy函数

        注意mul初值为1,因为对应的是乘法操作。

void init_lazy(int root){
    t[root].add=0;
    t[root].mul=1;
}

cal_lazy函数

        利用lazy_tag计算当前点和与平方和的真实值,推导如下:

        其中a代表加的lazy_tag,m代表乘lazy_tag。那么我们可以得到如下代码:

void cal_lazy(int root) {
        t[root].sum=t[root].mul*t[root].sum+(t[root].r+t[root].l-1)*t[root].add;
        t[root].square=t[root].mul*t[root].mul*t[root].square+
                2*t[root].mul*t[root].add*t[root].sum+
                (t[root].r-t[root].l+1)*t[root].add*t[root].add;
        return;
    }

        虽然看起来挺复杂,但是理解了线段树原理推导过程还是比较容易的。

union_lazy函数

        利用union_lazy合理处理lazy_tag之间的“关系”,向下传递给子节点。推导如下:

        1下标为父节点,2下标为子节点(当然这里只画了一个子节点) 。得到代码如下:

void union_lazy(int fa, int ch){//fa为父节点,ch为子节点
        t[ch].add=t[fa].mul*t[ch].add+t[fa].add;
        t[ch].mul=t[fa].mul*t[ch].mul;
    }

       综上,我们可以在原有的基础题上灵活改动,处理多修改多操作问题,做到“以不变应万变”。

4.来日正长——接下来我要学习的

       “艰难方显勇毅,磨砺始得玉成。”这次我重新捡起了一下去年学习的线段树,温故知新,收获颇丰。作为团队数据结构选手可能在接下来会巩固学习线段树的进阶操作:李超线段树、DDP等相关知识。同样的还有一些我提到的基础数据结构如前缀和、树状数组我也需要重温,所以我在中标题加入了(上)。那么感谢你能够看到最后,见证了我又一个成长的脚印。

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值