CCF-NOI2025第一试题目与解析(第二题、序列变换(sequence))

一、先看原题:


二、理解题目:

想象一排 n 个小格子,每个格子里放一个数字 a[i](这是数组 A)。然后每个格子还有两个"标签":一个是 b[i],表示"这个位置的价值";另一个是 c[i],表示"如果这个位置最后保留了非零数,要把答案计上一个权重(乘积)c[i]"。

我们可以做一种"消除相邻"的操作:选择相邻的两个格子 ii+1如果左边的数不大于右边的数(a[i] ≤ a[i+1]) ,就把左边设为 0,把右边变成 a[i+1] - a[i]。也可以把这个操作重复很多次、在不同位置做很多次。最终每个格子要么是 0,要么是一个正数(剩余的值)。

把最后的这一排数字记作 D = [d1,...,dn](每个 d_i 要么 0,要么 >0)。题目定义两个值:

  • f(D):把所有 d_i > 0 的位置对应的 b[i] 加起来(简单:保留位置的 b 的总和);

  • g(D):把所有 d_i > 0 的位置对应的 c[i] 相乘(简单:保留位置的 c 的乘积)。

现在问题是:对所有"可能得到的 D"(也就是从 A 经过若干次允许的操作能得到的所有最终序列),求:

  1. 最大的 f(D)(即找能保留最多 b 总价值的操作方案),记为 max f

  2. 把所有能达到该最大 f 的方案对应的 g(D) 求和,然后对 1,000,000,007 取模(注意 max f 本身不需要取模)。

我们要输出两项:max f 和 对应方案的 g 的和(模 1e9+7)。


三、直觉判断:

直接暴力枚举所有操作显然不行 ------ 状态太多。关键在于理解"经过若干次这种相邻消除的操作之后,哪些位置可能保留非零(即 d_i > 0)"以及这些保留位置之间有什么约束。下面用简单例子给出一些直觉:

  • 操作只发生在相邻位置上,并且是"把左边变 0,右边变差值"。把它展开看,本质上等价于把一段连续子区间的数通过交替相减合并成一个数,或者把它们化掉(变成 0)。这让我们把问题看成"把数组 A 划分成若干段,在每段里决定一个'保留位置'或者全部变 0" 的动态规划风格问题。

  • 更具体一点:当你在一段连续区间做这些交替消除时,最终能不能留下某个位置的正数,以及留下的值的符号(正还是零)取决于这段 a交替和 (也就是 a[l] - a[l+1] + a[l+2] - ... 或者相反的交替)。代码中用 t 前缀和来记录这种"交替求和"的值。

  • 另外,如果决定让一段里某个位置保留非零,那么那段里所有可能被"保留"的位置(根据奇偶)需要满足某些最小 b 的代价会被扣除(大意是:保留哪个位置会影响能否把段里别的位置化掉,以及对应的 b 的贡献如何计算)。代码里通过按位置奇偶保存最小的 bmi[2][i][j])和相应的计数(ct[2][i][j])来快速获取这些信息。

这些观察看起来抽象,但程序就是把这些"局部性质"预处理好,然后用从左到右的动态规划(把前缀看成状态)来把答案拼起来。


四、算法步骤:

想象你有一排格子(1..n)。每个格子里有三样东西:

  • a[i]:代表格子里"沙包"的重量(我们可以做"相邻消除"的动作);

  • b[i]:代表格子里的"奖励糖果数值";

  • c[i]:代表格子里的"旗子力量"(用于计算方案权重,最后要乘起来)。

我们要做一连串"把左边沙包放倒(变 0),右边变成差值"的消除,并在最后统计:剩下哪些格子(d_i>0)------把它们的糖果 b 加起来(这叫 f),并把它们的旗子 c 相乘(这叫 g)。我们要找能让糖果总和 f 最大的玩法数,并把这些玩法对应的 g(乘积)加起来。

1、前缀和 s 与交替前缀和 t ------ 把区间的信息"打包带走"

