分块和莫队算法讲解
前言
在算法竞赛中,当我们遇到一些复杂的区间查询或修改问题时,高级数据结构(如线段树、树状数组)有时难以施展拳脚。这时,分块 和 莫队 这两种“优雅的暴力”算法就成了我们的得力助手。它们思想简洁,代码直观,能在可接受的复杂度内解决大量问题。
下面我将为你详细讲解这两种算法,并搭配洛谷上的实战题目来加深理解。
分块:有序整理的暴力
核心思想
分块,顾名思义,就是把一个大的序列划分成若干个大小相对均匀的“块”。它的核心思想可以概括为:“大块维护,小块朴素”。
- 预处理:将长度为 \(n\) 的序列分成大约 \(\sqrt{n}\) 块,每块大小约为 \(\sqrt{n}\)。预先维护好每个块的整体信息(如块内和、块内最值等)。
- 区间查询/修改:
- 对于被查询区间 完全覆盖 的“整块”,直接利用预处理好的块信息,时间复杂度 \(O(1)\)。
- 对于区间两头 未被完全覆盖 的“零散”部分,由于它们长度不超过块大小(\(\sqrt{n}\)),可以直接进行暴力遍历。
通过这种“抓大放小”的策略,分块可以将区间操作的单次复杂度从 \(O(n)\) 优化到 \(O(\sqrt{n})\)。
代码模板与注释
下面的代码模板实现了一个支持区间求和和单点修改的序列操作。
#include <bits/stdc++.h>
using namespace std;
const int N = 100010; // 最大数据规模
const int M = 350; // 最大块数,约等于 sqrt(N)
int n, m; // n: 序列长度, m: 操作次数
int a[N]; // 原始数组 (1-indexed)
int sum[M]; // 第 i 个块内所有元素的和
int L[M], R[M]; // 第 i 个块的左右边界
int belong[N]; // 第 i 个元素属于哪个块
int block_size, block_cnt; // 块大小,块的数量
// 初始化分块,计算归属和块信息
void build() {
block_size = (int)sqrt(n); // 块大小通常取根号n
block_cnt = n / block_size;
if (n % block_size) block_cnt++; // 处理最后可能不完整的块
for (int i = 1; i <= block_cnt; i++) {
L[i] = (i - 1) * block_size + 1;
R[i] = i * block_size;
}
R[block_cnt] = n; // 最后一个块的右边界修正为n
// 计算每个元素所属的块,并预处理块内和
for (int i = 1; i <= n; i++) {
belong[i] = (i - 1) / block_size + 1;
sum[belong[i]] += a[i];
}
}
// 单点修改: 给位置p的元素加上x
void modify(int p, int x) {
a[p] += x;
sum[belong[p]] += x; // 更新所在块的和
}
// 区间查询: 查询区间 [l, r] 的和
int query(int l, int r) {
int res = 0;
int bl = belong[l], br = belong[r];
if (bl == br) { // 情况1: l 和 r 在同一个块内,直接暴力
for (int i = l; i <= r; i++) res += a[i];
return res;
}
// 情况2: 不同块
// 2.1 暴力处理左边零散部分 (从l到l所在块的末尾)
for (int i = l; i <= R[bl]; i++) res += a[i];
// 2.2 暴力处理右边零散部分 (从r所在块的开头到r)
for (int i = L[br]; i <= r; i++) res += a[i];
// 2.3 快速累加中间完整块的和
for (int i = bl + 1; i <= br - 1; i++) res += sum[i];
return res;
}
莫队:离线处理的优雅暴力
核心思想
莫队算法是一种离线处理区间查询的算法,它巧妙地将询问重新排序,以减少指针移动的次数。它的核心思想是:“暴力移动,巧妙排序” 。
- 离线处理:将所有的查询先存储下来。
- 分块排序:将所有查询按照 左端点所在的块 为第一关键字,右端点的位置 为第二关键字进行排序。这种排序方式是莫队算法复杂度的保证 。
- 区间转移:维护两个指针
l和r,代表当前已求出答案的区间[l, r]。然后按排序后的顺序处理每个查询[ql, qr],通过不断移动l和r指针,并 在移动过程中即时更新答案,直到当前区间与目标区间重合。
通过这种排序,左指针的移动被限制在块内,右指针的移动是单调的,从而将总复杂度优化到 (O(n\sqrt{n})$ 。
代码模板与注释
下面的代码模板解决的是区间内不同数字个数的问题。
#include <bits/stdc++.h>
using namespace std;
const int N = 50010; // 序列长度
const int M = 200010; // 询问次数
const int V = 1000010; // 值域大小
int n, m, a[N];
int block_size, ans[M];
int cnt[V]; // 统计当前区间内每个数字出现的次数
int cur; // 当前区间内不同数字的个数
struct Query {
int l, r, id;
} q[M];
// 莫队排序的cmp函数
bool cmp(Query &a, Query &b) {
// 先按左端点所在块排序
if (a.l / block_size != b.l / block_size)
return a.l / block_size < b.l / block_size;
// 块内按右端点排序
return a.r < b.r;
}
// 当加入位置p的元素时,更新答案
void add(int p) {
cnt[a[p]]++;
if (cnt[a[p]] == 1) cur++; // 如果这个数第一次出现,不同数字个数+1
}
// 当删除位置p的元素时,更新答案
void del(int p) {
cnt[a[p]]--;
if (cnt[a[p]] == 0) cur--; // 如果这个数不再出现,不同数字个数-1
}
int main() {
scanf("%d", &n);
block_size = (int)sqrt(n);
for (int i = 1; i <= n; i++) scanf("%d", &a[i]);
scanf("%d", &m);
for (int i = 1; i <= m; i++) {
scanf("%d%d", &q[i].l, &q[i].r);
q[i].id = i;
}
sort(q + 1, q + m + 1, cmp); // 对询问进行排序
int l = 1, r = 0; // 初始化当前区间为空
for (int i = 1; i <= m; i++) {
int ql = q[i].l, qr = q[i].r;
// 以下四个while循环顺序固定,用于扩展或收缩区间
// 注意:先扩后缩是保证正确性的一种常见写法
while (l > ql) add(--l); // 左指针向左移动,加入新元素
while (r < qr) add(++r); // 右指针向右移动,加入新元素
while (l < ql) del(l++); // 左指针向右移动,删除旧元素
while (r > qr) del(r--); // 右指针向左移动,删除旧元素
ans[q[i].id] = cur;
}
for (int i = 1; i <= m; i++) printf("%d\n", ans[i]);
return 0;
}
实战演练:洛谷题目精讲
理论付诸实践,我们来看几道洛谷上的经典题目。
题目一:分块练习 - P4145 上帝造题的七分钟 2 / 花神游历各国
题目链接:P4145 上帝造题的七分钟 2 / 花神游历各国
题目大意:给定一个序列,支持两种操作:
0 l r:将区间[l, r]内的每个数开平方(向下取整)。1 l r:查询区间[l, r]的和。
思路分析:
这道题是分块思想的经典应用 。
- 关键性质:一个 \(10^{12}\) 内的数,最多开方 6 次就会变成 1。变成 1 后,再对其开方,值将不再变化。
- 分块策略:
- 维护每个块的和
sum[i]。 - 维护每个块的“是否全为1”的标记
flag[i]。
- 维护每个块的和
- 操作实现:
- 区间开方:
- 零散部分:直接暴力遍历每个元素,对其进行开方,并同步更新元素值和块的和。
- 完整块:如果该块的
flag[i]为真(全为1),则跳过,无需操作。否则,暴力遍历该块内所有元素进行开方。开方完成后,检查该块是否全变为 1,若是,则更新flag[i]。由于每个元素最多被开方 6 次,所以即使暴力开方,总复杂度也是可以接受的。
- 区间求和:
- 零散部分:暴力累加。
- 完整块:直接累加
sum[i]。
- 区间开方:
代码实现(关键部分):
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N = 100010;
ll a[N], sum[350];
int L[350], R[350], belong[N];
bool flag[350]; // flag[i] = true 表示第i个块内所有元素都是1
int n, m, block_size, block_cnt;
void build() {
block_size = sqrt(n);
block_cnt = (n + block_size - 1) / block_size;
for (int i = 1; i <= block_cnt; i++) {
L[i] = (i - 1) * block_size + 1;
R[i] = i * block_size;
}
R[block_cnt] = n;
for (int i = 1; i <= n; i++) {
belong[i] = (i - 1) / block_size + 1;
sum[belong[i]] += a[i];
}
}
// 处理第bid块内的开方操作
void sqrt_block(int bid) {
if (flag[bid]) return; // 全是1,不用处理
flag[bid] = true;
sum[bid] = 0;
for (int i = L[bid]; i <= R[bid]; i++) {
a[i] = sqrt(a[i]);
sum[bid] += a[i];
if (a[i] > 1) flag[bid] = false; // 只要有一个大于1,块就不全为1
}
}
void update(int l, int r) {
int bl = belong[l], br = belong[r];
if (bl == br) {
// 同一块内,暴力
for (int i = l; i <= r; i++) {
sum[bl] -= a[i];
a[i] = sqrt(a[i]);
sum[bl] += a[i];
}
return;
}
// 左边零散
for (int i = l; i <= R[bl]; i++) {
sum[bl] -= a[i];
a[i] = sqrt(a[i]);
sum[bl] += a[i];
}
// 右边零散
for (int i = L[br]; i <= r; i++) {
sum[br] -= a[i];
a[i] = sqrt(a[i]);
sum[br] += a[i];
}
// 中间完整块
for (int i = bl + 1; i <= br - 1; i++) {
sqrt_block(i);
}
}
ll query(int l, int r) {
int bl = belong[l], br = belong[r];
ll res = 0;
if (bl == br) {
for (int i = l; i <= r; i++) res += a[i];
return res;
}
for (int i = l; i <= R[bl]; i++) res += a[i];
for (int i = L[br]; i <= r; i++) res += a[i];
for (int i = bl + 1; i <= br - 1; i++) res += sum[i];
return res;
}
题目二:莫队模板 - P2709 小B的询问
题目链接:P2709 小B的询问
题目大意:给定一个长度为 \(n\) 的序列 \(a\) 和 \(m\) 个询问。每次询问给定区间 \([l, r]\),求 \(\sum_{c}cnt_c^2\),其中 \(cnt_c\) 表示数字 \(c\) 在区间内出现的次数。
思路分析:
这道题是莫队算法最经典的模板题 。
- 问题转化:我们需要在移动指针时,能够快速地更新答案 \(ans = \sum cnt_c^2\)。
- 更新策略:
- 当我们在区间中加入一个数字 \(x\) 时,它的出现次数从 \(cnt_x\) 变为 \(cnt_x + 1\)。平方和的变化量为 \((cnt_x+1)^2 - cnt_x^2 = 2*cnt_x + 1\)。
- 当我们在区间中删除一个数字 \(x\) 时,它的出现次数从 \(cnt_x\) 变为 \(cnt_x - 1\)。平方和的变化量为 \((cnt_x-1)^2 - cnt_x^2 = -2*cnt_x + 1\)。
- 实现:利用这个 \(O(1)\) 的更新公式,配合莫队模板即可。
代码实现(关键部分):
#include <bits/stdc++.h>
using namespace std;
const int N = 50010;
int n, m, k, a[N];
int block_size;
int cnt[N]; // 计数数组
long long cur, ans[N];
struct Query {
int l, r, id;
} q[N];
bool cmp(Query &a, Query &b) {
if (a.l / block_size != b.l / block_size)
return a.l / block_size < b.l / block_size;
return a.r < b.r;
}
void add(int p) {
int x = a[p];
cur += 2 * cnt[x] + 1; // 根据公式更新答案
cnt[x]++;
}
void del(int p) {
int x = a[p];
cnt[x]--;
cur -= 2 * cnt[x] + 1; // 根据公式更新答案
}
int main() {
scanf("%d%d%d", &n, &m, &k);
block_size = sqrt(n);
for (int i = 1; i <= n; i++) scanf("%d", &a[i]);
for (int i = 1; i <= m; i++) {
scanf("%d%d", &q[i].l, &q[i].r);
q[i].id = i;
}
sort(q + 1, q + m + 1, cmp);
int l = 1, r = 0;
for (int i = 1; i <= m; i++) {
int ql = q[i].l, qr = q[i].r;
while (l > ql) add(--l);
while (r < qr) add(++r);
while (l < ql) del(l++);
while (r > qr) del(r--);
ans[q[i].id] = cur;
}
for (int i = 1; i <= m; i++) printf("%lld\n", ans[i]);
return 0;
}
题目三:莫队应用 - P1494 [国家集训队] 小 Z 的袜子
题目大意:在一个区间 \([l, r]\) 内随机抽取两只袜子,求抽到两只颜色相同袜子的概率。用最简分数表示。
思路分析:
这道题将莫队与概率、组合数学结合 。
- 概率计算:
- 从区间长度 \(len = r - l + 1\) 中抽取两只袜子的总方案数为 \(C(len, 2) = len * (len - 1) / 2\)。
- 设区间内颜色 \(c\) 的袜子有 \(cnt_c\) 只,那么抽到两只相同颜色 \(c\) 的方案数为 \(C(cnt_c, 2) = cnt_c * (cnt_c - 1) / 2\)。
- 最终概率的分子 \(ans = \sum_{c} cnt_c * (cnt_c - 1) / 2\)。
- 更新策略:
- 当加入一只颜色为 \(x\) 的袜子时,它所带来的方案数变化是:原来有 \(cnt_x\) 只,贡献为 \(cnt_x * (cnt_x - 1) / 2\);现在有 \(cnt_x + 1\) 只,贡献为 \((cnt_x + 1) * cnt_x / 2\)。变化量为 \(cnt_x\)。
- 同理,删除一只颜色为 \(x\) 的袜子,变化量为 \(- (cnt_x - 1)\)。
- 注意:当 \(len = 1\) 时,概率为
0/1。
代码实现(关键部分):
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N = 50010;
int n, m, a[N];
int block_size;
int cnt[N];
ll ans_mole[N], ans_deno[N]; // 分子 (ans_mole) 和分母 (ans_deno)
ll cur; // 当前区间内的相同袜子对总数(分子)
struct Query {
int l, r, id;
} q[N];
bool cmp(Query &a, Query &b) {
if (a.l / block_size != b.l / block_size)
return a.l / block_size < b.l / block_size;
return a.r < b.r;
}
void add(int p) {
int x = a[p];
cur += cnt[x]; // 增加 cnt[x] 个新的对数
cnt[x]++;
}
void del(int p) {
int x = a[p];
cnt[x]--;
cur -= cnt[x]; // 删除 cnt[x] 个对数
}
ll gcd(ll a, ll b) {
return b == 0 ? a : gcd(b, a % b);
}
int main() {
scanf("%d%d", &n, &m);
block_size = sqrt(n);
for (int i = 1; i <= n; i++) scanf("%d", &a[i]);
for (int i = 1; i <= m; i++) {
scanf("%d%d", &q[i].l, &q[i].r);
q[i].id = i;
}
sort(q + 1, q + m + 1, cmp);
int l = 1, r = 0;
for (int i = 1; i <= m; i++) {
int ql = q[i].l, qr = q[i].r;
if (ql == qr) {
ans_mole[q[i].id] = 0;
ans_deno[q[i].id] = 1;
continue;
}
while (l > ql) add(--l);
while (r < qr) add(++r);
while (l < ql) del(l++);
while (r > qr) del(r--);
ll len = qr - ql + 1;
ll mole = cur;
ll deno = len * (len - 1) / 2;
ll g = gcd(mole, deno);
ans_mole[q[i].id] = mole / g;
ans_deno[q[i].id] = deno / g;
}
for (int i = 1; i <= m; i++) {
printf("%lld/%lld\n", ans_mole[i], ans_deno[i]);
}
return 0;
}
掌握分块和莫队,你就拥有了解决一大类区间问题的利器。它们代码清晰,思想直观,是算法竞赛进阶之路的坚实基础。希望以上的讲解和例题能帮助你更好地理解和运用这两种算法。祝你在洛谷刷题愉快!

浙公网安备 33010602011771号