根号算法
CHANGE LOG
- 2022.2.14:重构莫队部分。
- 2022.2.15:重构根号分治部分。
1. 根号分治
1.1 算法简介
根号分治本质上是一种 按规模大小分类讨论 的思想而非分治算法。对于规模为 \(x\) 的问题,如果我们能在 \(\mathcal{O}(x)\) 和 \(\mathcal{O}(\frac n x)\) 的时间内解决,可以考虑根号分治:\(x\leq \sqrt n\) 时使用 \(\mathcal{O}(x)\) 的算法,\(x > \sqrt n\) 时使用 \(\mathcal{O}(\frac n x)\) 的算法。这相当于寻找 \(x\) 和 \(\dfrac n x\) 的较小值的最大值:显然,当 \(x = \sqrt n\) 时,\(\min\left(x, \dfrac n x\right)\) 取到最大值 \(\sqrt n\)。因此该算法的时间复杂度即 \(\mathcal{O}(q\sqrt n)\),其中 \(q\) 是询问组数。
更一般的,如果有若干算法 \(f_i\),在解决规模为 \(x\) 的问题时时间复杂度为 \(f_i(x)\),那么通过分类讨论,我们可以在 \(\min f_i(x)\) 的时间内解决规模为 \(x\) 的问题。总时间复杂度即 \(\mathcal{O}(q \max_x (\min_i f_i(x)))\)。
通常,问题规模较小时,我们通过预处理所有问题的答案做到均摊 \(\mathcal{O}(x)\)。因此,根号分治也可以看做在 预处理 和 询问 的复杂度之间寻找平衡的一种思想。
- Trick:根号分治进入较大的分支调不出来时,试试将块大小设为 \(1\)。
1.2 例题
I. CF797E Array Queries
注意到若 \(k > \sqrt n\),答案必定不大于 \(\sqrt n\),对于所有位置预处理出所有 \(k\leq \sqrt n\) 的答案,若 \(k>\sqrt n\) 直接暴力查询即可。时间复杂度 \(\mathcal{O}(n\sqrt n)\)。代码。
*II. CF1039D You Are Given a Tree
注意到若 \(k > \sqrt n\),答案必定不大于 \(\sqrt n\)。对于 \(1\leq k\leq \sqrt n\),直接暴力树形 DP。然后再枚举 \(1\leq ans\leq \sqrt n\),不过枚举的是 链的条数,即答案。显然答案单调不升,因此二分出答案为 \(ans\) 的 \(k\) 的区间即可。
树形 DP 求链上经过的点的个数为 \(k\) 时的答案:分两种情况讨论。记 \(mx_1,mx_2\) 为 \(i\) 的儿子所传入的最长的两条链,若 \(mx_1+mx_2+1\geq k\),将 \(i\) 与它的两个儿子配成一条链更优,答案加 \(1\);否则将 \(mx+1\) 传上去到其父节点即可。时间复杂度线性。
综上,总时间复杂度 \(\mathcal{O}(n\sqrt n \log n)\)。代码。
卡常技巧:预处理每个节点的父亲,然后将所有节点按照 dfs 序排序。这样树形 DP 就不需要 dfs 了。
IV. CF1580C Train Maintenance
一个非常显然的根号分治题目。若 \(x + y \leq B\),我们可以用桶记录其对 \(i\bmod (x + y) = d\) 的每个天数 \(i\) 的贡献。若 \(x + y > B\) 直接差分即可。注意取消差分贡献时下标对 \(i\) 取 \(\max\),因为作用在 \(i\) 以前的位置 \(j\) 的差分需要在 \(i\) 处更新:对差分数组位置 \(j\ (j+1<i)\) 的更新是不会在位置 \(i\) 中体现的,\(j\) 已经过时了。
时间复杂度 \(\mathcal{O} \left(\dfrac{nm}B + mB\right)\),取 \(B=\sqrt m\) 有最优复杂度 \(n\sqrt m\)。代码。
VI. P3591 [POI2015]ODW
比较套路的根号分治题目。由于当步长 \(>B\) 时最多走 \(\dfrac n B\) 步,所以我们设置阈值 \(B\),表示若步长 \(\leq B\) 则使用预处理的信息,若步长 \(>B\) 则暴力树上倍增计算。
预处理的信息只需要 \(v_{k,u}\) 表示 \(u\) 每次向上跳 \(k\) 步能到达的所有节点权值之和,即 \(\sum_{\\v\in \mathrm{ancestor}(u)}a_v[k\mid dep_u-dep_v]\)。可以在 \(\mathcal{O}(nB)\) 的复杂度内求得。
综上,时间复杂度 \(\mathcal{O}\left(nB+\dfrac{n^2}{B}\log n\right)\),当 \(B\) 取 \(\sqrt{n\log n}\) 时有理论最优复杂度 \(n\sqrt{n\log n}\)。如果用长链剖分求树上 \(k\) 级祖先则可做到严格 \(n\sqrt n\)。
由于数据原因,实际表现中取 \(B=20\) 会很快。
2. 分块
2.1 算法简介
分块的本质是 暴力重构 和 懒标记 的结合。
对于序列分块,我们会将序列分成 \(\sqrt n\) 个大小为 \(\sqrt n\) 的块。对于区间修改,遇到整块打标记,其余散点暴力重构其所在的块。因为最多重构两个块,打根号次标记,所以单次修改的时间复杂度一般为 \(\sqrt n\)。这是分块的基本思想,即时间复杂度能承受就重构,不能承受就打标记。
分块的主要作用有两个,一是 平衡复杂度,二是维护一些 \(\log\) 数据结构无法维护的信息。
- 对于区间加法,单点查询,一般的思路是使用树状数组维护。此时修改和查询的复杂度均为 \(\log n\)。分块的优势在于它可以让修改和查询当中的任意一个变为 \(\mathcal{O}(1)\),另一个变为 \(\sqrt n\)。当询问或修改的次数非常多时,如莫队二次离线算法中的 \(\mathcal{O}(n)\) 次区间修改,\(\mathcal{O}(n\sqrt n)\) 次单点查询,就可以使用分块平衡复杂度,做到非常优秀的 \(n\sqrt n\)。
- 当遇到无法 快速合并 和 快速删除 的信息时,对于 单点 修改,区间查询,传统的维护半群的线段树就失效了。但分块仍然可以做到优秀的复杂度:单点修改直接暴力重构,区间查询对整块和散点都容易直接查询。具体见例 I.
对于第二点,笔者在和机房同学(ycx)讨论后获得了更深刻的理解。普通的线段树也可以做到维护无法快速合并和删除的信息。
考虑将信息的合并 限制在一定层数内,这样我们必须将询问 下放 至该层数以下才能获得信息。具体地,设立阈值 \(B\),当区间长度 \(\leq 2 ^ B\) 时,合并两个子树的信息。否则视为空节点。对于区间查询,我们仅在区间长度 \(\leq 2 ^ B\) 的节点查询信息。这说明即使当前节点所表示的区间被查询区间完全包含,若其长度 \(> 2 ^ B\),说明它没有存储任何信息,必须向左右两个子节点递归直到区间长度 \(\leq 2 ^ B\)。
分析复杂度:视合并复杂度为区间长度,查询某区间信息的时间复杂度为 \(\mathcal{O}(1)\),则单次单点修改的复杂度为 \(1 + 2 + \cdots + 2 ^ B = \mathcal{O}(2 ^ B)\),区间查询的复杂度为 \(\mathcal{O}\left(\dfrac n {2 ^ B}\right)\)。不难发现令 \(B = \dfrac {\log_2 n} 2\) 时复杂度最优,为 \(n\sqrt n\)。
若将分块看成仅有 \(2\) 层的 \(\sqrt n\) 叉线段树,则暴力重构本质上就是将信息合并的规模限制在 \(\sqrt n\) 级别,并将区间查询下放到每个块和散点。它是上述做法的一种非常简便的实现。
2.2 时间轴分块:根号重构
对时间轴分块的思想可运用于多次修改和询问,需要用数据结构维护,但数据结构不支持修改的情况。若修改相对 独立,即我们能分开考虑用数据维护好的信息以及没有被更新的修改快速得到询问的答案,那么可设阈值 \(B\),若 “积压” 的修改数量 \(\geq B\) 则重构数据结构,否则暴力遍历所有没有在数据结构上更新的修改。
时间复杂度与重构复杂度相关。若重构复杂度为线性,则时间复杂度为线性根号。
2.3 例题
I. COCI2012/2013 Contest#2 F 市场监控
题意简述:单点加入 / 删除直线,每个位置最多有一条直线。查询一段区间的直线在 \(x=T\) 时的最值。保证 \(T\) 递增。位置数 \(n\leq 10^5\),操作数 \(m\leq 3\times 10^5\),\(T\leq 10^6\)。
带删除和区间查询让李超树没有了用武之地,因此像这种严格强于某个经典问题的题目,如果想不到 \(\mathrm{polylog}\) 的做法,可以考虑分块。
每个位置最多一条直线保证了重构直线凸包的复杂度,而 \(T\) 递增则保证了查询块内直线时不需要二分。块内插入直线时直接用 multiset 对直线斜率排序,修改(重构)一次的时间复杂度为 \(\mathcal{O}(\sqrt n+\log n)\)。总时间复杂度 \(\mathcal{O}(m \sqrt n)\)。
*II. 2019 五校联考镇海 小 ω 的树
见计算几何初步凸包部分例题。
*3. 莫队
莫队是优雅的暴力。
3.1 算法介绍
莫队算法用于 离线 处理多组询问。它支持修改,这将在带修莫队部分介绍。
莫队的核心思想十分简单:维护两个指针 \(l, r\) 表示当前区间,并按照一定顺序处理询问,使得时间复杂度最小。如果按询问顺序伸缩区间,每次指针移动的距离可能达到 \(\mathcal{O}(n)\),无法接受。
为了让指针移动距离尽可能少,我们可以将询问以某个端点为关键字排序。尽管该端点的移动距离均摊线性,但另一个端点的移动距离无法保证。
注意到两个端点的移动距离分别是 \(\mathcal{O}(n)\) 和 \(\mathcal{O}(nq)\)。为了平衡复杂度,自然想到使用 根号平衡 的思想。如果将左右端点的移动距离都控制在 \(n\sqrt n\) 以内,我们就得到了一个时间复杂度为 \(\mathcal{O}(n\sqrt n\times k)\) 的优秀算法,其中 \(k\) 是指针移动的复杂度。
考虑如何分块。设块大小为 \(B\)。我们将所有询问离线下来,并按照左端点 块编号 为第一关键字,右端点为第二关键字排序,按照该顺序处理所有询问的时间复杂度为 \(\mathcal{O}\left(\dfrac{nq}B + qB\right)\)。这是因为每个块内右端点的总移动距离不超过 \(n\),每次询问左端点的移动距离不超过 \(B\)。假设 \(n, q\) 同级,取 \(B = \sqrt n\),时间复杂度为 \(\mathcal{O}(n\sqrt n \times k)\)。
- 奇偶排序优化:如果左端点在奇块,右端点从小到大排序,否则从大到小排序。这保证了在左端点跨块时,右端点不需要再从最右边扫到最左边。其类似波浪的左右扫动可以有效减小常数。
3.2 莫队二次离线
对于大部分题目,伸缩区间的时间复杂度为 \(\mathcal{O}(1)\)。如果无法在线性时间内伸缩区间,我们可使用莫队二次离线优化时间复杂度。
考虑为什么无法快速伸缩区间:新增的位置对整个区间的贡献和区间内每个数都有关,需要用数据结构维护,如 区间逆序对数。通常这样的信息是 可减 的。因此,恰当地差分可以将贡献的形式写得更加整洁,从而通过再次离线求解。
接下来,我们以 P4887 为例,深入探究莫队二离的整个过程。
设 \(f([l, r], i)\) 表示区间 \([l, r]\) 对位置 \(i\) 的贡献,即 \(\sum\limits_{j\in [l, r]}[\mathrm{popcount}(a_j \oplus a_i) = k]\)。右端点 右移 \(r - 1\to r\) 时,新增的贡献为 \(f([l, r - 1], r)\)。若信息满足 可减性,\(f([l, r - 1], r)\) 可写作 \(f([1, r - 1], r) - f([1, l - 1], r)\)。因此,假设右端点 向右 移动 \(r\to r'\ (r < r')\),产生的贡献即
对于前半部分,对每个位置 \(i\) 预处理 \(f([1, i - 1], i)\),右端点移动时可即时计算。该部分时间复杂度 \(\mathcal{O}(n\binom {14} k + n\sqrt n)\)。
对于后半部分,可以看做一段 前缀 对一个 区间 的贡献。设 \(g([l_1, r_1], [l_2, r_2])\) 表示 \(\sum\limits_{i \in [l_2, r_2]} f([l_1, r_1], i)\) 即 \(\sum\limits_{j\in [l_1, r_1]} \sum\limits_{i\in [l_2, r_2]}[\mathrm{popcount}(a_j\oplus a_i) = k]\),则可写为 \(g([1, l - 1], [r + 1, r'])\)。
因为前缀数量为 \(n\),而 \([r + 1, r']\) 区间总长级别为 \(\mathcal{O}(n\sqrt n)\),故考虑将这些询问 二次离线(莫队本身就是一次离线)下来。具体地,我们在 \(l - 1\) 处插入区间 \([r + 1, r']\),然后用类似 扫描线 的方法,按序添加每个位置的贡献。添加位置 \(i\) 时我们就得到了前缀 \([1, i]\) 的信息,此时回答所有被挂在该位置上的询问区间 \([r + 1, r']\) 即可。
- 换句话说,\(g([1, l - 1], [r + 1, r'])\) 不仅等于 \(\sum\limits_{i\in [r + 1, r’]} f([1, l - 1], i)\),也可以看做 \(\sum\limits_{j\in [1, l - 1]} f(j, [r + 1, r'])\)。注意,对于本题,\(f(i, j)\) 和 \(f(j, i)\) 相等,即若 \(\mathrm{popcount}(a_j\oplus a_i) = k \iff \mathrm{popcount}(a_i\oplus a_j) = k\),因此 \(i, j\) 之间无序。但对于部分题目,如区间 逆序对 数量,\(f([1, l - 1], i)\) 相当于求 \(a_1\sim a_{l - 1}\) 当中有多少个数 大于 \(a_i\),而 \(f(i, [r + 1, r'])\) 相当于求 \(a_{r + 1} \sim a_{r'}\) 当中有多少个数 小于 \(a_i\)。此时要分清对应的大小关系。
不难发现上述做法需要进行 \(\mathcal{O}(n)\) 次修改,\(\mathcal{O}(n\sqrt n)\) 次查询,这是因为 \(\sum r' - r\approx n\sqrt n\)。 修改的本质是将某个数加入可重集 \(S\),而查询的本质是对于某个 \(a_i\),求数集 \(S\) 内有多少个数与 \(a_i\) 的异或和的 \(\mathrm{popcount} = k\)。
修改和查询的数量不在同一级别,考虑平衡复杂度:设 \(f_v\) 表示数集 \(S\) 内有多少个数与 \(v\) 的异或和在二进制下 \(1\) 的个数为 \(k\)。加入 \(a_i\) 时,对于所有 \(p\) 满足 \(\mathrm{popcount}(p\oplus a_i) = k\),令 \(f_p \gets f_p + 1\)。查询 \(a_i\) 的答案即 \(f_{a_i}\)。通俗地说,\(f\) 本质上就是一个桶。这一部分时间复杂度为 \(\mathcal{O}(n\binom {14} k + n\sqrt n)\)。
注意,以上仅是 \(r\to r'\ (r < r')\) 的处理方法。剩下三种情况(左 / 右端点左移和左端点右移)如法炮制即可,请读者自行推导。综上,我们在 \(\mathcal{O}(n\binom {14} k + n\sqrt n)\) 的时间复杂度内解决了问题。代码见例题 I.
从上述例题当中,我们可以感受到莫队二次离线的威力:在运用莫队的根号平衡思想基础上,利用 信息可减性 作差,并 转换贡献的相对关系, 离线 将计算转化为一段 前缀 对总长为 \(n\sqrt n\) 的区间的贡献,从而做到 线性 次修改。再利用修改次数为线性的性质,更进一步地通过各种数据结构(通常是分块)平衡 修改 和 查询 的复杂度,完美解决问题。它是一个非常高妙的算法。
-
推导贡献的过程中,注意 \(g\) 的符号。如当左端点 \(l\) 右移至 \(l'\) 时,贡献为 \(-\sum\limits_{i \in [l, l' - 1]} f([i + 1, r], i)\),拆成 \(\left(\sum\limits_{i \in [l, l' - 1]} f([1, i], i)\right) - g([1, r], [l, l' - 1])\)。
-
注意特殊考虑一个数对它本身的贡献:当 \(k = 0\) 时,\(f([1, i], i)\) 可由已经预处理的 \(f([1, i - 1], i)\) 加上 \(1\) 得到。
3.3 回滚莫队
维护不具有 可减性 的信息时(如区间最大值),尽管我们可以快速扩展区间,但无法高效地 缩短 区间。考虑如何不删除地回答询问。这看似是不可能的,但不要忘记,即使是不可减的信息,也可以快速 撤销。
将所有询问按照左端点所在的块排序,然后依次处理所有左端点落在某个块内的询问 \((l_i,r_i)\),需要确保这些询问按照 \(r_i\) 从小到大 有序。类似地,我们仍然维护两个指针,不同的是对于每个块,初始左指针 \(l\) 指向 下一个块的开头,右指针 \(r=l-1\) 表示当前区间为空。对于右端点,由于其有序,我们可以直接扩展。右端点扩展完毕后,再扩展左端点直到目标位置并记录答案。
接下来我们撤回扩展左端点时对信息的修改,这个可以在 \(\sqrt n\) 的时间内完成,因为左端点移动长度不超过 \(\sqrt n\)。撤回后再处理下一个询问,这就是回滚莫队。
- 回滚莫队无法处理左右端点在同一块的情况。此时直接暴力即可。
- 每做完一个块,都需要将所有信息清空,并初始化左端点。
若信息可 快速删除,但无法高效扩展,也可以使用回滚莫队。对于每个块,初始左端点指向当前块开头,右端点指向 \(n\)。容易使用类似的算法解决问题。此时我们可以处理左右端点在同一块的情况,不需要特判。
3.4 带修莫队
众所周知,莫队是一个静态离线算法,所以不支持修改。但我们可以在其基础上进行加工。注意到单次修改很容易处理,所以尝试再加入一维 修改指针。原来只有两个参数 \(l, r\),现在加入了一个修改参数 \(k\),只需将 \(k\) 类似 \(l,r\) 一样移动即可。
排序首先按照 \(l\) 所在块从小到大排,然后按照 \(r\) 所在块从小到大排,若 \(l, r\) 所在块相同则按 \(k\) 排序。考虑块大小 \(B\) 应该开多少。视询问和修改总次数与 \(n\) 同级。
- \(l\) 的移动次数:\(l\) 跨块时总移动次数为 \(n\),每两个询问之间 \(l\) 的移动距离为 \(B\),故总移动次数为 \(nB\)。
- \(r\) 的移动次数:\(l\) 跨块时总移动次数为 \(nB\),\(l\) 不跨块时总移动次数为 \(\dfrac {n ^ 2} B\)。
- \(k\) 的移动次数:对于 \(l, r\),一共有 \(\min\left(n, \dfrac{n ^ 2}{B ^ 2}\right)\) 个有效的块,每个块移动 \(n\) 次,故移动 \(k\) 的总复杂度为 \(\dfrac{n^3}{B^2}\)(若 \(\min\) 取到 \(n\) 则复杂度变成 \(n ^ 2\),显然不优,因此令 \(B > \sqrt n\))。
综上,我们要确定一个 \(B\) 使得 \(\max\left(\dfrac{n^3}{B^2},nB,\dfrac{n^2}{B}\right)\) 最小。当 \(nB = \dfrac{n ^ 3}{B ^ 2}\) 时,\(B = \sqrt[3]{n ^ 2}\),上式取到最小值。
3.5 例题
- 莫队:IV, V, VI, VIII, IX, X.
- 莫队二离:I, II, III, XI.
- 回滚莫队:VII, XIII.
- 带修莫队:XII.
I. P4887 【模板】莫队二次离线(第十四分块(前体))
莫队二离的例题。
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const int N = 1e5 + 5;
int n, k, q, cnt, a[N], id[N], buc[N];
ll f[N], ans[N];
struct query {
int l, r, blk, id;
bool operator < (const query &v) const {
return blk != v.blk ? blk < v.blk : blk & 1 ? r < v.r : r > v.r;
}
} c[N];
struct dat {
int l, r, id;
};
vector <dat> qu[N];
int main() {
cin >> n >> q >> k;
if(k > 14) {
for(int i = 1; i <= q; i++) puts("0");
exit(0);
}
for(int i = 0; i < 1 << 14; i++)
if(__builtin_popcount(i) == k)
id[cnt++] = i;
for(int i = 1; i <= n; i++) {
scanf("%d", &a[i]), f[i] = f[i - 1];
for(int j = 0; j < cnt; j++) f[i] += buc[a[i] ^ id[j]];
buc[a[i]]++;
}
for(int i = 1; i <= q; i++) {
scanf("%d %d", &c[i].l, &c[i].r);
c[i].id = i, c[i].blk = c[i].l / 333;
}
sort(c + 1, c + q + 1);
for(int i = 1, l = 1, r = 0; i <= q; i++) {
if(r < c[i].r) {
if(l > 1) qu[l - 1].push_back({r + 1, c[i].r, -c[i].id});
ans[c[i].id] += f[c[i].r] - f[r], r = c[i].r;
}
if(l > c[i].l) {
qu[r].push_back({c[i].l, l - 1, c[i].id});
ans[c[i].id] -= f[l - 1] - f[c[i].l - 1] + (k ? 0 : l - c[i].l), l = c[i].l;
}
if(r > c[i].r) {
if(l > 1) qu[l - 1].push_back({c[i].r + 1, r, c[i].id});
ans[c[i].id] -= f[r] - f[c[i].r], r = c[i].r;
}
if(l < c[i].l) {
qu[r].push_back({l, c[i].l - 1, -c[i].id});
ans[c[i].id] += f[c[i].l - 1] - f[l - 1] + (k ? 0 : c[i].l - l), l = c[i].l;
}
}
memset(buc, 0, sizeof(buc));
for(int i = 1; i <= n; i++) {
for(int j = 0; j < cnt; j++) buc[a[i] ^ id[j]]++;
for(dat it : qu[i]) {
int id = abs(it.id), sgn = id / it.id;
for(int p = it.l; p <= it.r; p++) ans[id] += buc[a[p]] * sgn;
}
}
for(int i = 2; i <= n; i++) ans[c[i].id] += ans[c[i - 1].id];
for(int i = 1; i <= n; i++) printf("%lld\n", ans[i]);
return 0;
}
II. P5047 Yuno loves sqrt technology II
区间逆序对数也是莫队二离的模板题。设 \(F([l_1, r_1], [l_2, r_2]) = \sum\limits_{i \in [l_1, r_1]}\sum\limits_{j\in [l_2, r_2]} [a_i > a_j]\),\(G([l_1, r_1], [l_2, r_2]) = \sum\limits_{i \in [l_1, r_1]}\sum\limits_{j\in [l_2, r_2]} [a_i < a_j]\),\(f_i = F([1, i - 1], i)\),\(g_i = G([1, i - 1], i)\)。不难发现 \(f_i\) 也等于 \(F([1, i], i)\),\(g_i\) 也等于 \(G([1, i], i)\)。
- 右端点向右扩展:\(\sum\limits_{i\in [r + 1, r']} F([l, i - 1], i)\),差分得 \(\sum\limits_{i\in [r + 1, r']} f_i - F([1, l - 1], i)\)。第二项可写为 \(-F([1, l - 1], [r + 1,r'])\)。
- 左端点向左扩展: \(\sum\limits_{i\in [l', l - 1]} G([i + 1, r], i)\),差分得 \(\sum\limits_{i\in [l', l - 1]} G([1, r], i) - g_i\)。 第一项写为 \(G([1, r], [l', l - 1])\)。
- 右端点向左扩展:相对于右端点向右扩展的情况,贡献符号相反。即 \(F([1, l - 1], [r’, r - 1]) - \sum\limits_{i\in [r', r - 1]} f_i\)。
- 左端点向右扩展:同理,相对于左端点向左扩展的情况,贡献符号相反。
综上,在莫队二离的过程中,我们需要维护两个 值域分块 数组,一个为了查询 \(F\),加入 \(a_i\) 时将 \(1\sim a_i - 1\) 加 \(1\)(查询 \(S\) 内有多少个数比它大,那么在加入一个数的时候将比它小的数的值 \(+1\)),另一个为了查询 \(G\),加入 \(a_i\) 时将 \(a_i+1\sim n\) 加 \(1\)。不要忘记离散化。时间复杂度 \(\mathcal{O}(n\sqrt n)\)。
*III. P5501 [LnOI2019]来者不拒,去者不追
考虑右端点右移时需要求出哪些信息:\(a_{r + 1}\) 的排名以及 \([l, r]\) 比 \(a_{r + 1}\) 大的数的和,使用莫队二离 + 值域分块即可。时间复杂度 \((n + m) (\sqrt n + \sqrt V)\)。
IV. P4462 [CQOI2018]异或序列
一道莫队裸题。对 \(a\) 求异或前缀和,根据 \(a \oplus b = k \iff a \oplus k = b\),记录每个数的出现次数即可。时间复杂度 \(\mathcal{O}(n\sqrt n)\)。
V. CF617E XOR and Favorite Number
双倍经验。
VI. P4396 [AHOI2013]作业
莫队 + 值域分块,时间复杂度线性根号。也可以三维偏序做到线性对数平方。
VII. P5906 【模板】回滚莫队
回滚莫队的模板题。
#include <bits/stdc++.h>
using namespace std;
const int N = 2e5 + 5;
const int B = 450;
struct dat {
int mx, mn, val;
} stc[B + 5];
struct query {
int l, r, blk, id;
bool operator < (const query &v) const {
return blk != v.blk ? blk < v.blk : r < v.r;
}
} c[N];
int n, m, q, top, a[N], d[N];
int pre[N], suf[N], ans[N];
int add(int x, int tp) {
if(tp) stc[++top] = {suf[a[x]], pre[a[x]], a[x]};
if(x > suf[a[x]]) suf[a[x]] = x;
if(x < pre[a[x]]) pre[a[x]] = x;
return max(x - pre[a[x]], suf[a[x]] - x);
}
void Rollback() {
while(top) {
pre[stc[top].val] = stc[top].mn;
suf[stc[top].val] = stc[top].mx, top--;
}
}
int main() {
cin >> n, memset(pre, 0x3f, sizeof(pre));
for(int i = 1; i <= n; i++) scanf("%d", &a[i]), d[i] = a[i];
cin >> m, sort(d + 1, d + n + 1);
for(int i = 1; i <= n; i++) a[i] = lower_bound(d + 1, d + n + 1, a[i]) - d;
for(int i = 1; i <= m; i++) {
int l, r; scanf("%d %d", &l, &r);
if(l / B == r / B) {
for(int j = l; j <= r; j++) ans[i] = max(ans[i], add(j, 1));
Rollback();
} else c[++q] = {l, r, l / B, i};
}
sort(c + 1, c + q + 1);
for(int i = 1, l, r, cur; i <= q; i++) {
if(i == 1 || c[i].blk != c[i - 1].blk) {
l = min(n + 1, c[i].blk * B + B), r = l - 1, cur = 0;
memset(pre, 0x3f, sizeof(pre));
memset(suf, 0, sizeof(suf));
}
while(r < c[i].r) cur = max(cur, add(++r, 0));
int tmp = cur;
while(l > c[i].l) cur = max(cur, add(--l, 1));
ans[c[i].id] = cur, cur = tmp, Rollback();
l = min(n + 1, c[i].blk * B + B);
}
for(int i = 1; i <= m; i++) printf("%d\n", ans[i]);
return 0;
}
VIII. P3709 大爷的字符串题
题意翻译过来就是求区间众数,使用莫队,维护每个数的出现次数以及出现次数为 \(i\) 的数有多少个即可。时间复杂度 \(\mathcal{O}(n\sqrt n)\)。
IX. P3730 曼哈顿交易
仍然是莫队裸题,求出现次数第 \(k\) 大可以分块,将根号平衡的思想贯彻到底。时间复杂度 \(\mathcal{O}(n\sqrt n)\)。
*X. P7708「Wdsr-2.7」八云蓝自动机 Ⅰ
一道莫队好题。本题最有价值的地方在于对单点修改的转化,以及对交换两个数的处理:维护原来每个位置现在的位置,以及现在每个位置原来的位置。
注意到单点修改并不方便实现,将其转化为交换两个数。对于 \(a_x\gets k\),我们新建 \(a_c = k\),并将其看做 \(a_x\) 与 \(a_c\) 交换。这一步非常巧妙,因为它消灭了单点修改这一类麻烦的操作。
多次询问一段区间的操作结果,一般使用莫队实现。因此,考虑区间在伸缩时需要维护哪些信息。为了支持在操作序列最前面加入交换两个数的操作,可以想到维护:
- 序列 \(a\) 在操作后的形态。
- \(pos_i\) 表示 原 位置 \(i\) 的 现 位置。
- \(rev_i\) 表示 现 位置 \(i\) 的 原 位置。
- \(add_i\) 表示 现 位置 \(i\) 上的数被查询了多少次。
- 当右端点右移 \(r - 1\to r\) 时:
- 若第 \(r\) 个操作是交换 \(x, y\),则交换 \(a_x\) 和 \(a_y\),\(rev_x\) 和 \(rev_y\),\(pos_{rev_x}\) 和 \(pos_{rev_y}\)。
- 若第 \(r\) 个操作是查询 \(x\),则令 \(ans\gets ans + a_x\),\(add_x\gets add_x + 1\)。
- 当左端点左移 \(l+1\to l\) 时:
- 若第 \(l\) 个操作是交换 \(x,y\),注意我们相当于 交换原位置 上的两个数,因此对答案有影响。先交换 \(a_{pos_x}\) 和 \(a_{pos_y}\),\(rev_{pos_x}\) 和 \(rev_{pos_y}\),\(pos_x\) 和 \(pos_y\)。由于交换原位置上的两个数并不影响现位置被查询的数的次数(因为我们已经交换了 \(a_{pos_x}\) 和 \(a_{pos_y}\),或者说 \(a\) 和 \(add\) 当中只要交换一个即可描述本次操作,多交换反而会让答案错误),因此答案加上 交换后 的 \((a_{pos_x} - a_{pos_y})(add_{pos_x} - add_{pos_y})\),相当于把每个数原来的贡献减掉,加上新的贡献。
- 若第 \(l\) 个操作是查询 \(x\),则令 \(ans\gets ans + a_{pos_x}\),\(add_{pos_x} \gets add_{pos_x} + 1\)。
右端点左移和左端点右移的情况分别与上述两种情况相似,仅是符号相反,此处不再赘述。时间复杂度 \(\mathcal{O}(n\sqrt n)\)。
XI. P3604 美好的每一天
一些字符重排后能形成回文串当且仅当出现奇数次的字符不多于 \(1\) 个,所以我们只需要知道一段区间所有字符出现次数的奇偶性,不难想到状压 + 开桶记录。时间复杂度 \(\mathcal{O}(|\Sigma|n\sqrt n)\),空间复杂度 \(2^{|\Sigma|}\),需要使用 unsigned short 压缩空间。
本题可以莫队二离去掉时间复杂度当中的的字符集因子。试着实现了一下,直接跑到了最优解(2022.2.15)。
XII. P1903 [国家集训队]数颜色 / 维护队列
带修莫队模板题。时间复杂度 \(\sqrt[3]{n^5}\)。
#include <bits/stdc++.h>
using namespace std;
const int N = 1e6 + 5;
int n, m, B, a[N], cnt, q, pos[N], col[N], buc[N], cur, ans[N];
void add(int x) {cur += !buc[x], buc[x]++;}
void del(int x) {buc[x]--, cur -= !buc[x];}
struct query {
int l, r, k, blkl, blkr, id;
bool operator < (const query &v) const {
if(blkl != v.blkl) return blkl < v.blkl;
if(blkr != v.blkr) return blkl & 1 ? blkr > v.blkr : blkr < v.blkr;
return blkr & 1 ? k > v.k : k < v.k;
}
} c[N];
int main() {
cin >> n >> m, B = pow(n, 0.67);
for(int i = 1; i <= n; i++) cin >> a[i];
for(int i = 1; i <= m; i++) {
char s; int l, r;
cin >> s >> l >> r;
if(s == 'Q') c[++q] = {l, r, cnt, l / B, r / B, q};
else pos[++cnt] = l, col[cnt] = r;
}
sort(c + 1, c + q + 1);
for(int i = 1, l = 1, r = 0, k = 0; i <= n; i++) {
while(r < c[i].r) add(a[++r]);
while(l > c[i].l) add(a[--l]);
while(r > c[i].r) del(a[r--]);
while(l < c[i].l) del(a[l++]);
while(k < c[i].k) {
k++;
if(l <= pos[k] && pos[k] <= r) del(a[pos[k]]);
swap(col[k], a[pos[k]]);
if(l <= pos[k] && pos[k] <= r) add(a[pos[k]]);
}
while(k > c[i].k) {
if(l <= pos[k] && pos[k] <= r) del(a[pos[k]]);
swap(col[k], a[pos[k]]);
if(l <= pos[k] && pos[k] <= r) add(a[pos[k]]);
k--;
}
ans[c[i].id] = cur;
}
for(int i = 1; i <= q; i++) cout << ans[i] << "\n";
return 0;
}
XIII. P8078 [WC2022] 秃子酋长
考虑用链表维护每个值的前驱和后继在原序列中的位置。由于在链表中插入一个数时,至少也需要 \(\log\) 的时间查询前驱后继,所以使用不需要修改的回滚莫队即可。时间复杂度 \(\mathcal{O}(n\sqrt n)\)。