我不会数据结构

线段树 / 树状数组 / KDT / 李超线段树

概述:线段树用于维护序列,是一个包含 2n1 个节点的树形结构, 其中非叶子节点都有两个儿子,中序遍历这颗树得到的恰好是 1,2,n

一般在存储时,空间会开到 4n 倍,有一种特殊的写法可以使空间优化到 2n 倍 :即线段树上 [l,r] 所对应的区间为 (l+r) |(lr), 读者可以自行证明其正确性。

一般我们使用的线段树为狭义线段树(标准线段树),它要求节点 [l,r] 的左儿子是 [l,mid], 右儿子是 (mid,r]

线段树的基本性质:

  • 任意两个节点的区间要么互相包含, 要么不交。
  • 区间 [l,r] 可以唯一拆分成线段树上若干个区间的不交并, 称这些区间构成 [l,r] 的区间拆分。
  • 最小区间拆分的大小不超过 2logn+c,其中 c 为常数。

线段树基本操作:

我们可以用线段树维护一系列区间数据结构问题

  • 最简单的是区间查询, 即询问给定序列的 [l,r] 的相关信息, 若我们对于每个区间维护一个 f([l,r]), 使得 f()f([l,l]) 容易计算,并且我们可以快速合并 f[a,c]=f[a,b]+f[b+1,c], 设合并一次的复杂度为 T, 则进行一次查询的复杂度为 nTlogn, 初始化复杂度为 nT

    • 维护区间和 : sum(l,r)=sum(l,mid)+sum(mid+1,r)

    • 区间乘积 : prod(l,r)=prod(l,mid)×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=i=0k(ki)xivki,维护区间 1,2,k 次方之和后组合数算一下即可。
    • 区间加,区间选取 k 个数乘积之和:设 fx,ix 节点选了 i 个数的总和, 转移直接卷积即可。 区间加时,有 fx,i=j=0i(ni+jj)fx,jvj,时间复杂度 O(nlogn×k2)

维护矩阵乘积:

有时线段树维护的区间信息满足一定的线性性和递推性,可以直接用矩阵表示转移,维护的 f(l,r) 看做行向量, 转移看成 n×n 的矩阵, 常见的结合律有乘法对加法, max 对加法,异或对与。 也称为动态 dp。

我们通过几道例题来实际感受一下它的用途:

一棵树,支持单点修改权值,维护全局最大独立集大小。

朴素 dp 设 f[x][0/1] 表示 x 子树内, x 是否选择的最大独立集大小, 有转移 :

f[x][0]f[x][0]+max(f[y][0],f[y][1])f[x][1]f[x][1]+f[y][0]

再考虑把树剖分, 设 sonxx 的重儿子, gx,0/1 表示 x 不考虑重儿子时的 dp 值, 将状态转移方程改写:

fx,0gx,0+max(fson,0,fson,1)fx,1gx,1+fson,0 , 表示成矩阵 :

[gx,0,gx,1gx,1,][fy,0fy,1]=[fx,0fx,1], 注意此时要求 xy 在同一条重链上,于是我们直接线段树维护这个矩阵即可。

修改路径时, 我们只用考虑轻儿子对父亲的贡献, 也就是一条重链的链顶对父亲的贡献,这是可以直接对父亲的转移矩阵操作的。

[THUSCH2017] 大魔法师

维护一个序列 A,B,C, 支持以下 7 种操作:

  • 区间 Ai=Ai+Bi, 区间 Bi=Bi+Ci, 区间 Ci=Ci+Ai
  • 区间 Aiv, 区间 Biv, 区间 Ci 赋值为 v
  • 询问区间 A,B,C 之和。

直接用记下 [A,B,C,len] 即可,转移易。

  1. 小 Y 和恐怖的奴隶主

    dp 直接写成矩阵即可。

  2. CF750E New Year and Old Subsequence

    dp 直接写成矩阵即可。

可持久化线段树 / 标记永久化

如果一棵线段树在修改时我们要维持改版本之前的线段树不变, 我们需要对需要修改的线段树节点复制后再修改, 称为可持久化。

单点修改的可持久化是简单的:我们只需要把根到叶节点的点全部复制一遍即可,因此修改 n 次的空间复杂度为 O(nlogn)

但当要支持区间操作时,每次 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, 字符集大小为 |s|=105 的子序列自动机, AC 自动机。

