根号算法学习笔记
最近整理并学习了一些根号算法,总共分为三个。
$1.$ 莫队
$2.$ 分块
$3.$ 根号分治
$1.$ 莫队
$1_.$ 序列莫队
这是一个离线算法(当然有在线的, 但是 CCF 不会卡吧)。
它可以在 $q\sqrt{n}+n\sqrt{n}$ 的时间内解决数列上多组询问的问题,问题大多给一个区间 $l$ $r$,让你输出 $[l,r]$ 的某个信息,比如区间和。
莫队的思想就是维护一个当前拥有信息的指针 $L$ $R$,通过不断地移动 $L$ $R$ 的位置添加或删除数字来得到 $l$ $r$ 的答案。
假如要求 $[2,5]$ 的区间和,现在手头上有 $[3,4]$ 的区间和,加上 $a[2]$ 和 $a[5]$ 的值就可以了。
现在归纳一下,假如有的信息是 $[L,R]$,要求 $[l,r]$,就可以通过 $|L-l| + |R-r|$ 次移动得到答案了。
所以我们要把所有的询问排一下序,使得 $\sum\limits_{i=2}^n |a[i].l - a[i - 1].l|+|a[i].r - a[i -1].r|$ 最小,这个有人证明过最小是 值域乘以 $\log$ 值域的,但怎么排列是一个 NP Hard,莫队算法的优势就是简洁好写,如果和 NP hard 弄在一起...
所以现在要做的就是找到一种容易求得的排列方式,代价又小。
如果仅仅按照右端点来排序,左端点一次在很前面,一次在很后面的交替,就卡死了。
如果按照左端点排序,右端点也可以移来移去还是不行。
我们需要找到一种兼顾左端点和右端点的排序方法,如果按照左端点所在的块排序,块相同的按照右端点排序就会很快。
设块长为 $b$,右端点在每个块内都会移动 $n$ 次,一共 $\frac{n}{b}$ 个块,所以 $\frac{n^2}{b}$。
左端点在每个块内每次都会移动 $b$,跨越块的移动 $b$ 次,每次 $\frac{n}{b}$,所以需要 $nb+n$
现在找到 $b$ 使得这两个加起来最小,显然 $b=\sqrt{n}$,这就是莫队算法。
$0xFF$ 浅谈在线莫队
在线莫队的思想其实很简单,先预处理出一些区间的答案,询问时找到离询问参数 $l$ $r$ 最近的预处理过的 $L$ $R$ 然后转移过去。
怎样预处理呢?我们每隔 $\sqrt{n}$ 个数就留下一个特征点,这样就有 $\sqrt{n}$ 个特征点,每两个特征点搭配组成特征区间,所以大约有 $\frac{n}{2}$ 个特征区间。
每次询问给定参数 $l$ $r$,对于部分莫队题目,加操作(即 l 向左,r 向右)能做,减操作无法处理,就要找到被 $l$ $r$ 包含的最大的特征区间,这样在线莫队代替回滚莫队?!!
对于加操作减操作都能处理的,那就找最近的特征区间啦!
莫队核心代码::
while (r < a[i].r) add (++ r); while (l > a[i].l) add (-- l); while (r > a[i].r) rem (r --); while (l < a[i].l) rem (l ++);
$0xFE$ 莫队总结
讲完这个,我们就来看几道例题。
能用莫队解决的题目,大概具有什么特点呢?
询问可支持离线(在线莫队不管),数据范围不卡 $n\sqrt{n}$(废话),没有修改操作(只是对于简单的莫队而言)
可以以较低的复杂度移动左右指针(为什么是较低,因为后面有一道题 $\log n$ 移动然后卡过了)。
例题
P2709 小 B 的询问
当然不像我们说的那么简单,维护区间和,这题要维护一个桶。
加入一个数 $x$ 之前其出现次数为 $b[x]$,那么加完后区间的答案 $ans$ 就变成 $ans - b[x]^2+(b[x] + 1)^2$。
当然可以化简成 $ans+2\times b[x]+1$,减去一个数同理啦,略。
#include <cmath> #include <iostream> #include <algorithm> using namespace std; int n, m, k; int l, r, sum = 1, block; int c[50010], ans[50010], cnt[50010]; struct Sec{int l, r, id;}a[50010]; void add (int x) { sum += 2 * cnt[c[x]] + 1; cnt[c[x]] ++; } void rem (int x) { sum -= 2 * cnt[c[x]] - 1; cnt[c[x]] --; } bool cmp (Sec s1, Sec s2){return s1.l / block < s2.l / block || s1.l / block == s2.l / block && s1.r < s2.r;} int main() { scanf("%d%d%d", &n, &m, &k); block = sqrt(n); for (int i = 1; i <= n; i ++) scanf("%d", &c[i]); for (int i = 1; i <= m; i ++) { scanf("%d%d", &a[i].l, &a[i].r); a[i].id = i; } sort (a + 1, a + m + 1, cmp); l = r = a[1].l; cnt[c[l]] ++; for (int i = 1; i <= m; i ++) { while (r < a[i].r) add (++ r); while (l > a[i].l) add (-- l); while (r > a[i].r) rem (r --); while (l < a[i].l) rem (l ++); ans[a[i].id] = sum; } for (int i = 1; i <= m; i ++) cout << ans[i] << "\n"; return 0; }
P4462 异或序列
属于是半思维半莫队啦!
首先你需要知道异或具有自反性,即 $a\oplus a=0$。
令 $o_i=a_1\oplus a_2\cdots \oplus a_i$,那么 $a_x\oplus a_{x+1}\cdots \oplus a_y$ 就等于 $o_y \oplus o_{x-1}$。
询问有多少子区间异或和为 $k$ 其实就相当于询问有多少个点 $x$ $y$,其中 $l-1 \leq x\leq r-1$,$l\leq y\leq r$,使得 $o_x \oplus o_y = k$。
$k$ 是已知的,假如我们要假如第 $R$ 个位置,那就需要统计在合法的范围内,$o_L \oplus o_R = k$,$L$ 的数量喽,可以先算出 $o_L$ 的值 $=k\oplus o_R$。
那么我们用一个桶维护,$b[x]$ 维护的就是 $o_L=x$ 的数量,减也同理就不说了。(注意这里我没有加减函数都写,而是用一个参数使一个函数替代这两个的作用了,码风更加清新)
#include <iostream> #include <algorithm> using namespace std; int n, m, k; int l = 1, r, res; int a[100005], b[100005], ans[100005]; struct Node {int l, r, id;}q[100005]; bool cmp (Node n1, Node n2) {return n1.l / 300 < n2.l / 300 || n1.l / 300 == n2.l / 300 && n1.r < n2.r;} void fun (int x, int s) { res += s * b[x ^ k]; b[x] += s; } int main () { cin >> n >> m >> k; for (int i = 1; i <= n; i ++) { cin >> a[i]; a[i] ^= a[i - 1]; } for (int i = 1; i <= m; i ++) { cin >> q[i].l >> q[i].r; -- q[i].l; q[i].id = i; } sort (q + 1, q + m + 1, cmp); for (int i = 1; i <= m; i ++) { while (r < q[i].r) fun (a[++ r], 1); while (l > q[i].l) fun (a[-- l], 1); while (l < q[i].l) fun (a[l ++], -1); while (r > q[i].r) fun (a[r --], -1); ans[q[i].id] = res; } for (int i = 1; i <= m; i ++) cout << ans[i] << "\n"; return 0; }
P3834 【模板】可持久化线段树
也是一道莫队的题目,我们可以采用莫队套线段树,每次加一个数就在权值线段树上加,查询就线段树上二分,时间复杂度为 $O(n\sqrt{n}\times \log n)$,加上奇偶排序(待会儿讲)就过了。
当然,如果你不会线段树,也不要紧,这仅仅是一个跳板,我们发现每加一个数时间复杂度 $O(\log n)$ 较高,这是我们不喜欢的,而查询时间复杂度为 $O(\log n)$,
这导致每次移动完左右区间,查询的复杂度加起来仅为 $O(n\log n)$,所以要找到一个加操作 $O(1)$,查询较低的来中和一下,那不就是值域分块吗?查询总时间复杂度 $O(n\sqrt{n})$,也不高于莫队,正正好好。
代码大家可以参考扶咕咕的题解。
其他题目
关于莫队的题目数不胜数,给个题单,https://www.luogu.com.cn/training/38213#problems。
带修莫队
带修莫队,顾名思义就是带修改的莫队,其实也很简单。
我们每个询问有三个参数了:$l$ $r$ $t$,$t$ 是前面经过了多少次的修改操作。
即我们把时间轴也当成一个参数来移动了,如果看不懂就看代码。
询问的排序大家可以先不看,自己口胡一下。
这里我要说一下区间的转移,首先还是 $l$ $r$ 移动到当前询问的 $L$ $R$,最后再移动 $t$ 到 $T$。
这样就可以做修改操作时不影响答案,举个例子:
$L = 2$ $R = 5$ $T = 1$。
$l = 3$ $r = 4$ $t = 0$。
其中 $1$ 号修改操作是 把第 $2$ 个改为某个新的颜色,这时如果你先做修改,发现这个修改不在 $l$ $r$ 内就忽略掉了,你的桶 $cnt$ 维护的是 $l$ $r$ 中数字出现了多少次。
大家大概明白了吧,但是这个我不好用准确的语言描述。
排序方法就是按照左端点所在的块排序,相同的按照右端点所在的块排序,再相同按照 $t$ 排序。
时间复杂度如何呢?我们设块长为 $b$,那么左端点每次会移动 $b$ 次,一共 $n$ 次,跨越区间移动 $n/b$ 次,每次 $b$,总共 $nb$。
右端点,它每次会移动 $b$ 次。跨越块的移动忽略不计。每次左端点换到下一个块了右端点移动 $n$ 次,一共切换 $n/b$ 次,时间复杂度为 $n^2/b+nb$。
$t$ 最好算了,左端点和右端点每在一个块内时,也就是 $(n/b)^2$ 种,每次右从前到后移动 $n$ 次,所以 $n^3/b^2$。
总时间复杂度为 $n^2/b+n^4/b^2+nb$,当 $b$ 取 $n^{\frac{2}{3}}$ 时有最优复杂度 $n^{\frac{3}{5}}$,足以过 $1e5$。
P1903 数颜色
写带修莫队时注意,看我发的警示贴
这题是一个最基本的带修莫队,主要是还要开一个结构体存修改操作,警示贴大家看过了我就不多说了,代码虽然很长,只要看带修莫队移动指针的部分就行了:
#include <cmath> #include <iostream> #include <algorithm> using namespace std; char op; int n, m, x, y; int su, sq, block;//su:sum of update sq:sum of query int l, r, tim, sum = 1;//time:time 但是不让用这个变量 int t[140000], ans[140000], cnt[1000010], color[140000]; struct Question{int l, r, t, id;}q[140000]; void add(int x) { if (! cnt[x]) ++ sum; ++ cnt[x]; } void rem(int x) { -- cnt[x]; if (! cnt[x]) -- sum; } struct Upd{int id, pre, val;}u[140000]; bool cmp (Question q1, Question q2) { if (q1.l / block < q2.l / block) return true; if (q1.l / block == q2.l / block && q1.r / block < q2.r / block) return true; if (q1.l / block == q2.l / block && q1.r / block == q2.r / block && q1.t < q2.t) return true; return false; } int main () { scanf("%d%d", &n, &m); block = pow (n, 2.0 / 3); for (int i = 1; i <= n; ++ i) { scanf("%d", &color[i]); t[i] = color[i]; } for (int i = 1; i <= m; ++ i) { cin >> op >> x >> y; if (op == 'Q') q[++ sq] = Question{x, y, su, sq}; else { u[++ su] = {x, t[x], y}; t[x] = y; } } sort (q + 1, q + sq + 1, cmp); l = r = q[1].l; cnt [color[l] ] ++; for (int i = 1; i <= sq; i ++) { while (r < q[i].r) add(color[++ r]); while (l > q[i].l) add(color[-- l]); while (r > q[i].r) rem(color[r --]); while (l < q[i].l) rem(color[l ++]); while (tim < q[i].t) { tim ++; if (l <= u[tim].id && r >= u[tim].id) { rem(u[tim].pre); add(u[tim].val); } color[u[tim].id] = u[tim].val; } while (tim > q[i].t) { if (l <= u[tim].id && r >= u[tim].id) { rem (u[tim].val); add (u[tim].pre); } color[u[tim].id] = u[tim].pre; tim --; } ans[q[i].id] = sum; } for (int i = 1; i <= sq; i ++) cout << ans[i] << "\n"; return 0; }
P2464 郁闷的小 J
和上一题一样,只不过要离散化,我这里用了一个垃圾回收站来处理,实在不行用 map 亦可。
代码略(好像是 ctj AC 的???)
莫队的优化
最简单的一个就是奇偶排序了,就从最基础的序列莫队看,当一个块处理完后,右端点最坏在最后一个,而在下一个块开始时,右端点又在第一个了。
每过一个块,右端点就会多移动 $n$ 次,所以左端点块相同,如果左端点所在块是奇数那么升序,否则降序,这样就能少一半常数,甚至能过 $1e6$!
大家有没有想过带修莫队,左端点总共只移动 $nb$ 次,而右端点要移动 $n^2/b$ 次,最多的 $t$,要移动 $n^3/b^2$ 次,有没有办法平衡一下呢?
其他莫队
其他的感觉大家看看题解啊能自己口胡出来的,那我就不说了,在线莫队都讲过了
$2.$ 分块
$1.$ 序列分块
遇到一道毒瘤题时,大家的选择是什么?就打数据结构认怂吗?不!我们还有对付毒瘤的专用武器,那就是分块。
分块常数小,甚至在数据 CPU 配置相同的情况下比单 log 的平衡树还要快(
我们来介绍一下分块算法吧,如果你学过线段树,那就会很轻松。
分块算法的思路也是打懒标记,一个长度为 $n$ 的区间,把它分成 $\sqrt{n}$ 个块,每块 $\sqrt{n}$ 个元素。
每个大块都会维护它的信息诸如和,懒标记等一些东西。
询问的时候,设左右端点为 $l$ $r$,找到它们中间的大块把答案统计掉,剩下的直接暴力。
可以发现,最多找到 $\sqrt{n}$ 个大块,合并这些信息的时间复杂度一般为 $\sqrt{n}$。
大块旁边的元素个数是小于 $\sqrt{n}$ 的,不然它又会成为一个大块了呀。
所以一次询问的时间复杂度为 $\sqrt{n}$。
例题
P3372 【模板】线段树 1
多么好的一道数据结构模板题啊,可是我就要用分块。
刚刚的内容已经讲的很清晰了,大家试着自己写下代码,我贴个 AC 代码:
#include <cmath> #include <iostream> using namespace std; long long n, m, sz; long long op, x, y, k; long long a[100010], sum[316], add[316]; int sec (int x) { if (x > sz * sz) return sz; return x / sz + (x % sz != 0); } int len (int x) { int l = sz * (x - 1) + 1, r = sz * x; if (x == sz) r = n; return r - l + 1; } int main () { scanf("%lld%lld", &n, &m); sz = pow(n, 0.5); for (int i = 1; i <= n; i ++) { scanf("%lld", &a[i]); sum[sec(i)] += a[i]; } while (m --) { scanf("%lld%lld%lld", &op, &x, &y); int secl = sec(x), secr = sec(y); if (op == 1) { scanf("%lld", &k); if (secl == secr) { for (int i = x; i <= y; i ++) a[i] += k; sum[secl] += (y - x + 1) * k; } else { for (int i = x; i <= sz * secl; i ++) a[i] += k; sum[secl] += k * (sz * secl - x + 1); secl ++; for (int i = sz * (secr - 1) + 1; i <= y; i ++) a[i] += k; sum[secr] += k * (y - sz * (secr - 1) ); secr --; for (int i = secl; i <= secr; i ++) add[i] += k; } } else { long long ans = 0; if (secl == secr) { for (int i = x; i <= y; i ++) ans += a[i]; printf("%lld\n", ans + add[secl] * (y - x + 1) ); } else { for (int i = x; i <= sz * secl; i ++) ans += a[i] + add[secl]; secl ++; for (int i = sz * (secr - 1) + 1; i <= y; i ++) ans += a[i] + add[secr]; secr --; for (int i = secl; i <= secr; i ++) ans += sum[i] + add[i] * len(i); printf("%lld\n", ans); } } } return 0; }
虽然在这个时候线段树的代码比分块短些,但是在有多个修改操作查询操作,(比如区间乘上 $k$)时,分块只需要在这上面加 $5$ 行左右就行了。
P5356 [Ynoi2017] 由乃打扑克
毒瘤题,不过掌握分块了还是很好写的,把块内排序然后二分就行了。
但排序了会打乱顺序,所以要一个备用数组。
先来考虑如何朴素的求出第 $k$ 小,我们可以二分,然后遍历块,块内二分比这个阀值大的有多少个,然后判断是否合法。
设块长为 $b$,单次的二分 check 时间复杂度为 $O(m\times ( (\frac{n}{b}) \log b + b) )$,$b$ 是外面零散的,$(\frac{n}{b})\log b$ 是二分大块的。
当 $b$ 取 $\sqrt{n}$ 时时间复杂度为$\sqrt{n}\times \log {\sqrt{n}}$,因为 $\log {\sqrt{n}}=\frac{1}{2} \log n$,所以没啥区别,时间复杂度 $\sqrt{n}\times\log n$。
当 $b$ 取 $\sqrt{n\log n }$ 时比较优秀,$n=100000$ 时单次 $1500$ 左右。(实际上,这玩意儿我不知道怎么算出来的)
$b$ 取 $\sqrt{n}$ 时,运行次数为 $2626$,所以取 $\sqrt{n\log n}$ 优秀。
二分还要乘上 $\log n$ 次。
再来看修改,显然:大块打标记,小块暴力搞。
时间复杂度为 $O(\frac{n}{b}+b\log b)$。(这时修改现用的改完直接 sort,备用的改完别 sort 就行了)
总复杂度 $O(n\times \log^2 n\times\sqrt{n} )$,省略了一大堆过程。
其他算法几乎过不掉,不过分块的常数小,过了(
$2.$ 值域分块