Skip to content

D. Powerful array - 题解

比赛与标签

比赛: Yandex.Algorithm 2011: Round 2 标签: data structures, implementation, math, two pointers, *2200 难度: *2200

题目大意喵~

主人,这道题是这样的呐:

我们有一个包含 n 个正整数的数组 a,还有 t 个查询。 每个查询会给我们一个区间 [l, r]。我们需要计算这个子数组 a[l...r] 的 “力量值”(Power)。

那...什么是“力量值”呢?对于子数组中的每一个出现过的数字 s,我们先数一数它出现了多少次,记作 Ks。然后,这个子数组的力量值就是所有 Ks * Ks * s 的总和。

简单来说,就是对子数组里每一种数值,算出它的 (出现次数的平方) * (数值本身),然后把这些结果全部加起来,就是这个子-数组的力量值啦!我们的任务就是回答所有 t 个查询。

解题思路喵!

这道题的数据范围 nt 都达到了 200000,如果对每个查询都傻乎乎地遍历一遍区间 [l, r] 来计算,那复杂度就是 O(n*t),肯定会超时的说!(。>ω<。)

所以,我们需要一个更聪明的办法!注意到题目并不要求我们“在线”回答查询,也就是说,我们可以先把所有查询读进来,再按我们喜欢的顺序去处理它们。这种可以离线处理的区间查询问题,马上就能让聪明的猫娘想到一个超级厉害的算法——莫队算法!喵~

莫队算法的核心思想就是,通过对查询进行巧妙的排序,来减少区间左右端点指针移动的总次数。

1. 莫队算法基础:分块与排序

首先,我们将整个数组 a 分成 sqrt(n) 个块,每个块的大小也是 sqrt(n)

然后,我们对所有的查询 [l, r] 进行排序。排序规则是:

  1. 如果两个查询的左端点 l不同的块里,那么就按 l 所在的块的编号从小到大排。
  2. 如果两个查询的左端点 l同一个块里,那么就按右端点 r 从小到大排。

这样排序后,处理查询时,左端点 l 会在一个块内缓慢移动,而右端点 r 会在整个数组上有序地移动。当左端点移动到下一个块时,右端点才会大幅度跳跃。这样整体上就大大减少了指针移动的总距离!

小优化(奇偶性排序/蛇形排序):为了让右端点 r 的移动更平滑,我们可以在排序时加一个小技巧。当左端点 l 所在的块编号是偶数时,我们让 r 升序排;当块编号是奇数时,我们让 r 降序排。这样,右端点在处理完一个块的查询后,就不用“跳”回数组的开头,而是可以顺势“滑”向下一个目标,就像一条小蛇一样扭来扭去,非常可爱,也更高效喵!

2. 指针移动与贡献计算

莫队算法的精髓在于,当我们把当前处理的区间 [L, R] 移动到下一个查询的目标区间 [l, r] 时,我们只需要一步一步地移动指针,并在每一步都快速更新答案。

假设我们已经算好了区间 [L, R] 的力量值 current_power。现在我们要把这个区间扩展或缩小。

添加一个元素 x: 当我们的区间右端点从 R 移动到 R+1 时,我们把新元素 x = a[R+1] 加入了区间。假设 x 在原区间 [L, R] 中出现了 k 次,它的贡献是 k*k*x。现在它出现了 k+1 次,贡献变成了 (k+1)*(k+1)*x。 那么力量值的变化量就是: Δ_power = (k+1)² * x - k² * x= (k² + 2k + 1) * x - k² * x= (2k + 1) * x 所以,我们只需要 O(1) 的时间就可以更新力量值啦!

删除一个元素 x: 当我们的区间左端点从 L 移动到 L+1 时,我们把元素 x = a[L] 移出了区间。假设 x 在原区间 [L, R] 中出现了 k 次,它的贡献是 k*k*x。现在它出现了 k-1 次,贡献变成了 (k-1)*(k-1)*x。 那么力量值的变化量就是: Δ_power = (k-1)² * x - k² * x= (k² - 2k + 1) * x - k² * x= (-2k + 1) * x 也就是说,力量值要减去 (2k - 1) * x。这也同样是 O(1) 的操作!

通过这两个神奇的 addremove 操作,我们就可以在指针的每次移动中,飞快地更新答案。整个算法的流程就是:

  1. 读入所有数据和查询。
  2. 对查询进行莫队排序(带奇偶性优化)。
  3. 初始化当前区间为空 [0, -1],力量值为 0
  4. 遍历排好序的查询,通过 addremove 函数,将当前区间 [current_l, current_r] 调整到目标查询区间 [q.l, q.r]
  5. 记录下当前的力量值作为该查询的答案。
  6. 所有查询处理完毕后,按原始顺序输出答案。

这样,我们就能在规定时间内解决问题啦,喵~!

代码实现喵

cpp
#include <iostream>
#include <vector>
#include <cmath>
#include <algorithm>

// 使用一个全局数组来记录频率,可以提高性能喵~
// a[i] 的最大值是 10^6
const int MAX_A_VAL = 1000001;
int counts[MAX_A_VAL] = {};

// 莫队算法状态的全局变量
long long current_power = 0;
std::vector<int> a;

// 查询的结构体,用来存放查询信息和原始ID
struct Query {
    int l, r, block, id;
};

// 将一个元素添加到当前区间
void add(int index) {
    int val = a[index];
    // 力量值的变化是 ((k+1)^2 - k^2) * val = (2k+1) * val
    // 这里的 k 是 val 当前的出现次数
    current_power += (2LL * counts[val] + 1) * val;
    counts[val]++; // 更新出现次数
}

