数据结构专题

兜兜转转,回到初中最喜欢的数据结构知识点,却发现自己已然成为一个门都没入的菜逼,甚至连抄板子都不会了。

去年(今年?) CTT 的时候就因为毫无数据结构技巧被搞掉 ~40pts ,做 ioi 的时候又反复被数据结构暴打,打模拟赛的时候又被数据结构暴打……

这大概就是只做 CF 和 AT 的后果吧。

因为自己做可能会无从下手,所以紧跟 cmd 的步伐做题。

以下基本上默认 \(n,Q,??\) 都是同阶的。以下基本都没写代码。

莫队二次离线

自然是从区间逆序对讲起。

左右端点是对称的,加入删除也是对称的,所以只考虑在左边加元素的情况。

假设现在的区间右端点是 \(R\) ,左端点从 \(r+1\) 移动到 \(l\) 。那么就会带来一组询问 \(f(R,l,r)\) ,表示对于每个 \(i\) 求出 \((i,R]\) 中有多少个 \(<a_i\) 的数。

先预处理出 \(pre_i\) 表示 \([1,i]\) 中有多少个 \(<a_i\) 的数,然后 \(f(R,l,r)\) 就转化为对于每个 \(i\) 求出 \([1,R]\) 中有多少个 \(<a_i\) 的数。

\(i\) 的总个数是 \(O(n\sqrt n)\) ,但是 \(R\) 只有 \(O(n)\) 种,所以可以对 \(R\) 扫描线,用 \(O(\sqrt n)\) 修改 \(O(1)\) 查询的分块维护即可。

时间复杂度 \(O(n\sqrt n)\) ,空间复杂度 \(O(n)\)

P7601 [THUPC2021] 区间本质不同逆序对

同样用莫队二次离线的技巧来处理。

\(pre_i,nxt_i\) 表示 \(a_i\) 上一个和下一个出现位置。

还是考虑移动左端点。加入一个 \(a_l\) 时,考虑会新增哪些本质不同的逆序对。

如果 \((a_l,a_i)\) 构成逆序对,那么 \(a_i\) 目前最靠右的出现位置必须夹在 \([l,nxt_l)\) 之间。但是“目前最靠右的出现位置”比较迷惑,经过脑洞之后可以转化为在 \([l,R]\) 中出现过,而在 \([nxt_l,R]\) 中没出现过。

注意到 \(a_l=a_{nxt_l}\) ,所以转化成询问 \(f(l,R)\) 表示 \([l,R]\) 中有多少个不同的 \(<a_l\) 的数。

只能三维数点:

\[\begin{align*} &\sum_{i} [l\le i\le R][pre_i< l][a_i<a_l]\\ =&\sum_{i} [1\le i\le R][pre_i< l][a_i<a_l]-\sum_{i< l} [a_i<a_l] \end{align*} \]

\(R\) 扫描线,就需要一个 \(O(\sqrt n)\) 修改, \(O(1)\) 查询的二维数点数据结构。

要不是这题我还真不敢相信这能做……

(嫖个 cmd 的图)

我们需要在修改的时候处理好很多东西,才能 \(O(1)\) 询问。

首先是红色的大块,需要维护它们的二维前缀和。因此大块的个数不能超过 \(n^{0.5}\) ,所以它们的大小是 \(n^{0.75}\times n^{0.75}\) 。它们把地图分成了 \(n^{0.25}\times n^{0.25}\) 的大坐标系。

然后是蓝色的中块,对于大坐标系中的同一行或同一列,需要维护它们的前缀和。因此同一行不能超过 \(n^{0.5}\) 个中块,而一行有 \(n^{0.25}\) 个大块,所以一个大块中只能有 \(n^{0.25}\) 个中块,所以大小是 \(n^{0.5}\times n^{0.75}\)

最后是绿色的小块,对每个大块中的小块维护二维前缀和。容易发现大小最小只能取到 \(n^{0.5}\times n^{0.5}\)

于是就留下了宽度为 \(n^{0.5}\) 的黄色区域还没处理。

注意到 \((l,a_l)\) 一共只有 \(n\) 种不同的坐标,并且两个 \(l\) 的坐标的两维都一定不同……吗?两个 \(a\) 是可能会撞的,但是在保持 \(pre,nxt\) 不变的情况下对 \(a\) 稍作调整即可。

所以一个 \((pre_i,a_i)\) 只会被 \(n^{0.5}\)\((l,a_l)\) 的黄色区域包含,暴力修改即可。

