简单线段树知识点详解

简单线段树知识点详解

本篇随笔讲解信息学奥林匹克竞赛中强大且常用的猛士数据结构——线段树。因为线段树博大精深,有许多变形和应用方式。区区一篇随笔是绝对无法尽叙的。所以在这里笔者只为读者讲解简单线段树。希望每一位有缘读到这篇随笔的人都能对线段树有一个深刻的理解,并会解决线段树的简单问题。

由于线段树属于一种高级数据结构。所以在学习线段树的时候需要的知识铺垫比较多。建议读者先对树状结构、二分以及递归编程法有深刻的认识和理解,然后再进行线段树的学习。这样的话会方便很多。当然,如果你缺少了前述铺垫知识的一项或几项,也并不代表你一定学不好线段树。勇于尝试、敢于挑战、努力思考。这会对你线段树及以后很多知识点的学习有极大的促进作用。

那我就开始了。


线段树的概念

在介绍线段树的概念之前,我先介绍线段树的用途:线段树一般用于区间统计。即统计\([x,y]\)区间内的某一个特性。这个特性可以有很多,比如区间求和,区间最值等等。

定义什么的特别复杂,我们争取用一张图搞清楚对线段树的直观理解。

上图是一棵1-5区间的线段树。

我们发现这个线段树是一棵二叉树,每个节点表示一个区间,根节点对应区间1-n.每个叶子节点都只表示单点,针对二叉树编号的性质(二叉树的每个父亲节点f的左节点编号是2f,右节点编号是2f+1),我们可以使用一维数组实现线段树。

也就是说,我们开一个一维数组,一维数组的下标表示这棵线段树的节点编号,里面存的值表示这个节点所表示的区间中我们要维护的特性:如和、最值等。


简单线段树支持的操作

刚刚已经说过,线段树是一种博大精深的数据结构,它的功能和操作实在是太多了。之所以反复强调这些,是为了让读者清楚,在线段树的海洋中,我们都不过是探其一角罢了,千万不要妄自尊大,以为自己已经把线段树全部搞完了。

简单线段树支持单点查询,区间查询,单点修改,区间修改,我们发现这和树状数组的一些支持项目类似,但是却不完全包含,因为树状数组仅支持区间求和,且必须是\(1-n\)的求和,如果想要\([x,y]\)的任意区间求和的话,必须需要使用差分思想来相减。也就是说,树状数组的所有题目都可以使用线段树统计解决,但是线段树的题目却不一定也能用树状数组解决,换句话说,线段树是树状数组的扩充版本。


简单线段树的实现

实现线段树的方法有两种,一般来讲,我们常常用结构体来实现线段树,在线段树的实现过程中,我们需要维护节点的编号,节点编号所表示的区间(左端点和右端点)。

同时,我们也可以用一维数组来实现线段树。

为什么一维数组可以实现线段树呢?

因为根据二叉树的性质,如果从根节点开始从1开始连续编号,那么对于任意编号为\(x\)的节点,它的左儿子编号就是\(2x\),右儿子的编号就是\(2x+1\)。所以我们的数组下标只维护节点编号。而节点的左右端点另用参数表示。这样也可以实现线段树。因为码量少,较直观,本篇随笔的例题和讲解统一使用这种写法来演示。

线段树的时间复杂度是\(O(logn)\)级别的。

下面我们通过一道例题来分析和讲解线段树的实现。

  • 讲解例题:有一个含有\(n\)个元素的数列,初始值不等,操作1将区间\([x,y]\)的所有值加\(k\),操作2查询区间\([x,y]\)的值的和。

假设\(n=5\),那么我们回到这个\(1-5\)的线段树的图示。

我们发现,我们的数列初始值就是上图中的叶子节点的值。如果要让线段树能支持我们进行修改和查询操作,我们首先要给线段树的叶子节点赋上值,并且通过叶子节点的值一层层地向上回溯,直至把整棵树都赋上正确的值。我们把这个过程叫做:建树

建树

因为线段树是二叉树,而我们在建树的时候又要从叶子节点一点点地向上赋值建树,那么我们很容易想到用递归进行建树。在写这个递归函数的时候,我们先进行搜索,搜到叶子节点之后就赋上值,然后递归回溯,一层一层地给上面的节点赋值。

代码如下:

#define lson pos<<1
#define rson pos<<1|1//位运算定义左右儿子
void build(int pos,int l,int r)
{
    int mid=(l+r)>>1;
    if(l==r)//表示搜到叶子节点
    {
        tree[pos]=a[l];//赋值
        return;
    }
    build(lson,l,mid);
    build(rson,mid+1,r);//递归建树
    tree[pos]=tree[lson]+tree[rson];//建树维护区间和的操作
}

其中\(tree[]\)为线段树数组,\(pos\)为节点编号,\(l,r\)为左右端点。

