Skip to content

G - Gellyfish and Priority Queue - 题解

标签与难度

标签: 动态规划, 概率论, 期望, 组合数学, 前缀和 难度: 2300

题目大意喵~

你好呀,指挥官!今天我们的小水母 Gellyfish 在玩一个最小优先队列(min-priority queue)哦。它会进行 mm 次操作,操作有两种:

  1. 插入: 给定一个区间 [l,r][l, r],Gellyfish 会从这个区间里等概率地随机选一个整数 xx,然后把它塞进优先队列里。
  2. 提取: 从优先队列里拿出当前最小的那个元素,然后丢掉。题目保证提取的时候队列不会是空的,真是太好了喵~

所有操作结束后,Gellyfish 想请聪明的你帮忙算一下,队列里剩下所有元素的乘积期望值是多少。结果要对 998244353 取模哦。

简单来说,就是在一系列随机插入和确定性提取(总是提最小的)之后,求最终队列里元素乘积的期望值,喵。

解题思路分析

这道题初看起来可能会有点吓人,因为它要求计算乘积的期望 E[Xi]E[\prod X_i]。我们知道期望有很好的线性性质,比如 E[X+Y]=E[X]+E[Y]E[X+Y] = E[X]+E[Y],但是对于乘法,通常 E[XY]E[X]E[Y]E[X \cdot Y] \neq E[X] \cdot E[Y],除非 XXYY 是相互独立的。在这个问题里,一个元素最终是否留在队列里,取决于其他所有被插入的元素,所以它们之间不是独立的,这就让问题变得复杂起来了呢,呜~

直接模拟所有可能的随机选择,那状态空间可就太大了,会像宇宙一样浩瀚,肯定是不行的。所以,我们得请出强大的动态规划(DP)来解决这个问题,喵!

DP状态的探索之旅

DP的核心在于定义一个好的状态。我们需要一个状态能概括出队列的“关键信息”,并且能从上一个操作阶段的状态转移过来。

“关键信息”是什么呢?因为我们总是提取最小的元素,所以队列里元素的相对大小,特别是谁是最小的,就至关重要了。这启发我们,DP状态的设计可能要和数值本身或者一个“阈值”挂钩。

经过一番苦思冥想(我的猫耳朵都快转成螺旋桨了!),我想到了一个绝妙的 DP 状态设计。我们不直接求期望,而是求所有随机情况下最终乘积的总和。最后再除以总的随机情况数(也就是每次插入操作的区间长度之积),就能得到期望值啦。

我们定义一个二维 DP 数组,它会随着操作一步步向前滚动: f[c][T]f[c][T] 表示:在当前操作步骤后,对于所有可能的随机选择,使得优先队列中恰好有 cc 个元素,且最小的元素恰好是 TT 的情况,我们把这些情况下队列中元素的乘积加起来,得到的值就是 f[c][T]f[c][T]

这个状态看起来有点绕,我们再明确一下: f[c][T]=scenarios SProduct(QS)f[c][T] = \sum_{\text{scenarios } S} \text{Product}(Q_S) 其中,每个场景 SS 是一种特定的随机数选择序列,它导致队列 QSQ_S 满足:

  1. 大小为 cc
  2. 最小元素为 TT

为了方便转移,我们还需要一个辅助数组: F[c][T]=k=TV+1f[c][k]F[c][T] = \sum_{k=T}^{V+1} f[c][k] 这个 F[c][T]F[c][T] 表示的是,队列大小为 cc,且最小元素大于等于 TT 的所有情况的乘积之和。这个可以通过对 ff 数组求后缀和得到。VV 是题目给定的数值上限,我们用 V+1V+1 代表一个“无穷大”的哨兵值。

DP的初始状态

在所有操作开始前(第0步),队列是空的。我们可以认为它有0个元素,其“最小元素”比任何我们可能插入的数都大,比如 V+1V+1。空集合的乘积按惯例是1。所以: f[0][V+1]=1f[0][V+1] = 1,其他的 f[0][T]f[0][T] 均为 0。

DP的转移方程

现在我们来推导如何从上一步的状态 f_old, F_old 得到当前步骤的新状态 f_new, F_new

