自学线段树的一些最最基本的操作

来源:互联网 发布:.杀破狼 - js 编辑:程序博客网 时间:2024/05/16 23:38

在最近两天的自学线段树当中,我领略了很多,接下来就讲一下我所领悟的东西。


首先,线段树的风格是比较重要的。每一个人写线段树都有很不一样的写法,我现在是看了老师教材里的写法写的,当然还有很多不一样的写法——如非递归、用record记录左儿子和右儿子之类的。

我的风格,也是教材里的风格是把x*2和x*2+1视为x的左儿子右儿子,另外我也觉得这样子挺方便的,与堆的储存方式很相像。


在昨天和今天的两天时间里,我学习了以下几种操作:

一、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,最开始的节点编号为1.(单点更新)

四、update(1,1,n,x,y,t):  在1~n的区间里欲把x~y区间里的每个数增加/减少t.(区间修改)

五、getsum(1,x,y):         欲求出区间[x..y]里面所有的值.(区间求和)


现在,依次把这三种操作详细讲一下:

一、maketree这个过程可以肯定,时间复杂度是O(n)级别的(但这并不是一个准确的数,实际上比n要大,下面所所有的线段树都是以根节点为区间最小值来构造的)

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

这里的叶子节点:49 87 24 67 93 39 66 31 13 53,就是我们输入的数。我们如果要构造一棵线段树的话,肯定要先把这些叶子节点赋值之后才能一步一步二分递进,才能像上面所说的把一个节点的值赋为其左儿子右儿子当中的较小值。所以,可以把递归建线段树的第一步理解为:

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

并且在对第x个节点进行赋值的时候,如果其非叶子节点,则可以肯定其左儿子右儿子必须是已经算好的了。而其左儿子右儿子如果是叶子节点的话,必然也是已经赋值了的,同上所说,叶子节点是赋值为当st=en的时的a[st].

所以,递归建树的第二步就可以视作:

直接把当前非叶子节点的节点赋值为其左儿子或右儿子当中的较小值。


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

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

{非递归代码}:

procedure maketree(x,st,en:longint);varmid: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]区间里的最小值/最大值.

先看个例子(红色为我们需要找的值,黄色为我们找红色时走过的地方):


对于这个[0..9]区间,如果我们要找[0..5]区间的最小值,应该如下查找:
首先,往区间[0..9]的左儿子递归,很明显,[0..9]的左儿子,已经被[0..5]包含了,所以我们无需继续往下找。直接判断f[x*2]的值是否需要更新即可。
然后因为我们递归完左儿子之后,肯定要去递归右儿子(因为我们发现我们只是算完了[0..5]之间的一部分而已),递归右儿子之后发现[5..9]并不被[5..5]完全包含([0..4]已经找到,所以只有[5..5]了),所以我们这个时候只能往左儿子递归(不可能往右儿子找)递归玩之后发现[ST..EN]的区间是[5..7],又继续往左儿子递归.
......
以此类推,最后我们一直按照如上步骤往下递归,就可以找到所有我们需要找的值了。


现在,把递归的步骤详解一遍:
如果当前在[ST..EN]区间里(mid=(st+en) div 2).
我们往左儿子递归的时候,发现[ST..MID]完全包含了[L..R],则我们无需往右儿子递归;
反之,如果我们往右儿子递归的时候,也发现[MID+1..EN]完全包含了[L..R],则无需往左儿子递归;

需要注意的是:这里指的都是完全包含.


如果上述两种情况都不符合的话,我们则需往左右儿子分别递归,但是在这种情况递归的时候,有一个需要注意的地方,就是我们递归的时候也要改变L或R的值。

这是因为——

如果发生这种情况,往左儿子递归时,因为r>mid,如果我们不把r的值设为mid,则会死循环。再换种角度来说,既然r已经大于mid了,并且[st..mid]并不完全包含[L..R](r>mid),我们往左儿子递归,本身就是取[L..R]区间的一部分来寻找最小值,我们设为[L..mid]并不会影响结果(答案),只是把[L..R]拆分为[L..MID]+L[MID+1..R]来计算而已.

代码:

procedure find(x,st,en,l,r:longint);varmid:longint;beginif (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)                elseif 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);varmid:longint;beginif 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)次,并不是一个最优的选择,原因是这样子的做法并没有很好地利用区间修改操作也是对一个区间进行操作的本性。

我们可以用之前区间查找的方法,把n个区间欲分成若干个小区间,依次往下查找能分的区间,查找到符合更改操作的时机就更改所对应的值。

这种方法的效率明显是极高的,只是在这种思想下,如果不继续探其精髓,则会出现以下错误:

例:

如果这时我们查找[0,0]区间的值则会得到3,显然这是一个错误的答案。

原因在于,我们在更新[0,1]区间的值时,并没有把与其“存在关系”的节点更新,导致了这种查找错误。

实际上,为了解决这种漏洞,我们采用了标记的方法:

例:

如果当前对应的情况是我们要把区间[5..9]的值加上t,则我们需把其区间所对应的编号标记一下,亦指把bj[3]的值同样加上t。这样,如果我们在查找区间的时候,对于bj[x],如果bj[x]有值的话则把x的左儿子右儿子都加上bj[x],并把bj[x]赋值为0。以此类推,在——区间修改,查找区间的时候都使用这样一个操作,以此确保当前线段树的查找是一定不会出错的。

这样的一个操作是区间修改不同于查找区间的其中一个地方,第二个地方就是我们很容易想到的,就是弹栈的时候把x的值更新为其左儿子右儿子当中的最大值,具体过程请看代码:

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)复杂度,快到极致。但如果中间Modify一个数呢?UPDATE一堆数呢?所以,对于这些繁琐的操作,介于前缀和的性质,并不好实现。所以还是采用线段树的方法。

很明显这个操作是非常容易实现的,与之前的Update和find过程的思想非常类似,find是找一个最大值,每次更新find的情况,这里只需每次把找到的数存起来,较容易实现,就不贴代码了。


现在比较下三个过程的时间复杂度:明显——maketree的时间复杂度是O(n),而Modify、find、update、getsum过程的时间复杂度都是O(LogN)的。

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

3 0