比喻:

  • s[i] 就像你走到第 i 个格子时口袋里装的糖果总数(从格子 1 到 i 的 b 总和)。所以要知道区间 (i+1..j) 的糖果和,只要 s[j] - s[i],就像从口袋里减去你之前装的糖果,剩下的就是这段的糖果。

  • t[i] 是"交替的净沙包"。想像每个格子在你走过时,奇数格你拿走 (视作负),偶数格你放回 (视作正),于是 t[j] - t[i] 就等于区间 (i+1..j) 做交替加减后的"净剩余"。这个量告诉我们:如果我们对这段做交替相减的操作,最终会剩下正数(>0)、为 0,还是负数(<0)------不同结果会决定我们是否必须牺牲某个 b(糖果)。

简单手算:

A = [3,5,6], B = [1,2,3]

  • s: s0=0, s1=1, s2=3, s3=6

  • t(按代码:奇数位 -a,偶数位 +a):

    t0=0

    t1 = 0 - 3 = -3

    t2 = -3 + 5 = 2

    t3 = 2 - 6 = -4

如果看区间 (1..3): t3 - t0 = -4 → 负,说明交替相减后"净剩余"是负的(会在奇数/偶哪边留下正数,后面说)。


2、预处理 v、ic、iv(与乘积有关)------把"整段旗子乘积"快速拿到

比喻:

c[i] 想成每个格子的"旗子力量";一整段的旗子总力是把这些小旗子都绑在一起的合力(乘起来)。我们常常需要快速得到区间 (i+1..j) 的旗子合力 c[i+1]*...*c[j]。把从头到第 k 的合力存在 v[k](前缀乘积),那区间合力就可以用 v[j] / v[i] 得到。在模运算里"除法"用逆元(iv)表示,所以区间合力就是 v[j] * iv[i]

ic[i]Inv(c[i]))就是单个旗子的逆元,我们在统计"如果某个位置被牺牲(扣掉 b),那对应的乘积应该除以它的 c"时会用到 ic

直观例子(数值,不取模,方便理解):

C = [2,3,5]

v1 = 2, v2 = 2*3=6, v3 = 30

区间 1..3 的乘积就是 v3 / v0 = 30。

如果要把整个区间乘积除去位置 3 的 c(也就是 prod / c3),那是 30 / 5 = 6;这也等于 v3 * ic3(把 ic3 理解为 1/5)。


3、预处理区间内按奇偶分组的最小 b 和计数 mi / ct ------ 谁是必须牺牲的小糖果?

比喻:

  • 想像区间里有奇数座位和偶数座位两类,某种操作后只能在某一类留下正数。那如果该类里不全能保住糖果,最小的糖果 (最不值钱的)就是我们必须牺牲的(相当于"扣掉最小 b")。所以我们需要知道区间里奇位/偶位的最小糖果 mi[par][l][r]

  • 但如果最小糖果出现在好几格(比如两个位置都等于最小值),每一种不同位置在 g(旗子乘积)上可能对应不同的乘积(因为会去掉不同位置的 c)。于是我们要把那些"最小 b 出现的位置"对应的 (把区间乘积除以该位置的 c) 的和都算上,最后再乘上前缀的 g[i]。程序里把计数信息存在 ct[par][l][r],它本质上是这些位置的 Inv(c[p]) 的和,配合前缀乘积可以还原出每个具体位置对应的贡献。

更简单的形象:

  • mi = 这段里的"最小糖果值"。

  • ct = 把"把整段的旗子合力除以每个最小糖果所在位置的旗子" 的这些结果合起来(在实现上用逆元做了方便的分解)。

手算举例(小区间,继续用上面例子):

区间 (1..3),奇数位是 1 和 3(位置 1、3),它们的 b 分别 1、3 → 最小是 1(在位置 1);所以 mi[奇][1..3] = 1,且出现位置只是 1,所以 ct[奇][1..3] 对应的是 inv(c1) = 1/2(在模下是 Inv(2))。


