根号算法
CHANGE LOG
- 2022.2.14:重构莫队部分。
- 2022.2.15:重构根号分治部分。
1. 根号分治
1.1 算法简介
根号分治本质上是一种 按规模大小分类讨论 的思想而非分治算法。对于规模为
更一般的,如果有若干算法
通常,问题规模较小时,我们通过预处理所有问题的答案做到均摊
- Trick:根号分治进入较大的分支调不出来时,试试将块大小设为
。
1.2 例题
I. CF797E Array Queries
注意到若
*II. CF1039D You Are Given a Tree
注意到若
树形 DP 求链上经过的点的个数为
综上,总时间复杂度
卡常技巧:预处理每个节点的父亲,然后将所有节点按照 dfs 序排序。这样树形 DP 就不需要 dfs 了。
IV. CF1580C Train Maintenance
一个非常显然的根号分治题目。若
时间复杂度
VI. P3591 [POI2015]ODW
比较套路的根号分治题目。由于当步长
预处理的信息只需要
综上,时间复杂度
由于数据原因,实际表现中取
2. 分块
2.1 算法简介
分块的本质是 暴力重构 和 懒标记 的结合。
对于序列分块,我们会将序列分成
分块的主要作用有两个,一是 平衡复杂度,二是维护一些
- 对于区间加法,单点查询,一般的思路是使用树状数组维护。此时修改和查询的复杂度均为
。分块的优势在于它可以让修改和查询当中的任意一个变为 ,另一个变为 。当询问或修改的次数非常多时,如莫队二次离线算法中的 次区间修改, 次单点查询,就可以使用分块平衡复杂度,做到非常优秀的 。 - 当遇到无法 快速合并 和 快速删除 的信息时,对于 单点 修改,区间查询,传统的维护半群的线段树就失效了。但分块仍然可以做到优秀的复杂度:单点修改直接暴力重构,区间查询对整块和散点都容易直接查询。具体见例 I.
对于第二点,笔者在和机房同学(ycx)讨论后获得了更深刻的理解。普通的线段树也可以做到维护无法快速合并和删除的信息。
考虑将信息的合并 限制在一定层数内,这样我们必须将询问 下放 至该层数以下才能获得信息。具体地,设立阈值
分析复杂度:视合并复杂度为区间长度,查询某区间信息的时间复杂度为
若将分块看成仅有
2.2 时间轴分块:根号重构
对时间轴分块的思想可运用于多次修改和询问,需要用数据结构维护,但数据结构不支持修改的情况。若修改相对 独立,即我们能分开考虑用数据维护好的信息以及没有被更新的修改快速得到询问的答案,那么可设阈值
时间复杂度与重构复杂度相关。若重构复杂度为线性,则时间复杂度为线性根号。
2.3 例题
I. COCI2012/2013 Contest#2 F 市场监控
题意简述:单点加入 / 删除直线,每个位置最多有一条直线。查询一段区间的直线在
时的最值。保证 递增。位置数 ,操作数 , 。
带删除和区间查询让李超树没有了用武之地,因此像这种严格强于某个经典问题的题目,如果想不到
每个位置最多一条直线保证了重构直线凸包的复杂度,而
*II. 2019 五校联考镇海 小 ω 的树
见计算几何初步凸包部分例题。
*3. 莫队
莫队是优雅的暴力。
3.1 算法介绍
莫队算法用于 离线 处理多组询问。它支持修改,这将在带修莫队部分介绍。
莫队的核心思想十分简单:维护两个指针
为了让指针移动距离尽可能少,我们可以将询问以某个端点为关键字排序。尽管该端点的移动距离均摊线性,但另一个端点的移动距离无法保证。
注意到两个端点的移动距离分别是
考虑如何分块。设块大小为
- 奇偶排序优化:如果左端点在奇块,右端点从小到大排序,否则从大到小排序。这保证了在左端点跨块时,右端点不需要再从最右边扫到最左边。其类似波浪的左右扫动可以有效减小常数。
3.2 莫队二次离线
对于大部分题目,伸缩区间的时间复杂度为
考虑为什么无法快速伸缩区间:新增的位置对整个区间的贡献和区间内每个数都有关,需要用数据结构维护,如 区间逆序对数。通常这样的信息是 可减 的。因此,恰当地差分可以将贡献的形式写得更加整洁,从而通过再次离线求解。
接下来,我们以 P4887 为例,深入探究莫队二离的整个过程。
设
对于前半部分,对每个位置
对于后半部分,可以看做一段 前缀 对一个 区间 的贡献。设
因为前缀数量为
- 换句话说,
不仅等于 ,也可以看做 。注意,对于本题, 和 相等,即若 ,因此 之间无序。但对于部分题目,如区间 逆序对 数量, 相当于求 当中有多少个数 大于 ,而 相当于求 当中有多少个数 小于 。此时要分清对应的大小关系。
不难发现上述做法需要进行
修改和查询的数量不在同一级别,考虑平衡复杂度:设
注意,以上仅是
从上述例题当中,我们可以感受到莫队二次离线的威力:在运用莫队的根号平衡思想基础上,利用 信息可减性 作差,并 转换贡献的相对关系, 离线 将计算转化为一段 前缀 对总长为
-
推导贡献的过程中,注意
的符号。如当左端点 右移至 时,贡献为 ,拆成 。 -
注意特殊考虑一个数对它本身的贡献:当
时, 可由已经预处理的 加上 得到。
3.3 回滚莫队
维护不具有 可减性 的信息时(如区间最大值),尽管我们可以快速扩展区间,但无法高效地 缩短 区间。考虑如何不删除地回答询问。这看似是不可能的,但不要忘记,即使是不可减的信息,也可以快速 撤销。
将所有询问按照左端点所在的块排序,然后依次处理所有左端点落在某个块内的询问
接下来我们撤回扩展左端点时对信息的修改,这个可以在
- 回滚莫队无法处理左右端点在同一块的情况。此时直接暴力即可。
- 每做完一个块,都需要将所有信息清空,并初始化左端点。
若信息可 快速删除,但无法高效扩展,也可以使用回滚莫队。对于每个块,初始左端点指向当前块开头,右端点指向
3.4 带修莫队
众所周知,莫队是一个静态离线算法,所以不支持修改。但我们可以在其基础上进行加工。注意到单次修改很容易处理,所以尝试再加入一维 修改指针。原来只有两个参数
排序首先按照
的移动次数: 跨块时总移动次数为 ,每两个询问之间 的移动距离为 ,故总移动次数为 。 的移动次数: 跨块时总移动次数为 , 不跨块时总移动次数为 。 的移动次数:对于 ,一共有 个有效的块,每个块移动 次,故移动 的总复杂度为 (若 取到 则复杂度变成 ,显然不优,因此令 )。
综上,我们要确定一个
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
区间逆序对数也是莫队二离的模板题。设
- 右端点向右扩展:
,差分得 。第二项可写为 。 - 左端点向左扩展:
,差分得 。 第一项写为 。 - 右端点向左扩展:相对于右端点向右扩展的情况,贡献符号相反。即
。 - 左端点向右扩展:同理,相对于左端点向左扩展的情况,贡献符号相反。
综上,在莫队二离的过程中,我们需要维护两个 值域分块 数组,一个为了查询
*III. P5501 [LnOI2019]来者不拒,去者不追
考虑右端点右移时需要求出哪些信息:
IV. P4462 [CQOI2018]异或序列
一道莫队裸题。对
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 大爷的字符串题
题意翻译过来就是求区间众数,使用莫队,维护每个数的出现次数以及出现次数为
IX. P3730 曼哈顿交易
仍然是莫队裸题,求出现次数第
*X. P7708「Wdsr-2.7」八云蓝自动机 Ⅰ
一道莫队好题。本题最有价值的地方在于对单点修改的转化,以及对交换两个数的处理:维护原来每个位置现在的位置,以及现在每个位置原来的位置。
注意到单点修改并不方便实现,将其转化为交换两个数。对于
多次询问一段区间的操作结果,一般使用莫队实现。因此,考虑区间在伸缩时需要维护哪些信息。为了支持在操作序列最前面加入交换两个数的操作,可以想到维护:
- 序列
在操作后的形态。 表示 原 位置 的 现 位置。 表示 现 位置 的 原 位置。 表示 现 位置 上的数被查询了多少次。- 当右端点右移
时:- 若第
个操作是交换 ,则交换 和 , 和 , 和 。 - 若第
个操作是查询 ,则令 , 。
- 若第
- 当左端点左移
时:- 若第
个操作是交换 ,注意我们相当于 交换原位置 上的两个数,因此对答案有影响。先交换 和 , 和 , 和 。由于交换原位置上的两个数并不影响现位置被查询的数的次数(因为我们已经交换了 和 ,或者说 和 当中只要交换一个即可描述本次操作,多交换反而会让答案错误),因此答案加上 交换后 的 ,相当于把每个数原来的贡献减掉,加上新的贡献。 - 若第
个操作是查询 ,则令 , 。
- 若第
右端点左移和左端点右移的情况分别与上述两种情况相似,仅是符号相反,此处不再赘述。时间复杂度
XI. P3604 美好的每一天
一些字符重排后能形成回文串当且仅当出现奇数次的字符不多于
本题可以莫队二离去掉时间复杂度当中的的字符集因子。试着实现了一下,直接跑到了最优解(2022.2.15)。
XII. P1903 [国家集训队]数颜色 / 维护队列
带修莫队模板题。时间复杂度
#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] 秃子酋长
考虑用链表维护每个值的前驱和后继在原序列中的位置。由于在链表中插入一个数时,至少也需要
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
· .NET周刊【3月第1期 2025-03-02】