题解:P13977 数列分块入门 2·彻底怒了
FlowerAccepted
·
2025-10-04 17:41:22
·
题解
0x-1 前言
保姆级 分块板子教程。
这题我调了整整两天!(wssb?)
为了帮助广大 OIer 理解分块,为了防止我忘记自己的成果,为了给所有人避坑,也为了咕值, 本蒟蒻于刚通过本题之际,撰写此文。
希望大家能在本题解中寻得一些不一样的体验。
0x00 分块简介
分块是一种神奇的算法,号称「优雅的暴力」。思路是将数组分成若干个大块遵循「整块标记,散块暴力」的常规(具体接下来会解释)。
0x01 本题思路
0x00 基本建块
我们来看看一个长度为 11 的 \tt1-based 数组是如何分块的。我们首先设定块长 B=\lfloor\sqrt{n}\rfloor ,于是有 \lceil\frac{n}{B}\rceil 个块。其中前 B 块是满的,有 B 个元素;剩下最多一个块(如果 n 是完全平方数则没有这个块)有 n - B^2 个元素。为什么取根 n 会在接下来解释。
有图有真相,看图更好理解:
[^1]
:::align{center}
图一
简单的建块例子「可鼠标悬停」
:::
与别的一些实现不同,我不想花费常数开销去存储一堆 L 、R 等等数组。那么现在考虑怎么计算某个下标所在块的编号、起终下标 与某个块的起终点 。
首先看某个块 bx 的起终点 blkl(bx) 和 blkr(bx) 。
观察图一可得块 bx 的终点 blkr(bx) 是 \min(bx\times B, n) 。若它是完整的则为 bx\times B ,否则按 bx\times B 算得的终点会超过 n ,这显然是不对的,所以取到 \min 。
又发现它的起点 是「上一个块的终点(或零)」的下一项 ,所以有 blkl(bx) = (bx - 1)\times B + 1 。
然后看某个下标 x 所在块的编号 id(x) 。看图,注意到『「x 所在块的起点」的上一项下标』除以 B 刚好得到了「x 所在块的编号」减一的值,所以有 id(x) = \lfloor\frac{x - 1}{B}\rfloor + 1 。
接下来的某个下标所在块的起终下标 cacl(x) 和 cacr(x) [^2] 就好办了,直接套上 blkl(id(x)) 和 blkr(id(x)) 即可。
这一部分代码:
int n, B; // 变量函数名如正文
inl_force int id(int x) { // 非递归函数强制内联优化效率
return (x - 1) / B + 1;
}
inl_force int blkl(int bx) {
return (bx - 1) * B + 1;
}
inl_force int blkr(int bx) {
return min(bx * B, n);
}
inl_force int cacl(int x) {
return blkl(id(x));
}
inl_force int cacr(int x) {
return blkr(id(x));
}
0x01 进阶建块
我们已经完成了基本的建块,考虑如何完成题目的操作。
首先看到操作 \tt0 :区间加。
那么我们想想,分了块,怎样才能优化加法?
肯定是把区间中的整块整体加,这里我们引入一个长度为块的数量的新数组:懒标记数组 (lazy_{1\sim \lceil\frac{n}{B}\rceil} )。学过 \tt SgT [^3] 的童鞋们都知道这个东西。
转眼看操作 \tt1 :查询 [l, r] 中小于 c^2 的数的数量。
既然是查询不等式,看来得二分,那么我们还需要维护序列每块内 的有序版本。唔。
所以建块的时候同时要初始化排序后的数组 ,如下图(红色是 lazy ,紫色是原数组,蓝色是排序后数组):
:::align{center}
图二
进阶的建块例子
:::
这部分代码如下:
inl_force void rebuild(int bx) { // 重构(排序)块,因为后面还要用到所以定义函数
for (int i = blkl(bx); i <= blkr(bx); i ++) { // 重新拷贝
sorted[i] = a[i];
}
sort(sorted + blkl(bx), sorted + blkr(bx) + 1); // 加一是因为排序为左闭右开
}
inl_force void imp(int _n, Tp _a[]) { // 从数组导入块
for (int i = 1; i <= _n; i ++) {
a[i] = _a[i];
}
n = _n;
B = sqrt(n); // 块长,记得 import cmath
for (int i = 1; i <= id(n); i ++) { // 枚举的是块
rebuild(i);
}
}
现在,每一个数
那么具体怎么实现两个操作呢?
0x02 区间加
若 \mathrm{opt} = 0 ,表示将位于 [l, r] 的之间的数字都加 c 。
首先考虑 l ,r 在一个块内的情况(如 4,5 )。此时暴力枚举 [l, r] 并加 c (如 3 )即可。结束后要重建整块 。
:::align{center}
图三
:::
```cpp line-numbers
if (id(l) == id(r)) {
for (int i = l; i <= r; i ++) { // 暴力枚举
a[i] += c;
}
rebuild(id(l)); // 重构块
return;
}
```
然后考虑 $l$,$r$ 不在一个块内的情况(如 $2,10$)。
首先看 $l$ 和 $r$ 所在的块。这两个块**不一定全部被查询区间覆盖**,所以暴力枚举 $[l, cac\bold r(id(l))]$ 与 $[cac\bold l(id(r)), r]$ 并加 $c$(如 $4$)即可。结束后要**重建整块**。
对于其间的整块,怎么用 $lazy$ 呢?
其实很简单,直接枚举编号为 $(id(l), id(r))$(即**不包含两端块**,因为它们是前文提到的散块已经计算过)的块,将它们的 $lazy$ 加上 $c$ 即可。**由于整块总体加数不会影响元素的相对大小,所以不重排**。
看不懂?上图!