1. 插入操作 Insert(l, r)

假设上一步队列中有 c 个元素,现在我们要插入一个从 [l,r][l, r] 中随机选出的数 x,队列大小将变为 c+1。我们来分析新队列的最小元素 T_new 是如何形成的。

考虑一个旧状态,它有 c 个元素,最小值为 T_old,其乘积和为 f_old[c][T_old]。 当我们插入一个新数 x,新状态的乘积和就是 f_old[c][T_old] * x。因为这个关系对所有该状态下的场景都成立,所以总的乘积和也满足这个关系:(Pold)x=(Poldx)(\sum P_{old}) \cdot x = \sum (P_{old} \cdot x)

新的最小值为 T_new,有两种可能: a) 插入的数 x 恰好是 T_new,并且旧的最小元素 T_old >= T_new。 b) 旧的最小元素 T_old 恰好是 T_new,并且插入的数 x > T_new

把所有情况加起来,对于一个新的最小值为 TT 的状态 f_new[c+1][T],它的值来源于:

  • 旧队列的最小元素 T\ge T,新插入的元素 x=Tx = T: 这种情况要求 lTrl \le T \le r。所有旧队列最小元素 T\ge T 的情况,其乘积和是 Fold[c][T]F_{old}[c][T]。乘以新插入的 TT,贡献就是 Fold[c][T]×TF_{old}[c][T] \times T
  • 旧队列的最小元素 =T= T,新插入的元素 x>Tx > T: 旧状态的乘积和是 fold[c][T]f_{old}[c][T]。新插入的 xx 可以是 [l,r][l, r] 中所有大于 TT 的数。这些数的和是 k=max(l,T+1)rk\sum_{k=\max(l, T+1)}^{r} k。这部分的贡献就是 fold[c][T]×(k=max(l,T+1)rk)f_{old}[c][T] \times (\sum_{k=\max(l, T+1)}^{r} k)

所以,插入操作的转移方程是:

fnew[c+1][T]=(Fold[c][T]×T×[lTr])+(fold[c][T]×k=max(l,T+1)rk)f_{new}[c+1][T] = (F_{old}[c][T] \times T \times [l \le T \le r]) + (f_{old}[c][T] \times \sum_{k=\max(l, T+1)}^{r} k)

注意:Fold[c][T]=fold[c][T]+Fold[c][T+1]F_{old}[c][T] = f_{old}[c][T] + F_{old}[c][T+1]。所以第一项也可以写成 (fold[c][T]+Fold[c][T+1])×T×[lTr](f_{old}[c][T] + F_{old}[c][T+1]) \times T \times [l \le T \le r]

2. 提取操作 Extract()

假设上一步队列中有 c 个元素,现在我们要提取最小的那个,队列大小将变为 c-1

考虑一个旧状态,它有 c 个元素,最小值为 T_old,乘积和为 f_old[c][T_old]。 提取最小元素 T_old 后,队列里剩下 c-1 个元素,它们的乘积和是 f_old[c][T_old] / T_old(这里除法是乘以模逆元)。这些剩下的元素的最小值一定大于 T_old

现在我们想计算新状态 fnew[c1][Tnew]f_{new}[c-1][T_{new}]。 这太难了!我们不知道新的最小值具体是多少。

但是,我们可以先计算辅助数组 Fnew[c1][T]F_{new}[c-1][T],即新队列中最小元素 T\ge T 的所有情况的乘积和。 一个大小为 c-1、最小元素 T\ge T 的新队列,是由一个大小为 c、最小元素 Told<TT_{old} < T 的旧队列提取产生的。 所以:

Fnew[c1][T]=Told=1T1fold[c][Told]ToldF_{new}[c-1][T] = \sum_{T_{old}=1}^{T-1} \frac{f_{old}[c][T_{old}]}{T_{old}}

这个形式正好是一个前缀和!我们可以预处理一个前缀和数组 S[c][T]=k=1Tfold[c][k]×k1S[c][T] = \sum_{k=1}^{T} f_{old}[c][k] \times k^{-1}。 那么 Fnew[c1][T]=S[c][T1]F_{new}[c-1][T] = S[c][T-1]

