浅谈线段树

来源:互联网 发布:帝国cms仿站视频下载 编辑:程序博客网 时间:2024/06/06 06:53

注意的:

本文中,特需要注意的用了粗体标注,阅读时可以额外注意.

下面步入正题

线段树

可以说是迈向高级数据结构的必掌握的一个数据结构,其所涉及的范围很广,可以衍生出很多分支,灵活地套在一些题目上,使题目“码量”以及“错误率”增加.

下面我们来详细讲解一下有关线段树的基本知识,尝试深度地理解线段树.

重要的

我们每学习一种数据结构,必然有其精华所在,也就是我们为什么要学某个数据结构,它有什么优点?又有什么不足的地方,其实说到线段树的优点,就两个字——快、广.
虽然说线段树的常数比树状数组要大许多,但是只要树状数组能做的,线段树基本都能做,反过来就不一样了.
广,就是它涉及的题目范围类型较广,由其衍生出的问题有很多,可以说,掌握了线段树就等同于掌握了一个利器,有助于思考问题时的方向,以及了解出题人的思路.

知道优点,再想想,它为什么会快?为什么涉及范围广?

如果对线段树有了解的,可以直接翻到find过程那儿,你可以清晰直观地了解线段树“快”的优点,想知道“广”,你需要阅读全文

①写法&风格

线段树的风格并不是完全一致的。每一个人写线段树都有很不一样的写法,我现在是最普通的写法,(即将x2以及x2+1设为x的左儿子右儿子),当然还有很多其他的黑科技,暂且不探讨.

②支持的操作

线段树至少支持以下几个操作:
1、对于一堆数,O(nlogn)的时间构造一棵线段树.
2、在某个区间里O(logn)查找最值.
3、单点修改O(logn)(如果直接用区间修改,常数有点大,可以采用更普通的方法).
4、区间修改O(logn)(对区间进行加减修改).
5、求一段区间和O(logn).

对应的过程:
一、maketree(1,1,n):对于序列的区间[1,n]内的数,构造一颗线段树,最开始的节点编号为1.
二、find(1,1,n,x,y):在[1,n]的区间里寻找[x,y]区间里的最大值或最小值,最开始的节点编号为1.(区间查找)
三、modify(1,1,n,xx,yy):在[1,n]的区间把第xx编号的数改为yy(或增加/减少yy),最开始的节点编号为1.(单点更新)
四、update(1,1,n,x,y,t):在[1,n]的区间里欲把[x,y]区间里的每个数增加/减少t.(区间修改)
五、getsum(1,x,y):欲求出区间[x,y]里面所有的值.(区间求和)

③线段树的核心思想——二分


看如上的一颗线段树,很明显可以看出这是一颗“二分树”,为什么是“二分树”,从每个节点所表示的区间范围以及它的左儿子右儿子表示的区间范围中可以明显看出.

④储存方式

那么,对于一颗线段树的每个节点,固然有它所储存的位置,即其对应的编号.
我们可以这么编号,至上到下从左到右,按照先纵后横的顺序依次编号,即[1,10]所对应的编号为1[1,5]对应的编号为2,[6,10]对应的编号为3,以此类推…

很明显也可以看出,对于一颗线段树,每一个节点w的左儿子的编号就是其编号x的两倍,右儿子则是其编号x的两倍+1.

那么对于每一个编号为x的节点w,其一定有一个所对应表示的区间[l,r]内的值.(这个值可以是最值,也可以是总和——权值线段树统计方案时,分情况讨论,一般来说是最值)

那么,显然有状态:Tree[l,r]=f(tree[l,(l+r)shr1],Tree[(l+r)shr1+1,r]),函数f可以是取max,min,也可以是取sum.

⑤具体操作

当完全领悟上面四个步骤时,接下来的一切都会变得异常简单,注意一些处理的细节即可.

操作一、maketree过程——根据一堆数建一颗线段树

首先可以肯定一点,在建树的过程,是每次把一个节点的左儿子和右儿子中的较小值赋为这个节点的值。

假设现在我们求最小值,那么对于maketree过程,如何操作?


例:
这里的叶子节点:49 87 24 67 93 39 66 31 13 53,就是我们输入的数。

显然de——

我们如果要构造一棵线段树,肯定要把一颗线段树当中的叶子节点赋值,之后才能像上面所说的把一个非叶子节点的值赋为其左儿子右儿子当中的较小值

建树第一步:

递归建线段树的第一步可以理解为:
一步一步往当前非叶子节点的节点的左儿子和右儿子递归,直到st=en,表示这个区间里只有一个值,并且这个节点就是叶子节点了,于是就把第x个节点赋值为a[st].

建树第二步:

递归建线段树的第二步可以理解为:
直接把当前非叶子节点的节点赋值为其左儿子或右儿子当中的较小值。

其实这种递归赋值的思路与非递归构造线段树的思路是差不多的.

非递归思路就是把已经确定好的节点数先算出来,其实就是省去第一步,直接赋完值之后,再去赋值非叶子结点的值。但这种方法,其实并没有优化多少时间,因为建树基本上只会建一次。

dg代码看起更清晰直观.

procedure maketree(x,st,en:longint);var    mid:longint;begin        if st=en then f[x]:=a[st]        else        begin                mid:=(st+en) shr 1;                maketree(x shl 1,st,mid);                maketree(x shl 1+1,mid+1,en);                f[x]:=max(f[x shl 1],f[x shl 1+1]);        end;end;

操作二、find过程——寻找最大值/最小值的过程

这个操作其实稍微有点麻烦,但其实只要弄懂了线段树的本质,这个也完全可以掌握.

