线段树入门
引言
线段树是一种较为强大的数据结构,支持多种操作:
- 区间询问
- 区间修改
- 单点询问
- 单点修改
其实单点操作当成特殊的区间操作就可以了。
正文
一下以维护区间和为例。
结构
线段树的思想是分治,将数组分为若干子区间进行维护,其中
- 编号为 \(1\) 的区间管理 \([1,n]\),它的左儿子是 \(2\),管理 \([1,mid]\)(左半)区间;右儿子是 \(3\),管理 \([mid+1,n]\)(右半)区间。
- ……
- 编号为 \(i\) 的区间管理 \([l,r]\),它的左儿子是 \(ls\),管理 \([l,mid]\)(左半)区间;右儿子是 \(rs\),管理 \([mid+1,r]\)(右半)区间。
(记 \(mid=\lfloor\cfrac{l+r}{2}\rfloor,ls=2i,rs=2i+1\))。
可以参考来自 OI Wiki 的图片:
建树
我们通过 \(sgt_i\) 表示编号 \(i\) 的区间和。
问题来了:数组 \(sgt\) 要开多大?
经过亿些简单的计算可知要开 \({2^{\left\lceil\log{n}\right\rceil+1}-1}\)。
到底是个什么概念?
图中蓝色函数为 \(y=4x\),绿色为 \(y=2^{\left\lceil\log{x}\right\rceil+1}-1\)(\(x\) 为 \(n\),\(y\) 为 \(|sgt|\))。
可以看出其实无脑开 \(4n\) 就可以了。
考虑递归建树,递归函数 build(int pos,int l,int r)
的 \(3\) 个形参分别表示当前区间编号和区间的两个端点。
- 递归规则:左
build(2*pos,l,mid)
;右build(2*pos,mid+1,r)
。回溯时更新 \(sgt_{pos}=sgt_{ls}+sgt_{rs}\)。 - 终止条件:
l==r
。此时 \(sgt_{pos}=a_l\)。请自行思考为什么。
区间询问
我们当然希望询问的区间正好是我们维护的区间,但这样的愿望难以实现。
依然以最开始的图为例:
如果查询区间 \([1,3]\) 的和,那么直接返回 \(sgt_2=33\) 即可。
那如果查询 \([1,4]\) 呢?
可以将 \([1,4]\) 拆成 \([1,3]\) 和 \([4,4]\) 并合并,所以返回 \(sgt_2+sgt_6=46\) 就可以了。
如果要查询的区间是 \([l,r]\),则可以将其拆成最多为 \(O(\log n)\) 个极大的区间,合并这些区间即可求出 \([l,r]\) 的答案。
依然递归实现:
- 如果询问区间包含当前区间,直接返回 \(sgt_{pos}\)。
- 如果询问区间和左区间有重合,那么递归询问左区间。右区间同理。
- 累加左右区间返回的值并将它返回。
区间修改与 Lazy Tag
假设修改操作均是区间加:\(a_i=a_i+x,l\le i\le r\)(\(x\) 是区间加上的数)。
如果要修改线段树中 \([l,r]\) 区间的值,那么常规方法需要对于每个包含 \([l,r]\) 的区间进行修改,最坏复杂度 \(O(n\log n)\),无法承受。
一个很聪明的办法是给每个包含 \([l,r]\) 的极大的区间打标记,这需要维护另一个数组 \(tag_i\) 表示编号为 \(i\) 的区间加上 \(tag_i\)。
如果要修改区间 \([1,4]\),那么给区间 \([1,3],[4,4]\) 打上标记,代表修改,同时只修改 \([1,3],[4,4]\) 的 \(sgt\) 值。
如果此时询问 \([1,2]\),我们发现 \([1,3]\) 有标记,所以要下放给 \([1,2]\),同时修改 \(sgt_4\)(即 \([1,2]\) 的 \(sgt\) 值)。
一个通俗的比喻:假期作业很多,你让每一科的作业都显得已经完成(改 \(sgt\)),同时记在作业本上以免忘记(打标记),如果老师粗略的看(直接访问整个区间),那么老师就发现不了你没做作业。如果老师深究,那就把作业给自己的儿子做,你的儿子也会懒得做作业,只打标记,然后伪装自己已经做了。
如果要给已经有标记的区间打标记,那么直接重上去(+=
)即可。
同时,下传标记在询问中已经完成,均摊了复杂度,所以区间修改是 \(O(\log n)\) 的。
递归:
- 如果修改区间包含当前区间,那么直接打标记:\(sgt_{pos}=sgt_{pos}+x\times(r-l+1),tag_{pos}=tag_{pos}+x\)。
- 否则递归左右区间去修改(如果与修改区间有重合)。
这样,我们实现了区间加法。能不能再来一个区间乘法?
当然可以。
维护两个标记数组 \(tgm_i,tgp_i\),表示区间 \(i=[l,r]\) 进行 \(a_j=a_j\times tgm_i+tgp_i,l\le j\le r\)。
其他原理都一样,麻烦的是如果已经有标记时如何处理。
假设区间 \(i=[l,r]\) 进行 \(a_j=a_j\times m+p,l\le j\le r\) 的操作(为什么不是 \(a_j=(a_j+p)\times m\)?因为可以化简为 \(a_j=a_j\times m+(p\times m)\),转换为 \(a_j\times m+p\) 的形式),而区间 \(i\) 已经打过了 \(a_j\times tgm_i+tgp_i\) 的标记,那么可以进行化简:
发现添加乘法标记时 \(tgm_i,tgp_i\) 都 \(\times m\) 即可,而添加加法标记时则直接将 \(tgp_i=tgp_i+p\)。
记得要更新 \(sgt\) 哦。
小技巧
线段树也可以维护其他区间信息,如 P4198 楼房重建,通过修改更新(合并)方式达到目的。此题中可以保存区间内可见楼房数量和最大值,在设计一个函数 calc 进行递归求解。
可以在节点中存下 l
和 r
,免得每次递归计算 & 其他函数内使用。
代码
其实 OI Wiki 里挺全的:点我传送
后记
其实这篇文章(以及我的博客里的所有文章)都是写给自己的,因为怕忘记。
看不下去就 OI Wiki 吧,也挺好的。