线段树入门

发布时间 2023-10-12 11:52:03作者: Po7ed

引言

线段树是一种较为强大的数据结构,支持多种操作:

  • 区间询问
  • 区间修改
  • 单点询问
  • 单点修改

其实单点操作当成特殊的区间操作就可以了。

正文

一下以维护区间和为例。

结构

线段树的思想是分治,将数组分为若干子区间进行维护,其中

  • 编号为 \(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\) 的标记,那么可以进行化简:

\[\begin{aligned} a_j&=(a_j\times tgm_i+tgp_i)\times m+p\\ &=(a_j\times tgm_i\times m+tgp_i\times m)+p\\ &=a_j\times(tgm_i\times m)+(tgp_i\times m+p) \end{aligned} \]

发现添加乘法标记时 \(tgm_i,tgp_i\)\(\times m\) 即可,而添加加法标记时则直接将 \(tgp_i=tgp_i+p\)

记得要更新 \(sgt\) 哦。

代码

其实 OI Wiki 里挺全的:点我传送

后记

其实这篇文章(以及我的博客里的所有文章)都是写给自己的,因为怕忘记。

看不下去就 OI Wiki 吧,也挺好的。

参考资料