其实,在有的题目中,我们并不需要建树。那么,什么时候需要建树,什么时候不需要呢?

其实很简单啦!如果数列中有初值的话就建树,没有初值就不用建了(本来就是空的)

修改

建完树之后,我们下一步的操作就应该是按照题意对数列上的区间进行修改。如果是朴素算法,我们很容易想到从\(x\)\(y\)进行循环修改,这样的时间复杂度是\(O(n)\),在一些题目种肯定是过不了的。而我们刚刚说到过,线段树的复杂度是\(O(logn)\)。因为这是一种二叉树,所以我们考虑递归搜索。

假如我们要修改的区间是\([x,y]\),那么我们从根节点开始搜索,如果到了叶子节点且这个叶子节点在\([x,y]\)之内,那就进行修改,然后再一层层递归回溯更新即可。

但是,这样真的有必要么?

继续贴原图:

现在假设我们要修改区间\([1,3]\)中的每个数,按照我们之前的模拟,我们需要从1号点一直搜到8,9,5三个点,然后进行回溯修改。但是,通过分析上图,我们发现如果搜到了2号点,2号点表示的区间正好是\([1,3]\),那如果直接修改2号点的权值,在以后查询的时候就不会影响正确答案了。(我这个说法是错的,为什么错一会讲)所以,基于这个“偷懒”的思想,我们引入线段树的精华修改——\(lazy\)标记。

\(lazy\)标记和\(pushdown\)操作

先来一波规范的定义:假设我们要修改区间\([x,y]\),而遇到了某个节点维护的区间是区间\([a,b]\),而区间\([a,b]\)又是区间\([x,y]\)的子集,那么我们就在当前节点修改整个区间的属性,而不用修改到叶子节点。(否则的话,时间复杂度要比\(O(n)\)的还大)。那么这个就是lazy标记了。

也就是说,当搜到一个子集点的时候,直接把这个点打上标记,把这个点的线段树数组加上修改的区间长度*修改的数值(好好想想为啥这么干)。然后把标记数组加上要修改的数值。

而当我们继续搜索的时候,需要加入一个pushdown的操作。

为什么呢?

因为我们要维护搜索的正确性。试想,我们刚刚完成了一次区间修改,而这种区间修改只修改了子集节点。它往下的那些节点都没有改。问题来了:我们如果有多个操作,涉及到这个子集节点的往下的节点,那就出问题了,因为那些东西并没有被修改,这也是常常出锅的原因。

\(pushdown\)应运而生,这个操作的正式名字叫标记下传。顾名思义,就是把当前点的lazy标记下传到它的左右儿子,同时把这个节点的lazy值清零。

大体长成这个样子:

void mark(int pos,int l,int r,int k)
{
    tree[pos]+=(r-l+1)*k;
    lazy[pos]+=k;
}
void pushdown(int pos,int l,int r)
{
    int mid=(l+r)>>1;
    mark(lson,l,mid,lazy[pos]);
    mark(rson,mid+1,r,lazy[pos]);
    lazy[pos]=0;
}
void update(int pos,int l,int r,int x,int y,int k)
{
    int mid=(l+r)>>1;
    if(x<=l && r<=y)
    {
        mark(pos,l,r,k);
        return;
    }
    pushdown(pos,l,r);
    if(x<=mid)
        update(lson,l,mid,x,y,k);
    if(y>mid)
        update(rson,mid+1,r,x,y,k);
    tree[pos]=tree[lson]+tree[rson];
}

查询

查询的模板和以上修改的大同小异。我们新加一个ret变量,然后递归进行累加即可。

代码如下:

int query(int pos,int l,int r,int x,int y)
{
    int ret=0;
    int mid=(l+r)>>1;
    if(x<=l && r<=y)
        return tree[pos];
    pushdown(pos,l,r);
    if(x<=mid)
        ret+=query(lson,l,mid,x,y);
    if(y>mid)
        ret+=query(rson,mid+1,r,x,y);
    return ret;
}

总结

简单线段树的知识点差不多就是这些,以上演示的是区间和的求法。经常用线段树维护的还有区间最值。当然,区间最值的求法可以有很多,ST表也是非常常用的一种。如果只是单纯的求RMQ,而没有其他的一些要求,个人还是推荐大家用ST表来求最值。这样的复杂度总会更优一些。

总之,线段树是一种需要好好体会的数据结构。提高以上的题目经常会用到这个模板做一些东西。而如果这个模板拍不熟,真正在考场上就会“心有余而力不足”,明知这道题可以用线段树做,却无从下手,不知道写什么。那样的话就太糟糕了。希望读者们能多做题,多体会。举一反三,彻底掌握这种数据结构。AK 所有比赛(我飘了)

posted @ 2019-08-05 10:55  Seaway-Fu  阅读(1679)  评论(0编辑  收藏  举报