题解:P13977 数列分块入门 2·彻底怒了

· · 题解

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} 图一
简单的建块例子「可鼠标悬停」 :::

与别的一些实现不同,我不想花费常数开销去存储一堆 LR 等等数组。那么现在考虑怎么计算某个下标所在块的编号、起终下标某个块的起终点

首先看某个块 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

首先考虑 lr 在一个块内的情况(如 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$ 即可。**由于整块总体加数不会影响元素的相对大小,所以不重排**。 看不懂?上图! ![](https://pic1.imgdb.cn/item/68dfd009c5157e1a88547817.svg "l,r 在多个块内的区间加") :::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` 选中区域标记为了橄榄色,例子里面散块刚好没有懒惰的加所以就是真值): ![](https://pic1.imgdb.cn/item/68dfd156c5157e1a88547879.svg "区间查询例子") :::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]: 线段树。