线段树与树链剖分
【前置知识】
运算律,单位元。
运算律基本就是交换律、结合律、分配律。
单位元:
假设我们现在有一个运算 \(\bigoplus\)。
如果 \(a\bigoplus b=b\bigoplus a=a\),称 \(b\) 是运算 \(\bigoplus\) 的单位元。
【线段树】
线段树是一个树形数据结构。
-
满二叉树;
-
倍增的思想,分治的手法;
-
点/区间进行查询/修改;
-
常考,码量大。
【线段树的设计】
为了满足线段树满二叉树的定义,我们先把数组补成 2的幂 个。
补充的数肯定不能影响我们的计算,于是我们把它们设成单位元。
记 \(n\) 为补充后的元素个数。
我们让这 \(n\) 个元素组成线段树的叶结点们。
递归建立线段树:
-
如果当前节点就是叶结点,用给好的元素组成;
-
否则递归建立左右子结点,然后用子结点的属性更新自身。
根结点代表区间 \([1,n+1)\)。
凡是有子结点的结点,设其代表区间 \([l,r)\),\(m=\frac{(l+r)}{2}\),则其左儿子代表区间 \([l,m)\),右儿子代表区间 \([m,r)\)。
注意:线段树有 \(2n-1\) 个结点,而一开始可能补了一倍,再翻倍,说明线段树最多会开出四倍的空间。
【单点查询】
不妨我们现在查询 \(p\) 号元素的属性。
递归查询:
-
如果当前节点是叶子结点,返回当前节点的属性;
-
否则设当前节点代表区间 \([l,r)\),\(m=\frac{(l+r)}{2}\),如果 \(p<m\),那么递归进入左子结点查询;否则进入右子结点。
复杂度:显然每次区间长度除以 2,\(O(\log n)\)。
【单点修改】
与单点查询差别不大。
-
如果当前节点是叶子结点,返回当前节点的属性;
-
否则设当前节点代表区间 \([l,r)\),\(m=\frac{(l+r)}{2}\),如果 \(p<m\),那么递归进入左子结点查询;否则进入右子结点。
-
递归结束之后,重新更新当前节点的属性。
注意最后要重新更新。
复杂度:同上,也是 \(O(\log n)\)。
【区间查询】
设我们要查询区间 \([l,r)\)。
-
如果当前结点代表的区间完全被 \([l,r)\) 包含,直接返回当前节点的属性;
-
如果当前节点代表的区间与 \([l,r)\) 完全无交,直接返回单位元;
-
否则递归查询左右儿子的答案,然后合并左右儿子的答案并返回。
复杂度:还是 \(O(\log n)\)。
注意到一个性质:
一个区间,只有包含左右端点的结点可能递归。
那么每层递归的区间个数最多两个。
也就是说我们最多需要处理四个。
递归到叶结点,也只是 \(O(\log n)\),省略 4 的常数,整体复杂度还是 \(O(\log n)\)。
到这里,看做题总结的【无区间修改】部分。
【区间修改】
树状数组也可以做到区间查询、单点修改,但是以上都没有做到。
现在,我们来讲一讲线段树的精华—— lazy tag。
【lazy tag】
先说一说想法。
区间查询的瓶颈在于,我们需要深入到每个叶结点进行修改。
于是,我们决定不要修改所有叶结点,而是把修改的信息记录下来,等到需要的时候再拿出来用。
于是,lazy tag 就诞生了。
lazy tag,又称懒标记,延迟标记。
每个结点上都有一个 lazy tag,记录着节点代表区间的修改信息。
比如,我现在有一个 8个元素的数组。
现在,我想把 \([1,5)\) 加 4,我就只把 \([1,5)\) 对应结点的 lazy tag 加上 4,并且把该节点的和也加上 4 × 4。
很显然,如果我要修改 \([2,6)\) 的话,就没有现成的结点了。
因此我们有了一个修改的思路:
-
如果当前区间完全被修改区间包含,直接在本结点上更新标记;
-
如果当前区间与修改区间完全无交,直接返回;
-
否则递归给左右儿子修改,并且改完之后用左右儿子的属性更新本身。
而查询,我们也顺水推舟:
单点:
-
如果当前结点是叶结点,返回当前结点的值加 tag;
-
哪边有要查询的去哪;
-
返回的时候返回 子结点返回的结果+本结点的tag。
区间:
-
如果当前区间被完全包含,直接返回当前节点的属性;
-
如果完全无交,返回单位元;
-
递归查询左右儿子,并应用本结点的tag。
【pushdown】
但是,我们发现,如果按上面的方法做,可能会出一些问题。
比如赋值运算,新来的修改可能比老修改更靠下,这样在一路应用回去的时候就会被覆盖。
本质在于赋值运算不满足交换律。
于是我们想了一个办法:只要新来的修改到达一个结点,立刻把这个结点的标记分发给左右儿子,直到新修改完毕。
这样操作我们就可以把交换律的问题解决。
去看【有区间修改】
【总结】
三个律:结合律,交换律,分配律。
三个运算:val+val,val×tag,tag×tag。
val + val:结合律。(小区间拼成大区间)
tag × tag:结合律。(val*tag1*tag2=val*(tag1*tag2))
pushdown:为了解决修改不符合交换律的问题。
区间修改:需要满足修改运算对查询运算的分配律,不然小区间拼成大区间,直接 ×tag 没有分配律会出错。
应对措施:
如果没有 val+val 的结合律,区间查询就变成单点查询,再求和。
如果没有 tag×tag 的结合律,在每个结点上记录一个 tag 数组。
如果没有交换律,pushdown。
如果没有分配律,区间修改就变成单点修改。
【树链剖分】
分块思想:把整体分成一个个小块,这样我们每次处理的时候就可以整体处理。
我们考虑把整个树剖分成若干条链。
定义:
一个结点的子结点中,子树规模最大的子结点,称为该节点的“重子”,其余的称为“轻子”,叶结点的重子是本身。
显然一个结点恰有一个重子。
记录重子,我们可以在每个结点的 vector 上通过 swap 把重子换到 0 号位置。
从一个结点通向其重子的边称为重边。
若干条连续重边连起来,就成了链。
树就被剖分成若干条链。
在每个节点上,记录 \(top\),表示当前节点所在链的链顶。
显然这是一个自上而下传递的属性。
因此我们需要两边搜索,第一次找重子,第二次找 top。
【树链剖分找LCA】
显然当两个结点在同一条链上时,它们的 LCA 是较高的那个点。
所以:当两个结点的 top 相等,返回较高的那个。
否则:因为只有两个结点在同一条链上时,才有可能一个是另一个的 LCA,于是我们可以让链顶较深的结点 \(x\) 跳到 \(p[top[x]]\),这样 \(x\) 就到了另一条链上。
循环往复,一定会到同一条链上。
【先重子深搜序】
我们深搜时优先走重子,得到了先重子深搜序。
记录 \(dfn\) 和 \(rev\),分别记录结点在先重子深搜序中的位置,和先重子深搜序中的位置对应的结点。
我们发现,一棵子树和一条树链,在深搜序中是连续的。
因此我们可以用线段树维护。
比如我们要把一条路径 \((u,v)\) 上的结点权值都+1。
按照上面找LCA的方法,只是在跳到链顶的时候利用线段树和 \(rev\) 数组把 当前节点 到 当前节点链顶 的这一段 进行区间修改。
注意:同链比自身深度,异链比链顶深度。左闭右开
复杂度分析:子树是一个区间,复杂度贡献 \(O(\log)\);如果要处理链,最多有 \(O(\log n)\) 个区间,因为每作为一次轻儿子,节点个数至少两倍。所以链贡献复杂度 \(O(\log^2 n)\)。