4、计算 pl 和 pr(区间有效性的左右边界)------从 i 出发能"拉"多远?

比喻(很直观):

站在格子 i,你向右连续做"相邻消除"------每次把左放倒(变 0),右变差值。你一直做,直到不能再"正数"下去(差变成 ≤0)或者到达末尾。pl[i] 就是从 i 向右这样做能到达的最远格子。向左类似,pr[i] 是从 i 向左能到的最远格子。

为什么需要它?

因为在 DP 里我们把一块 (i+1..j) 当成"整体"处理,但并不是所有的 (i+1..j) 都能通过允许的操作变成我们想的那种形态------只有满足 pr[j] <= some <= pl[i+1] 的才行。l = max(i+1, pr[j]), r = min(j, pl[i+1]) 就是判断有没有交集(有没有合法的位置来做最小 b 的选择)。如果 l>r,说明这段在操作规则下不可能达成我们要的结构,必须跳过。

具体小例子(A=[3,5,6])我们之前算过:

  • pl[1]=3,pl[2]=3,pl[3]=3;pr[1]=1,pr[2]=2,pr[3]=3。

    这说明从 1 向右可以拉到 3,从 2 向右可以拉到 3;从 3 向左可以拉到 3 到 2 的时候就停止(因为差变非正)。


5、动态规划主过程(把小块拼成大块)------"积木拼楼房"的比喻

比喻:

我们从左往右搭积木(把序列分成一段段),f[i] 表示"当我把前 i 个格子做完后,能拿到的最大糖果总数是多少";g[i] 表示当拿到这个最大糖果总数时,对应的旗子乘积(所有达成该最大糖果总数的玩法的旗子乘积之和)。

转移(用更通俗的话):

  • 你已经搭好了前 i 个格子(这给你当前糖果 f[i] 和方案权重 g[i])。

  • 现在你试图把接下来的 i+1..j 这一段当成下一块来处理(即把一段合并成"要么全消,要么留下某个位置")。

  • 先看这段是否合法 (用 l,r 判定)------如果不合法就不能当一块。

  • T = t[j] - t[i](交替和):

    • 如果 T == 0:这段可以全部互相抵消,不必牺牲糖果。你就可以把 s[j] - s[i](这段的所有糖果)全部加到 f[i] 上,变成候选 f[j]

      • 对应的旗子乘积就是:之前的 g[i] 乘上这段所有 c 的乘积(也就是 h = g[i] * v[j]*iv[i])。
    • 如果 T > 0(正):说明这段经过消除后会"剩下"在偶数位这一侧(原理基于交替和),因此不能同时保住这段所有的糖果。必须 在这段里的偶数位里挑一个最小的 b 牺牲掉(因为奇偶哪侧最后会剩数,那一侧的最小 b 是最不值钱、被牺牲的)。

      • f 上我们要把这段的 s[j]-s[i] 加上去再减去 mi[0][l][r](偶位最小值)。

      • g 上我们要把 h 乘以 ct[0][l][r](把所有导致最小值的不同位置对应的 c 贡献一起加上)。

    • 如果 T < 0(负):同理,但牺牲的是奇数位那一类(用 mi[1][l][r] / ct[1][l][r])。

每次得到一个候选 cand(cand 是候选的 f[j]),就比较:

  • 如果 cand > f[j]:更新 f[j] = cand,并把 g[j] 重设为对应的权重(替换)。

  • 如果 cand == f[j]:说明又多了一种不同的方式能达到同样的最大糖果,那就把对应的权重累加到 g[j](加法,取模)。


6、把 v[j]*iv[i]*ct 的工作做个简单代数说明(为什么它等于"对区间内所有最小 b 出现位置 p,求 sum(prod_except_c_p)")

这是关键点,必须看懂 ct 为何要累加 ic[j] 才能在 DP 中直接用 v[j]*iv[i] 恢复出"去掉某个位置 c 的区间乘积"。

