C++算法竞赛中的线段树

线段树是一种用于处理区间查询和修改的数据结构,常用于解决区间最值问题。文章通过实例介绍了线段树的基本构建、查询和更新操作,并讲解了在数据规模增大时如何利用线段树优化算法,避免超时。此外,还提到了懒惰标记的概念,用于提高处理大规模数据时的效率。
摘要由CSDN通过智能技术生成

一、什么是线段树?


    • 什么时候用线段树?

当我们所要求的值与区间有关(如区间最值、区间和等),且数据范围很大(复杂度要超时,需要nlogn的复杂度),此时就需要用到线段树(有兴趣的童鞋可以了解一下树状数组,相比树状数组,线段树的应用范围更广)。


    • 线段树的形式

如图:



我们可以看到,线段树就是把一个区间不停地划分成两部分直到分成单个节点,除了整棵树的最后一层,线段树是一颗完全二叉树,所以我们用完全二叉树节点的编号方法给线段树编号,即:以p为编号的点的左儿子的编号为,右儿子的编号为。为了简便,我们一般在程序的开头加上这两句话:

#define p2 (p<<1)
#define p3 (p<<1|1)

p2表示p点左儿子的编号,p3表示p点右儿子的编号,两个位运算的式子大家可以自己去推算一下,分别等同于


二、线段树的应用

    • 区间最值问题(RMQ)