子序列自动机:前一个位置的转移只由后一个位置加一个单点修改。

AC 自动机:一个点的转移只在其失配指针基础上修改它有出边的儿子。

区间数颜色:维护 prei 表示上一个颜色和 i 相同的点, 则 [l,r] 的颜色数等于 [l,r]prek<l 的个数。

区间 mex:对于每一个 r 维护 prei, 答案就是第 r 棵主席树中第一个 prek<l 的点, 线段树二分即可。

可持久线段树的最常见用法是充当一个线段树的前缀和。

当询问满足可减性时, 进行 [l,r] 的询问时用第 r 棵树减去第 l1 棵树,例如权值线段树可持久化后得到主席树, 可以支持区间 k 大。

当询问是在线时, 一般使用主席树使问题降一维。 若可以离线则可以树状数组。

[AH2017/HNOI2017]影魔

势能分析

正常的线段树当 [l,r][ql,qr]=[l,r] 就会停止, 但某些问题我们有可能到了 [l,r][ql,qr]=[l,r] 依然无法快速更新,这时线段树的时间复杂度需要依靠势能分析。

例:[雅礼集训 2017] 市场

维护一个长度为 n 的序列,支持 m 次操作,操作包括区间加,区间除一个数下取整,以及查询区间最小值和区间和。

直接用线段树记录区间最大最小,向下取整时,若 mxmxd=mnmnd, 即差值相等时,直接打标记退出。