设区间 L..R 的总乘积 Prod = c[L]*c[L+1]*...*c[R]

若某个位置 p 被选择为"牺牲最小 b",则该方案的旗子乘积应该是 Prod / c[p](因为最终那一位不被保留就不乘 c[p])。我们要把所有"p 属于最小 b 的位置"的 Prod / c[p] 加起来:


7、把整个过程再用一个小例子完整走一遍(包含 g 的数值,用普通分数代替模运算,便于理解)

还是例子:

A = [3,5,6], B = [1,2,3], C = [2,3,5]。我们算一遍主要的 DP 路径。

前缀:

  • s = [0,1,3,6]

  • t = [0,-3,2,-4]

  • v = [1,2,6,30](v[0]=1)

  • ic = [ -, 1/2, 1/3, 1/5 ](每个 c 的倒数)

pl/pr 如前: pl=[,3,3,3], pr=[,1,2,3]

初始化:

  • f0=0, g0=1

转移重要一步:i=0 → j=3(把整个 1..3 当作一块)

  • l = max(1, pr[3]=3) = 3

  • r = min(3, pl[1]=3) = 3 → 合法

  • T = t3 - t0 = -4 → 负,说明牺牲奇位上的最小 b(奇位在 [3..3] 是 b3=3,S={3})

  • cand f = f0 + (s3-s0) - mi_奇 = 0 + 6 - 3 = 3 → 候选 f3 = 3

  • h = g0 * Prod(c1..3) = 1 * 30 = 30

  • ct_奇 = sum Inv(c[p] for p in S) = Inv(c3) = 1/5

  • 对 g 的贡献 = h * ct_奇 = 30 * 1/5 = 6 → 这等价于把整段乘积除去 c3(即 2*3 = 6)

  • 因此 g3(此路径带来的贡献) = 6

(解释:这个 6 表示:如果我们选择把 1..3 合并并牺牲第 3 号位置来换取最大糖果,那么最终该种方案对应的旗子乘积是 6。)

最后你会比较所有从不同 i 得到的 f3 值,取最大的(是 3),并把所有产生 f3=3 的路径对应的 g 的权重相加(就是程序最后输出的 g[n])。


五、参考程序:

cpp 复制代码
#include <bits/stdc++.h>
#define FL(i, a, b) for (int i = (a); i <= (b); ++i) // 从 a 到 b 的 for 循环简写;
#define FR(i, a, b) for (int i = (a); i >= (b); --i) // 从 a 递减到 b 的 for 循环简写;
using namespace std;
typedef long long ll; // 长整型别名

const int N = 5e3 + 10; // 数组最大长度(根据题目约束);
const int MOD = 1e9 + 7; // 取模常数;
const ll INFLL = 1e18; // 表示一个很大的长整型(作为负无穷的对照使用); 

// 全局变量(输入与预处理用)
int n, a[N], b[N], c[N], ic[N]; // n 和三个数组 a,b,c;ic 存 c 的逆元;
int pl[N], pr[N], v[N], iv[N]; // pl/pr 用于区间合法性边界;v 为 c 的前缀乘积;iv 为 v 的逆元;
ll mi[2][N][N], ct[2][N][N]; // mi 按奇偶保存区间最小 b,ct 保存对应的 c 的贡献(用于方案计数);
ll s[N], t[N], f[N], g[N]; // s: b 的前缀和;t: a 的交替前缀和;f/g 为 DP 数组;

// 快速幂,用于计算模的幂(求逆元时会用到)
int Qpow(int a, int b) {
    int ret = 1; // 结果初值 1;
    for (; b; b >>= 1) {
        if (b & 1)
            ret = (ll)ret * a % MOD; // 当当前位为 1 时乘入基数并取模;
        a = (ll)a * a % MOD; // 基数平方并取模;
    }
    return ret; // 返回 a^b % MOD;
}

