我不会数据结构
线段树 / 树状数组 / KDT / 李超线段树
概述:线段树用于维护序列,是一个包含 \(2n - 1\) 个节点的树形结构, 其中非叶子节点都有两个儿子,中序遍历这颗树得到的恰好是 \(1, 2, \dots n\)。
一般在存储时,空间会开到 \(4n\) 倍,有一种特殊的写法可以使空间优化到 \(2n\) 倍 :即线段树上 \([l, r]\) 所对应的区间为 $ (l+r) \ | (l \neq r)$, 读者可以自行证明其正确性。
一般我们使用的线段树为狭义线段树(标准线段树),它要求节点 \([l, r]\) 的左儿子是 \([l, mid]\), 右儿子是 \((mid,r]\)。
线段树的基本性质:
- 任意两个节点的区间要么互相包含, 要么不交。
- 区间 \([l,r]\) 可以唯一拆分成线段树上若干个区间的不交并, 称这些区间构成 \([l, r]\) 的区间拆分。
- 最小区间拆分的大小不超过 \(2 \log n + c\),其中 \(c\) 为常数。
线段树基本操作:
我们可以用线段树维护一系列区间数据结构问题:
-
最简单的是区间查询, 即询问给定序列的 \([l,r]\) 的相关信息, 若我们对于每个区间维护一个 \(f([l,r])\), 使得 $f(\emptyset) $ 和 \(f([l, l])\) 容易计算,并且我们可以快速合并 \(f[a,c] = f[a,b]+f[b+1,c]\), 设合并一次的复杂度为 \(T\), 则进行一次查询的复杂度为 \(nT \log n\), 初始化复杂度为 \(nT\)。
-
维护区间和 : \(sum(l,r) = sum(l,mid) + sum(mid+1,r)\)。
-
区间乘积 : \(prod(l,r) = prod(l,mid) \times prod(mid+1,r)\)。
-
区间最大子段和:额外维护 \(pre[l,r]\),\(suf[l,r]\) 表示前缀 / 后缀最值,则 \(ans(l,r) = \max(\max(ans(l,mid), ans(mid+1,r),suf(l,mid)+pre(mid+1,r))\)。
-
-
当进行区间修改时,我们维护了懒标记:对于每个点额外维护了一套标记信息 \(g(l,r)\), 使得在修改 \([l,r]\) 时我们不需要暴力修改, 而是先将修改的信息保存在 \(g(l,r)\),算出他对 \(f(l,r)\) 的影响。 此后无论是修改还是询问, 只要经过这个点我们都对标记进行下放,并把 \(g(l,r)\) 清空。
在设计标记时,要注意标记之间能否快速合并, 是否能够快速算出对 \(f(l,r)\) 的影响。
-
区间乘,区间加,我们维护 $g(l,r) = (mul,add)$ 表示区间先乘上 $mul$ 在加上 $add$,而不维护先加上 $add$ 再乘上 $mul$。
- 区间加, 区间 \(k\) 次方之和: \((x+v)^k = \sum\limits_{i = 0}^{k} \dbinom k i x^iv^{k - i}\),维护区间 \(1, 2, \dots k\) 次方之和后组合数算一下即可。
- 区间加,区间选取 \(k\) 个数乘积之和:设 \(f_{x,i}\) 为 \(x\) 节点选了 \(i\) 个数的总和, 转移直接卷积即可。 区间加时,有 \(f_{x,i} = \sum\limits_{j = 0}^{i} \dbinom {n - i+j} j f_{x,j}v^j\),时间复杂度 \(O(n\log n \times k^2)\)。
-
维护矩阵乘积:
有时线段树维护的区间信息满足一定的线性性和递推性,可以直接用矩阵表示转移,维护的 \(f(l,r)\) 看做行向量, 转移看成 \(n \times n\) 的矩阵, 常见的结合律有乘法对加法, \(\max\) 对加法,异或对与。 也称为动态 dp。
我们通过几道例题来实际感受一下它的用途:
一棵树,支持单点修改权值,维护全局最大独立集大小。
朴素 dp 设 \(f[x][0/1]\) 表示 \(x\) 子树内, \(x\) 是否选择的最大独立集大小, 有转移 :
\(f[x][0] \leftarrow f[x][0]+ \max(f[y][0], f[y][1])\), \(f[x][1] \leftarrow f[x][1] + f[y][0]\),
再考虑把树剖分, 设 \(son_x\) 为 \(x\) 的重儿子, \(g_{x,0 / 1}\) 表示 \(x\) 不考虑重儿子时的 dp 值, 将状态转移方程改写:
\(f_{x,0} \leftarrow g_{x,0} + \max(f_{son,0}, f_{son,1})\), $f_{x,1} \leftarrow g_{x,1} + f_{son,0} $ , 表示成矩阵 :
\(\begin{bmatrix} g_{x,0} ,g_{x,1} \\ g_{x,1} ,-\infty\end{bmatrix}\begin{bmatrix} f_{y,0} \\ f_{y,1} \end{bmatrix} = \begin{bmatrix} f_{x,0} \\ f_{x,1} \end{bmatrix}\), 注意此时要求 \(x\) 和 \(y\) 在同一条重链上,于是我们直接线段树维护这个矩阵即可。
修改路径时, 我们只用考虑轻儿子对父亲的贡献, 也就是一条重链的链顶对父亲的贡献,这是可以直接对父亲的转移矩阵操作的。
[THUSCH2017] 大魔法师
维护一个序列 \(A, B, C\), 支持以下 7 种操作:
- 区间 \(A_i = A_i+ B_i\), 区间 \(B_i = B_i + C_i\), 区间 \(C_i = C_i + A_i\)
- 区间 $A_i $ 加 \(v\), 区间 \(B_i\) 乘 \(v\), 区间 \(C_i\) 赋值为 \(v\)。
- 询问区间 \(A, B, C\) 之和。
直接用记下 \([\sum A, \sum B, \sum C, len]\) 即可,转移易。
-
小 Y 和恐怖的奴隶主
dp 直接写成矩阵即可。
-
CF750E New Year and Old Subsequence
dp 直接写成矩阵即可。
可持久化线段树 / 标记永久化
如果一棵线段树在修改时我们要维持改版本之前的线段树不变, 我们需要对需要修改的线段树节点复制后再修改, 称为可持久化。
单点修改的可持久化是简单的:我们只需要把根到叶节点的点全部复制一遍即可,因此修改 \(n\) 次的空间复杂度为 \(O(n\log n)\)。
但当要支持区间操作时,每次 push_down 时都要新建儿子, 这会使空间大大消耗,此时我们考虑标记永久化,直接将标记放到对应的位置后不下传, 只在询问时计算它的贡献。
要满足标记要有可交换律。
例:主席树区间加,应该这么写:
inline int update(int p, int l, int r, int ql, int qr, int v) {
int P = ++tot;
tre[P] = tre[p], tre[P].sum = tre[p].sum + 1ll * (min(qr, r) - max(ql, l) + 1) * v;
if(ql <= l && r <= qr) {
tre[P].tag += v;
return P;
}
int mid = l + r >> 1;
if(ql <= mid) tre[P].l = update(tre[P].l, l, mid, ql, qr, v);
if(qr > mid) tre[P].r = update(tre[P].r, mid + 1, r, ql, qr, v);
return P;
}
inline ll Query(int p, int q, int l, int r, int ql, int qr) {
if(ql <= l && r <= qr) return tre[q].sum - tre[p].sum;
int mid = l + r >> 1;
ll res = 1ll * (min(qr, r) - max(ql, l) + 1) * (tre[q].tag - tre[p].tag);
if(ql <= mid) res += Query(tre[p].l, tre[q].l, l, mid, ql, qr);
if(qr > mid) res += Query(tre[p].r, tre[q].r, mid + 1, r, ql, qr);
return res;
}
应用
总长度为 \(n\), 字符集大小为 \(|\sum s| = 10^5\) 的子序列自动机, AC 自动机。
子序列自动机:前一个位置的转移只由后一个位置加一个单点修改。
AC 自动机:一个点的转移只在其失配指针基础上修改它有出边的儿子。
区间数颜色:维护 \(pre_i\) 表示上一个颜色和 \(i\) 相同的点, 则 \([l,r]\) 的颜色数等于 \([l,r]\) 中 \(pre_k < l\) 的个数。
区间 mex:对于每一个 \(r\) 维护 \(pre_i\), 答案就是第 \(r\) 棵主席树中第一个 \(pre_k < l\) 的点, 线段树二分即可。
可持久线段树的最常见用法是充当一个线段树的前缀和。
当询问满足可减性时, 进行 \([l,r]\) 的询问时用第 \(r\) 棵树减去第 \(l - 1\) 棵树,例如权值线段树可持久化后得到主席树, 可以支持区间 \(k\) 大。
当询问是在线时, 一般使用主席树使问题降一维。 若可以离线则可以树状数组。
[AH2017/HNOI2017]影魔
势能分析
正常的线段树当 \([l,r] \cap [ql, qr] = [l, r]\) 就会停止, 但某些问题我们有可能到了 \([l,r] \cap [ql, qr] = [l,r]\) 依然无法快速更新,这时线段树的时间复杂度需要依靠势能分析。
例:[雅礼集训 2017] 市场
维护一个长度为 n 的序列,支持 m 次操作,操作包括区间加,区间除一个数下取整,以及查询区间最小值和区间和。
直接用线段树记录区间最大最小,向下取整时,若 \(mx - \dfrac{mx}{d} = mn - \dfrac{mn} d\), 即差值相等时,直接打标记退出。
[BZOJ 5312] 冒险
维护一个长度为 n 的序列,支持 m 次操作,操作包括区间按位或一个数,区间按位与一个数,以及查询区间最大值。
用线段树记录区间的与, 或和,计算出区间不是所有元素都有的位置, 当按位与 / 或时完全包含这个区间或交为空集时直接打上区间与 / 或标记, 因为此时值的相对大小不会改变。
【模板】线段树 3
维护一个长度为 n 的序列, 支持区间加, 区间对一个数取 min,区间和, 区间最大值, 区间历史最大值,区间历史和。
合并与分裂 / 优化 dp
合并即是合并若干棵总点数为 \(O(M)\) 的动态开点线段树, 可以直接按叶子个数启发式合并, 但我们可以直接合并。
定义 Merge(x,y) 表示合并 x 和 y 子树。
- 若 x = 0, 返回 y;若 y = 0,返回 x。
- 若两个点都是叶子,直接合并信息。
- 否则,递归合并 merge(lsx, lsy), merge(rsx, rsy), 然后将 x 和 y 的信息合并。
分裂依葫芦画瓢就行,可以看看代码:
inline int merge(int x, int y, int l, int r) {
if(!x || !y) return x + y;
if(l == r) return t[x].siz += t[y].siz, addr(y), x; int mid = l + r >> 1;
t[x].l = merge(t[x].l, t[y].l, l, mid), t[x].r = merge(t[x].r, t[y].r, mid + 1, r);
t[x].siz += t[y].siz, addr(y); return x;
}
inline void split(int &x, int &y, int l, int r, int ql, int qr) {
if(ql <= l && r <= qr) return y = x, x = 0, void();
int mid = l + r >> 1; if(!y) y = New_node();
if(ql <= mid) split(t[x].l, t[y].l, l, mid, ql, qr);
if(qr > mid) split(t[x].r, t[y].r, mid + 1, r, ql, qr);
t[x].siz = t[t[x].l].siz + t[t[x].r].siz, t[y].siz = t[t[y].l].siz + t[t[y].r].siz;
}
[HEOI2016/TJOI2016]排序
有一种简单的二分 + 线段树 \(O(n\log^2n)\) 的做法, 使用线段树合并 + 分裂可以做到在线 \(O(n \log n)\) 并求出所有区间。
\(O(n \log^2 n)\) 做法:直接二分第 \(p\) 位的值, 将 \(< x\) 的位置设为 0, \(\geq x\) 的位置设为 1, 容易发现当 x 小于 p 位的真实值时为 \(1\), 否则为 \(0\), 然后区间排序就变成了区间 0 / 1 赋值。
\(O(n \log n)\) 做法:发现当对 \([l,r]\) 排序后,若我们知道排序是从大到小还是从小到大, 再加上 \([l,r]\) 的所有值域范围, 则 \([l,r]\) 可以直接确定。
注意这个排序有点像珂朵莉树中的"推平"操作, 于是我们直接对每一个有序的连续段维护一个值域线段树, 则排序只用把 \([l,r]\) 所有连续段线段树合并一下即可, split 操作可以通过线段树分裂实现。
由于推平连续段总复杂度 \(O(n)\), 线段树合并 + 分裂为 \(O(\log n)\), 故总时间复杂度 \(O(n \log n)\)。 感觉珂朵莉树还可以和其他连续段数据结构套起来。
[NOI2020] 命运 & [PKUWC2018]Minimax
这两个题都差不多,讲一个就行了。
设 \(f[x][i]\) 表示 \(x\) 的权值为 (离散化后的)\(i\) 的方案, 显然有转移:
当 \(x\) 为叶子, \(f[x][a[x]] = 1\); 当 \(x\) 一个儿子:\(f[x] = f[son_x]\);
当 \(x\) 两个儿子:\(f[x][i] = p \times (\sum\limits_{j = 1}^{i}f[ls][j] \times f[rs][i]+\sum\limits_{j = 1}^{i - 1}f[rs][j] \times f[ls][i])+(1 - p) \times (\sum\limits_{j = i}^{n}f[ls][j] \times f[rs][i] +\sum\limits_{j = i +1}^{n}f[rs][j] \times f[ls][i])\)
维护一下 \(ls, rs\) 的前后缀和即可,当 \(x\) 和 \(y\) 有一个为 0 时直接打上标记即可。
[PKUSC2019]树染色
同样有 \(f[x][i] = (sum_y - f[y][i]) \times f[x][i]\), 限制可以将 \(x\) 的子树合并完后置为 0 即可,最开始有个全局加 1。
注意此时的线段树的非叶节点是有信息的,它表示 \([l,r]\) 的值都是相同的,所以在 push_down 时若没有儿子要新建儿子, 与上面维护的要区分, 上面的非叶节点的信息只是为了 push_down 给叶节点。
转移就显然了:若 x 为一个连续段, 直接打上 \(sum - f[y][i]\) 的标记, 若 \(y\) 为一个连续段,还需将 \(y\) 取反后加上 \(sum_y\) 再与 x 相乘。 维护一个加乘标记即可。
[九省联考 2018] 秘密袭击 coat
神题。
建议配合 拉格朗日插值食用。
所有连通块的 \(k\) 大显然不可做, 考虑转化:
若一个连通块的 \(k\) 大为 \(v\), 则我们在 \(x = 1, 2, \dots v\) 都对这个连通块算 1 的贡献, 这样显然是正确的。 而 \(x = x_0\) 的实际意义就是有多少个连通块, 其 \(k\) 大 \(\geq x\)。
考虑和 排序 一样的思路,将 \(\geq x\) 的设为 1, 否则设为 \(0\),则问题就转化为有多少个连通块, 其权值 \(\geq k\),对于一个 \(x\), 我们显然可以 \(O(n^2)\) 计算。总复杂度 \(O(n^2 W)\)。
考虑优化,数据范围只要我们优化一个 \(n\) 或 \(W\) 成 \(\log\) 即可。
设 \(F_x = z^{a_x \geq v}\prod\limits_{y \in son_x}(1+F_y\)), 对于一个 \(v\), 我们算的是 \(ans_v = \sum\limits_{x = 1}^{n}\sum\limits_{i \geq k} F[x][i]\)。
拿这个直接取做线段树合并显然是不行的, 因为 \(z^{a_x \geq v}\) 是平移操作 ,线段树没办法平移。
继续转化, 将限制缩小, 设 \(F_{x,i}\) 表示 \(x\) 子树内 \(v = i\) 的多项式, 转移还是一样,设 \(G_{x,i}\) 表示 \(x\) 子树内所有 \(F_{x,i}\) 之和, 即 \(G_{x,i} = F_{x,i}+\sum\limits_{y \in son_x}G_{y,i}\), 答案就是:
\(ans = \sum\limits_{v = 1}^{w}\sum\limits_{i \geq k}[x^i]G_{1,v} = \sum\limits_{i \geq k} [x^i]\sum\limits_{v = 1}^{w}G_{1,v}\) (想一想,为什么要这么转化?)
显而易见的是对于每一个 \(v\), \(F_{x,v}\) 和 \(G_{x,v}\) 都是关于 \(z\) 的不超过 \(n\) 次的多项式,我们考虑直接把 \(\sum\limits_{v = 1}^{w}G_{1,v}\) 的多项式带入 \(z = 1, 2, \dots n+1\), 然后 \(O(n^2)\) 拉格朗日插回系数。
考虑 \(z = z_0\) 时怎么快速求值,再回顾一下我们的转移:
\(F_{x,i} = z^{a_x \geq i}\prod\limits_{y \in son_x}(1+F_{y,i})\) , \(G_{x,i} = F_{x,,i}+\sum\limits_{y \in son_x}G_{y,i}\)
这时我们线段树的下标 p 就表示 \(F_{x,p}\) 和 \(G_{x,p}\) (都是点值),那么就相当于最开始在 x 的线段树,下标在 \([1,a_x]\) 的位置 \(f\) 乘 \(z_0\), 然后合并时 \(F_y\) 全局加 1 后 F 对应位置相乘, G 对应位置相加,最后把 \(F_x\) 加到 \(G_x\), 这怎么做?
考虑这么多操作, 都是线性的,那就用矩阵来表示!
直接维护 \(3 \times 3\) 的矩阵复杂度直接爆炸,考虑优化,发现:
\(\begin{bmatrix} f, g ,1 \end{bmatrix} \times \begin{bmatrix} a, c, 0 \\ 0, 1, 0 \\ b, d, 1 \end{bmatrix} = \begin{bmatrix} f', g', 1\end{bmatrix}\)
于是只用保存 \(a, b, c, d\) 四个状态即可,初始向量为 \([0,0,1]\)。
嗯,很好, 比暴力慢了 6 倍。
结构分析
在使用线段树维护区间操作时,设操作 \([l, r],\)可以根据标记作用的不同类型来将影响到的区间划分成以下五种:
- 被 \([l, r]\) 完全包含,此时一定存在标记。
- 位于被包含区间的内部,没有影响。
- 在寻找区间时经过的点,一定没有标记。
- 在寻找区间时经过的点下传标记影响到的点,若它的祖先链上存在一个有标记的点,则它一定有标记。
- 位于第 4 类区间的内部,没有影响。
若要动态维护线段树上每个区间的标记个数,可设 \(f_i\) 表示 \(i\) 是否有标记,\(g_i\) 表示 \(i\) 到祖先链上是否有标记,转移为:
- 第一类:\(f'_i = g'_i = 1\)。
- 第二类:\(f'_i = f_i, g'_i = 1\)。
- 第三类:\(f_i' = g_i' = 0\)。
- 第四类:\(f_i‘ = g_i' = g_i\)。
- 第五类:\(f_i' = f_i, g'_i = g_i\)。
例:
[ZJOI2020] 传统艺能
[ZJOI2019]线段树
区间单调栈
以一道例题作为引入:
LG4198 楼房重建
维护长度为 \(n\) 的数组,支持单点修改,查询全局不同前缀最大值个数。
若利用线段树直接 push_up,需要记录当前区间的答案,最大值,但合并两个区间不好合并,因为要查询左侧最大值为栈顶时的可能的右端点个数。
设 \(c([l, r], x)\) 表示区间 \([l, r]\) 中 \(\geq x\) 的不同前缀最大值个数,等价于 \(ans_{lc}+c([mid+1, r], mx_{lc})\)。 而 \(c\) 的求解仍然可以在线段树上递归:
- 若当前区间最大 \(<x\),返回 0。
- 递归到叶子节点,简单判断即可。
- 若左侧 \(mx >x\),将答案加上 \(ans_p - ans_{lc}\),递归左子树。
- 否则直接递归右子树。
这样,我们通过将 push_up 以一个 $\log $ 的代价完成了维护。
容易发现这利用了前缀最大值的可减性。
若不利用减法,还可以这样维护: