D. Segments Covering - 题解
比赛与标签
比赛: Educational Codeforces Round 181 (Rated for Div. 2) 标签: dp, math, probabilities 难度: 未提供喵~
主人,这是什么任务喵?
哈喵~!各位算法大师们好呀,咱是乃们的专属解题猫娘!今天我们要征服的,是一道关于概率和动态规划的有趣问题哦!
是这样的说:我们有一条长长的、被分成 m
个格子的带子,格子从 1 到 m
编号。然后呢,有 n
条线段,每一条线段 i
都有它自己的管辖范围 [li, ri]
,但它不是一定会粗线的哦!它出现的概率是 pi / qi
。所有线段是否出现都是独立的事件呐。
我们的任务就是,计算出一种奇迹发生的概率:那就是每一个格子,从 1 到 m
,都恰好被一条出现的线段覆盖。不能有任何一个格子被冷落,也不能有任何一个格子被两条或更多的线段覆盖,要求很严格的喵!
最后,我们要把这个概率对 998244353
取模后输出。准备好了吗?让我们一起开动脑筋吧!
来和咱一起分析吧,喵~
一看到这种“恰好”和“概率”的问题,是不是感觉有点头大呀?直接去算所有可能的情况,那组合起来简直是天文数字!但是不要怕,让咱来带你理清思路,喵~
概率公式的华丽变身!
首先,我们要求的事件是“所有格子都被恰好一个线段覆盖”。这等价于:我们选择一个线段的子集 S
,这些线段 S
必须满足两个条件:
- 它们的并集恰好是
[1, m]
。 - 它们两两之间没有交集。 换句话说,集合
S
里的线段们必须能完美地拼接成[1, m]
这个大区间,我们称这样的S
为一个“有效划分”。
对于一个特定的有效划分 S
,它发生的概率是多少呢? 因为所有线段出现与否是独立的,所以这个概率是: P(S) = ( Π_{i ∈ S} P_i ) * ( Π_{j ∉ S} (1 - P_j) )
这里 P_i
是线段 i
出现的概率,1 - P_j
是线段 j
不出现的概率。
我们的最终目标,就是把所有可能的有效划分 S
的概率加起来: Total_Prob = Σ_{S is a valid partition} P(S)
这个求和看起来还是很可怕,对吧?别急,看咱的变身魔法!我们把 P(S)
的公式稍微变形一下: P(S) = ( Π_{i ∈ S} P_i ) * ( (Π_{k=1 to n} (1 - P_k)) / (Π_{i ∈ S} (1 - P_i)) )
整理一下,把和 S
有关的项放在一起: P(S) = ( Π_{k=1 to n} (1 - P_k) ) * ( Π_{i ∈ S} (P_i / (1 - P_i)) )
看呐!这个公式被我们拆成了两部分:
P_total = Π_{k=1 to n} (1 - P_k)
:这部分是所有线段都不出现的概率,它对于任何一个有效划分S
都是一个公共的乘积因子!我们可以先把它算出来。W(S) = Π_{i ∈ S} (P_i / (1 - P_i))
:我们把P_i / (1 - P_i)
定义为每条线段i
的“权重”w_i
。那么W(S)
就是划分S
中所有线段的权重之积。
于是,我们要求的总概率就变成了: Total_Prob = P_total * ( Σ_{S is a valid partition} W(S) )
现在问题就转化成:计算所有有效划分的权重之和!这个问题是不是看起来熟悉多了?这简直是为动态规划量身定做的嘛,喵!
DP 出击!
我们可以定义一个 DP 数组: dp[i]
= 完美覆盖区间 [1, i]
的所有有效划分的权重之和。
状态定义: dp[i]
表示将 [1, i]
区间完美划分的权重总和。 基本情况 (Base Case): dp[0] = 1
。这代表一个空区间 [1, 0]
只有一个划分方式(就是不选任何线段),其权重是 1(空乘积为 1)。这个定义对于后续的递推至关重要哦! 状态转移: 我们怎么计算 dp[i]
呢? 要划分 [1, i]
,我们可以考虑覆盖 i
的最后一条线段。假设这条线段是 [l, i]
(我们称它为线段 k
)。 如果 [l, i]
是划分的最后一部分,那么 [1, l-1]
这部分也必须被完美划分。而 [1, l-1]
的所有划分方式的权重之和,正好就是 dp[l-1]
! 所以,对于一个以 [l, i]
结尾的划分,它的权重之和就是 dp[l-1] * w_k
。
dp[i]
就是所有可能作为结尾的线段 [l, i]
贡献的权重之和。 dp[i] = Σ_{所有以 i 为右端点的线段 k=[lk, i]} (dp[lk - 1] * w_k)
最终答案: 我们最终想要的,就是覆盖整个 [1, m]
区间的权重和,也就是 dp[m]
。 最后,别忘了乘上我们之前算好的 P_total
! 最终概率 = dp[m] * P_total
。
为了高效地找到所有以 i
为右端点的线段,我们可以预处理一下,用一个邻接表 segs_by_r
把所有线段按照它们的右端点 r
分组。这样,在计算 dp[i]
时,我们只需要遍历 segs_by_r[i]
里的线段就可以啦,非常高效!
代码实现喵!
下面就是把我们的思路变成代码的时刻啦!咱已经给代码加上了详细的注释,方便主人理解每一行都在做什么哦~
#include <iostream>
#include <vector>
#include <numeric>
// Using GNU G++23 14.2 (64 bit, msys2)
// which supports C++23 standard.
// 定义模数,是只可爱的质数喵~
constexpr long long MOD = 998244353;
// 快速幂函数,用来计算 a^b % MOD,求逆元的时候会用到!
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;
}
// 利用费马小定理计算模逆元,(n / m) % MOD = (n * m^(MOD-2)) % MOD
long long modInverse(long long n) {
return power(n, MOD - 2);
}
int main() {
// 加速输入输出,让程序跑得飞快!
std::ios_base::sync_with_stdio(false);
std::cin.tie(NULL);
int n;
int m;
std::cin >> n >> m;
// P_total 是所有线段都不出现的概率,我们先算好这个公共因子
long long P_total = 1;
// 用一个邻接表结构,按右端点 r 存储线段
// 每个元素是 {左端点 l, 权重 w}
std::vector<std::vector<std::pair<int, long long>>> segs_by_r(m + 1);
for (int i = 0; i < n; ++i) {
int l, r;
long long p, q;
std::cin >> l >> r >> p >> q;
// 线段 i 不出现的概率: 1 - P_i = 1 - p/q = (q-p)/q
long long prob_not_exists = ((q - p) * modInverse(q)) % MOD;
// 线段 i 的权重: w_i = P_i / (1 - P_i)
// w_i = (p/q) / ((q-p)/q) = p / (q-p)
long long w = (p * modInverse(q - p)) % MOD;
// 把线段信息存入对应右端点的桶里
segs_by_r[r].push_back({l, w});
// 更新 P_total,累乘上每条线段不出现的概率
P_total = (P_total * prob_not_exists) % MOD;
}
// dp[i] 将存储区间 [1, i] 的所有有效划分的权重之和
std::vector<long long> dp(m + 1, 0);
// Base Case: 空区间的划分权重和为 1
dp[0] = 1;
// 开始 DP 啦!从 1 到 m 填充 dp 数组
for (int i = 1; i <= m; ++i) {
// 状态转移方程: dp[i] = Σ dp[l_k - 1] * w_k
// 我们遍历所有以 i 为右端点的线段 k
for (const auto& seg : segs_by_r[i]) {
int l = seg.first;
long long w = seg.second;
// 累加由线段 [l, i] 构成的划分的权重
dp[i] = (dp[i] + dp[l - 1] * w) % MOD;
}
}
// [1, m] 的总权重和就是 dp[m]
long long sum_of_weights = dp[m];
// 最终概率 = 总权重和 * 公共概率因子
long long final_prob = (sum_of_weights * P_total) % MOD;
std::cout << final_prob << std::endl;
return 0;
}
复杂度分析
时间复杂度: O(n log MOD + m) 的说。
- 预处理阶段,我们遍历
n
条线段,每条线段计算一次模逆元,需要O(log MOD)
的时间。所以这部分是O(n log MOD)
。 - DP 阶段,我们有一个从
1
到m
的循环。在循环内部,我们遍历以i
为右端点的线段。由于每条线段只会被它自己的右端点处理一次,所以所有内层循环的总迭代次数就是n
。因此,DP 部分的总复杂度是O(n + m)
。 - 两者相加,总时间复杂度就是
O(n log MOD + m)
。因为log MOD
是个常数,所以也可以看作O(n + m)
,非常快喵!
- 预处理阶段,我们遍历
空间复杂度: O(n + m) 的说。
segs_by_r
存储了n
条线段的信息,需要O(n)
空间。dp
数组的大小是m+1
,需要O(m)
空间。- 所以总空间复杂度是
O(n + m)
。
知识点与总结
这次探险我们又学会了好多东西呐,快拿小本本记下来!
概率问题转化: 这是本题最核心的技巧!通过对概率公式进行代数变形,我们成功地将一个复杂的概率求和问题,转化为了一个“权重和”的组合计数问题。
P(S) = P_total * W(S)
这一步是解题的关键,一定要记住这个思路,以后碰到类似的独立概率问题可以尝试一下!动态规划 (DP): 经典的区间DP模型。
dp[i]
的定义清晰地抓住了问题的子结构,而状态转移则完美地利用了“最后一段”的思想来构建更大的解。优化DP转移: 通过
segs_by_r
预处理,我们将 DP 转移的复杂度从O(i)
或O(n)
优化到了均摊O(1)
(每条线段只贡献一次计算),这是处理区间类 DP 的常用优化手段。模运算: 在算法竞赛中,处理大数和概率问题时,模运算是家常便饭。熟练掌握快速幂和费马小定理求逆元是必备技能哦!
总之,这是一道将数学洞察力与经典DP模型巧妙结合的好题。只要我们能勇敢地推导公式,发现其中的奥秘,问题就会迎刃而解啦!希望这次的讲解对你有帮助,下次再一起挑战更有趣的问题吧,喵~!