// 从当前区间移除一个元素
void remove(int index) {
    int val = a[index];
    counts[val]--; // 先更新出现次数
    // 力量值的变化是 ((k-1)^2 - k^2) * val = (-2k+1) * val
    // 相当于减去 (2k-1) * val。
    // 在 counts[val]-- 之后,新的 counts[val] 是 k-1
    // 所以 (2 * (新次数) + 1) * val 就是 (2*(k-1)+1)*val = (2k-1)*val
    // 所以我们直接减去 (2LL * counts[val] + 1) * val 就好啦
    current_power -= (2LL * counts[val] + 1) * val;
}

int main() {
    // 快速 I/O,让程序跑得更快一点~
    std::ios_base::sync_with_stdio(false);
    std::cin.tie(NULL);

    int n, t;
    std::cin >> n >> t;

    a.resize(n);
    for (int i = 0; i < n; ++i) {
        std::cin >> a[i];
    }

    std::vector<Query> queries(t);
    // 计算块的大小,这是莫队算法的关键一步
    int block_size = static_cast<int>(sqrt(n));
    if (block_size == 0) { // 防止n=0的情况
        block_size = 1;
    }

    for (int i = 0; i < t; ++i) {
        std::cin >> queries[i].l >> queries[i].r;
        // 转换为 0-based 索引,方便编程喵
        queries[i].l--;
        queries[i].r--;
        queries[i].id = i; // 记录原始ID,以便最后按顺序输出
        queries[i].block = queries[i].l / block_size; // 计算左端点所在的块
    }

    // 使用 lambda 表达式对查询进行排序,这就是莫队的核心!
    std::sort(queries.begin(), queries.end(), [&](const Query& q1, const Query& q2) {
        if (q1.block != q2.block) {
            return q1.block < q2.block; // 按块编号排序
        }
        // 奇偶性优化(蛇形排序)
        // 如果块号是偶数,r 升序;如果是奇数,r 降序
        if (q1.block % 2 == 0) {
            return q1.r < q2.r;
        }
        return q1.r > q2.r;
    });

    std::vector<long long> answers(t);
    // 初始化莫队算法的当前区间状态
    int current_l = 0;
    int current_r = -1; // 代表一个空区间 [0, -1]

    // 遍历排好序的查询
    for (const auto& q : queries) {
        int l = q.l;
        int r = q.r;

        // 调整当前区间 [current_l, current_r] 到 [l, r]
        // 注意:最好先扩展区间再收缩区间,避免处理空的或无效的中间状态
        
        // 扩展左边界
        while (current_l > l) {
            current_l--;
            add(current_l);
        }
        // 扩展右边界
        while (current_r < r) {
            current_r++;
            add(current_r);
        }
        // 收缩左边界
        while (current_l < l) {
            remove(current_l);
            current_l++;
        }
        // 收缩右边界
        while (current_r > r) {
            remove(current_r);
            current_r--;
        }
        
        // 区间调整完毕,记录答案
        answers[q.id] = current_power;
    }

    // 按原始顺序输出所有答案
    for (int i = 0; i < t; ++i) {
        std::cout << answers[i] << "\n";
    }

    return 0;
}

复杂度分析喵

  • 时间复杂度: O((N + T) * sqrt(N)) 的说。

    • 对查询排序的时间是 O(T log T)
    • 指针移动的总时间是分析的关键:
      • 右指针 r:由于奇偶性排序,对于每个块,r 指针最多从头到尾或从尾到头扫一遍数组,移动 O(N)。总共有 sqrt(N) 个块,所以 r 指针总移动次数是 O(N * sqrt(N))
      • 左指针 l:当 l 在同一个块内移动时,每次移动距离不超过块的大小 sqrt(N),总共有 T 个查询,这部分是 O(T * sqrt(N))。当 l 跨块时,最多跳 2 * sqrt(N),这种情况最多发生 sqrt(N) 次,总共是 O(N)
    • 两者相加,总时间复杂度由 O(N * sqrt(N) + T * sqrt(N)) 主导,也就是 O((N + T) * sqrt(N))。对于这道题的数据范围,这个复杂度是可以通过的!
  • 空间复杂度: O(N + T + max(a_i)) 的说。

    • O(N) 用于存储输入数组 a
    • O(T) 用于存储查询和答案。
    • O(max(a_i)) 用于 counts 数组来统计数字出现的次数,这里是 O(10^6)

知识点与总结喵

这道题是莫队算法的一个非常经典的模板题,非常适合用来学习和练习这个算法呐!

  1. 核心算法莫队算法 (Mo's Algorithm)。它是一种处理离线区间查询的强大工具,通过分块和对查询重排序来优化暴力计算。
  2. 适用场景
    • 查询是离线的(可以打乱顺序处理)。
    • 从区间 [l, r][l+1, r], [l-1, r], [l, r+1], [l, r-1] 的答案可以快速计算(通常是 O(1)O(log N))。
  3. 关键技巧
    • 分块思想:将问题分解为更小、更易于管理的单元。
    • O(1) 贡献更新:本题解题的关键在于推导出添加/删除一个元素时,力量值变化的数学公式 (2k+1)*x,这使得每次指针移动的更新成本极低。
    • 奇偶性排序:一个简单但有效的常数优化,能让 r 指针的移动路径更短。
  4. 注意事项
    • 一定要记得用 long long 来存储答案 current_power,因为 Ks² * s 的结果可能会很大,会超出 int 的范围。
    • 数组下标是 0-based 还是 1-based 要处理清楚,统一用 0-based 会让代码更简洁。

希望这篇题解能帮助到主人!如果还有不懂的地方,随时可以再来问我哦,喵~ (ฅ'ω'ฅ)

Released under the MIT License.