Skip to content

G. Hits Different - 题解

比赛与标签

比赛: Codeforces Round 871 (Div. 4) 标签: data structures, dp, implementation, math 难度: *1600

喵呜~ 题目到底在说什么呀?

主人你好呀!这道题是一个非常可爱的打罐子游戏呐~ 想象一下,有一个超级大的金字塔,由好多好多的罐子堆成。

  • 金字塔的样子:第一行有1个罐子,第二行有2个,第三行有3个……以此类推。罐子们从上到下,从左到右被依次编号为 1, 2, 3, 4, 5, 6, ...
  • 打罐子的规则:我们用球击中一个编号为 n 的罐子。这个罐子会掉下来,并且,所有在它“正上方”支撑着它的罐子也会跟着掉下来,形成一个连锁反应,就像图里画的那样,会掉下来一个倒三角形区域的罐子。
  • 我们的任务:计算所有掉下来的罐子的编号的平方和。比如说,如果打中了9号罐子,掉下来的是1, 2, 3, 5, 6, 9号罐子,那我们就要计算 1*1 + 2*2 + 3*3 + 5*5 + 6*6 + 9*9 的值。

输入会给我们很多个 n,我们对每个 n 都要快速给出答案。要注意,答案可能会很大,需要用 long long 来存哦!

本喵的解题思路!

一看到这种“多组查询”而且“查询之间互不影响”的题目,本喵的直觉就告诉我——预处理大法好!喵~ 我们可以先把所有可能的答案都算出来,存起来。之后每次查询,直接拿出答案就好啦,这样就能跑得飞快!

那么问题来了,怎么预处理呢?这就要用到我们聪明的动态规划 (DP) 思想啦!

1. 状态定义

我们把金字塔看成一个二维坐标系,dp[r][c] 表示击中第 r 行第 c 列的罐子时,所有掉落罐子的编号平方和。

2. 状态转移方程

当一个罐子 (r, c) 被击中时,总分由两部分组成:

  1. 它自己的分:Num(r, c) * Num(r, c),其中 Num(r, c) 是这个位置罐子的编号。
  2. 它上面所有掉落罐子的总分。

它上面的罐子是怎么掉的呢?其实是 (r-1, c-1)(r-1, c) 这两个罐子所引发的连锁反应。所以,上面掉落罐子的总分,就是 dp[r-1][c-1]dp[r-1][c] 的总和吗?

不完全是哦!dp[r-1][c-1]dp[r-1][c] 它们自己引发的连锁反应中,有一个重叠的部分,就是以 (r-2, c-1) 为顶点的那个更小的金字塔。根据容斥原理,这部分被加了两次,所以我们要减掉一次!

于是,我们得到了超级酷炫的状态转移方程: dp[r][c] = Num(r, c)^2 + dp[r-1][c-1] + dp[r-1][c] - dp[r-2][c-1]

3. 怎么计算罐子编号?

r 行第 c 列的罐子编号 Num(r, c) 也很好算。前 r-1 行总共有 1 + 2 + ... + (r-1) = (r-1)*r/2 个罐子。所以,Num(r, c) = (r-1)*r/2 + c

4. 空间优化

主人请看,计算第 r 行的状态,我们只需要第 r-1 行和第 r-2 行的信息。所以我们不需要一个完整的二维 dp 数组,可以用滚动数组来优化空间!我们只需要三个一维数组,分别代表 r-2 行 (F0),r-1 行 (F1) 和当前计算的 r 行 (F2) 就够啦。

总结一下预处理步骤:

  1. 创建一个大大的 res 数组,res[i] 用来存击中 i 号罐子的答案。
  2. 创建三个滚动数组 F0, F1, F2
  3. 从第一行开始,一行一行地计算金字塔。
  4. 对于每一行的每一个罐子 (i, j)
    • 计算它的编号 base_num
    • 用我们的DP方程计算出 dp[i][j] 的值,存到 F2[j]
    • 把结果存起来:res[base_num] = F2[j]
  5. 一行计算完后,更新滚动数组:F0 = F1, F1 = F2,然后继续计算下一行。
  6. 全部算完后,res 数组里就装着所有我们想要的答案啦!

本喵的代码实现喵~

cpp
#include <iostream>
#include <vector>
using namespace std;