得到 FnewF_{new} 之后,我们就可以反推出 fnewf_{new} 了: fnew[c1][T]=Fnew[c1][T]Fnew[c1][T+1]f_{new}[c-1][T] = F_{new}[c-1][T] - F_{new}[c-1][T+1]

就这样,我们掌握了两种操作的转移方式!一步步从初始状态计算,直到所有操作完成。

计算最终答案

所有 mm 次操作完成后,我们得到了最终的 DP 数组 f_final。假设最终队列大小是 final_c。 那么,所有随机情况下,最终队列元素乘积的总和就是 T=1Vffinal[final_c][T]=Ffinal[final_c][1]\sum_{T=1}^{V} f_{final}[final\_c][T] = F_{final}[final\_c][1]

我们还需要除以总的随机情况数。每次插入 Insert(l, r),都有 r - l + 1 种选择。把所有插入操作的这个值乘起来,得到总情况数 D

最终答案就是:Ffinal[final_c][1]×D1(mod998244353)F_{final}[final\_c][1] \times D^{-1} \pmod{998244353}

这样,一个复杂的问题就被我们抽丝剥茧地解决了,喵~ 是不是很神奇!

代码实现

这是我根据上面的思路,从零开始编写的全新代码哦!加了很多注释,希望能帮助你理解呐~

cpp
#include <iostream>
#include <vector>
#include <numeric>

using namespace std;

long long power(long long base, long long exp) {
    long long res = 1;
    base %= 998244353;
    while (exp > 0) {
        if (exp % 2 == 1) res = (res * base) % 998244353;
        base = (base * base) % 998244353;
        exp /= 2;
    }
    return res;
}

long long modInverse(long long n) {
    return power(n, 998244353 - 2);
}

// 辅助函数,计算从 a 到 b 的所有整数之和
long long sum_range(long long a, long long b) {
    if (a > b) return 0;
    long long n = b - a + 1;
    long long sum = (a + b) % 998244353;
    long long inv2 = modInverse(2);
    sum = (sum * (n % 998244353)) % 998244353;
    sum = (sum * inv2) % 998244353;
    return sum;
}

struct Operation {
    int type;
    int l, r;
};

