分块:优雅的暴力

分块是一种思想,通过对原数据的适当划分,并在划分后的每一个块上预处理部分信息,从而较一般的暴力算法取得更优的时间复杂度。

分块的应用面十分广泛,包括但不限于数组、树形结构等。

1. 块状数组

块状数组是分块思想最简单的应用。

它将一个数组分成若干块,然后对数组进行区间操作。对于每一个区间操作,区间中的整块可以整体处理,左右的零散块就暴力单独处理。

一般来说,我们会将一个长度为 \(n\) 的数组划分成 \(\sqrt{n}\) 块,每块长 \(\sqrt{n}\)。这样,在进行区间操作的时候,整块的处理是 \(\mathcal O(\sqrt{n})\) 的,零散块的单独处理也是 \(\mathcal O(\sqrt{n})\) 的,那么一次区间操作的复杂度即为 \(\mathcal O(\sqrt{n})\)

显然,块状数组的效率远远不及线段树、树状数组等 \(\log\) 级别的数据结构,但是块状数组的灵活性却比这些数据结构都要强,可应用的场景也比这些数据结构多得多。

下面给出一种基础的建立块状数组的代码。这份代码对应下面的例题 1。

len = (int)sqrt(n);
int last = 0;
for(int i = 1; i <= n; i++)
{
    cin >> a[i];
    id[i] = (i - 1) / len + 1;
    b[id[i]].sum += a[i];
    if(last != id[i])
    {
        b[id[i - 1]].r = i - 1;
        b[id[i]].l = i;
        last = id[i];
    }
}
b[id[n]].r = n;

例题 1 LibreOJ 6280. 数列分块入门 4

给定一个长度为 \(n\) 的序列 \(\{a_i\}\),需要执行 \(n\) 次操作。操作分为两种:

  1. 区间 \([l, r]\) 之间的所有数加上 \(x\)

  2. \(\sum\limits_{i = l}^{r}{a_i} \bmod (x + 1)\)

\(1 \le n \le 5 \times 10^4\)

考虑对序列进行分块,块长为 \(\lfloor \sqrt{n} \rfloor\)。块内维护整块的元素和 \(b_i\) 以及区间修改的标记 \(tag_i\)。这里 \(tag_i\) 记录的是每个块的整体赋值情况,这样就不用对每个块直接修改。

对于修改操作,我们分为两种情况:

  1. \(l\)\(r\) 在同一个块内,直接暴力修改,单次复杂度为 \(\mathcal O(\sqrt{n})\)

  2. \(l\)\(r\) 不在同一个块内,则区间可分成三个部分:

    • \(l\) 开头的不完整块;

    • 中间的若干完整块;

    • \(r\) 结尾的不完整块。

    对于以 \(l\) 开头和 以 \(r\) 结尾的不完整块,直接暴力修改,复杂度为 \(\mathcal O(\sqrt{n})\)

    对于中间的若干完整块,则修改这些完整块的整体标记 \(tag_i\),复杂度为 \(\mathcal O(\sqrt{n})\),则单次修改的复杂度为 \(\mathcal O(\sqrt{n})\)

对于查询操作,我们同样分为两种情况:

  1. \(l\)\(r\) 在同一个块内,直接暴力求和,并加上修改标记,单次复杂度为 \(\mathcal O(\sqrt{n})\)

  2. \(l\)\(r\) 不在同一个块内,则区间可分成三个部分:

    • \(l\) 开头的不完整块;

    • 中间的若干完整块;

    • \(r\) 结尾的不完整块。

    对于以 \(l\) 开头和 以 \(r\) 结尾的不完整块,直接暴力求和,并加上修改标记 \(tag_i\),复杂度为 \(\mathcal O(\sqrt{n})\)

    对于中间的若干完整块,则将这些完整块记录的区间和 \(b_i\) 求和,加上修改标记 \(tag_i\),复杂度为 \(\mathcal O(\sqrt{n})\),则单次查询的复杂度为 \(\mathcal O(\sqrt{n})\)

综上,我们即可在 \(\mathcal O(n \sqrt{n})\) 的复杂度内解决此问题。