// 计算模逆(模 MOD 下的乘法逆元)
int Inv(int x) {
    return Qpow(x, MOD - 2);  // 费马小定理求逆元(MOD 为质数);
}

void Solve() {
    scanf("%d", &n); // 读取单个测试点的 n;

    // 读入 a,并构造交替前缀和 t
    FL(i, 1, n) {
        scanf("%d", &a[i]);
        t[i] = t[i - 1] + (i & 1? -a[i] : a[i]);
        // t[i] 为 a 的交替前缀和,代码中奇数位减,偶数位加;
    }

    // 读入 b,并构造普通前缀和 s
    FL(i, 1, n) {
        scanf("%d", &b[i]);
        s[i] = s[i - 1] + b[i]; // s[i] 为 b 的前缀和,方便 O(1) 计算区间和;
    }

    // 读入 c,同时构造前缀乘积 v,及需要的逆元 ic, iv
    v[0] = iv[0] = 1; // 空前缀乘积为 1;
    FL(i, 1, n) {
        scanf("%d", &c[i]);
        v[i] = (ll)v[i - 1] * c[i] % MOD; // 前缀乘积 v[i] = product c[1..i] % MOD;
        ic[i] = Inv(c[i]); // 每个 c 的模逆(用于 ct 的构造);
        iv[i] = Inv(v[i]); // 前缀乘积 v[i] 的模逆(用于区间乘积的快速计算);
    }

    // 预处理区间信息:对每个起点 i,计算到 j 的区间内按奇偶分类的最小 b(mi)及其贡献计数(ct)
    FL(i, 1, n) {
        mi[0][i][i - 1] = mi[1][i][i - 1] = INFLL; // 初始化空区间的最小值为很大(便于取 min);
        ct[0][i][i - 1] = ct[1][i][i - 1] = 0; // 空区间贡献为 0;
        FL(j, i, n) {
            FL(k, 0, 1) {
                mi[k][i][j] = mi[k][i][j - 1];
                ct[k][i][j] = ct[k][i][j - 1];
                // 先继承前一个 j-1 的值(滚动更新);
            }
            mi[j & 1][i][j] = min(mi[j & 1][i][j], (ll)b[j]);
            // 根据下标奇偶更新该类的最小 b 值;
            ct[j & 1][i][j] = (ct[j & 1][i][j] + ic[j]) % MOD;
            // 把该位置的 ic(c 的逆元)累加到对应的 ct 上;
            // 说明:这里把 ic 累加起来,配合后面用 v[j]*iv[i] 可以得到区间内这些位置的 c 的乘积之和(在模意义下)
        }
    }

    // 计算 pl/pr:每个位置 i 向右/向左交替相减能扩展到的边界
    FL(i, 1, n) {
        int d = a[i];
        FL(j, i, n) {
            d = a[j + 1] - d; // 模拟从 i 开始向右交替相减的过程(注意访问 a[j+1]);
            if (j == n || d <= 0) {
                pl[i] = j; // 当越过末尾或差变非正时停止,记录右边界 pl[i]
                break;
            }
        }
        d = a[i];
        FR(j, i, 1) {
            d = a[j - 1] - d; // 向左模拟交替相减
            if (j == 1 || d <= 0) {
                pr[i] = (j > 1 && !d? j - 1 : j);
                // 注意当刚好为 0 时的特殊处理(pr 的定义在代码里作了调整)
                break;
            }
        }
    }

    // 初始化 DP 数组 f 和 g;f 为极小值,g 为 0
    fill(f, f + n + 1, -INFLL); // f 用来记录最大 b 和,先填为负无穷
    fill(g, g + n + 1, 0); // g 用来记录达到该 f 时对应的 c 乘积之和(模 MOD)
    f[0] = 0, g[0] = 1; // 空前缀的值为 0,方案权重为 1(乘法中性元)

    // 主 DP:枚举前缀 i,然后枚举下一块末端 j
    FL(i, 0, n) {
        FL(j, i + 1, n) {
            int l = max(i + 1, pr[j]), r = min(j, pl[i + 1]);
            // l,r 用于判断区间 [i+1..j] 是否在 pl/pr 限制下合法
            if (l > r){
                continue; // 不合法区间跳过
            }

            // 计算把前缀 i 的方案 g[i] 与区间 c[i+1..j] 的乘积合并后的基数 h
            int h = (ll)g[i] * v[j] % MOD * iv[i] % MOD;
            // h = g[i] * product(c[i+1..j]) % MOD,作为新方案的基础权重

            if (t[j] - t[i] == 0) {
                // 若交替和为 0,则区间内无需扣除最小 b,全部 b 都能加上
                f[j] = max(f[j], f[i] + (s[j] - s[i]));
                g[j] = (g[j] + h) % MOD; // 所有这样的方案将其 g 权重累加
            } else if (t[j] - t[i] > 0) {
                // 若交替和为正,则需扣除区间 [l..r] 中偶数下标的最小 b
                f[j] = max(f[j], f[i] + (s[j] - s[i]) - mi[0][l][r]);
                // 对应的方案权重按该最小 b 出现位置的 c 的贡献累加
                g[j] = (g[j] + (ll)h * ct[0][l][r]) % MOD;
            } else {
                // 若交替和为负,则需扣除区间 [l..r] 中奇数下标的最小 b
                f[j] = max(f[j], f[i] + (s[j] - s[i]) - mi[1][l][r]);
                g[j] = (g[j] + (ll)h * ct[1][l][r]) % MOD;
            }
        }
    }

    // 输出:最大 f[n](原值),以及对应的 g[n](已模)
    printf("%lld %lld\n", f[n], g[n]);
}

