jry 线段树学习笔记
一些有用的资料
区间最值操作 的 时间复杂度证明
首先规定整棵线段树被称为 \(T\),\(|T| = n\)(线段树大小为 \(n\))。
接着定义函数 \(f(x)\) 为树节点 \(x\) 中不同数的数量,易证明上限为 \(|x|\),下限为 \(1\)。
定义函数 \(\Phi(T) = \sum_{x \subseteq T} f(x)\),易证明上限为 \(|T|\log|T|\),下限为 \(|T|\)。
我们再定义一个概念:对于一个线段树节点,如果它对应的区间包含于操作区间 \([l, r]\),且它的祖先不包含于 \([l, r]\) 那么称其为 “线段树节点区间” 。其实我说的就是普通线段树操作递归结束的那个节点。
以下证明正式开始:
首先对于操作区间 \([l, r]\),我们第一步是将其划分为若干个“线段树节点区间”。这一步骤最多使线段树的 \(\Phi(T)\) 增加 \(\log_2n\),即“线段树节点区间”数量。\(m\) 个操作累加起来 \(\Phi(T)\) 的增大总量最多为 \(m \log n\),请读者留意这一概念。
可能有人会疑惑,不是 \(\Phi(T)\) 的上限为 \(n \log n\) 吗,万一增出限制怎么办?但,不是在找到了包含于 \([l, r]\) 的“线段树节点区间”后,我们还有可能往下面子节点递归吗?接下来递归的过程中,就不可能使 \(\Phi(T)\) 增长了,只可能使其减少。
对于当前递归到的节点 \(x\),可分为两类:
-
递归停止,即 \(mx_x \le v\) 或 \(se_x < v < mx_x\)。此种情况,\(f(x)\) 不作变化。
-
递归继续,即 \(se_x \ge v\)。此种情况,\(f(x)\) 必然至少减小 \(1\)(\(se_x\) 和 \(mx_x\) 都被置为了 \(v\))。
显然情况 \(1.\) 的数量级与情况 \(2.\) 是一样的,没必要太考虑它造成的时间复杂度代价。而又显然,\(f(x)\) 的减少会导致 \(\Phi(T)\) 的变化,时间复杂度明显与 \(\Phi(T)\) 有关系。所以总时间复杂度代价不会超过 “$\Phi(T)_{\max} + $ 其增大总量”,即 \(O((n+m) \log n)\)。
证毕。
对于 \(se\) 作用的理解
一方面,\(se\) 可看作维护 \(mx\) 在与 \(v\) 取 \(\min\) 后,是否仍为 \(mx\) 的准绳。即所谓“将值域分为‘最大值’与‘非最大值’”处理。
另一方面,\(se\) 可看作值域大小(即 \(f(x)\))是否改变的标杆。背后的底层逻辑可能是,因为值域的变化在某种意义上是 “单调”“可控” 的,所以我们要尽量不重复访问相同值域,使得时间复杂度与值域有关联。
算了,越说越玄。
感觉 jry 在想到这个 idea 的时候单纯是为了方便打懒标记,然后发现出来的算法复杂度恰好正确。
教练语:\(se\),包括后面区间历史最值中引入的 4 个 \(tag\),都运用了一种值得学习的,将线段树内的树分为两种处理的分治思想。
为什么不直接使用 \(f(x)\) 作为势函数?
势函数即用来得到时间复杂度的一个辅助函数,如刚刚的 \(\Phi(T)\)。
因为在从“线段树区间节点”继续向下递归的过程中,我们不能保证 \(f(T)\) 随着遍历节点数(算法运行时间)增加而递减,而只能保证 \(\sum f(x) = \Phi(T)\) 的单调。
带加减操作 的 时间复杂度证明
不会。
区间历史最值 为什么一定要将 \(\min\) 操作转化为 \(add\) 操作
若不这么转化的话,就需要开两个 \(tag\):\(tag_{\min}\) 与 \(tag_{add}\)。并假定:下传懒标记时先下传 \(tag_{add}\)。
此时,将一个节点的 \(mx\) 变为 \(\min(mx, k)\),那么 \(tag\) 的变化为:\(tag_{\min} = \min(tag_{\min}, k), tag_{add} = tag_{add}\);而将 \(mx\) 变为 \(mx+k\),\(tag\) 的变化为:\(tag_{\min} = tag_{\min}+k, tag_{add} = tag_{add}+k\)。这在没有 \(opt = 5\) 的情况下是正确的,请自行模拟。
但是 \(opt = 5\) 时就不一样了。这个操作要求我们必须明确知晓哪些值是真正可能取到的,这意味着必须确定 \(\min\) 操作与 \(add\) 操作的先后顺序,而不是用上述的方法巧妙转化。
举个例子,该节点先与 \(x\) 取 \(\min\),再加上了 \(y\),我们通过上述办法转化为 \(\min(mx, x)+y = \min(mx+y, x+y)\)。但是,你怎么知道这是不是该节点先加上 \(y\),再与 \(x+y\) 取 \(\min\)?这两种理解方式的区别就在于:\(mx+y\) 是否为一个能被取到的值。这可能会导致区间历史最值的误判。
所以,将两种操作转化为一种操作,就不会有顺序问题了。
关于实现
主要是要维护四个懒标记。
struct SegmentTree{
int l, r;
ll sum, mx, se, cnt, mxb, addam, addas, addbm, addbs;//开 ll 原因:se 可能要炸
#define l(x) tree[x].l
#define r(x) tree[x].r
#define mx(x) tree[x].mx
#define se(x) tree[x].se
#define cnt(x) tree[x].cnt//最大值的数量
#define sum(x) tree[x].sum
#define mxb(x) tree[x].mxb
#define len(x) (r(pt)-l(pt)+1)
#define addam(x) tree[x].addam//作用于最大值的加减标记
#define addas(x) tree[x].addas//作用于其它数的加减标记
#define addbm(x) tree[x].addbm//最大值加减标记的历史最大值
#define addbs(x) tree[x].addbs//其它数加减标记的历史最大值
//为什么要维护其它数加减标记的历史最大值?
//因为若不这么做,假设左区间的最大值为当前区间的最大值,而右区间的最大值不是
//那么左区间可以正常更新 mxb,而右区间则无法更新到正确的 mxb
} tree[MAXN*4];