Code

#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N = 5e4 + 5;
int n, len, a[N], id[N];
struct node
{
    int l, r, sum, tag;
}b[N];
void update(int l, int r, int w)
{
    int x = id[l], y = id[r];
    if(x == y)
    {
        for(int i = l; i <= r; i++)
            a[i] += w;
        b[x].sum += (r - l + 1) * w;
        return;
    }
    for(int i = l; i <= b[x].r; i++)
        a[i] += w;
    b[x].sum += (b[x].r - l + 1) * w;
    for(int i = b[y].l; i <= r; i++)
        a[i] += w;
    b[y].sum += (r - b[y].l + 1) * w;
    for(int i = x + 1; i < y; i++)
        b[i].tag += w;
    return;
}
int query(int l, int r, int c)
{
    int x = id[l], y = id[r], ans = 0;
    if(x == y)
    {
        for(int i = l; i <= r; i++)
            ans = (ans + a[i] + b[x].tag) % (c + 1);
        return ans;
    }
    for(int i = l; i <= b[x].r; i++)
        ans = (ans + a[i] + b[x].tag) % (c + 1);
    for(int i = b[y].l; i <= r; i++)
        ans = (ans + a[i] + b[y].tag) % (c + 1);
    for(int i = x + 1; i < y; i++)
        ans = (ans + b[i].sum + b[i].tag * (b[i].r - b[i].l + 1)) % (c + 1);
    return ans;
}
signed main()
{
    cin >> n;
    len = (int)sqrt(n);
    int last = 0;
    for(int i = 1; i <= n; i++)
    {
        cin >> a[i];
        id[i] = (i - 1) / len + 1;
        b[id[i]].sum += a[i];
        if(last != id[i])
        {
            b[id[i - 1]].r = i - 1;
            b[id[i]].l = i;
            last = id[i];
        }
    }
    b[id[n]].r = n;
    for(int i = 1; i <= n; i++)
    {
        int opt, l, r, w;
        cin >> opt >> l >> r >> w;
        if(opt == 0)
            update(l, r, w);
        else
            cout << query(l, r, w) << "\n";
    }
    return 0;
}

例题 2 洛谷P2801 教主的魔法

给定一个长度为 \(n\) 的序列 \(\{a_i\}\),需要执行 \(q\) 次操作。操作分为两种:

  1. 区间 \([l, r]\) 之间的所有数加上 \(x\)

  2. 查询区间 \([l, r]\) 内大于等于 \(x\) 的数的个数。

\(n \le 10^6\)\(q \le 3000\)

这道题比上面那道题稍难一些。

对于每一个块,我们可以将块内的元素降序排序,单独存至另一个数组 \(t\) 中。

对于查询,分为两种情况:

  1. \(l\)\(r\) 在同一个块中,直接暴力统计,复杂度为 \(\mathcal O(\sqrt{n})\)

  2. \(l\)\(r\) 不在同一个块中。

    • 对于散块,直接暴力查询,复杂度为 \(\mathcal O(\sqrt{n})\)

    • 对于整块,可以对 \(t\) 进行二分,找到 \(x\) 在块中从大到小的排名,即为块中大于等于 \(x\) 的数的个数,累加起来即可。复杂度为 \(\mathcal O(\sqrt{n} \log{\sqrt{n}})\)

    那么对于查询操作,总复杂度为 \(\mathcal O(\sqrt{n} \log{\sqrt{n}})\)

对于修改,同样分为两种情况

  1. \(l\)\(r\) 在同一个块中,直接暴力修改,然后对 \(t\) 进行重构。复杂度为 \(\mathcal O(\sqrt{n} \log{\sqrt{n}})\)

  2. \(l\)\(r\) 不在同一个块中。

    • 对于散块,暴力修改,并对 \(t\) 进行重构,复杂度为 \(\mathcal O(\sqrt{n} \log{\sqrt{n}})\)

    • 对于整块,直接修改标记 \(tag_i\) 即可。复杂度为 \(\mathcal O(\sqrt{n})\)

    那么修改的复杂度即为 \(\mathcal O(\sqrt{n} \log{\sqrt{n}})\)

