根号算法学习笔记

最近整理并学习了一些根号算法,总共分为三个。

$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.$ 值域分块

 

posted @ 2023-05-16 21:39  Xy_top  阅读(50)  评论(0编辑  收藏  举报