数据结构学习笔记——分块
简介
分块算法,是一种“优化过的暴力”。其实,分块并不是一种特定的数据结构,而是一种思想。
分块算法维护的东西与线段树类似,但由于分块算法的灵活性,使得其能够维护线段树不能维护的某些不具有加法性质的信息,因为其不需要信息合并操作。
分块的渐进时间复杂度为 \(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 教主的魔法
例题的解法后面再补(
总结
在网上看到的分块算法八字真言:大块维护,小块朴素!