// 金字塔的最大行数,题目说是2023,我们就设这么大~
const int R_max = 2023;
// 计算出最大行数下,罐子的最大编号是多少
const long long max_base = (long long)R_max * (R_max + 1) / 2;

int main() {
    // 预处理的结果都放在这里喵~ res[i] 就是打中罐子 i 时得到的总分!
    vector<long long> res(max_base + 10, 0);
    
    // 这是我们的滚动数组,用来优化空间的说。
    // F2 是当前行(i),F1 是上一行(i-1),F0 是上上行(i-2)~
    vector<long long> F0(R_max + 10, 0);
    vector<long long> F1(R_max + 10, 0);
    vector<long long> F2(R_max + 10, 0);

    // 开始预处理,一行一行地构建我们的答案
    for (int i = 1; i <= R_max; ++i) { // i 是当前行号
        for (int j = 1; j <= i; ++j) { // j 是当前列号
            // 计算当前罐子 (i, j) 的编号,就是前 i-1 行的罐子总数 + j 嘛
            long long base_num = (long long)i * (i - 1) / 2 + j;
            
            // DP 的第一步,先把当前罐子自己的分值平方加上~
            F2[j] = base_num * base_num;

            // 如果不是第一行,就需要加上面罐子的影响
            if (i >= 2) {
                // 加上左上方 (i-1, j-1) 罐子引发的连锁反应总分
                if (j - 1 >= 1) {
                    F2[j] += F1[j - 1];
                }
                // 加上右上方 (i-1, j) 罐子引发的连锁反应总分
                if (j <= i - 1) {
                    F2[j] += F1[j];
                }
                // 如果不是前两行,就需要处理重叠部分
                if (i >= 3) {
                    // 根据容斥原理,减去重叠的部分,也就是更上面 (i-2, j-1) 罐子引发的总分
                    if (j - 1 >= 1 && j - 1 <= i - 2) {
                        F2[j] -= F0[j - 1];
                    }
                }
            }
            
            // 把算好的结果存到我们的最终答案数组里
            if (base_num <= max_base) {
                res[base_num] = F2[j];
            }
        }
        // 更新滚动数组,为下一行的计算做准备!
        F0 = F1;
        F1 = F2;
    }

    // 处理 t 组查询
    int t;
    cin >> t;
    while (t--) {
        int n;
        cin >> n;
        // 直接从预处理好的数组里 O(1) 取出答案,超快的说!
        cout << res[n] << '\n';
    }

    return 0;
}

复杂度分析时间!

  • 时间复杂度: O(R_max^2) 的说。 我们的主要开销在预处理部分。我们需要遍历金字塔里的大部分罐子来计算答案。金字塔的总罐子数大约是 1 + 2 + ... + R_max,也就是 R_max * (R_max + 1) / 2,所以总的计算量是 O(R_max^2)。之后的每次查询都是 O(1),可以忽略不计啦。
  • 空间复杂度: O(R_max^2) 的说。 空间开销主要来自 res 数组,它的大小取决于金字塔里罐子的总数,也就是 O(R_max^2)。我们用来做DP的滚动数组 F0, F1, F2 的大小都只是 O(R_max),相比之下就小多啦。

知识点与总结喵~

这道题真是太棒了,让本喵学到了好多东西!

  1. 核心思想:预处理 + 动态规划 (DP) 对于这种多组查询且输入范围固定的题目,预处理是yyds(永远的神)!一次计算,终身受益,喵~

  2. DP 设计:容斥原理 这道题的 DP 状态转移设计非常巧妙,利用了容斥原理来处理重叠子问题。这是 DP 问题中一个很常见的技巧哦,主人一定要记住!

  3. 空间优化:滚动数组 用滚动数组把依赖前几行的DP问题的空间复杂度大大降低,虽然这题的空间瓶颈在结果数组上,但这个技巧在其他题目里可能会起到决定性作用,一定要掌握喵!

  4. 注意事项:数据类型 最后再叮嘱一下,记得开 long long 呐!数字的平方和会变得非常非常大,一不小心就会溢出,让本喵的努力白费啦!

好啦,这次的题解就到这里啦!希望对主人有帮助喵~ 下次也要一起加油解题哦!(ฅ'ω'ฅ)

Released under the MIT License.