题解:洛谷 P2709 小 B 的询问

【题目来源】

洛谷:P2709 【模板】莫队 / 小 B 的询问 - 洛谷

【题目描述】

小 B 有一个长为 \(n\) 的整数序列 \(a\),值域为 \([1,k]\)
他一共有 \(m\) 个询问,每个询问给定一个区间 \([l,r]\),求:

\[\sum\limits_{i=1}^k c_i^2 \]

其中 \(c_i\) 表示数字 \(i\)\([l,r]\) 中的出现次数。

小 B 请你帮助他回答询问。

【输入】

第一行三个整数 \(n,m,k\)

第二行 \(n\) 个整数,表示小 B 的序列。

接下来的 \(m\) 行,每行两个整数 \(l,r\)

【输出】

输出 \(m\) 行,每行一个整数,对应一个询问的答案。

【输入样例】

6 4 3
1 3 2 1 1 3
1 4
2 6
3 5
5 6

【输出样例】

6
9
5
2

【核心思想】

  1. 问题分析:给定长度为 \(n\) 的序列(值域 \([1, k]\))和 \(m\) 个区间查询,每个查询求区间内各数字出现次数的平方和 \(\sum_{i=1}^{k} c_i^2\)。若对每个查询暴力统计,时间复杂度为 \(O(m \cdot n)\)。关键观察是:相邻查询的区间往往重叠,通过离线排序使指针移动总次数最小化,可以将复杂度优化至 \(O((n + m) \sqrt{n})\)

  2. 算法选择

    • 莫队算法(Mo's Algorithm):离线处理区间查询,按分块排序后,用双指针维护当前区间,通过 \(O(1)\) 的增删操作调整区间
    • 分块排序策略:按左端点所在块排序,同块内按右端点排序,减少指针跳跃
  3. 关键步骤

    • 读入与初始化:读入序列和查询,计算分块大小 \(B = \lfloor \sqrt{n} \rfloor\)
    • 离线排序:按左端点所在块升序排序,同一块内按右端点升序排序
    • 双指针维护区间:初始化 \(l = 1, r = 0\)(空区间),对每个查询:
      • while (l > q[i].l) add(a[--l]):左边界左扩
      • while (r < q[i].r) add(a[++r]):右边界右扩
      • while (l < q[i].l) del(a[l++]):左边界右缩
      • while (r > q[i].r) del(a[r--]):右边界左缩
    • \(O(1)\) 增删维护答案
      • add(x)sum -= cnt[x]^2; cnt[x]++; sum += cnt[x]^2
      • del(x)sum -= cnt[x]^2; cnt[x]--; sum += cnt[x]^2
    • 按原序输出:用 ans[id] 保存答案,最后按查询编号输出
  4. 时间/空间复杂度

    • 时间复杂度:\(O((n + m) \sqrt{n})\),排序 \(O(m \log m)\),指针移动总次数 \(O(n \sqrt{n})\)
    • 空间复杂度:\(O(n + m)\),序列、计数器、查询和答案数组
  5. 莫队算法的核心思想

    • 离线排序降低移动代价:通过将查询按特定顺序排列,使得相邻查询的区间变化尽可能小,从而将暴力枚举的 \(O(n)\) 每次查询降为均摊 \(O(\sqrt{n})\)
    • 分块平衡左右指针:左端点按块排序确保左指针在同一块内只单向移动,右端点排序确保右指针在块间跳跃时总体移动可控
    • 增量维护答案adddel 操作利用 \(\Delta = (c+1)^2 - c^2 = 2c + 1\)\(O(1)\) 内更新答案,避免了每次重新计算
    • 奇偶性优化(Hilbert 曲线):进阶版本可用 Hilbert 曲线排序进一步减少指针移动,但基础分块已足够应对大多数场景
    • 适用于离线区间查询、区间统计、需要频繁增删维护答案的问题,是暴力与数据结构之间的优雅折中

【算法标签】

提高+ #莫队

【代码详解】

#include <bits/stdc++.h>
using namespace std;

// 定义长整型别名,便于处理大数据
#define int long long

// 定义数组最大容量
const int N = 200005;

// 全局变量声明
int n, m, k, B;         // n: 数组长度, m: 询问次数, k: 未使用参数, B: 分块大小
int a[N];               // 原始数组
int cnt[N];             // 计数器,记录每个数值出现的次数
int ans[N];             // 存储每个询问的答案
int sum;                // 当前区间内所有数的出现次数的平方和

// 询问结构体
struct Q
{
    int l, r;           // 区间左右端点
    int id;             // 询问编号
} q[N];

// 莫队排序比较函数(奇偶性优化)
bool cmp(Q a, Q b)
{
    if (a.l / B != b.l / B)
        return a.l < b.l;           // 不同块按左端点升序
    return a.r < b.r;               // 同块按右端点升序
}

// 添加一个数到当前区间
void add(int x)
{
    sum -= cnt[x] * cnt[x];         // 减去旧贡献
    cnt[x]++;                       // 增加计数
    sum += cnt[x] * cnt[x];         // 加上新贡献
}

// 从当前区间删除一个数
void del(int x)
{
    sum -= cnt[x] * cnt[x];         // 减去旧贡献
    cnt[x]--;                       // 减少计数
    sum += cnt[x] * cnt[x];         // 加上新贡献
}

// 主函数入口
signed main()
{
    // 读取数组长度、询问次数和未使用的参数k
    cin >> n >> m >> k;

    // 计算分块大小
    B = sqrt(n);

    // 读取原始数组
    for (int i = 1; i <= n; i++)
        cin >> a[i];

    // 读取所有询问
    for (int i = 1; i <= m; i++)
    {
        cin >> q[i].l >> q[i].r;
        q[i].id = i;                // 记录原始顺序
    }

    // 按照莫队顺序排序询问
    sort(q + 1, q + m + 1, cmp);

    // 初始化当前区间为空,l=1, r=0表示空区间
    for (int i = 1, l = 1, r = 0; i <= m; i++)
    {
        // 移动指针到目标区间
        while (l > q[i].l)          // 左边界向左扩展
            add(a[--l]);
        while (r < q[i].r)          // 右边界向右扩展
            add(a[++r]);
        while (l < q[i].l)          // 左边界向右收缩
            del(a[l++]);
        while (r > q[i].r)          // 右边界向左收缩
            del(a[r--]);

        // 存储当前区间的答案
        ans[q[i].id] = sum;
    }

    // 按原始顺序输出所有答案
    for (int i = 1; i <= m; i++)
        cout << ans[i] << endl;

    return 0;
}

【运行结果】

6 4 3
1 3 2 1 1 3
1 4
2 6
3 5
5 6
6
9
5
2
posted @ 2026-07-02 22:44  团爸讲算法  阅读(7)  评论(0)    收藏  举报