数据结构学习笔记——分块

简介

分块算法,是一种“优化过的暴力”。其实,分块并不是一种特定的数据结构,而是一种思想

分块算法维护的东西与线段树类似,但由于分块算法的灵活性,使得其能够维护线段树不能维护的某些不具有加法性质的信息,因为其不需要信息合并操作。

分块的渐进时间复杂度为 \(O(n\sqrt n)\) ,相较于线段树和树状数组而言复杂度较高,且容易被卡常。不过,分块思想在 提高/省选 级别的信息学竞赛中仍然是非常重要的。

下面将会讲解分块的主要思想和实现方法。

思想

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

以上是OI-wiki对于分块思想的简要阐述。通俗地讲,分块思想就是将信息分为若干个块,对于一个区间的操作,如果其中有完整的大块,则进行整体的修改标记;如果其中有不完整的小块,则直接进行暴力修改。其实还是很容易理解的 \(qwq\)

让我们通过一道经典题目来看分块的具体实现:
洛谷P3368
本题需要在序列上实现两个操作:区间加法,单点查询。

我们首先需要将序列分为若干个块,假设序列长为 \(N\) ,每块长为 \(S\) ,则 \([1,S]\) 为第一块, \([S+1,2S]\) 为第二块……以此类推。

需要注意的是,由于序列长度 \(N\) 不一定是 \(S\) 的倍数,因此最后一块可能是不完整的。

定义 \(id\) 数组,用于存储每个元素所在的块的编号,则 \(id_i\) 的计算公式为 \((i-1)/s+1\)

P.S.本文中所有的除法及根号运算均向下取整。

接下来,我们来看修改操作:(令 \(l\) 为要修改的区间的左端点, \(r\) 为右端点,数组 \(a\) 表示原始序列,数组 \(tag\) 表示整块的修改标记,下标为块的编号)

  • \(id_l=id_r\) ,即区间完全在一个块中,直接进行暴力更新即可。
  • \(id_l \neq id_r\) ,即区间跨越多个块,则有三部分需要维护:
    • \(l\) 开头的不完整块
    • 中间的几个完整块
    • \(r\) 结尾的不完整块

对于两个不完整块,直接进行暴力更新即可;对于中间的完整块,我们可以直接利用 \(tag\) 数组,进行单个块的整体标记即可。这就是分块算法核心优化的地方。

我们画一张图来加深理解:

上图中,在 \([l,r]\) 区间中很明显看到③④两块是完整的块,而以 \(l\) 开始的块②的一部分,以及以 \(r\) 结尾的块⑤的一部分是不完整的块。

假设要增加的数为 \(k\) ,对于不完整的块,我们直接 \(a_i+=k\) 暴力修改即可;

对于完整的块③④,我们修改它们的整体标记:\(tag_3+=k\)\(tag_4+=k\) 即可,查询的时候,\(tag\) 标记的信息可以直接使用。

看单点查询,若需要查询的点为 \(i\) ,那么答案直接就是 \(a_i+tag_{id_i}\)

这是因为,\(tag\) 标记的信息适用于整个块内的所有元素,所以可以直接加上,而前面的暴力修改是直接修改 \(a\) 数组的信息,由此得出答案。

事实上,区间查询和区间修改是一个原理,若 \(l\)\(r\) 在一个块中,直接进行暴力求和;若不在一个块中,则将答案分为三部分,分别利用暴力和 \(tag\) 标记求和即可,大家可自行理解。

最后,分块算法的时间复杂度是 \(O(n(\frac{n}{s}+s))\) ,由基本不等式可知,当 \(\frac{n}{s}=s\) ,即 \(s=\sqrt n\) 时,算法的时间复杂度最优,为 \(O(n\sqrt n)\)

时间复杂度证明:

已知基本不等式:
\(a,b>0\) 则有 \(\sqrt{ab} \leq \frac{a+b}{2}\)
该式等号成立 当且仅当 \(a=b\)
\(a=\frac{n}{s},b=s\)
\(\frac{n}{s}=s\)\(s=\sqrt n\)
\(2\sqrt n=\frac{n}{s}+s\)
所以算法最优时间复杂度为 \(O(n(\frac{n}{s}+s))=O(n\cdot 2\sqrt n)=O(n\sqrt n)\)

实现

分块算法实现区间修改,单点查询,区间查询的代码如下:马蜂良好

#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N = (int)5e5 + 5;

int n, m, s, op, l, r, k;
int a[N], sum[N], id[N], tag[N]; //a[N]:序列, sum[N]:块内元素总和, id[N]:元素所在块的编号, tag[N]:整体标记
inline void add(int l, int r, int k){ //区间修改
    int ll = id[l], rr = id[r]; //l 和 r 所在的块编号
    if (ll == rr){ //若在一个块中
        for (int i = l; i <= r; i++){ //暴力修改
            a[i] += k;
            sum[ll] += k;
        }
    }else{
        for (int i = l; id[i] == ll; i++){ //以 l 开头的不完整的块
            a[i] += k;
            sum[ll] += k;
        }
        for (int i = ll + 1; i < rr; i++){ //完整的块
            tag[i] += k; //整体加标记
            sum[i] += k * s;
        }
        for (int i = r; id[i] == rr; i--){ //以 r 结尾的不完整的块
            a[i] += k;
            sum[rr] += k;
        }
    }
}
inline int query(int l, int r){ //区间查询(和区间修改差不多)
    int ll = id[l], rr = id[r], ret = 0;
    if (ll == rr){
        for (int i = l; i <= r; i++){ //暴力累加
            ret += a[i] + tag[ll];
        }
        return ret;
    }else{
        for (int i = l; id[i] == ll; i++){ //以 l 开头的不完整的块
            ret += a[i] + tag[ll];
        }
        for (int i = ll + 1; i < rr; i++){ //完整的块
            ret += tag[i]; //直接累加标记
        }
        for (int i = r; id[i] == rr; i--){ //以 r 结尾的不完整的块
            ret += a[i] + tag[rr];
        }
        return ret;
    }
}
signed main(){
    cin >> n >> m;
    s = sqrt(n); //最优块长为 √n
    for (int i = 1; i <= n; i++){
        cin >> a[i];
        id[i] = (i - 1) / s + 1;
        sum[id[i]] += a[i]; //初始化
    }

    for (int i = 1; i <= m; i++){
        cin >> op;
        switch (op){
            case 1:
                cin >> l >> r >> k;
                add(l, r, k);
                break;
            case 2:
                cin >> l;
                cout << a[l] + tag[l / s] << endl; //单点查询答案
                break;
            case 3:
                cin >> l >> r;
                cout << query(l, r) << endl;
                break;
        }
    }

    //最优时间复杂度 O(n√n)

    return 0;
}

例题

CF816B Karen and Coffee
洛谷P4168 [Violet] 蒲公英
洛谷P2801 教主的魔法

例题的解法后面再补(

总结

在网上看到的分块算法八字真言:大块维护,小块朴素

posted @ 2024-08-07 09:57  RTX7070Ti  阅读(26)  评论(0编辑  收藏  举报