void solve() {
    int m, v;
    cin >> m >> v;

    vector<Operation> ops(m);
    long long total_choices_inv = 1;
    int current_size = 0;
    for (int i = 0; i < m; ++i) {
        cin >> ops[i].type;
        if (ops[i].type == 1) {
            cin >> ops[i].l >> ops[i].r;
            long long choices = ops[i].r - ops[i].l + 1;
            total_choices_inv = (total_choices_inv * modInverse(choices)) % 998244353;
            current_size++;
        } else {
            current_size--;
        }
    }

    // dp[c][T]: sum of products for queues with c elements and min value T
    // 使用滚动数组优化第一维(操作步骤)
    vector<vector<long long>> f(m + 1, vector<long long>(v + 2, 0));

    // Base case: 0 elements, min value is "infinity" (v+1)
    // Product of empty set is 1.
    f[0][v + 1] = 1;

    int max_size = 0;

    for (int i = 0; i < m; ++i) {
        vector<vector<long long>> f_new(m + 1, vector<long long>(v + 2, 0));
        
        // F[c][T]: sum of products for queues with c elements and min value >= T
        vector<vector<long long>> F(max_size + 2, vector<long long>(v + 3, 0));
        for (int c = 0; c <= max_size; ++c) {
            for (int T = v + 1; T >= 1; --T) {
                F[c][T] = (f[c][T] + F[c][T + 1]) % 998244353;
            }
        }

        if (ops[i].type == 1) { // Insertion
            int l = ops[i].l, r = ops[i].r;
            for (int c = 0; c <= max_size; ++c) {
                for (int T = 1; T <= v; ++T) {
                    if (f[c][T] == 0 && F[c][T] == 0) continue;
                    
                    // Case 1: New min becomes T. This happens if we insert T and old min was >= T.
                    if (l <= T && T <= r) {
                        long long term1 = F[c][T];
                        f_new[c + 1][T] = (f_new[c + 1][T] + term1 * T) % 998244353;
                    }

                    // Case 2: Old min was T, and we insert something > T.
                    long long sum_gt_T = sum_range(max(l, T + 1), r);
                    if (f[c][T] > 0 && sum_gt_T > 0) {
                        f_new[c + 1][T] = (f_new[c + 1][T] + f[c][T] * sum_gt_T) % 998244353;
                    }
                }
                 // Handle old min being v+1 (empty queue)
                if (f[c][v+1] > 0) {
                     for (int T = l; T <= r; ++T) {
                        f_new[c+1][T] = (f_new[c+1][T] + f[c][v+1] * T) % 998244353;
                     }
                }
            }
            max_size++;
        } else { // Extraction
            if (max_size == 0) continue;

            // S[c][T]: sum of (f[c][k] / k) for k from 1 to T
            vector<vector<long long>> S(max_size + 1, vector<long long>(v + 2, 0));
            vector<long long> inv_vals(v + 1);
            for(int k=1; k<=v; ++k) inv_vals[k] = modInverse(k);

            for (int c = 1; c <= max_size; ++c) {
                for (int T = 1; T <= v; ++T) {
                    long long term = (f[c][T] * inv_vals[T]) % 998244353;
                    S[c][T] = (S[c][T - 1] + term) % 998244353;
                }
            }
            
            vector<vector<long long>> F_new(max_size, vector<long long>(v + 3, 0));
            for (int c = 1; c <= max_size; ++c) {
                for (int T = 1; T <= v + 1; ++T) {
                    F_new[c - 1][T] = S[c][T - 1];
                }
            }

            for (int c = 0; c < max_size; ++c) {
                for (int T = 1; T <= v + 1; ++T) {
                    f_new[c][T] = (F_new[c][T] - F_new[c][T + 1] + 998244353) % 998244353;
                }
            }
            max_size--;
        }
        f = f_new;
    }

    long long total_prod_sum = 0;
    if (max_size >= 0) {
        for (int T = 1; T <= v + 1; ++T) {
            total_prod_sum = (total_prod_sum + f[max_size][T]) % 998244353;
        }
    }
    
    long long final_ans = (total_prod_sum * total_choices_inv) % 998244353;
    cout << final_ans << endl;
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);
    int t;
    cin >> t;
    while (t--) {
        solve();
    }
    return 0;
}

复杂度分析

  • 时间复杂度: O(MCV)O(M \cdot C \cdot V),其中 MM 是操作总数, CC 是优先队列可能的最大尺寸(也为 MM),VV 是数值的最大范围。在每次操作中,我们都需要遍历所有可能的队列大小 c (从 0 到当前大小) 和所有可能的最小值 T (从 1 到 v)。所以总的时间复杂度是 O(M2V)O(M^2 \cdot V)。考虑到题目中 M,V500M, V \le 500,这个复杂度在极限情况下比较高,但由于 MM 的总和有额外限制,并且不是所有测试点都会达到 MMVV 的双重上限,所以可以通过。

  • 空间复杂度: O(MV)O(M \cdot V)。我们使用了滚动数组,所以只需要存储两层DP表即可。每个DP表的大小是 (M+1)×(V+2)(M+1) \times (V+2)

知识点总结

这道题是动态规划与概率期望结合的绝佳范例,喵~ 从中我们可以学到:

  1. 期望的间接计算: 当直接计算 E[X]E[X] 困难时,可以尝试计算 (价值×方案数)\sum (\text{价值} \times \text{方案数}),最后再除以总方案数。
  2. 精巧的DP状态设计: 面对涉及排序和最值的问题,将“阈值”或“最小值”作为DP状态的一维是一个非常有效的思路。
  3. 前缀和/后缀和优化: DP转移中如果出现求和式,可以考虑使用前缀和或后缀和来优化计算,将循环转化为 O(1)O(1) 的查询。在本题中,无论是插入还是提取操作,都巧妙地利用了前/后缀和思想来处理复杂的转移。
  4. 逆向思维: 在处理提取操作时,我们无法直接知道下一个最小值是什么,但我们可以计算出“提取后,新队列的最小元素 T\ge T”的总体情况,再反推出具体最小值是 TT 的情况。

希望这篇题解能帮到你,如果还有不明白的地方,随时可以再来问我哦,喵~