综上,我们即可在 \(\mathcal O(q\sqrt{n} \log{\sqrt{n}})\) 的复杂度内解决该问题。

Code

#include<bits/stdc++.h>
using namespace std;
const int N = 1e6 + 5;
int n, m, len, a[N], t[N], id[N];
struct node
{
    int l, r, tag;
}b[N];
void Sort(int x)
{
    for(int i = b[x].l; i <= b[x].r; i++)
        t[i] = a[i];
    sort(t + b[x].l, t + 1 + b[x].r);
    return;
}
void update(int l, int r, int w)
{
    int x = id[l], y = id[r];
    if(x == y)
    {
        for(int i = l; i <= r; i++)
            a[i] += w;
        Sort(x);
        return;
    }
    for(int i = l; i <= b[x].r; i++)
        a[i] += w;
    for(int i = b[y].l; i <= r; i++)
        a[i] += w;
    for(int i = x + 1; i < y; i++)
        b[i].tag += w;
    Sort(x), Sort(y);
    return;
}
int query(int l, int r, int c)
{
    int ans = 0, x = id[l], y = id[r];
    if(x == y)
    {
        for(int i = l; i <= r; i++)
            if(a[i] + b[x].tag >= c)
                ans++;
        return ans;
    }
    for(int i = l; i <= b[x].r; i++)
        if(a[i] + b[x].tag >= c)
            ans++;
    for(int i = b[y].l; i <= r; i++)
        if(a[i] + b[y].tag >= c)
            ans++;
    for(int i = x + 1; i < y; i++)
        ans += b[i].r - (lower_bound(t + b[i].l, t + 1 + b[i].r, c - b[i].tag) - t - 1);
    return ans;
}
int main()
{
    cin >> n >> m;
    len = (int)sqrt(n);
    int last = 0;
    for(int i = 1; i <= n; i++)
    {
        cin >> a[i];
        id[i] = (i - 1) / len + 1;
        if(last != id[i])
        {
            b[id[i - 1]].r = i - 1;
            b[id[i]].l = i;
            last = id[i];
        }
    }
    b[id[n]].r = n;
    for(int i = len; i <= n; i += len)
        Sort(id[i]);
    for(int i = 1; i <= m; i++)
    {
        char c;
        int l, r, w;
        cin >> c >> l >> r >> w;
        if(c == 'M')
            update(l, r, w);
        else
            cout << query(l, r, w) << "\n";
    }
    return 0;
}

以上便是块状数组最简单的应用了。感兴趣的同学可以根据下面的题单进一步加强。

  1. 洛谷P3870 [TJOI2009] 开关

  2. 洛谷P4109 [HEOI2015] 定价

  3. 洛谷P4168 [Violet] 蒲公英

  4. 洛谷P3203 [HNOI2010] 弹飞绵羊

  5. 洛谷P4117 [Ynoi2018] 五彩斑斓的世界

  6. 洛谷P5692 [MtOI2019] 手牵手走向明天

2. 块状链表

由于块状链表使用的并不多,所以笔者在这里只稍微提一下。

块状链表就是将一个数组分块成若干数组,然后用一个链表存起来,每个结点指向一块。

一般来说,这里的分块块长也是 \(\sqrt{n}\)

这里放出 OI Wiki 上的一张图。

块状链表支持:分裂、插入、查找。复杂度均为 \(\mathcal O(\sqrt{n})\)

3. 莫队算法

莫队算法是由莫涛提出的算法。莫涛提出莫队算法之前,莫队算法已经在 Codeforces 的高手圈里小范围流传,但是莫涛是第一个对莫队算法进行详细归纳总结的人。莫涛提出莫队算法时,只分析了普通莫队算法,但是经过 OIer 和 ACMer 的集体智慧改造,莫队有了多种扩展版本。

莫队算法可以解决一类离线区间询问问题,适用性极为广泛。同时将其加以扩展,便能轻松处理树上路径询问以及支持修改操作。

posted @ 2024-07-25 12:52  Luckies  阅读(51)  评论(0)    收藏  举报