线段树
千呼万唤始出来。
线段树是算法竞赛中最常用的、应用最广的用来维护 区间信息 的数据结构。
线段树可以在 $O(\log n)$ 的时间复杂度内实现单点修改、区间修改、区间查询等操作。
定义:
线段树将每个长度不为 $1$ 的区间划分成左右两个区间递归求解,把整个线段划分成一个树形结构。
设 $x$ 的管辖区间为 $[l,r]$,则 $x$ 的左儿子的管辖区间为 $[l,mid]$,$x$ 的右儿子的管辖区间为 $[mid+1,r]$。
在实际应用中,采用二进制编码的方式表示树上的结点:
设当前结点为 $x$,则左儿子为 $x<<1$,右儿子为 $x<<1|1$。
(用一个结构体维护结点蕴含所有信息,把左右儿子的序号直接存进结构体也是一种常见写法,但是笔者看来更适合之后的动态开点线段树)
因为这样的堆式存储使得部分结点就算在线段树没有实际左右儿子,但是同样会占用数组内存,所以线段树总结点个数为 $2^{\lceil{\log n}\rceil+1}-1$。当 $n=2^x+1$ 时,结点树数取得最大值:$4n-5$。所以一般线段树开 $4n$ 的空间。
性质:
对于一个 $[x,y]$ 一定能被拆分成 $O(\log n)$ 个线段树上结点所表示的区间并。
这是支持线段树完成 $O(\log n)$ 实现区间操作的基本原理。
证明:
在线段树上递归时,考虑第一个中点位于 $[x,y]$ 之间的区间 $[l,r]$。此时,$[x,y]$ 便可以依 $mid$ 划分为两部分:$[x,mid]$ 和 $[mid+1,y]$。$mid$ 是 $[l,mid]$ 的右端点,$mid+1$ 是 $[mid+1,r]$ 的左端点。
先考虑 $[mid+1,r]$ 中划分 $[mid+1,y]$ 的情况。
容易发现,$[mid+1,r]$ 中划分 $[mid+1,y]$ 的第一个区间长度是 $\leq y-mid$ 的最大 $2$ 的整数幂。之后的递归也是同理,所以 $[mid+1,y]$ 就被划分成了若干个严格递减的 $2$ 的整数幂长度的区间,容易发现,这个至多是 $O(\log n)$ 级别的。$[l,mid]$ 划分 $[x,mid]$ 同理。
综上:一个 $[x,y]$ 一定能被拆分成 $\log n$ 个线段树上结点所表示的区间并。
(本文以线段树维护区间和,区间加示例)
建树:
按定义所规定的管辖区间递归建树即可。用子结点信息更新父节点信息。
1 | void build(int t,int l,int r){ |
区间询问:
在具体的线段树代码实现中,不唯一,不局限。只要本质相同即可。
下面以笔者代码分析:
1 | int query(int t,int l,int r,int x,int y){ |
当递归到的结点所在区间完全包含于询问区间,则直接返回结点信息。
询问区间在递归时,可以改成前文中的 $[x,mid]$ 和 $[mid+1,y]$,不影响结果。因为之后递归的结点区间若完全包含于 $[x,mid]$ 或 $[mid+1,y]$ 则一定也完全包含于 $[x,y]$。
单点修改:
线段树的单点修改较区间修改更复杂,下面先考虑单点修改。
(单点修改是区间修改的子集,若线段树一定能支持某类操作的区间修改,则一定能支持单点修改,反之不然)
从线段树的根节点开始,递归到 $[x,x]$ 所在结点。后回溯更新祖先结点信息。
1 | void update(int t,int l,int r,int x,int v){ |
子结点信息更新父节点信息:
一般将此操作写作 push_up
作为一个单独的函数出现。
1 | void push_up(int t){ |
区间修改:
类比区间询问,将修改区间 $[x,y]$ 拆分成线段树上 $O(\log n)$ 个区间。把修改的信息标记到这个区间上。但是这时候就能发现,这样并没有维护住线段树的定义信息。标记的区间的儿子结点的信息没有更新,标记的区间的父节点的信息也没有更新。可要让所有结点的信息都完成更新,则需要修改 $O(n)$ 个结点的信息,这是不能接受的。
标记永久化:
修改操作和询问操作是紧密联系在一起的,如果不支持询问的操作,那么修改的支持便毫无意义。
以区间加,单点查询为例:
因为单点查询时,必然递归经过所有包含单点的区间。所以可以把修改的信息和初始信息分开考虑,将标记就固定在修改到的区间上。那么在查询时,把递归路径上经过的区间上的标记信息累计起来即可。
但是对于区间询问,就有所不同,因为区间询问时不会递归到拆分之后的区间的后代结点,而若修改的标记标记到了某个拆分之后的区间的后代结点上,就会对答案产生影响。所以对于区间询问,永久化的标记同样要作用于祖先结点。查询时只会查询到标记的某一个祖先结点而不会有多个,所以不影响答案。
同时,标记永久化具有较大的局限性,例如在信息不可直接叠加值时,便不具有可永久化的性质。比如:区间赋值;同时维护区间加和区间乘。
标记永久化也有它的优点,例如:不用像懒标记那样下传标记,常数较小;在可持久化线段树中,对区间修改标记永久化,空间复杂度可以少乘 $O(\log n)$ 。
懒标记:
对于懒的解释:延迟下传。
在前文考虑维护住线段树定义时,是因为要保证其它点也满足线段树的定义。但是,
修改操作和询问操作是紧密联系在一起的。试想,如果在后续的询问操作中,没有用到这里修改操作所涉及的点,那么是否还需要去把所有点都更新?
因此,秉持不用不管的原则,只有当询问操作递归经过这个区间时,才将这个区间上的标记信息下传更新子结点的信息。至于父结点信息,在修改操作递归到这个区间的过程中直接修改即可。
注意,懒标记的标记与标记永久化的标记不同,懒标记的标记是与线段树结点的信息结合起来的,是需要通过懒标记去修改结点上的信息的。而标记永久化的标记是与线段树结点的信息独立的。也就是标记永久化过程中的结点信息是不变的(始终为建树时的信息)而懒标记过程中的结点信息是会随着懒标记的下传而更新的(当前结点的懒标记下传后,懒标记信息清空,结点信息更新)
懒标记具有较强的扩展性,可以维护更多信息。但是需要维护的内容更多,代码更长。
懒标记中,懒标记下传函数一般写作 push_down
,结点信息更新一般不写成函数,笔者写作 eval
。
1 | void eval(node &t,int l,int r,int v){ |
懒标记还有一个要注意的点,何时 push_down
。
根据前文:“不用不管”,那么就是说只要递归到了这个区间,那就要 push_down
。所以在递归前就 push_down
且任何修改和询问操作,只要递归到了这个点,就应该 push_down
,一般应用中,push_down
的位置置于 if
后面,递归前面。
下面是完整的区间加、区间和的懒标记线段树代码:
1 |
|