例1:洛谷P1816 忠诚(题目传送门

时间限制:1秒 内存限制:125MB

题目描述

老管家是一个聪明能干的人。他为财主工作了整整10年,财主为了让自已账目更加清楚。要求管家每天记k次账,由于管家聪明能干,因而管家总是让财主十分满意。但是由于一些人的挑拨,财主还是对管家产生了怀疑。于是他决定用一种特别的方法来判断管家的忠诚,他把每次的账目按1,2,3…编号,然后不定时的问管家问题,问题是这样的:在a到b号账中最少的一笔是多少?为了让管家没时间作假他总是一次问多个问题。

输入

输入中第一行有两个数m,n表示有m(m< =100000)笔账,n表示有n个问题,n< =100000。 第二行为m个数,分别是账目的钱数 后面n行分别是n个问题,每行有2个数字说明开始结束的账目编号。

0<= 钱数 <=1000000

输出

输出文件中为每个问题的答案。具体查看样例。

样例输入
10 3
1 2 3 4 5 6 7 8 9 10
2 7
3 9
1 10
样例输出
2 3 1
提示

这题求的是区间最值,且数据范围较小,复杂度可以通过,可以采用ST大法,由于本文着重讲解的是线段树,故对ST不做解释,直接上代码给各位童鞋抄作业(想要了解ST的可以看这篇:传送门,看了下洛谷的几篇题解,这篇介绍的还算仔细):

#include<bits/stdc++.h>
using namespace std;
const int M=100005;
int m,n,a[M],f[M][18],lg[M]; //lg[i]表示长度为i的一段区间最值由长度为2^k的两端求得
void init(){
    int t=log(m)/log(2)+1;
    for(int j=1; j<t; j++)
        for(int i=1; i<=m-(1<<j)+1; i++)
            f[i][j]=min(f[i][j-1],f[i+(1<<j-1)][j-1]);
    for(int i=2; i<=m; i++) lg[i]=lg[i/2]+1;
}
int rmq(int l,int r){
    int k=lg[r-l+1];
    int minv=min(f[l][k],f[r-(1<<k)+1][k]);
    return minv;
}
int main(){
    scanf("%d%d",&m,&n);
    for(int i=1; i<=m; i++) scanf("%d",&a[i]),f[i][0]=a[i];
    init();
    while(n--){
        int a,b; scanf("%d%d",&a,&b);
        printf("%d ",rmq(a,b));
    }
    return 0;
}
思考

如果数据再大一些呢?我们把区间范围由扩大到,这下复杂度的算法铁定是要超时了,只能搬出我们的主角:线段树。我们把题目修改如下。

例2:再问账

时间限制:1秒 内存限制:128MB

题目描述

老管家是一个聪明能干的人。他为财主工作了整整10年,财主为了让自已账目更加清楚。要求管家每天记k次账,由于管家聪明能干,因而管家总是让财主十分满意。但是由于一些人的挑拨,财主还是对管家产生了怀疑。于是他决定用一种特别的方法来判断管家的忠诚,他把每次的账目按1,2,3…编号,然后不定时的问管家问题,问题是这样的:在a到b号账中最少的一笔是多少?为了让管家没时间作假他总是一次问多个问题。 在询问过程中账本的内容可能会被修改。

输入

输入中第一行有两个数m,n表示有m(m< =100000)笔账,n表示有n个问题,n< =100000。 接下来每行为3个数字,第一个p为数字1或数字2,第二个数为x,第三个数为y 当p=1 则查询x,y区间 当p=2 则改变第x个数为y

0<= 钱数 <=1000000

输出

输出文件中为每个问题的答案。具体查看样例。

样例输入
10 3
1 2 3 4 5 6 7 8 9 10
1 2 7
2 2 0
1 1 10
样例输出
2 0
题解

本题需要运用到线段树中的单点修改以及求区间最值两个操作,具体操作见代码注释:

先写好基本的输入部分(注意!这里我们为了顺应习惯,颠倒了题目中n和m的意义!)

#include<bits/stdc++.h>
using namespace std;
#define p2 (p<<1)
#define p3 (p<<1|1)
const int M=100005;
int n,m,a[M];
int main(){
    scanf("%d%d",&n,&m); //注意!这里我们为了顺应习惯,颠倒了题目中n和m的意义!
    for(int i=1; i<=n; i++) scanf("%d",&a[i]);
    return 0;
}

接下来我们来定义一颗线段树:

struct node{
    //这里我们用结构体数组来记录每个节点代表区间(线段)的左右端点以及题目中所要求的区间最小值,熟练后也可省略左右端点,在函数中用形参表示,直接采用普通数组记录所求值
    int l,r,v; //l表示该节点表示区间(线段)的左端点,r表示右端点,v表示区间最小值
}tr[M<<2]; //这是线段树必备知识,线段树数组空间要开区间的4倍!有兴趣的童鞋可以去了解一下原理哟

4倍空间原理传送门

有了线段树与初始数据,本题需要对线段树进行初始化,即:建树。这里我们用build函数表示:

void build(int l,int r,int p){ //p为当前节点编号,l、r为当前节点代表的线段的左右端点
    tr[p].l=l,tr[p].r=r;
    if(l==r){tr[p].v=a[l]; return;} //到达叶子结点,记录值,返回
    int mid=(l+r)/2; //二分线段
    build(l,mid,p2); build(mid+1,r,p3); //建左右子树
    tr[p].v=min(tr[p2].v,tr[p3].v); //统计区间最值
}
......
build(1,n,1); //主函数内的建树语句,此处的后一个1表示线段树的入口,线段树的每个函数操作都需要,因为要访问线段树中的每一个节点都需要通过根(即总区间)二分得到

初始化完,就是本题的两个主要操作了,输入就不说了,这里我们来重点看一下两个操作函数的代码:

int query(int x,int y,int p){ //查询函数
    if(tr[p].l==x && tr[p].r==y) return tr[p].v; //特判,如果要查询的区间正好为某个节点,直接返回该节点所代表区间的最值
    int mid=(tr[p].l+tr[p].r)/2,ret=0;
    //分段查找
    if(y<=mid) ret=query(x,y,p2);
    else if(x>=mid+1) ret=query(x,y,p3);
    else ret=min(query(x,mid,p2),query(mid+1,y,p3));
    return ret;
}
void upd(int x,int y,int p){
    if(tr[p].l==tr[p].r){tr[p].v=y; return;} //到达叶子结点直接修改数值并返回
    int mid=(tr[p].l+tr[p].r)/2;
    if(x<=mid) upd(x,y,p2);
    else upd(x,y,p3);
    tr[p].v=min(tr[p2].v,tr[p3].v); //更新当前区间最值
}

最后再展示一下完整的代码:

#include<bits/stdc++.h>
using namespace std;
#define p2 (p<<1)
#define p3 (p<<1|1)
const int M=100005;
struct node{
    int l,r,v;
}tr[M<<2];
int n,m,a[M];
void build(int l,int r,int p){
    tr[p].l=l,tr[p].r=r;
    if(l==r){tr[p].v=a[l]; return;}
    int mid=(l+r)/2;
    build(l,mid,p2); build(mid+1,r,p3);
    tr[p].v=min(tr[p2].v,tr[p3].v);
}
int query(int x,int y,int p){
    if(tr[p].l==x && tr[p].r==y) return tr[p].v;
    int mid=(tr[p].l+tr[p].r)/2,ret=0;
    if(y<=mid) ret=query(x,y,p2);
    else if(x>=mid+1) ret=query(x,y,p3);
    else ret=min(query(x,mid,p2),query(mid+1,y,p3));
    return ret;
}
void upd(int x,int y,int p){
    if(tr[p].l==tr[p].r){tr[p].v=y; return;}
    int mid=(tr[p].l+tr[p].r)/2;
    if(x<=mid) upd(x,y,p2);
    else upd(x,y,p3);
    tr[p].v=min(tr[p2].v,tr[p3].v);
}
int main(){
    scanf("%d%d",&n,&m);
    for(int i=1; i<=n; i++) scanf("%d",&a[i]);
    build(1,n,1);
    //printf("l=%d r=%d v=%d\n",tr[1].l,tr[1].r,tr[1].v);
    while(m--){
        int t,x,y,ans; scanf("%d%d%d",&t,&x,&y);
        if(t==1){
            ans=query(x,y,1);
            printf("%d ",ans);
        }else{
            upd(x,y,1);
        }
    }
    return 0;
}
    • 懒惰标记

懒惰标记,简单来说就是如果当前区间与所需更新的区间相同,则先只更新当前节点(即整体更新整个区间),并标记当前节点,等到下一次需要查询到该区间以下细分部分时,取消该节点的标记,向下更新直到出现上述情况时标记新的符合条件的节点

这样说对大家来讲可能还是难以接受,接下来就让我们结合例题与代码来深入了解一下懒惰标记。

例题:外星植物

题目描述

在遥远的星球,有一种奇怪外星植物。为了方便起见,我们将这个植物放到平面坐标系中来看。

这种植物由N条茎组成,每天长出一条茎,N天后则会凋萎。第i天长出一个高度为i,横跨xi和yi的茎。例如:

第一天长出了一条茎,高度为1,横跨了(1,4)

第二天又长出了一条茎,高度为2,横跨了(3,7)

第三天,高度为3,横跨了(1,6)

第四天,高度为4,横跨了(2,6)

而每天,新长出来的茎,会与之前的茎相交(严格相交,可看图),如果相交则会开花。

现在研究者希望你写个程序来计算一下,每天会有多少朵新的花开放。

输入

第一行一个数N,表示植物的生命周期。

接下来N行,每描述一对(xi,yi),表示这一天生长的茎,横跨了xi和yi (注意xi可能大于yi)。

输出

N行,每行一个数,表示每天新开放的花的个数。

样例输入
4
1 4
3 7
1 6
2 6
样例输出
0
1
1
2
数据范围

对于60%的数据N≤3000

对于100%的数据 N≤100 000,1≤xi<yi≤100 000。

题解

这题中根据茎越来越高这一特性,可以把题目转换为用线段树统计当前茎两端横坐标被之前茎覆盖的次数,用数组保存上一次各横坐标上已有开花的数量,两者相减求得答案,同时对当前茎新覆盖区域的线段树进行更新。

由于单纯用nlogn的线段树求解仍会超时,所以我们采用O(n)的懒惰标记来解决问题:由于大部分代码与上面的线段树是类似的,所以我们直接展示完整代码,注意,这里的线段树数组我只开了普通数组,如同上一题所说的,我们把l,r两个参数当做形参写入函数中:

#include<bits/stdc++.h>
using namespace std;
#define p2 (p<<1)
#define p3 (p<<1|1)
int n,tr[400005],t[100005];
int query(int l,int r,int x,int p){
    if(l==r) return tr[p]; //所求区间就是当前区间,直接返回
    //需要继续向下搜索
    if(tr[p]){ //更新懒惰标记
        tr[p2]+=tr[p];
        tr[p3]+=tr[p];
        tr[p]=0;
    }
    int mid=(l+r)/2;
    if(x<=mid) return query(l,mid,x,p2);
    else return query(mid+1,r,x,p3);
}
void upd(int l,int r,int x,int y,int p){
    if(l==x && r==y) {tr[p]++; return;} //这里由于题目所求的量仅限于叶子结点,我们就把数据整体存储在tr[]数组的上面部分中,即懒惰标记。
    int mid=(l+r)/2;
    if(y<=mid) upd(l,mid,x,y,p2);
    else if(x>mid) upd(mid+1,r,x,y,p3);
    else upd(l,mid,x,mid,p2),upd(mid+1,r,mid+1,y,p3);
}
int main(){
    //freopen("plant.in","r",stdin);
    //freopen("plant.out","w",stdout);
    scanf("%d",&n);
    for(int i=1; i<=n; i++){
        int x,y; scanf("%d%d",&x,&y);
        int sx=query(1,100000,x,1);
        int sy=query(1,100000,y,1);
        printf("%d\n",sx-t[x]+sy-t[y]);
        t[x]=sx,t[y]=sy;
        if(x>y) swap(x,y);
        if(y-x>1) upd(1,100000,x+1,y-1,1);
    }
    return 0;
}

同样是这道题,用树状数组写起来可能会更简单,等下次我写树状数组专题时再给小伙伴们分享!

三、总结

作为一个蒟蒻,在此浅薄地发表一下我对线段树的初步认识与见解、心得,希望对大家有所帮助,大家多多支持,再次祝各位小伙伴新年愉快!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值