int main() {
    int T;
    scanf("%*d %d", &T); // 输入文件格式中第一项为样例编号,这里跳过它,读取 T(测试点个数)
    while (T--) {
        Solve();
    }
    return 0;
}

六、小结:

  • s 是普通糖果前缀和;t 是交替前缀和(记得奇数位减、偶数位加)。

  • v 是 c 的前缀乘积;iv 是它的逆;ic 是每个 c 的逆(用于"除去某个位置的 c")。

  • mi[par][l][r]:在区间里按奇/偶找最小糖果;ct[par][l][r]:把"把区间乘积除去最小位置的 c"这类贡献预先整理成能快速相乘的形式(用逆元)。

  • pl/pr:判断从某点向左右扩展交替减能走多远(保证区间合法)。

  • DP:把区间当积木拼接;看交替和决定是否需要牺牲哪一类;把 f 用最大化,g 按权重累加(等于用前缀乘积乘以那些 inv(c) 的和)。

相关推荐
是苏浙42 分钟前
蓝桥杯备战day1
算法
A charmer43 分钟前
内存泄漏、死锁:定位排查工具+解决方案(C/C++ 实战指南)
c语言·开发语言·c++
断剑zou天涯43 分钟前
【算法笔记】KMP算法
java·笔记·算法
程序员东岸1 小时前
《数据结构——排序(下)》分治与超越:快排、归并与计数排序的终极对决
数据结构·c++·经验分享·笔记·学习·算法·排序算法
无限进步_1 小时前
C++初始化列表详解:语法、规则与最佳实践
java·开发语言·数据库·c++·git·github·visual studio
某林2121 小时前
在slam建图中为何坐标base_link,laser,imu_link是始终在一起的,但是odom 会与这位三个坐标在运行中产生偏差
人工智能·算法
想看一次满天星1 小时前
阿里140-n值纯算
爬虫·python·算法·网络爬虫·阿里140
Keep__Fighting1 小时前
【机器学习:逻辑回归】
人工智能·python·算法·机器学习·逻辑回归·scikit-learn·matplotlib
想唱rap1 小时前
C++之红黑树
开发语言·数据结构·c++·算法