简单线段树知识点详解
本篇随笔讲解信息学奥林匹克竞赛中强大且常用的猛士数据结构——线段树。因为线段树博大精深,有许多变形和应用方式。区区一篇随笔是绝对无法尽叙的。所以在这里笔者只为读者讲解简单线段树。希望每一位有缘读到这篇随笔的人都能对线段树有一个深刻的理解,并会解决线段树的简单问题。
由于线段树属于一种高级数据结构。所以在学习线段树的时候需要的知识铺垫比较多。建议读者先对树状结构、二分以及递归编程法有深刻的认识和理解,然后再进行线段树的学习。这样的话会方便很多。当然,如果你缺少了前述铺垫知识的一项或几项,也并不代表你一定学不好线段树。勇于尝试、敢于挑战、努力思考。这会对你线段树及以后很多知识点的学习有极大的促进作用。
那我就开始了。
线段树的概念
在介绍线段树的概念之前,我先介绍线段树的用途:线段树一般用于区间统计。即统计([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 所有比赛(我飘了)