「题目讨论」数据结构
这篇博客主要是扒 \(wzh\) 大佬的课件的,对于一些做题的思路有个人的理解。
基础数据结构
一般数据结构
都进 \(\text{OI}\) 很久了,这些基础数据结构都应该知道:
- 栈:后进先出的存储结构。
- 队列:先进先出的存储结构。
- 堆:结构是完全二叉树,用于支持插入、删除和求最值的基本操作。完成各操作主要通过每个点的权值一定为子树的最值这个性质展开。
- 左偏树(可并堆):结构是二叉树,是可以合并的堆。因为插入和删除可以用合并实现因而基本操作只有合并。对每个节点维护往右走到叶子的步数 \(dis\) 。并保证 \(dis(left(x))\le dis(right(x))+1\) , \(dis(right(x))\le dis(left(x))\) 。用归纳可以得出树高级别为 \(\mathcal O(\logn)\) 。基于该性质可以保证复杂度,便在合并操作中维护该性质即可。
进阶数据结构
树状数组
对一个序列进行信息维护,支持单点修改、区间查询,要求信息具有可合并性。
一般只对有可减性信息作维护(例如和、异或和)而不对没有可减性信息作维护(例如最值),维护没有可减性信息将增加树状数组的复杂度。
以维护区间和为例,树状数组将记数组 \(C_i=Sum(i-lowbit(i)+1,i)\) 。基于 \(lowbit\) 的性质在维护时不论修改还是询问都只需要访问 \(\mathcal O(\logn)\) 个位置。
而 \(lowbit\) 可这样实现
inline int lowbit(const int i){return i&(-i);}
线段树
一棵二叉树结构,对一个序列进行信息维护,支持单点修改,并在修改标记具有可合并性时支持区间修改,亦支持区间查询,要求信息具有可合并性。
每个节点代表了一个区间,一个区间的左右儿子恰是该区间对半分的两边,因而该结构树高为 \(\mathcal O(\logn)\) 。区间的修改和查询可以被拆分到 \(\mathcal O(\logn)\) 个线段树节点代表的区间上。
编号方法如果采取左儿子的编号是乘 \(2\),右儿子的编号是乘 \(2\) 加 \(1\),则最坏情况下编号会到 \(4n\),需要 \(4\) 倍空间。线段树实际只有 \(2n\) 个点,而动态开点虽然只有 \(2n\) 个编号,但由于需要多维护左儿子和右儿子指针需要 \(6\) 倍空间 十分高级的逆向优化
在有些毒瘤空间严苛的题目里,采取如下函数的编号方法,可以使线段树中序遍历从 \(2\) 开始的一段连续,即点的编号从 \(2-2n+1\),只需要 \(2\) 倍空间。
inline int getid(const int l,const int r){return l+r|l!=r;}
线段树的区间修改通过懒标记实现。
我们并非将所有需要修改的区间直接修改,而是发现一次区间修改可以分解为 \(\mathcal O(\log n)\) 个线段树的子树修改,在子树的根上用懒标记表示子树需要修改,并将根节点的值修改以方便到根路径的信息更新。
有了懒标记,只要在任何线段树自上而下的过程中将标记推下即可。
由于一个区间可能被修改多次,而将标记堆成队列复杂度很高,因而要求标记具备可合并性(如 \(+2\) 和 \(+3\) 可以合并成 \(+5\))
在涉及多种修改类型时,应该设定好它们的执行顺序,如同时有区间加和区间乘,可以定义先乘再加(一个区间的标记是 \(\times 5\) 和 \(+3\) 意味着一个 \(x\) 应该修改成 \(5x+3\))
例题
「JZOJ-4238」纪念碑
「BZOJ-3821」玄学
给一个长度为 \(n\) 的序列 \(a\) 以及一个长度为 \(m\) 的修改操作序列,每一项是一个形如将 \([l,r]\) 内的每一个 \(a_i\) 修改为 \(a\times a_i+b\) 的修改操作。
你需要回答q个询问,每个询问形如按从左到右的顺序依次执行修改操作序列区间[l,r]的修改操作后序列 \(a\) 中 \(a_k\) 的值是多少。询问互相独立。
\(n,m+q≤500000\)。
这里介绍一种离线做法(还不足以 \(A\) 掉这道题,此题强制在线)
可以考虑扫描线。把修改操作两端点以及询问操作的位置放在一起排序然后扫描。
修改操作是一次函数,其的叠加仍是一次函数(下面会具体讲如何合并)。
以修改操作序列建线段树,区间维护依次执行完对应所有修改操作后一次函数的系数。 该信息是可合并的。
即若左儿子是 \(ax+b\) 右儿子是 \(cx+d\) 将会合并为 \(c(ax+b)+d=acx+(bc+d)\) 。
在同一个位置,按修改操作左端点,询问,修改操作右端点的优先级访问。
如果扫到修改操作左端点,加入到线段树中,扫到右端点即在线段树中删除。
扫的询问操作直接在线段树里询问出一次函数,将该一次函数作用于序列原来的值即可。
时间复杂度 \(\mathcal O(n\logn)\) 。
「BZOJ十连测」线段树(节选)
有一个长度为 \(n\) 的序列与 \(m\) 个修改操作,每个修改操作是将序列 \([l,r]\) 的元素都修改为这个区间的最大值。
现有 \(q\) 个操作,要么是修改序列的一个元素,要么是询问执行 \([l,r]\) 的修改操作后,第 \(k\) 个元素是多少。询问之间独立,而修改会造成影响。
\(n,m,q≤100000\)。
我们容易发现,每一个位置都可以被表示成一段区间的最大值。
我们枚举修改操作序列右端点r来离线做,把所有询问操作挂在其对应右端点上。
例如位置 \(k\),找到当前操作前最后一个覆盖其的操作,然后继续找最后一个覆盖该区间左端点的操作,最后一个覆盖该区间右端点的操作,最后一个覆盖最后一个覆盖该区间左端点的操作区间的左端点……
也就是,我们需要一直往左找,一直往右找,找到最左的 \(ll\) 与最右的 \(rr\),使得 \(k\) 这个位置可以表示成 \([ll,rr]\) 的最大值。
把每个操作当做一个结点,我们维护两颗树:一棵叫左树一棵叫右树,左树中,一个结点的父亲所对应区间是在该结点之前的最后一个可以覆盖其左端点的区间,右树同理维护往右。倍增一下,便可以快速找到 \(ll\) 与 \(rr\)。
如何得知最晚覆盖其的区间?可以用线段树,每加入一个区间就区间赋值。
然后,顺序扫 \(q\) 个操作,用线段树维护 \(a\),遇到修改就修改,遇到询问就询问对应区间最大值。
「UOJ#164」V
线段树的合并
合并两颗线段树的过程非常简单。
如果两颗有其中一颗为空,即可返回另一颗。否则递归合并左右子树,用其中一个作根,并合并信息。
然而,单次线段树合并最坏的复杂度显然是 \(\mathcal O(n)\) 的。
一般常用的是将原本 \(n\) 颗大小为 \(1\) 的线段树最后合并成一颗,复杂度是 \(\mathcal O(n\log n)\) 的。
设势函数为线段树的节点数,则由初始可得势函数不超过 \(n\log n\)。每次线段树的合并复杂度若为 \(k\),则意味着合并了 \(k\) 个节点,将会使得势函数减少 \(k\) 。因而通过势能分析可得这样做的复杂度是 \(\mathcal O(n\log n)\) 的。
平衡树
平衡树是复杂度有保证的二叉排序树。
二叉排序树:中序遍历有序的二叉树,显然形态不唯一。
常用平衡树
常用平衡树为 Splay
,Treap
和替罪羊树。
Splay
:依靠伸展操作(splay
操作)完成平衡。可以支持分裂与合并。均摊复杂度 \(\mathcal O(\logn)\)。Treap
:给每个节点随机优先级 \(fix\),并保证 \(fix\) 形成堆的结构,来完成平衡。亦分为旋转版本(插入为基本操作)和非旋转版本(合并为基本操作),都能支持分裂与合并,期望复杂度 \(\mathcal O(\log n)\)。- 替罪羊树:对设定常数 \(a(0.5<a<1)\),保证每个节点的儿子子树大小不超过其子树大小的 \(a\) 倍完成平衡。每次操作后将最高不满足要求的子树重构。均摊复杂度 \(\mathcal O(\log n)\)。
Splay
和 Treap
可以进行启发式合并。在之前的合并操作中,其中一颗的排序将严格小于另一颗,如果没有这样的保证,则应该进行启发式合并。
将大小较小的那颗平衡树按中序遍历将节点插入进另一颗中即可。
单次合并的复杂度无法保证,但根据定理可以保证将 \(n\) 颗大小为 \(1\) 的最终合并为一颗复杂度为 \(\mathcal O(n\log n)\)。
重量平衡树
重量平衡树:单次基本操作所影响的最大子树的大小的最坏/期望/均摊是 \(\mathcal O(\log n)\) 的平衡树称为重量平衡树。
替罪羊树由于每次是暴力重构子树,因而其是重量平衡的。
Treap
亦可证明是一种重量平衡树。
假设新插入了一个节点,旋转到了祖先 \(k\) 这个位置。那么影响的大小就是 \(size[k]\)。
又因为 Treap
的定义,新插入点的 \(fix\) 在 \(k\) 的子树中一定最小,这个概率应当是 \(\frac{1}{size[k]}\) 的,那么对期望的贡献是 \(1\) 。
Treap
期望树高为 \(\log n\),因此每次期望影响大小是 \(\log n\)。
而从 Treap
中删除一个节点,直接暴力重构该节点的子树即可。因为期望树高 \(\log n\),祖先后代的关系数期望为 \(n\log n\),也即子树和期望为 \(n\log n\),子树大小的期望为 \(\log n\)。
那么可看出 Treap
单次基本操作所影响子树大小期望为 \(\log n\),符合重量平衡树的定义。
例题
阿凡达(出处未知)
维护一个长度为 \(n\) 的序列 \(A\),初始全为 \(0\)。
有 \(q\) 次操作,共两种:
- 把 \([l,r]\) 区间每个 \(A[i]\) 修改为 \((i-l+1)∗X \mod Y\);
- 询问区间 \([l,r]\) 的和;
\(n≤10^9,q≤50000\)。
考虑虑将一次修改的区间看作一个颜色段。
那么整个序列无时无刻都由若干颜色段组成,且颜色段数量为 \(\mathcal O(q)\)。
用平衡树维护颜色段,每个点上存储一段的和。
修改分情况讨论对原先一些颜色段分裂或覆盖。
剩下问题是我们如何计算一个颜色段的和。
注意到 \((i-l+1)∗X \mod Y=(i-l+1)\times X-Y\times ⌊\frac{(i-l+1)∗X}{Y}⌋\)
前者很好求和,后者可以用经典的类欧几里得算法。总复杂度一个 \(log\) 。
「BZOJ-2658」小蓝的好友
求一个 \(n\times m\) 网格里有多少矩阵包含至少一个黑格子。
其中每个格子都是白色或黑色。
\(n,m≤40000\),黑格子的数目不超过 \(100000\)。
数据保证随机。
正难则反,考虑有多少矩阵不包含黑格子。
用常规单调栈做法的思路,对某一行,每个格子求出其最长上升的距离,得到一个数组 \(up\)。
然后,对 \(up\) 数组建立笛卡尔树。
那么,我们知道对于笛卡尔树中任意一个结点 \(x\),其对答案贡献为 \(\frac{up[x]-up[fa[x]])\times size[x]\times size[x]+1)}{2}\)。再记录 \(sum[x]\) 统计子树中贡献和即可。
这是一行的答案。
将该行转移到下一行时,相当于将所有位置的 \(up\) 加一,并对新的一行有黑格子的位置将对应节点的 \(up\) 修改为 \(0\)。注意到
Treap
可以看作是基于 \(fix\) 的笛卡尔树,因而用Treap
来维护即可。而由于数据随机,符合Treap
的 \(fix\) 随机的条件,复杂度不会退化。
「BZOJ-3065」带插入区间K小值
一个长度为 \(n\) 的序列,有 \(m\) 次操作,包括:
- 修改一个位置的值
2、在一个位置前插入一个数(即会使得序列长度加一)
3、查询一个区间第 \(k\) 小的数是多少
\(n,插入个数≤35000\),\(修改个数,查询个数≤70000\)
用替罪羊树维护序列,每个结点放一棵包含该子树所有结点的权值线段树,也就是平衡树套权值线段树。
由于外层是平衡树,那么就能实现插入一个结点:找到它的位置,在根到其路径上所有结点的线段树中插入这个值。
查询区间第 \(k\) 大:找到这个区间包含若干棵子树,拿出他们的权值线段树,同时做线段树上二分。即如果左子树的大小和超过 \(k\) 就一起往左子树找,否则用 \(k\) 减去并往右子树找。
修改则与插入类似。
当外层平衡树失衡时,即替罪羊树用重构操作保持自身平衡时,可以自底向上用线段树合并重构每个节点新的线段树。复杂度 \(\mathcal O(n\log^2n)\)。
反过来套或许会更简单。
我们可以维护一颗权值线段树,每个区间套一颗平衡树。
\([l,r]\) 区间上只保留权值在 \([l,r]\) 中的,按在序列中位置的大小为关键字形成的平衡树。
修改和插入的话,沿途修改平衡树即可。
查询仍然可以在权值线段树上二分,在区间对应的平衡树上查出序列上一个区间内有多少个数在一个值域区间内。
这个平衡树可以用能够维护序列的
Splay
或Treap
。
「BZOJ-3600」没有人的算术
我们定义一种新的数:
要么是 \(0\)
要么是一个“数”对(这里的“数”指我们新定义的数) \((l,r)\),其中 \(l\) 和 \(r\) 也是“数”。
每一个“数”如果不是 \(0\),其一定可以一直拆分直至不可拆分。
“数”的大小关系定义:\(0\) 最小,其余“数”按照“数”对第一项为第一关键字,第二项为第二关键字。
维护一个由 \(n\) 个这样的数组成的序列,初始每个位置都是 \(0\)。
支持两种操作,将 \(a[k]\) 修改为 \((a[l],a[r])\),或询问区间最小值。操作总数为 \(m\)。
\(n,m\le 10^5\)。
我们用一颗平衡树来维护这些“数”从小到大排序后组成的序列。那么“数”可以用平衡树中某节点的编号代替。初始平衡树中只有一个节点,代表的“数”为0。
我们进行“数”->实数的映射, \(f(x)\) 表示这样的映射。
并有如果 \(x<y\),那么 \(f(x)<f(y)\)。则比较“数”的大小可转为比较f函数得出的实数的大小。
我们假设平衡树每个节点对应一个开区间 \((l,r)\),其中根节点对应 \((0,1)\)
\(x\) 对应区间是 \((l,r)\),那么 \(x\) 左儿子对应区间是 \((l,mid)\),右儿子对应区间 \((mid,r)\)。
定义 \(f(x)=\frac{(l+r)}{2}\)
然后我们发现,\(f(x)\) 就是 \(x\) 对应开区间的中点。\(x\) 左儿子的数大小都小于 \(x\),其 \(f\) 值也一定小于中点。\(x\) 右儿子的数大小都大于 \(x\),其 \(f\) 值也一定大于中点。
因此 \(x<y\),必有 \(f(x)<f(y)\)。
如若 \(f\) 已被算好,则任意两个“数”可以在 \(\mathcal O(1)\) 时间内比较大小。
因此可以用线段树维护出序列最值的编号。
现在我们每次执行一次修改操作,会向平衡树中插入新的数。数的比较可以拆为数对两项的比较,由于数对两项都一定是平衡树中已有的数,可以直接利用已算好的 \(f\) 值,因此向平衡树插入一个新数的复杂度为 \(\mathcal O(\log n)\)。
插入新数后需要重构所影响子树中所有点的f值。
因此平衡树应当选用重量平衡树(如
Treap
)来保证每次重构的复杂度。由于
Treap
期望树高为 \(\log n\),因而精度不会出问题。本题最终复杂度为 \(\mathcal O(n\log n)\)。
并查集
定义及常见优化
并查集是一种树型的数据结构,用于处理一些不相交集合的合并及查询问题。常常在使用中以森林来表示。
其基本操作为 \(link\) 和 \(find\)。即合并两棵树与查找根。朴素实现复杂度为 \(\mathcal O(n)\)。
按秩合并:定义秩为原始树高,在合并两棵树时将秩小的接到秩大的上,复杂度 \(\mathcal O(\log n)\)。
路径压缩:每次查找完将路径上所有点的父亲直接设为根,均摊复杂度 \(\mathcal O(\log n)\)。
按秩合并+路径压缩:两个都用,均摊复杂度 \(\mathcal O(\alpha(n))\)。
例题
冷战
「CF468D」树中的配对
其实这道题与并查集没有什么关系,但是还是可以去看看,对于思维要求很高。
结语
其实这篇文章,省掉了 可持久化 与 LCT
,这一部分可能后面还会再做补充 但是现在还是算了吧 ,如果有兴趣可以去了解一下。