假设我现在要在[st,en]的区间里找到[l,r]区间的最大值,那么必有st<=len>=r,则我们可以这么想,如果整个[l,r]区间都存在在[st,en]区间的左子树里,那么我就直接递归左子树,[l,r]不动,如果完全在右子树也一样,那么如果左右子树都有,则两边都递归.(这时[l,r]就得更新了,具体看看标程里是如何实现的——技巧)

这时需要注意一个问题,也是一开始打线段树容易被坑的地方,就是我们递归左子树时,只要r<=mid就往左子树递归,至于为什么,自己想想(等于号切记,不能省)

其实,很多东西都是由 一种思想+无数技巧 组成,线段树的中心思想就是二分,通过不停分治,找出子问题的值,最后得到原问题的值.

思考:那么线段树为什么快呢?

原来,我们在查找一个区间[l,r]的值时,只要st=l 并且en=r,则我们可以直接得到区间[l,r]的值,不需要继续往[l,r]的子树递归,从而提高效率,可以保证,这样被找到的完全相等的st=len=r不超过Logn.

procedure find(x,st,en,l,r:longint);var        mid:longint;begin        if (st=l) and (en=r) then            ans:=max(ans,f[x])        else        begin                mid:=(st+en) shr 1;                if r<=mid then find(x shl 1,st,mid,l,r)                else                if l>mid then find(x shl 1+1,mid+1,en,l,r)                else                begin                        find(x shl 1,st,mid,l,mid);                        find(x shl 1+1,mid+1,en,mid+1,r);                end;        end;end;

操作三、Modify——单点更新.

顾名思义,也就是把一个数更改成另一个数,或者加上某一个数所更改某一个数的值的操作.
Modify过程只会把第xx个节点赋成yy之后,再把这个节点的父亲更新,父亲的父亲更新……以此类推.
有人会问,为什么不直接用上一个操作,其实是因为那个常数有点大了…

procedure modify(x,l,r:longint);var        mid:longint;begin        if l=r then f[x]:=yy        else        begin                mid:=(l+r) shr 1;                if mid>xx then modify(x shl 1,l,mid) else modify(x shl 1+1,mid+1,r);                f[x]:=max(f[x shl 1],f[x shl 1+1]);        end;end;

操作四、update——区间修改。

顾名思义,就是把一个区间里所有的数都加上或减少一个数的操作.

实现这个操作,如果我们用单点修改的方法执行这个操作,则易证更改的选择必定超过了O(logn)次,并不是一个最优的选择,原因是这样子的做法并没有很好地利用区间修改操作的特性.

我们可以仿照用之前区间查找的方法,在找到一个相等的st=l,en=r时,则把这个区间对应的编号赋值,那么很明显如果只这么赋值,是不行的,当我们查找[st,en]子树值,答案就会出错.
实际上,为了解决这种漏洞,我们采用了标记的方法:
例:
我们现在对对应的编号x加上t,那么我可以标记一下,把这个位置的g[x]()同样加上t,那么下次,当我们查询它的子树或修改它的子树时,可以看看当前编号x的标记是否存在值,如果有值,我就把它的子树值加上g[x],并把子树的标记也加上g[x],达到层层递进的思想,以此类推……

易证时间复杂度依然是O(logn),可能常数会有点大.

注意:当我们区间修改时,有时候并不会把子树一次全部赋值,那么当我查询时依然会出错,所以,当存在区间修改时,查询的时候也要进行与区间修改相同的操作——复制标记(可以参考标程)

procedure update(x,st,en,l,r,t:longint);var        mid:longint;begin        if (st=l) and (en=r) then        begin                f[x]:=f[x]+t;                g[x]:=g[x]+t;         //标记        end        else        begin                inc(f[x*2],g[x]);                inc(f[x*2+1],g[x]);                inc(g[x*2],g[x]);                inc(g[x*2+1],g[x]);                    g[x]:=0;                //即复制标记                mid:=(st+en) div 2;                if r<=mid then update(x*2,st,mid,l,r,t) else                if l>mid then update(x*2+1,mid+1,en,l,r,t) else                begin                        update(x*2,st,mid,l,mid,t);                        update(x*2+1,mid+1,en,mid+1,r,t);                end;                f[x]:=max(f[x*2],f[x*2+1]);        end;end;


五、很明显求一个区间的和是很容易想到前缀和的,O(1)复杂度,快到极致。但如果之间修改了数呢?所以,对于这些繁琐的操作,介于前缀和的性质,并不好实现。还是得采用线段树的方法.(可以看出广吧)
很明显这个操作是非常容易实现的,与之前的Updatefind过程的思想非常类似,find是每次找到合适的区间,求一个最大值,这里只需每次把找到的数存起来即可,较容易实现,就不贴代码了.

现在比较下三个过程的时间复杂度:明显——maketree的时间复杂度是O(n),而

Modifyfindupdategetsum
过程的时间复杂度都是
O(LogN)
的.

由时间复杂度可以看出,对于一个序列,如果只查找区间里面的一个最大值/最小值或这个区间的和,线段树并没有什么优点,但如果查找的数多了,修改的次数多了,则线段树的优势就可以体现出来了。

欲做题:
https://jzoj.net/senior/#main/show/1537
总结题:
https://jzoj.net/senior/#main/show/3512
https://jzoj.net/senior/#main/show/3901
https://jzoj.net/senior/#main/show/4064
https://jzoj.net/senior/#main/show/4063
https://jzoj.net/senior/#main/show/1435
https://jzoj.net/senior/#main/show/1347

1 0