线段树和平衡树
归约矩乘 https://www.luogu.com.cn/blog/Ynoi/qian-tan-gui-yue-ju-cheng
1 fhq-treap
对于一个集合,需要维护它,我们考虑一个包含集合中所有元素的有序序列,并使用一棵树,其中每一个子树都对应着一个子序列:
这棵树有什么性质呢?首先它是一棵二叉查找树,也就是要保持内部有序性。如何保持?它按照中序遍历是有序的。如下图,是集合 \(\{1, 4, 2, 8, 5, 7\}\) 映射到的两棵二叉查找树。
这种树上可以进行查询,例如第 \(2\) 小的数是哪个?考虑第一颗树。根的左儿子有三个节点,应该在左儿子中。递归直到确定在根上。
这样做,支持动态加入点,并且减少了遍历次数。是比较优秀的。但我们知道树的期望高度是 \(\log n\),因此最好让树高相对“平衡”一些。
那么我们考虑给每个点赋随机权值,让每个点形成一个二元组 \((val, rand)\)。然后让这棵树对 \(rand\) 形成一个小根堆。那么这就是 treap。它是笛卡尔树的一种,当所有二元组确定下来的时候具有唯一性。
建树和一些基本的操作都可以用分裂/合并这两个 treap 上操作描述。下面介绍两种操作。
1.1 合并
考虑两棵子树,一棵上的节点都小于等于 \(k\),另一棵上的节点都大于 \(k\)。(也就是两个互相有序的数组)需要合并为一棵树。(保证了有序能做什么?等会会说)
期望复杂度 \(O(\log n)\)。
考虑建树过程。新插入一个数 \(k\)。我们先将维护着的树分裂成 \(\le k\) 和 \(>k\) 这两棵树 \(a,b\),且令只有单独一个节点 \(k\) 的树是 \(c\),然后执行 \(\operatorname{merge}(\operatorname{merge}(a, c), b)\)。
还有另一种方式,静态建树:先按照中序遍历排好,然后每次找到 \(rand\) 值最小的节点作为根,然后递归下去。总时间复杂度也是 \(O(n \log n)\)。
1.2 分裂
一棵平衡树,分裂成两棵树,要求:左树小于右树。并且,左树的大小为 \(k\)。
也可以是要求左树的元素 \(\le k\)。方法大同小异。
P3391 文艺平衡树
【题意】
给定一个初始为 \(1 \sim n\) 的序列,\(m\) 次区间翻转,求最后的序列。
\(n,m\le 10^5\)
【分析】
平衡树的节点上打上翻转标记。下传的时候(merge,split,dfs的时候都要下传)先左右子树swap,然后标记传下去。
【实现细节】
平衡树板子,第一次写,踩了很多坑。
- merge 的时候,size 要设置成左子树 size + 右子树 size + 1,像这样:
int merge(int l, int r) {
if(l == 0) return r;
if(r == 0) return l;
pushdown(l); pushdown(r);
if(t[l].rnd < t[r].rnd) {t[l].rc = merge(t[l].rc, r); t[l].size = t[t[l].lc].size + t[t[l].rc].size + 1; return l;}
else {t[r].lc = merge(l, t[r].lc); t[r].size = t[t[r].lc].size + t[t[r].rc].size + 1; return r;}
}
- split 的时候,如果左子树恰好有 \(k\) 个,别忘了 t[now].lc = 0。
pii split(int now, int k) {
pushdown(now);
if(t[t[now].lc].size == k) {t[now].size -= k; int tmp = t[now].lc; t[now].lc = 0;return make_pair(tmp, now);}
else if(t[t[now].lc].size < k) {
pii dl = split(t[now].rc, k - t[t[now].lc].size - 1);t[now].rc = dl.first; t[now].size = k; return make_pair(now, dl.second);}
else {pii dl = split(t[now].lc, k); t[now].lc = dl.second; t[now].size -= k; return make_pair(dl.first, now);}
}
- 每次 split 之后合并的时候,别忘了更新 root。
void rev(int l, int r) {
pii st = split(root, r);
pii pt = split(st.first, l - 1);
t[pt.second].tag ^= 1;
int ts = merge(pt.first, pt.second);
root = merge(ts, st.second);
}
1.3 基本应用
- 查询 \(k\) 的排名:按照 \(k\) 分裂,左子树大小即为答案。
- 查询第 \(k\) 大数:分裂成三棵树,分别大小为 \(k-1,1,n-k\),然后输出中间那棵树的树根即可。
1.4 维护区间
平衡树可以维护一些连续段;如果需要分裂,那么找到那个根节点将其分裂成两个点,注意 val 需要重新给一个!
2 线段树和平衡树维护序列信息
2.1 基础思想
大概拿来解决什么样的问题?
- 给你一个序列,每次查询区间的分治信息,可能有单点修改或者区间修改。
- 给你一棵树,每次查询路径的分治信息,可能有单点修改或者路径修改。
这里维护的信息是可以快速合并的信息,具体是什么不严谨判断,感性理解。
当考虑线段树合并区间答案的时候,如果答案都在左区间,都在右区间的情况。剩下的情况就是这次合并需要额外维护的信息。
可以列个表记录需要维护的区间信息有哪些。初始仅需要维护答案,在合并过程中会出现一些需要额外维护的信息,合并需要额外维护的信息时还有可能需要额外在线段树节点上维护新的信息。
对于区间修改需要打标记的问题:考虑三个方面:
- 标记与标记之间的合并。例如区间加问题,加法标记,先前有标记 \(+a\),如果新来了一个标记 \(+b\),那么需要可以合并成一个标记 \(a+b\)。可以合并标记,是线段树支持区间操作的一个重要前提。
有些标记有交换律,有一些标记没有,在普通的线段树中,下传的时候父亲的标记的时间永远比儿子的标记的时间更晚,所以普通线段树可以做的标记更广泛,只需要有结合律即可。而标记永久化线段树自顶向下查询的时候无法区分标记时间,所以一般是需要有交换律的。
例如,标记:“区间加” 有交换律;“区间乘一个数然后区间加一个数” 没有交换律。
-
标记和信息之间的合并。这个问题通常是个难点。
-
信息与信息之间的合并。如果这都没有,那你连维护都没办法维护,就别说区间修改了。
常见标记:
- 区间加
- 区间乘
- 区间染色(修改为同一个数)
- 区间翻转(平衡树上打的多)
- 区间 xor
这个“区间翻转”为什么是平衡树上打的多?平衡树任意一个区间在一个点上可以表示出来,但是线段树不行。线段树只有特定的区间在上面,并不是每一个区间都在线段树上。
楼房重建
单点修改,全局询问有多少个数是前缀最大值。
维护个数。考虑合并,对于左子树没有变化,右子树加了左子树的限制。如果左子树最大值大于右子树最大值那右子树就没了,于是维护最大值。
考虑右子树有前面一些内容被左子树挡住了,后面一些内容没有被挡住。这时候要有感觉,处理肯定带 \(\log\)。
那么想着怎么用这个 \(\log\) 好一点,考虑二分到左右子树,再判断,容易发现如果左子树有存活的那么右子树一定全部存活。
这样时间复杂度 \(O(n \log^2 n)\)。
等差子序列
给定一个 \(1 \sim n\) 排列,问里面有没有等差子序列。
朴素枚举 \(O(n^2)\)。但是有个思想很牛逼的 trick,就是考虑枚举中项,那么如果存在 \(a_i + x\) 访问过,\(a_i - x\) 没有,那么就出现了等差子序列。
这怎么验证呢,非常牛逼,考虑是不是回文串就好了。用线段树维护。
P1471 方差
区间修改,维护序列平均数和方差。
平均数,就是维护区间和。方差呢?
需要手推一下柿子。
因此只需要维护平方和和和的平方即可。
区间上维护平方和和和。询问的时候一并处理即可。
P1972 数颜色
一个序列,不带修,区间查询颜色数(不同数字的个数)。
CF1771F 是带修的特殊版本,是查询出现过奇数次的颜色数。这个直接主席树就好了,因为相当于异或和。
回到原题。首先是离线无修改版本。考虑把询问对右端点排序,然后对于特定的右端点类似树状数组的方式查询。注意到对于一个固定的右端点,每一个颜色都只有最靠右侧的一个有用,因此扫描线扫到一个右端点插入数的时候,把之前那个数的位置给替代掉。然后树状数组维护后缀和即可。
不带修在线查询怎么办。考虑一个位置用一个二元组 \((ind, lst)\) 表示。\(ind\) 表示当前位置,\(lst\) 表示上一个这个颜色在哪里。那么每次 \([l, r]\) 查询的时候返回的是:\(l \le ind \le r\) 并且 \(lst < l\) 的个数。就是个正方形数点。在线的二维数点只能主席树,没别的办法。二维平面上 \(n\) 个点,开 \(n\) 棵线段树,每棵表示前 \(i\) 行第 \(j\) 列的权值和。
二维数点,离线就是扫描线+树状数组,在线就是主席树。
2.2 线段树上二分
线段树上二分,可以解决的是一类线段树上查询对“+”操作(合并操作)具有区间单调性(子区间逐级单调)可二分性质的信息的问题。时间复杂度是 \(O( \log n)\),分为两步。
我们假设每次询问 \([l,r]\) 上第一个 \(\le k\) 的数。使用合并操作为 \(\min\) 的线段树维护,那么一个区间的对应节点如下图所示:
接下来考虑线段树二分的过程。
一共经过了 \(2 \times \log n\) 个节点。
考虑实现。
容易证明是正确的。常数大概是 \(4\)。
实现上,考虑 \((l, r), (x, y)\)。如果 \((l, r)\) 在 \((x, y)\) 内,那么从左右两个儿子里面选一个进入;如果 \((l, r)\) 和 \((x, y)\) 不交,那么返回 NULL;如果 \(l = r\),那么返回 \(l\);否则先考虑尝试进入 \((l, mid)\) 然后如果不行再进入 \((mid + 1, r)\)。
容易发现,这个流程和一般的区间操作的差别是,这个流程在 \((l, r)\) 在 \((x, y)\) 内时候会往下顺着一条路径走,也就是每次多 \(\log n\),所以复杂度是对的。
//查询 [x, y] 最左边的 >= k 的数的位置
//维护了最大值 mx
//令 NULL = -1
int get(int now, int l, int r, int x, int y, int k) {
if(y > x) return -1;
if(l > y || r < x) return -1;
if(mx[now] < k) return -1;
if(l == r) return l;
int mid = (l + r) >> 1;
pushdown(mid);
if(l >= x && r <= y) {
if(mx[now * 2] < k) return get(now * 2 + 1, mid + 1, r, x, y, k);
else return get(now * 2, l, mid, x, y, k);
}
int tmp = get(now * 2, l, mid, x, y, k);
if(tmp != -1) return tmp;
else return get(now * 2 + 1, mid + 1, r, x, y, k);
}
2.3 标记永久化
普通线段树,采用的是懒惰标记下传的方式维护区间修改和区间查询,某一个时刻,某一个节点(被观测的时候)的信息和标记的关系是:真实信息等于节点信息;真实区间标记等于节点标记。标记永久化,是另一种组织信息的方式,其从不下传标记,其某一个节点的信息和标记的关系是:其真实信息等于节点信息 \(\times\) 祖先所有标记;真实区间标记等于节点标记 \(\times\) 祖先所有标记。
考虑线段树维护的信息的统一性和多样性:
- 存在信息半群以及标记半群
- 信息之间的 \(\circ_1\) 发生在合并两个子树的时候。如果可以区间修改,那么某一个点的信息一定可以快速得出,而不是必须向子树方向递归之后才能得出。
- 信息与标记之间的 \(\circ_2\) 发生在对目前观测点的查询/修改上。查询的时候可能只观测信息的一部分,所以把第三个 op 变成第二个 op 可能会起到降低复杂度的作用。例如,二维线段树中外层节点维护的是一个 \(n\) 长度的数组,而查询的时候可能查询的是数组的某一个点,但是标记的作用对于一个点和一个数组是一样的,这时候查询了再带上标记会更好些。
- 标记与标记之间的 \(\circ_3\) 发生在即将前往儿子节点的时候。(但是标记合并也不一定是好做的)普通线段树的下传蕴含了时间顺序,含义一定是,先对儿子节点的信息做儿子节点的标记,然后再做父亲节点的标记。
- 半群性质意味着 \((a \circ_2 b) \circ_2 c = a \circ_2 (b \circ_3 c)\),存在把第三个 op 消除的理论依据。
标记永久化就是取消了第三个 op,全部集中到第二个 op 上。
其可实现的非常重要的条件就是标记之间不区分先后顺序(也就是有交换律)。对于经典写法,可以规定标记的先后顺序,然后下传下来的标记永远迟于本身自己的标记(这个迟,指的是打上去的时间更迟。如果早的话那这个标记至少应该在下面);但是标记永久化写法不行。例如区间覆盖,你如果写标记永久化的话那么得对标记加一个信息:时间戳。
2.4 树套树
做二维平面上问题用的。
主要有两种问题:
- 维护一个 \(n \times n\) 的数组,支持修改和查询。
- 维护二维平面上 \(n\) 个点(矩形),支持修改和查询。
KDT 主要解决二维平面上矩形修改矩形查询问题;树套树主要解决二维平面上单点修改矩形查询或者矩形修改单点查询问题,如果能做标记永久化也可以做矩形修改矩形查询问题;如果有差分那么前缀看做单点;CDQ 分治功能上和树套树等价。
这些树其实都是对空间的划分,其中树套树是 Range Tree,划分为若干个区间。
\(d\) 维偏序上的范围查询定义为对 \(l_1 \le a_i \le r_1, l_2 \le b_i \le r_2 ...\) 的 \(i\) 进行一次查询。
\(d\) 维偏序上的单点修改定义为对某一个点做修改。
树套树能在 \(\log^d n\) 的复杂度内进行一次 \(d\) 维偏序的范围查询或者单点修改。空间为 \(n^d\)。
三维情况下常数和空间都起飞了,所以只讨论二维的情况。
采用函数化数据结构的思想,把一个外层的单点操作看成一个内层树上操作。
套什么树也是很重要的,有树状数组套平衡树,树状数组套线段树,线段树套线段树等等。会在一些问题上有时间或者空间上的差别,例如 kth 平衡树就不好一起二分。
一起二分:由于线段树/Trie 的结构相似性,可以对多个树考虑左节点是否走,从而可以一起二分。
线段树套平衡树:每个节点维护一个平衡树,表示区间内部元素排序好后的平衡树;每次修改更新 \(O(\log )\) 个节点的平衡树,每次查询用 \(O(\log)\) 个不相交节点里 \(\le k\) 的元素个数拼出这次询问的区间。
对于不可差分的信息,如果想用线段树套线段树做到区间修改区间查询,有什么困难?
外层线段树不支持 pushup。所以你修改的时候,遇到不一定被修改区间包含的区间,需要快速能够计算其对当前区间的影响。这对信息和修改的性质要求极高。两者形式越相近,越容易满足条件,例如矩形取 \(\max\),矩形求 \(\max\)。实际上这和标记永久化无关,而是所维护信息在不支持 pushup 时,它和修改所必须具有的性质。就算是单点修改不用标记永久化,也是一样的。
举个例子,你走到节点 \([l, r]\),现在对区间 \([x, y] \subseteq [l, r]\) 做一个区间取 \(\max\) 操作,这个节点内的这一棵线段树做什么修改。
你发现打钩的区间做的修改都是 \(\max = k\)。这是可以快速得出的。
我们维护标记的时候你沿路走的区间就都能打上正确的标记了。
作为比较,区间加区间 \(\max\) 就是不能这么维护的,因为虽然标记有交换律,但是你并不知道对一个不一定包含这个区间的修改对这个区间有什么影响。
2.5 主席树
考虑目前有 \(n\) 个线段树,然后每两个线段树之间就差一个单点修改或者区间修改,这时候线段树之间是很相似的。我们可以考虑把两个重合的信息放在一起记录。这里考察有区间修改的情况,某个点的实际信息等于其信息 \(\circ_2\) 上所有祖先标记合并的结果;如果递归到这一层,那么祖先标记都是没有的。两个点重合当且仅当其信息和标记均没有改变。
这些发生改变的区间都要新建节点,但是节点个数维持在 \(\log\) 级别。
-
需要注意,这类问题不一定直接说明“在某个版本处修改”,可能要求“回溯到某个版本”,这时候就要知道是回溯到什么版本了。
-
如果不要求强制在线,有一个“操作树”的科技,就是如果第 \(k\) 个版本是在第 \(q\) 个版本下修改得到的,那么 \(q \rightarrow k\) 这样建树,然后变成修改和撤销(回溯的时候撤销)两个操作。虽然原理是一样的(直接去撤销也不超过插入复杂度)但是这个可以省空间,但是要不强制在线的题目可以用。
2.6 动态开点线段树
值域比较大,元素个数比较小的时候可以采用动态开点,空间开 \(O(n \log v)\),因为一个元素最多占 \(\log v\) 个节点。
ARC159D
给定若干段形如 \(3, 4, 5, 6, 7\) 的连续上升序列记为 \([l, r] = [3, 7]\),求它们拼起来的数列里面最长上升子序列。
【分析】
显然考虑 dp,\(dp_{i} = \max _{j> i} dp_j + \min(r_i - l_i + 1, l_j - l_i)\),考虑所有 \(l\) 递增的时候,分三段:一段不采用;一段加上 \(1, 2, ...\) 取 \(\max\),一段加上一个定值取 \(\max\)。维护两个权值线段树维护 \(dp_i\) 以及 \(dp_i + l_i\),要动态开点。