:::align{center}
图四
$l$,$r$ 在多个块内的区间加
:::
这一部分代码:
```cpp
for (int i = l; i <= cacr(l); i ++) { // 左边散块
a[i] += c;
}
rebuild(id(l));
for (int i = id(l) + 1; i < id(r); i ++) { // 中间整块
lzy[i] += c;
}
for (int i = cacl(r); i <= r; i ++) { // 右端散块
a[i] += c;
}
rebuild(id(r));
```
## 0x03 区间查询
上文提到二分查询,那么具体怎么做捏?
同样**要特判 $l$,$r$ 在一个块的情况**,不然会多算。
我们先看散块。散块由于未对应到排序后数组,所以只能**暴力求出**。注意判断时需要使用**真实值**。
然后是整块,使用 `std::lower_bound` 即可。由于 `lower_bound` 算的是**第一个大于等于给出数的位置**,所以要在减去数组地址的基础上减去一。正好 $blkl$ 是一个块的第一个位置(不是第零个),所以直接减去 $blkl(i)$ 即可。具体看下面的代码。
先放个例子(被 `std::lower_bound` 选中区域标记为了橄榄色,例子里面散块刚好没有懒惰的加所以就是真值):

:::align{center}
图五
区间查询例子
:::
```cpp line-numbers
inl_force int query(int l, int r, Tp c) {
Tp x = c * c;
int ans = 0;
if (id(l) == id(r)) { // 一定要特判
for (int i = l; i <= r; i ++) {
if (real(i) < x) { // 计算真值
ans ++;
}
}
return ans;
}
for (int i = l; i <= cacr(l); i ++) { // 左侧散块
if (real(i) < x) { // 真值!
ans ++;
}
}
for (int i = id(l) + 1; i < id(r); i ++) { // 中间整块二分
ans += lower_bound(sorted + blkl(i), // 块的左端
sorted + blkr(i) + 1, // 块的右端,注意左闭右开所以要加一
x - lzy[i]) - // 注意算 lazy,但是 原值 + lazy < x <=> 原值 < x - lazy
(sorted + blkl(i)); // 减去数组地址后,减去块的起始下标
}
for (int i = cacl(r); i <= r; i ++) { // 右侧散块
if (real(i) < x) { // 真值!
ans ++;
}
}
return ans;
}
```
# 0x02 复杂度分析
两个操作都需要散块暴力,$\mathcal{O}(B\log B)$,也需要整块操作,假设在整个序列上操作,那么就是 $\mathcal{O}(\frac{n}{B})$,为了均衡两者,所以块长 $B$ 约取 $\sqrt{n}$。(当然你可以乱搞)
# 0x03 避坑指南
## 0x00 我遇到的
1. **五个索引公式(id, cacl, cacr, blkl, blkr)别写错了**
最致命的一集。不要指望什么 $x - x \mod B$ 之类的嘞。
除此之外,**$blkr$ 一定要和 $n$ 取 $\min$**!
2. **不特判左右边界在一个块内的情况**
会整整多算一个区间(别指望减去多算的!lazy 可以为负,但是区间查询怎么减去一个区间的结果?)
3. **重构的时候没有拷贝整个块**
想啥?希望一个元素在排序序列中有历史和最新版本共存吗?
## 0x01 极有可能遇到的
1. **查询不取真值(没加 lazy)**
另一个致命的,但是好调。
# 0x04 代码呈现
```cpp line-numbers
#include <algorithm>
#include <iostream>
#include <cstdio>
#include <cstring>
#include <cmath>
using namespace std;
typedef long long ll;
const int MAXN = 3e5 + 5, MAXB = 560;
ll a[MAXN];
#define inl_force __attribute__((always_inline)) inline // 强制编译器进行内联(勿喷,这个真的可以增加效率)
template <typename Tp> // 封装。需要开五个就老实了 AwA
struct BlkDeco {
Tp a[MAXN] = {}, lzy[MAXB] = {}, sorted[MAXN] = {};
int n, B;
inl_force int id(int x) {
return (x - 1) / B + 1;
}
inl_force int blkl(int bx) {
return (bx - 1) * B + 1;
}
inl_force int blkr(int bx) {
return min(bx * B, n);
}
inl_force int cacl(int x) {
return blkl(id(x));
}
inl_force int cacr(int x) {
return blkr(id(x));
}
inl_force ll real(int x) {
return lzy[id(x)] + a[x];
}
inl_force void rebuild(int bx) {
for (int i = blkl(bx); i <= blkr(bx); i ++) {
sorted[i] = a[i];
}
sort(sorted + blkl(bx), sorted + blkr(bx) + 1);
}
inl_force void imp(int _n, Tp _a[]) {
for (int i = 1; i <= _n; i ++) {
a[i] = _a[i];
}
n = _n;
B = sqrt(n);
for (int i = 1; i <= id(n); i ++) {
rebuild(i);
}
}
inl_force void add(int l, int r, Tp c) {
if (id(l) == id(r)) {
for (int i = l; i <= r; i ++) {
a[i] += c;
}
rebuild(id(l));
return;
}
for (int i = l; i <= cacr(l); i ++) {
a[i] += c;
}
rebuild(id(l));
for (int i = id(l) + 1; i < id(r); i ++) {
lzy[i] += c;
}
for (int i = cacl(r); i <= r; i ++) {
a[i] += c;
}
rebuild(id(r));
}
inl_force int query(int l, int r, Tp c) {
Tp x = c * c;
int ans = 0;
if (id(l) == id(r)) {
for (int i = l; i <= r; i ++) {
if (real(i) < x) {
ans ++;
}
}
return ans;
}
for (int i = l; i <= cacr(l); i ++) {
if (real(i) < x) {
ans ++;
}
}
for (int i = id(l) + 1; i < id(r); i ++) {
ans += lower_bound(sorted + blkl(i),
sorted + blkr(i) + 1,
x - lzy[i]) -
(sorted + blkl(i));
}
for (int i = cacl(r); i <= r; i ++) {
if (real(i) < x) {
ans ++;
}
}
return ans;
}
};
BlkDeco<ll> S;
int main() {
ios::sync_with_stdio(0);
cin.tie(0); // 实测关流速度比快读快写快??
int n;
cin >> n;
for (int i = 1; i <= n; i ++) {
cin >> a[i];
}
S.imp(n, a); // 导入
for (int _ = 1; _ <= n; _ ++) {
int op, l, r;
ll c;
cin >> op >> l >> r >> c;
if (!op) {
S.add(l, r, c);
} else {
cout << S.query(l, r, c) << '\n';
}
}
return 0;
}
```
# 0x~1 后记
成文、画图不易,您的每一个赞都是激励我做的更多更好的信任!
若有问题欢迎在评论区提出。
# 0x~0 注释
[^1]: 「[图片]」本来这张图是一个 [base64](https://baike.baidu.com/item/base64/8545775),但看着文章里多出的 10kb,我沉思着,还是放弃 base64 吧。
[^2]: 「$cacl(x)$ 和 $cacr(x)$」凑活吧,想不出什么名字,就拿来了 $\tt calcuate$ 一词。
[^3]: 线段树。