zkw 线段树和 BIT 的几个小trick
一.关于Tag
zkw 线段树标记永久化的写法有的时候是不够的。
比如说区间加区间求和。
直接差分或纵向差分固然可行,但其实还有更好理解(或许也更通用?)的做法。
直接标记永久化的话,正常思路的瓶颈就在于,每次区间查询,每找到一个对答案有贡献的节点,都要一路往上累加 Tag ,导致复杂度(疑似)退化为 \(\operatorname{O}(\log^2n)\)。
我们观察所有的可能对答案产生贡献的节点,它们的 Tag 贡献就是父亲及以上的 Tag 和。观察 zkw 树的查询结构,每个有用的点的父亲,一定可以被两条叶子到根的链覆盖。
于是把 Tag 直接前缀和就好了~提供一种比较方便的写法:先把两条链全部 Tag 的和算出来,左右儿子一路向上的时候不断扣除自己这个点上的 Tag ,会比较舒服。
代码。
当然,观察到这个性质后,自顶向下一路放 Tag 也变得方便了~
具体的,对两条链分别进行从根到叶子一路下放 Tag。
代码。
以上两份代码建议关掉 loj 的代码格式化看,不知道为什么感觉很丑。
“其实堆式存储也可以自顶向下访问。”——zkw
“线段树的非递归、差分、标记永久化是相互独立的优化手段。”——lester
二.关于模拟BIT
BIT 的本质是砍掉一半的线段树换个遍历方式。
众所周知 BIT 的常数优于线段树,而 zkw 的更新应该已经几乎无法优化了,我们试图能不能用线段树模拟 BIT 以减少前缀查询的常数。
来一步步分析:
首先有个坑,常见的 zkw 写法是把序列平移过的,所以编号为 \(i\) 的节点对应的叶子是 \(M+i\) 而不是 \(M+i-1\),所以前缀查询的时候我们需要先把查询前缀 \(+1\)。
然后相当于只需要解决这个问题:\((x-\operatorname{lowbit}(x),x]\) 这个长度为 \(\operatorname{lowbit}(x)\) 的区间在线段树上的哪里?
这个区间是一个节点显然非常显然吧。
我们定义叶子是第 \(1\) 层,叶子的父亲第 \(2\) 层,爷爷第 \(3\) 层,以此类推。
对于每层,最左边的节点为第 \(1\) 个,接着是第 \(2\) 个,以此类推。
我们可以很方便地归纳得到第 \(i\) 层的第 \(j\) 个节点的对应区间为 \(((j-1)\times2^i,j\times2^i]\),长度为 \(2^i\),在线段树上对应的节点编号为 \(\dfrac{M}{2^i}+j-1\)。
记 \(\operatorname{ffs}(x)\)=\(\log_2(\operatorname{lowbit}(x))\) 即 \(x\) 最右边的 \(1\) 从右往左数是第几个,再减一。
则
推出
那么节点对应的编号就是 \(\dfrac{M+x}{2^{\operatorname{ffs}(x)}}-1\),可以使用位运算加速了。
至于 \(\operatorname{ffs}(x)\) 怎么求,测下来比较快的大概是这样:
int __ffs[1 << 16];
//定义
for (int i = 1; i < (1 << 16); ++i)
__ffs[i] = (i & 1) ? 0 : (__ffs[i >> 1] + 1);
//初始化
(x & 0xffff) ? __ffs[x & 0xffff] : __ffs[x >> 16] + 16
//ffs
至此,我们的 zkw 树已经能模拟 BIT 了,实测常数能小不少。
当然这是比较野蛮的做法,稍微优美一点的话,直接处理出每个下标对应 BIT 区间在 zkw 树上的编号就行了,暴力做。
单个数 \(x\) 计算的复杂度是 \(\operatorname{O}(\operatorname{ffs}(x))\) 的,那总的合起来就是线性的。
三.关于末尾追加
全局最值显然是直接边操作边维护做成常数时间。
平衡树解法:fhq-treap 维护序列,末尾追加先咕着,查询的时候把还没处理的追加部分,笛卡尔树形式线性建树,然后和大树 merge 起来。
但它的常数太大了,用一些小常数的数据结构比如线段树,如果按照正常的写法,每一次追加都一路更新到根,显然是时间不够用的。
我们思考:如果一个子树永远不会被查询到,那更新它有什么用?
不如先不更新了,晚点再更。
一个简单的想法:每次查询的时候把要用到的节点现场计算,然后记录下来。
复杂度是对的,但还不够优美。事实上我们直接一个全局查询就会使得每一个可能被用到的节点全部被用到。
那其实只要分析哪些节点可能用到,哪些节点永远用不到就好了。
用不到的呢只有所管区间还没填满的。
因此,我们末尾追加的时候,自底向上,如果发现正要更新的这个节点已经不满了,那再往上都不会被用到了,就不更新了。
追加完之后满了的,追加之前不满的,只有以追加位置为右端点的区间!
显然一个节点所包含的区间的右端点等于它右儿子右儿子的右端点。
所以,更新追加的时候,只需要从叶节点一路向上,什么时候发现自己是父亲的左儿子了,就退出。
分析复杂度。假设追加了 \(n\) 次,那他们在大树上影响的节点数量显然是线性的。因为如果以这些点作为序列建一棵 zkw 线段树的话,它一定是大树中包含这些追加数的一棵子树,而且一定是最小的子树。而这棵子树的节点数是线性的。
然后,每个被影响到的节点只会被更新一次,追加到自己右端点的时候那一次。
所以末尾追加的复杂度是均摊 \(\operatorname{O}(1)\) 的
讲完 zkw 线段树的末尾追加再来讲讲 BIT 的末尾追加。
本质上是一样的,理解了的应该可以直接跳掉,不理解的这里扔个代码应该就理解了吧。
void append(const int& x) {
bit[++tot] = x;
for (int w = tot - 1, tar = tot & (tot - 1); w > tar; w &= w - 1) {
bit[tot] += bit[w];
}
}
复杂度分析:单次 \(\operatorname{O}(\operatorname{ffs}(n))\),总计线性,均摊 \(\operatorname{O}(1)\)。
BIT 末尾追加的板子题,注意这题因空间原因而卡掉了线段树。
四.关于堆
这一节的东西比较鸡肋,主要是上面两个 trick 的综合。
我们尝试用线段树实现一个比较简陋的小根堆,维护 int
范围内的正整数。
(其实其它的数据类型也都能做,这里就举个比较方便的例子。)
维护一个序列和最小值下标。
push:末尾追加+更新最小值,常数时间。
top:就是维护的最小值,常数时间。
increase_key/decrease_key:
把给定的下标的权值直接修改,然后一路向上更新直到当前节点未满(<-常用常数优化)。
然后运行一遍全局查询(使用模拟 BIT 的方法来优化)来更新最小值。
单 \(\log\) 时间。
pop:把最小值下标 increase_key 为 INT_MAX,单 \(\log\) 时间。
其它的比如线性合并单 \(\log\) 有序遍历就不说了。
本地专测的一些数据结构的插入速度(可能有一些奇怪偏差,如时间计算方法与云端的不同):
数据结构 | 插入总时间 | 数据范围 |
---|---|---|
fhq-treap | \(1.2\textit{s}\) | \(10^6\) |
二叉堆 | \(1.2\textit{s}\) | \(10^8\) |
STL 优先队列 | \(1.8\textit{s}\) | \(10^8\) |
上面这个屑堆 | \(0.8\textit{s}\) | \(10^8\) |