[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(nlog2n) 的做法, 使用线段树合并 + 分裂可以做到在线 O(nlogn) 并求出所有区间。

O(nlog2n) 做法:直接二分第 p 位的值, 将 <x 的位置设为 0, x 的位置设为 1, 容易发现当 x 小于 p 位的真实值时为 1, 否则为 0, 然后区间排序就变成了区间 0 / 1 赋值。

O(nlogn) 做法:发现当对 [l,r] 排序后,若我们知道排序是从大到小还是从小到大, 再加上 [l,r] 的所有值域范围, 则 [l,r] 可以直接确定。

注意这个排序有点像珂朵莉树中的"推平"操作, 于是我们直接对每一个有序的连续段维护一个值域线段树, 则排序只用把 [l,r] 所有连续段线段树合并一下即可, split 操作可以通过线段树分裂实现。

由于推平连续段总复杂度 O(n), 线段树合并 + 分裂为 O(logn), 故总时间复杂度 O(nlogn)。 感觉珂朵莉树还可以和其他连续段数据结构套起来。

[NOI2020] 命运 & [PKUWC2018]Minimax

这两个题都差不多,讲一个就行了。

f[x][i] 表示 x 的权值为 (离散化后的)i 的方案, 显然有转移:

x 为叶子, f[x][a[x]]=1; 当 x 一个儿子:f[x]=f[sonx]

x 两个儿子:f[x][i]=p×(j=1if[ls][j]×f[rs][i]+j=1i1f[rs][j]×f[ls][i]+(1p)×(j=inf[ls][j]×f[rs][i]+j=i+1nf[rs][j]×f[ls][i])

维护一下 ls,rs 的前后缀和即可,当 xy 有一个为 0 时直接打上标记即可。

[PKUSC2019]树染色

同样有 f[x][i]=(sumyf[y][i])×f[x][i], 限制可以将 x 的子树合并完后置为 0 即可,最开始有个全局加 1。

注意此时的线段树的非叶节点是有信息的,它表示 [l,r] 的值都是相同的,所以在 push_down 时若没有儿子要新建儿子, 与上面维护的要区分, 上面的非叶节点的信息只是为了 push_down 给叶节点。

转移就显然了:若 x 为一个连续段, 直接打上 sumf[y][i] 的标记, 若 y 为一个连续段,还需将 y 取反后加上 sumy 再与 x 相乘。 维护一个加乘标记即可。

[九省联考 2018] 秘密袭击 coat

神题。

建议配合 拉格朗日插值食用。

所有连通块的 k 大显然不可做, 考虑转化:

若一个连通块的 k 大为 v, 则我们在 x=1,2,v 都对这个连通块算 1 的贡献, 这样显然是正确的。 而 x=x0 的实际意义就是有多少个连通块, 其 kx

考虑和 排序 一样的思路,将 x 的设为 1, 否则设为 0,则问题就转化为有多少个连通块, 其权值 k,对于一个 x, 我们显然可以 O(n2) 计算。总复杂度 O(n2W)

考虑优化,数据范围只要我们优化一个 nWlog 即可。

Fx=zaxvysonx(1+Fy), 对于一个 v, 我们算的是 ansv=x=1nikF[x][i]

拿这个直接取做线段树合并显然是不行的, 因为 zaxv 是平移操作 ,线段树没办法平移。

继续转化, 将限制缩小, 设 Fx,i 表示 x 子树内 v=i 的多项式, 转移还是一样,设 Gx,i 表示 x 子树内所有 Fx,i 之和, 即 Gx,i=Fx,i+ysonxGy,i, 答案就是:

ans=v=1wik[xi]G1,v=ik[xi]v=1wG1,v (想一想,为什么要这么转化?)

显而易见的是对于每一个 vFx,vGx,v 都是关于 z 的不超过 n 次的多项式,我们考虑直接把 v=1wG1,v 的多项式带入 z=1,2,n+1, 然后 O(n2) 拉格朗日插回系数。

考虑 z=z0 时怎么快速求值,再回顾一下我们的转移:

Fx,i=zaxiysonx(1+Fy,i)Gx,i=Fx,,i+ysonxGy,i

这时我们线段树的下标 p 就表示 Fx,pGx,p (都是点值),那么就相当于最开始在 x 的线段树,下标在 [1,ax] 的位置 fz0, 然后合并时 Fy 全局加 1 后 F 对应位置相乘, G 对应位置相加,最后把 Fx 加到 Gx, 这怎么做?

考虑这么多操作, 都是线性的,那就用矩阵来表示!

直接维护 3×3 的矩阵复杂度直接爆炸,考虑优化,发现:

[f,g,1]×[a,c,00,1,0b,d,1]=[f,g,1]

于是只用保存 a,b,c,d 四个状态即可,初始向量为 [0,0,1]

嗯,很好, 比暴力慢了 6 倍。

结构分析

在使用线段树维护区间操作时,设操作 [l,r]可以根据标记作用的不同类型来将影响到的区间划分成以下五种:

  • [l,r] 完全包含,此时一定存在标记。
  • 位于被包含区间的内部,没有影响。
  • 在寻找区间时经过的点,一定没有标记。
  • 在寻找区间时经过的点下传标记影响到的点,若它的祖先链上存在一个有标记的点,则它一定有标记。
  • 位于第 4 类区间的内部,没有影响。

https://cdn.luogu.com.cn/upload/image_hosting/tepkursq.png

若要动态维护线段树上每个区间的标记个数,可设 fi 表示 i 是否有标记,gi 表示 i 到祖先链上是否有标记,转移为:

  • 第一类:fi=gi=1
  • 第二类:fi=fi,gi=1
  • 第三类:fi=gi=0
  • 第四类:fi=gi=gi
  • 第五类:fi=fi,gi=gi

例:

[ZJOI2020] 传统艺能

[ZJOI2019]线段树

区间单调栈

以一道例题作为引入:

LG4198 楼房重建

维护长度为 n 的数组,支持单点修改,查询全局不同前缀最大值个数。

若利用线段树直接 push_up,需要记录当前区间的答案,最大值,但合并两个区间不好合并,因为要查询左侧最大值为栈顶时的可能的右端点个数。

c([l,r],x) 表示区间 [l,r]x 的不同前缀最大值个数,等价于 anslc+c([mid+1,r],mxlc)。 而 c 的求解仍然可以在线段树上递归:

  • 若当前区间最大 <x,返回 0。
  • 递归到叶子节点,简单判断即可。
  • 若左侧 mx>x,将答案加上 anspanslc,递归左子树。
  • 否则直接递归右子树。

这样,我们通过将 push_up 以一个 log 的代价完成了维护。

容易发现这利用了前缀最大值的可减性

若不利用减法,还可以这样维护:

树状数组

李超线段树

KDT

本文作者:henrici3106

本文链接:https://www.cnblogs.com/henrici3106/p/16747839.html

版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。

posted @   henrici3106  阅读(69)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示
💬
评论
📌
收藏
💗
关注
👍
推荐
🚀
回顶
收起
  1. 1 404 not found REOL
404 not found - REOL
00:00 / 00:00
An audio error has occurred.