233玩游戏 - 题解
标签与难度
标签: 生成函数, 多项式, NTT, 概率DP, 分治 难度: 2500
题目大意喵~
各位Master,下午好喵~ 让我们一起来玩个有趣的游戏吧!
游戏有 轮,我们从 1级 开始,初始血量是 。
在每一轮,如果我们的等级是 ,血量是 ,会发生以下事件:
- 有 的概率 升级。
- 如果当前是1级,会升到2级,但血量会减少 点。
- 如果当前是2级,再次“升级”的话,血量会直接变成0,游戏就结束啦!
- 有 的概率 不升级。
- 在这种情况下,又有 的概率血量减少1点。
- 剩下 的概率血量不变。
每一次事件(升级、掉血、或无事发生)都标志着一轮的结束。
我们的任务是,对于从第1轮到第 轮的每一轮 ,计算出在第 轮结束后,血量恰好为 的概率是多少。所有概率都要对 取模哦!
解题思路分析
这道题看起来像一个动态规划问题,但是状态包含了轮数、等级和血量,而且血量的转移概率还和当前的血量有关,直接DP的话状态空间太大了,会超时的说!(>ω<)
当遇到这种复杂的转移和需要求精确值的概率DP时,通常是我们的好朋友——生成函数——大显身手的时候了,喵!
我们可以用生成函数来描述概率分布。不过,这次我们不把多项式的变量 对应血量,而是对应轮数!
建立生成函数模型
我们定义两个系列的生成函数:
- :其中 是在第 轮后,等级为1、血量为 的概率。
- :其中 是在第 轮后,等级为2、血量为 的概率。
我们的最终目标是求出 ,也就是 。
根据题意,我们可以写出概率 和 的递推关系:
初始条件是 ,其他所有在第0轮的概率都是0。
把这些递推关系转换成生成函数的形式,两边同时乘以 再对所有 求和,经过一番魔法推导(涉及到分离 的项和重新索引求和),我们可以得到关于 和 的关系式:
这里 是一个克罗内克符号,当 时为1,否则为0。
求解 (等级1的概率)
我们来简化一下记号,让我的爪爪打字轻松一点~ 令 , , 。
关系式变成了:
因为血量最高从 开始,所以 for 。 我们可以从 开始向下递推: ... 一直推到我们关心的血量 ,可以得到:
这个表达式的分子和分母都是关于 的多项式!
- 分母 是一个度数为 的多项式。我们可以用分治+NTT在 的时间里把它算出来。
- 分子 更简单,因为 是 的一次项,所以分子是 的形式,其中 是一个可以 预处理逆元后算出的常数。
于是,。我们只需求出 的多项式逆元,再和分子乘起来,就得到了 的系数,也就是各轮在等级1、血量为 的概率啦!
求解 (等级2的概率)
等级2的生成函数 稍微复杂一点,喵~
这是一个关于 的线性递推式。把它展开,可以得到 的表达式:
把我们之前解出的 代入,并通分,最后可以化简成:
其中 是我们上面求出的同一个分母, 是一个只含常数和 的幂次的项。
这个式子的核心是计算分子那个巨大的和式。我们令它为 。 仔细观察, 是一个度数只有 的多项式,我们可以把它看作一个大小为 的滑动窗口。当求和的索引 变化时,这个窗口也跟着滑动。 而 部分化简后是 的形式。
所以,。
我们可以写一个循环,从 到 :
- 维护一个大小为 的滑动窗口,计算出窗口内多项式 。窗口每滑动一次,就乘上一个新的线性多项式,再除以一个旧的。因为是线性多项式,乘除法都是 的。
- 计算出常数 。
- 把 乘上窗口多项式,累加到一个总和多项式 上。
这个过程的复杂度是 。只要 不太大,这就是可以接受的! 计算出 后,。
总结与合成
- 分治NTT求出公共分母 。
- 多项式求逆得到 。
- 计算 的系数。
- 用 的滑动窗口方法计算出 的分子多项式 。
- 计算 的系数。
- 将 和 的系数相加,得到每一轮 的最终答案 。
好啦,思路清晰了,让我们用代码来实现这个魔法吧!喵~
代码实现
#include <iostream>
#include <vector>
#include <numeric>
#include <algorithm>
// MOD and NTT parameters
const int MOD = 998244353;
const int G = 3;
// Fast power function
long long power(long long base, long long exp) {
long long res = 1;
base %= MOD;
while (exp > 0) {
if (exp % 2 == 1) res = (res * base) % MOD;
base = (base * base) % MOD;
exp /= 2;
}
return res;
}
// Modular inverse function
long long modInverse(long long n) {
return power(n, MOD - 2);
}
// NTT implementation
namespace Poly {
std::vector<int> rev;
std::vector<long long> w;
void precompute_fft(int n) {
if (w.size() >= n) return;
w.resize(n);
int l = 0;
while ((1 << l) < n) l++;
rev.resize(n);
for (int i = 0; i < n; i++) {
rev[i] = (rev[i >> 1] >> 1) | ((i & 1) << (l - 1));
}
long long wn = power(G, (MOD - 1) / n);
w[0] = 1;
for (int i = 1; i < n; i++) {
w[i] = w[i - 1] * wn % MOD;
}
}
void ntt(std::vector<long long>& a, bool invert) {
int n = a.size();
for (int i = 0; i < n; i++) {
if (i < rev[i]) std::swap(a[i], a[rev[i]]);
}
for (int len = 2; len <= n; len <<= 1) {
int mid = len >> 1;
for (int i = 0; i < n; i += len) {
for (int j = 0; j < mid; j++) {
long long t = w[n / len * j] * a[i + j + mid] % MOD;
a[i + j + mid] = (a[i + j] - t + MOD) % MOD;
a[i + j] = (a[i + j] + t) % MOD;
}
}
}
if (invert) {
std::reverse(a.begin() + 1, a.end());
long long inv_n = modInverse(n);
for (int i = 0; i < n; i++) {
a[i] = a[i] * inv_n % MOD;
}
}
}
std::vector<long long> multiply(std::vector<long long> a, std::vector<long long> b) {
int sz = 1;
while (sz < a.size() + b.size()) sz <<= 1;
a.resize(sz);
b.resize(sz);
precompute_fft(sz);
ntt(a, false);
ntt(b, false);
for (int i = 0; i < sz; i++) a[i] = a[i] * b[i] % MOD;
ntt(a, true);
return a;
}
std::vector<long long> inverse(const std::vector<long long>& a, int n) {
if (n == 1) return {modInverse(a[0])};
std::vector<long long> a0 = inverse(a, (n + 1) / 2);
int sz = 1;
while (sz < 2 * n) sz <<= 1;
std::vector<long long> current_a(a.begin(), a.begin() + std::min((int)a.size(), n));
current_a.resize(sz);
a0.resize(sz);
precompute_fft(sz);
ntt(current_a, false);
ntt(a0, false);
for (int i = 0; i < sz; i++) {
a0[i] = a0[i] * (2 - current_a[i] * a0[i] % MOD + MOD) % MOD;
}
ntt(a0, true);
a0.resize(n);
return a0;
}
}
// Function to compute product of linear polynomials using divide and conquer
std::vector<long long> product_tree(const std::vector<std::vector<long long>>& polys, int l, int r) {
if (l == r) return polys[l];
if (l + 1 == r) return Poly::multiply(polys[l], polys[r]);
int mid = l + (r - l) / 2;
return Poly::multiply(product_tree(polys, l, mid), product_tree(polys, mid + 1, r));
}
int main() {
std::ios_base::sync_with_stdio(false);
std::cin.tie(NULL);
int n, a, b, t, o;
std::cin >> n >> a >> b >> t >> o;
long long p = (long long)a * modInverse(b) % MOD;
long long q = (1 - p + MOD) % MOD;
// Precompute modular inverses for k and coefficients v_k
std::vector<long long> invs(n + o + 2);
std::vector<long long> v(n + o + 2);
invs[1] = 1;
for (int k = 2; k <= n + o + 1; ++k) {
invs[k] = MOD - (long long)(MOD / k) * invs[MOD % k] % MOD;
}
for (int k = 1; k <= n + o + 1; ++k) {
v[k] = q * (k - 1) % MOD * invs[k] % MOD;
}
// Part 1: Compute the common denominator polynomial P(z)
std::vector<std::vector<long long>> d_polys;
for (int k = t; k <= n + o; ++k) {
d_polys.push_back({1, (MOD - v[k]) % MOD});
}
std::vector<long long> P = product_tree(d_polys, 0, d_polys.size() - 1);
P.resize(n + 1, 0);
// Get its inverse
std::vector<long long> P_inv = Poly::inverse(P, n + 1);
// Part 2: Compute F_t(z)
int shift_f = n + o - t;
long long C_f = 1;
for (int k = t; k <= n + o - 1; ++k) {
C_f = C_f * q % MOD * invs[k + 1] % MOD;
}
std::vector<long long> prob_l1(n + 1, 0);
for (int i = 0; i <= n; ++i) {
if (i >= shift_f) {
prob_l1[i] = C_f * P_inv[i - shift_f] % MOD;
}
}
// Part 3: Compute G_t(z)'s numerator part S(z)
std::vector<long long> S(o + 1, 0);
if (t <= n) {
// Sliding window for polynomial product W_j(z)
std::vector<long long> W = {1};
for (int k = n + 1; k <= n + o - 1; ++k) {
std::vector<long long> next_term = {1, (MOD - v[k]) % MOD};
W = Poly::multiply(W, next_term);
W.resize(o + 1);
}
// Sliding window for constant C_j'
long long C_prefix = 1;
for (int k = t; k <= n - 1; ++k) C_prefix = C_prefix * q % MOD * invs[k + 1] % MOD;
long long C_suffix = 1;
// C_j = p * C_prefix * C_suffix
for (int j = n; j >= t; --j) {
long long C_j_prime = p * C_prefix % MOD * C_suffix % MOD;
for (int k = 0; k < W.size(); ++k) {
S[k] = (S[k] + C_j_prime * W[k]) % MOD;
}
// Update for j-1
if (j > t) {
// Update W
std::vector<long long> D_j_plus_o_inv = {1, v[j + o - 1]}; // (1-v_k z)^-1 ~ 1+v_k z
for (int k = o; k > 0; --k) D_j_plus_o_inv[k] = (D_j_plus_o_inv[k] + v[j + o - 1] * D_j_plus_o_inv[k - 1]) % MOD;
W = Poly::multiply(W, D_j_plus_o_inv);
std::vector<long long> D_j = {1, (MOD - v[j]) % MOD};
W = Poly::multiply(W, D_j);
W.resize(o+1);
// Update C
C_prefix = C_prefix * (j) % MOD * modInverse(q) % MOD;
C_suffix = C_suffix * q % MOD * invs[j + o] % MOD;
}
}
}
// Compute G_t(z)
int shift_g = n - t + 1;
std::vector<long long> N_g;
if (t <= n) {
N_g.resize(shift_g, 0);
for(long long coeff : S) N_g.push_back(coeff);
}
std::vector<long long> prob_l2_num = Poly::multiply(N_g, P_inv);
// Final answer
for (int i = 1; i <= n; ++i) {
long long ans = prob_l1[i];
if (i < prob_l2_num.size()) {
ans = (ans + prob_l2_num[i]) % MOD;
}
std::cout << ans << "\n";
}
return 0;
}复杂度分析
时间复杂度: 。
- 计算分母多项式 使用分治NTT,复杂度为 ,但通过更优化的实现可以做到 。这里 是多项式度数,约等于
n+o。 - 多项式求逆的复杂度是 。
- 计算 分子的循环部分,有 次迭代,每次迭代中涉及滑动窗口,主要是度数为 的多项式乘法,复杂度是 。总共是 。如果用 的朴素乘法代替NTT,则是 。我的代码里偷懒用了 的NTT乘法。
- 最终的各项计算都是 级别。
- 主导复杂度的是 。
- 计算分母多项式 使用分治NTT,复杂度为 ,但通过更优化的实现可以做到 。这里 是多项式度数,约等于
空间复杂度: 。
- 主要空间开销来自于存储各个多项式,以及NTT需要的辅助数组,与多项式的最高次(即 )成正比。
知识点总结
这道题是一道非常硬核的生成函数应用题,将它攻克下来,Master一定能收获满满,喵!
- 概率与生成函数: 对于复杂的概率DP问题,特别是转移与状态值本身相关时,生成函数是一个强有力的工具。将轮数作为变量,可以把递推关系转化为代数方程。
- 多项式全家桶:
- NTT (快速数论变换): 在模意义下快速计算多项式乘法(卷积)的核心算法。
- 多项式求逆: 求解 的基本操作,通常用牛顿迭代法实现。
- 分治求多项式乘积: 高效计算 形式的多项式。
- 滑动窗口思想: 在计算 的分子时,我们巧妙地将一个复杂乘积的维护看作一个滑动窗口,每次迭代只需要 的代价来更新,避免了重复计算。
- 化繁为简的数学推导: 解题的关键在于将复杂的递推关系一步步转化为可以计算的多项式形式。虽然过程可能有点绕,但这是通往正确解法的必经之路,呐!
希望这篇题解能帮助到你,Master!要继续加油哦!喵~ (ฅ'ω'ฅ)