分块和莫队算法讲解

前言

在算法竞赛中,当我们遇到一些复杂的区间查询或修改问题时,高级数据结构(如线段树、树状数组)有时难以施展拳脚。这时,分块莫队 这两种“优雅的暴力”算法就成了我们的得力助手。它们思想简洁,代码直观,能在可接受的复杂度内解决大量问题。

下面我将为你详细讲解这两种算法,并搭配洛谷上的实战题目来加深理解。

分块:有序整理的暴力

核心思想

分块,顾名思义,就是把一个大的序列划分成若干个大小相对均匀的“块”。它的核心思想可以概括为:“大块维护,小块朴素”

  • 预处理:将长度为 \(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;
}

莫队:离线处理的优雅暴力

核心思想

莫队算法是一种离线处理区间查询的算法,它巧妙地将询问重新排序,以减少指针移动的次数。它的核心思想是:“暴力移动,巧妙排序”

  • 离线处理:将所有的查询先存储下来。
  • 分块排序:将所有查询按照 左端点所在的块 为第一关键字,右端点的位置 为第二关键字进行排序。这种排序方式是莫队算法复杂度的保证 。
  • 区间转移:维护两个指针 lr,代表当前已求出答案的区间 [l, r]。然后按排序后的顺序处理每个查询 [ql, qr],通过不断移动 lr 指针,并 在移动过程中即时更新答案,直到当前区间与目标区间重合。

通过这种排序,左指针的移动被限制在块内,右指针的移动是单调的,从而将总复杂度优化到 (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 / 花神游历各国

题目大意:给定一个序列,支持两种操作:

  1. 0 l r:将区间 [l, r] 内的每个数开平方(向下取整)。
  2. 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 的袜子

题目链接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;
}

掌握分块和莫队,你就拥有了解决一大类区间问题的利器。它们代码清晰,思想直观,是算法竞赛进阶之路的坚实基础。希望以上的讲解和例题能帮助你更好地理解和运用这两种算法。祝你在洛谷刷题愉快!

posted @ 2026-02-27 20:44  Co_led  阅读(0)  评论(0)    收藏  举报