P5113 Sabbat of the witch

(口胡的一个做法,不过经过了 cmd 检验)

分块?分块!

直接对序列分块,一次操作被拆成 \(O(\sqrt n)\) 个整块操作和 \(O(1)\) 个零散块操作。下面对每个块分别分析,不过实际操作的时候要同时进行。

考虑这个块到目前为止的时间线,会有若干个整块赋值,中间夹着一些零散块赋值。

对于连续的零散块赋值(称为一段),维护每个位置从晚到早经历的赋值操作,存在链表里。一段里至少有一个零散块赋值,而总零散块赋值次数是 \(O(n)\) ,所以这里的空间复杂度是 \(O(n\sqrt n)\)

加入一个赋值操作时,要么暴力给一些位置叠上一层 buff (零散块赋值),要么隔开新的一段。

删除一个赋值操作时,如果删的是零散块赋值那么就把这次操作标记一下,然后(对被删除的赋值操作所在的段)扫一遍每个位置求出最近一次还没被删除的赋值操作。如果是整块赋值,首先观察它隔开的两段是否都非空。如果其中一边是空的那就无事发生,否则减少了段数,可以暴力 \(O(\sqrt n)\) 把两段的链表合并。

进行操作的时候不难对每个段维护每个位置现在是某个零散块的值还是底下的整块的值,也不难维护整个块的和。零散块查询就直接无脑暴力。

时空复杂度均为 \(O(n\sqrt n)\)

P3604 美好的每一天

莫队二次离线的基础题目。

显然可以转化为区间中有多少个 \(i,j\) ,使得 \(|pre_i\oplus pre_j|\le 1\) 。无脑莫队即可做到 \(O(n\sqrt n|\Sigma|)\)

使用莫队二次离线,在加入一个点的时候 \(O(|\Sigma|)\) 修改,然后 \(O(1)\) 查询,即可 \(O(n\sqrt n+n|\Sigma|)\) 。似乎因为 \(2^{|\Sigma|}\) 太大了还需要给后者带一个离散化的 \(\log n\)

P7126 [Ynoi2008] rdCcot

分析一个 \(C\) 连通块的性质。经过随机游走,猜想一个连通块可以抽出一个树形结构,每个点只连向能走到的最浅的点。边权可以减掉任意个 \(eps\) ,所以相同深度的点可以任意排序。

考虑这样是否会使得两个本来直接有边相连的点不在同一连通块。发现让深度较深的那个点跳一步之后仍然与另外一个点距离相差不超过 \(C\) ,所以跳若干步之后必定走到同一个根。

所以只需要对根计数即可,也就是没有出边的点。

\(l_i,r_i\) 表示 \(i\) 往左往右第一个连出去的点,转化为求 \(l,r\) 。点分治然后乱搞即可。

P6779 [Ynoi2009] rla1rmdq

所有点都只能往上走,而在出现祖先关系之前原树上的所有点都只会被遍历一次。

而当两个点有祖先关系时,这两个点任意时刻都只会有一个是有用的。

分块,每个块预处理出虚树,则所有点都只会在块内的虚树上跳。对每个块的虚树预处理出 \(O(n\log n)-O(1)\) 的查询 \(k\) 级祖先。

然后在虚树上遍历就很容易知道现在的哪些点是有用的,以及再跳几步会又出现一对祖先关系。每个整块维护块内最小值。

对于零散块的修改可以直接 \(O(\sqrt n)\) 暴力重构。

对于整块,只需要打上一个 tag ,然后暴力让所有当前有用的点往上走一步,求出新的最小值。因为对于每个块,原树上的点只会被走到一次,所以这里暴力走的复杂度是对的。如果此时出现了祖先关系就 \(O(\sqrt n)\) 重构。每多一个关系就会少一个点,所以这里重构的复杂度也是 \(O(n\sqrt n)\)

总复杂度 \(O(n\sqrt n)\)

不过实际上没有必要建虚树,只需要每次跳的时候都给当前点 mark 一下,如果跳到已经有 mark 的点就把自己删掉即可。注意被删掉的点可能通过零散块操作会活过来。

P7124 [Ynoi2008] stcm

对于菊花图,可以用类似线段树分治的做法,操作次数 \(n\log n\)

先重链剖分,把轻儿子按顺序加一遍再删一遍,就把重链处理完了,可以把重链上的点都加进集合中。对每条重链都这样做,复杂度是所有轻边的 \(size\) 之和。有 \(f(n)=\max_\limits {1\le a<n} f(a)+f(n-a)+\min(a,n-a)\) ,归纳证明 \(f(n)={1\over 2}n\log n\)\(\log\) 是以 2 为底):

不妨假设 \(a\ge {1\over 2}n\) ,则 \(\min(a,n-a)=n-a\)

\[f'(a)={1\over 2} (\log a+1)\\ f'(n-a)=-{1\over 2} (\log (n-a)+1)\\ (n-a)'=-1 \]

\[g'(a)={1\over 2}\log{a\over n-a} -1 \]

发现 \(a\to n\) 时并不优秀,所以只能是 \(a={1\over 2}n\) 时取到最大值。归纳假设成立。

然后对所有轻儿子建哈夫曼树,用和线段树分治一模一样的做法即可。

把所有哈夫曼树套在一起的深度应该是 \(O(\log n)\) ,不过常数具体是什么不太清楚。

P5064 [Ynoi2014] 等这场战争结束之后

先把操作树建出来。

由第 \(k\) 大想到整体二分,但是递归到子区间的时候操作树的大小无法缩减,没法做。

那么把二分改成值域分块,块大小为 \(B\) 。用可撤销并查集求出每个询问的答案所在的块,复杂度 \(O({n^2\log n/B})\)

然后简单的想法就是枚举答案所在块内的每一个点,判断是否与自己连通。这样的复杂度是 \(O(nB\log n)\) ,总复杂度 \(O(n\sqrt n\log n)\)

考虑优化第一部分:并查集的瓶颈在于跳父亲而不在于合并,所以可以一次做 \(O(\log n)\) 个块,并查集维护连通块中 \(O(\log n)\) 个值。这样就平衡了跳父亲的复杂度和合并的复杂度。总复杂度 \(O(n^2/B+nB\log n)=O(n\sqrt{n\log n})\) ,但空间是 \(O(n\log n)\)

第二部分也可以继续优化:对操作树分块,使得每个点所在的块根离自己距离不超过 \(\sqrt n\) 。对每个块根暴力预处理出走到这里的时候的连通性,然后每个询问就是在块根的基础上加 \(\sqrt n\) 条边,可以 BFS 得到新的连通性。这样第二部分的复杂度就被优化到了 \(O(nB)\) ,总复杂度 \(O(n\sqrt n)\)

不过还是不懂树分块的做法怎么把第一部分的空间优化成 \(O(n)\)

P6105 [Ynoi2010] y-fast trie

先把每个数模 \(C\) ,那么相加之后最多减掉一个 \(C\)

\(C\) 的情况显然就是直接取两个最大值,判掉。

把值域分成 \([0,C/2),[C/2,C)\) 两段。称第一段的数为小数,第二段为大数,不会在取两个小数,而如果取两个大数也必然是取最小值,判掉。

然后就只关心小数大数之间匹配的情况了。不妨令小数指向匹配的大数。用线段树维护每个小数匹配到最优的大数得到的结果。

用线段树维护每个存在的数 \(x\) 在另外一边匹配最大的 \(< C-x\) 的数之后能得到的最大值。

插入一个大数时,会使得小数的一个区间从匹配自己的前驱变成匹配自己,也就是区间加。删除的时候从自己变为前驱,就是区间减。插入删除小数则直接查询前驱即可。

然而沙雕出题人卡空间,而如果改成平衡树大概就会被卡时间,所以不太行。

冷静一下,每个大数会获得一个区间的小数的青睐,我们维护这些区间,以及每个大数匹配到最优的小数的结果,存在优先队列里。

插入删除大数的时候就是合并或拆分区间,很容易做。插入删除小数时则是对某个大数进行微调,也很容易做。

这时候好像和正解也没什么太大区别了。

P5398 [Ynoi2018] GOSICK

虽然 \(5\times 10^5\) 但还是要勇敢地莫队,因为别的都看起来没什么救……

那么自然要二次离线莫队,变成查询 \([1,r]\) 中有多少个能和 \(a_i\) 有贡献的。

\(r\) 扫描线,然后要给 \(a_r\) 的倍数和约数 +1 。

约数肯定是没有问题的,但是 \(a_r<\sqrt n\) 的时候枚举倍数的复杂度变得不可承受。

那么把 \(<\sqrt n\) 的数作为约数的贡献单独拉出来考虑。对于一个询问,变成

\[\sum_{x<\sqrt n} (cnt_{x,r}-cnt_{x,l-1})\sum_{i=l}^r [x|a_i] \]

对每个 \(x\) 预处理前缀和即可。复杂度 \(O(n\sqrt n)\)

P5069 [Ynoi2015] 纵使日薄西山

lxl 竟然有 \(O(n\log n)\) 只出 \(10^5\) 的题?

注意到如果 \(a_i>a_{i-1},a_i\ge a_{i+1}\) ,那么这个关系永远不会改变,所以 \(a_i\) 的减小只能是自己造成的。而 \(a_i\) 减到 0 的时候 \(a_{i-1},a_{i+1}\) 也都消失了。

发现这是一个和外界没什么关系的孤岛,可以断开。于是可以推出答案就是每次把最靠左的最大值拿出来,把自己和相邻两个数删掉,并让答案加上自己。

仔细感受一下,发现可以把所有“山峰”(即上面那个 \(i\) )拿出来,然后直接求出山峰之间的数的贡献。复杂度 \(O(n\log n)\)

P5608 [Ynoi2013] 文化课

不难想,不过一些神奇细节比较神奇。

在没有区间赋值的情况下看起来线段树就可以做了:维护区间的前缀积和后缀积,以及中间的和,很容易合并两边。

但是区间赋值需要我们维护每个区间的多项式。这也不难,因为多项式的指数之和是 \(O(len)\) ,所以只会有 \(O(\sqrt {len})\) 项,而 \(len\) 每次除 2 ,所以不会带 \(\log\) 。稍微算一下会发现这样的空间也是 \(T(n)=2T(n/2)+\sqrt{n}=O(n)\)

然而一个容易被忽视的细节是算点值的时候不能每一项都用快速幂,否则就会多带 \(\log\) 。然后沙雕出题人又卡空间,所以没法存下光速幂。解决方法是从 \(x^i\) 推到 \(x^j\) 的时候用 \(O(\log(j-i))\) 的时间算。用神奇数学方法可以分析出这样做的 \(\log\) 就没了。

P6019 [Ynoi2010] Brodal queue

被打傻了,只能复读题解了。

根号分治太难处理小颜色的情况了,所以只能对序列分块。

询问

把答案拆成三部分:零散块内部、零散块 $\to $ 整块、整块内部。因为一些暂时还不知道的原因,如果一个块内颜色相同,那么把它也算进零散块的范围中。下面的 \(cnt,f\) 同样包括纯色块。

零散块只需要扫一遍即可。

零散块 $\to $ 整块。需要维护 \(cnt_{i,j}\) 表示前 \(i\) 个块中 \(j\) 的出现次数,然后直接算。

整块内部。设询问包含了 \([L,R]\) 的整块。设 \(f_{i,j}=\sum_x cnt_{i,x}cnt_{j,x}\) ,那么答案大约是 \(f_{R,R}-2f_{L-1,R}+f_{L-1,L-1}\) ,再带上一些常数。

目前看来没有太大问题。

修改

需要维护 \(cnt,f\) 。不妨假设 \(f_{i,j}\)\(i\le j\)

区间覆盖所常用的复杂度分析要求“删除一个颜色连续段”,但一个颜色连续段覆盖的块可能有很多个,这会给 \(f\) 的维护带来麻烦。所以把纯色块单独拿出来考虑,这样删除颜色连续段就只会对 \(O(1)\) 个块带来影响了。

所以可以变成 \(O(n+m)\) 次修改,每次修改给第 \(k\) 个块的颜色 \(x\) 出现次数增加 \(c\)

每次修改直接暴力重构 \(x\) 对应的 \(cnt\) 即可。

\(C\) 为修改之前的 \(cnt\) ,那么对 \(f_{i,j}\) 带来的 \(\Delta\) 则是

\[\begin{align*} &(C_{i,x}+c\cdot [i\ge k])(C_{j,x}+c\cdot [j\ge k])-C_{i,x}C_{j,x}=[i\ge k]C_{j,x}c+[j\ge k]C_{i,x}c+[i\ge k] c^2 \end{align*} \]

\([i\ge k]\) 的部分枚举 \(j\) ,用差分更新。\([j\ge k]\) 的部分则枚举 \(i\) 。询问的时候分别枚举一遍把差分的贡献拉上来。

没什么显然做法的时候就先考虑询问会用到什么信息,然后凭着信仰去维护它。

P6774 [NOI2020] 时代的眼泪

一个口胡的不知道对不对的做法,暂且放在这里。

对值域分治,每次值域除以二的时候长度也会除以二,问题不大。

当询问的值域区间完全包含当前段的时候用简单的区间逆序对做法求解。我们只需要在分治点被询问值域区间包含的时候算出上面对下面的贡献。

对序列分块,那么发现块间的贡献很好处理:上下的关系和左右的关系都确定了。

对于单独一块的贡献,注意到同一块中只有 \(O(n)\) 个本质不同的值域区间,全部用二维前缀和预处理出答案即可。

时间复杂度 \(O((n+q)\sqrt n)\) 。只要把该离线的离线了应该空间是 \(O(n)\)

LOJ#6507. 「雅礼集训 2018 Day7」A

这没做出来,非常生气(

与和或的两种思路:1. 一个数变化次数不多。 2. 相同的数很多。

因为同时出现了两种运算,所以要从相同的位入手。

对于一个区间来说,一旦某次操作覆盖了一整个区间,就会把某些位刷成完全一致。因此设置势能为区间中不同的位数,然后只要某一位不是完全一致且需要修改就直接往下递归。一次操作只会使得 \(O(\log n)\) 个节点的一致性被破坏,所以问题不大。

P7476 苦涩

线段树,每个节点维护一个堆,标记永久化。

一次删除操作,如果在某个节点中遇到了需要删除的数,就可以直接把一次删除换成两次加入。否则暴力递归即可。

可以发现,一次删除至多转换为 2 个加入,问题不大。

P6792 [SNOI2020] 区间和

这种题是不是先写个暴力然后随便改改就能过了?

肯定要 segment tree beats ,然后需要思考每个节点具体要维护什么信息。

一个想法是直接把整个关于最小值的分段函数直接维护出来。但是这个分段函数的长度是 \(O(cnt)\) 的,而且很容易发生变化,所以感觉没什么前途。

(如果分块之后每个块分别暴力线段树可以吗?)

下一个想法是只维护现在这个分段函数的最近这一段,一旦超出了这一段就暴力往下递归更新。

我们发现答案的变化一定是因为某个前缀最小值或后缀最小值的长度发生了变化,而这两者都是单调不降,所以一个点最多变化 \(O(len)\) 次。每次变化都可能需要递归到这个点来更新,所以是 \(O(\sum len\times dep)=O(n\log^2 n)\)

P4786 [BalkanOI2018]Election

我们要让前缀和和后缀和全部非负。

对于每个 \(-1,-2,\cdots\) ,把取到这个值的最靠左的前缀和最靠右的后缀的位置拿出来。第 \(i\) 个前缀/后缀里至少要删掉 \(i\)\(-1\)

考虑贪心:每个前缀的位置都在最右边删,然后把剩下不合法的后缀删掉。

可以发现,把前缀看做右括号,后缀看做左括号,那么我们可以省下来的步数就是能匹配的括号对数。

离线,从左往右枚举右端点,维护现在的后缀最小后缀和的位置,然后用楼房重建的讨论维护左端点。递归到一个区间时,如果可用的右括号都在右边,那么把左边的左括号先匹配掉,然后递归右边;否则只需要直接拿出右边的信息,然后递归左边。修改的 pushup 也是类似。

然而这样是两个 \(\log\) ,爆了。

考虑括号匹配还有什么操作。我们可以把右括号看做 \(-1\) ,左括号看做 \(1\) ,然后求最小前缀和,这样也能得到匹配数。

发现这样的定义和原数列有很强的联系:考虑第 \(i\) 个右括号,它此处的原数列的前缀和恰好也是 \(-i\)

因此,我们只需要考虑第 \(i\) 个右括号左边有多少个左括号。反过来就是右边有多少个左括号。假设右边有 \(j\) 个,那么括号序列的最小前缀和就和 \(-i-j\) 有关。

我们发现最大化 \(j\) 的时候恰好会最小化括号序列的最小前缀和。再进一步会发现, \(pos_i<pos_j\) 可以是任选的前后缀,但是只有是相邻的最小前缀/后缀的时候才会最优。再取反,就是要最大化 \(sum(pos_i+1,pos_j-1)\) ,也就是最大子段和。

因此是 \(O(n\log n)\)

posted @ 2021-07-10 17:22  p_b_p_b  阅读(1314)  评论(1编辑  收藏  举报