尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

从LightOJ 1408题解看马尔可夫链期望问题的建模与求解

从LightOJ 1408题解看马尔可夫链期望问题的建模与求解 1. 项目概述从一道概率题到解方程思维的实战训练看到LightOJ 1408 Batting Practice这个标题很多刚接触算法竞赛的同学可能会有点懵这听起来像是个体育模拟题实际上这是一道隐藏在“棒球练习”情景下的、经典的离散概率期望与解方程问题。它来自知名的在线判题平台 LightOJ题号 1408在算法竞赛圈尤其是准备区域赛ICPC和各类在线编程挑战的选手中这道题有着相当的知名度。我最初遇到它时也被其看似复杂的描述绕了进去但一旦剥离情景抓住其概率论与代数的核心就会发现这是一道锻炼数学建模和方程求解能力的绝佳例题。这道题的核心价值在于它强迫你从一个动态的、带有终止条件的随机过程中抽象出严谨的数学关系并最终通过建立和求解线性方程组来得到精确解。这不仅仅是套公式而是需要你理解“期望”在具有吸收态本题中的成功或失败达到指定次数的马尔可夫链中是如何计算的。对于开发者而言这种将现实问题即使是简化的转化为数学模型并求解的能力在游戏伤害模拟、系统可靠性分析、金融风险评估等需要处理随机性的领域都是非常宝贵的。接下来我将彻底拆解这道题从题意理解、数学推导、方程构建到代码实现分享我的解题思路和踩过的坑目标是让你不仅能AC这道题更能掌握这一类问题的通解思维。2. 题意解析与数学建模把棒球比赛抽象成状态机要解决任何问题第一步永远是彻底理解题意。LightOJ 1408 的题目描述大致如下一名击球手正在进行击球练习。在每一次尝试中他有概率p击出一个“好球”成功有概率(1-p)击出一个“坏球”失败。但是练习的结束规则有点特别如果击球手连续击出 k1 个好球则练习成功结束。如果击球手连续击出 k2 个坏球则练习失败结束。题目需要我们计算的是这个练习过程总共需要进行的击球尝试次数的数学期望是多少这里的p是一个介于 0 到 1 之间的实数k1和k2是正整数。输入就是这三者输出是期望值。核心难点洞察这个过程不是简单的独立重复试验。每一次击球的结果好/坏都会影响“连续计数”而连续计数直接决定了过程是否会立即终止。这是一个典型的带有吸收态的马尔可夫过程。我们可以把击球手在任何时刻的状态定义为他当前连续好球数和连续坏球数吗仔细一想不对。因为“连续”意味着不能同时存在两个计数如果刚打出一个好球那么连续坏球数就应该清零。所以一个更精简且完备的状态定义是状态(i, j)表示当前击球手已经连续击出了 i 个好球并且在此之前他没有连续击出 k1 个好球即未成功也没有连续击出 k2 个坏球即未失败。其中i和j不能同时大于0因为不可能既连续好球又连续坏球。实际上我们只需要一个变量来表示当前的“连续趋势”。因此最有效的状态定义是设状态E[i]表示当前已经连续击出了 i 个好球0 i k1且过程尚未结束从这一状态出发直到练习结束无论成功或失败还需要进行的击球次数的期望值。同理我们需要对称地考虑坏球的情况吗是的因为规则是对称的。所以再设状态F[j]表示当前已经连续击出了 j 个坏球0 j k2且过程尚未结束从这一状态出发直到练习结束还需要进行的击球次数的期望值。这里有一个特殊状态E[0]。它表示“当前没有连续的好球趋势”这通常发生在一开始或者刚刚打出一个坏球之后。实际上E[0]和F[0]是等价的它们都表示“当前没有任何连续计数”我们可以统一用一个基准状态来考虑但为了方程清晰我们暂时保留两者。建立状态转移关系这是建模的关键。假设我们当前处于状态E[i]连续 i 个好球。以概率p下一次击出好球。那么连续好球数变为i1。此时需要检查如果i1 k1则练习立即成功结束后续需要的次数为 0。如果i1 k1则转移到状态E[i1]从那个状态开始的期望次数是E[i1]加上我们已经进行的这1次击球所以总贡献为1 E[i1]。以概率(1-p)下一次击出坏球。那么连续好球趋势被中断新的状态变为“连续1个坏球”即状态F[1]。所以贡献为1 F[1]。根据期望的线性性质全期望公式我们可以为E[i]写出方程E[i] p * ( (i1 k1) ? 0 : (1 E[i1]) ) (1-p) * (1 F[1])对于i 0, 1, ..., k1-1。注意对于i k1-1i1 k1所以第一项为p * 0。完全对称地对于状态F[j]连续 j 个坏球以概率(1-p)下一次击出坏球转移到F[j1]或失败结束当j1 k2。以概率p下一次击出好球连续坏球趋势中断转移到E[1]。 方程如下F[j] (1-p) * ( (j1 k2) ? 0 : (1 F[j1]) ) p * (1 E[1])对于j 0, 1, ..., k2-1。我们最终要求解的是整个练习开始时的期望击球次数。开始时既无连续好球也无连续坏球这个状态对应E[0]或F[0]。但注意根据我们的方程E[0]和F[0]都依赖于对方E[0]依赖于F[1]F[0]依赖于E[1]。所以我们需要联立求解所有E[i]和F[j]构成的线性方程组。关键理解为什么状态要这样定义因为“连续计数”是决定终止的唯一因素所以状态必须包含这个信息。而期望值E[i]的定义从该状态到结束的期望次数让我们可以写出递归或迭代关系这是解决此类“带记忆的随机过程期望”问题的标准方法——动态规划或高斯消元。3. 方程构建与化简从递归关系到线性方程组上一节我们得到了方程组的雏形但它看起来有点复杂尤其是E[0]和F[0]相互依赖形成了一个环。我们需要将其整理成标准的线性方程组形式A * x b以便求解。这里有k1 k2个未知数E[0], E[1], ..., E[k1-1]和F[0], F[1], ..., F[k2-1]。让我们把方程写得更规范一些避免条件判断方便编码。定义E[k1] 0成功吸收态和F[k2] 0失败吸收态。虽然它们不是未知数已知为0但可以让方程形式统一。对于i 0, 1, ..., k1-1E[i] p * (1 E[i1]) (1-p) * (1 F[1])但这里有一个问题当i k1-1时E[i1]即E[k1] 0所以公式p*(10)是成立的。然而仔细看这个公式对于所有i都成立吗它实际上隐含了“无论是否达到k1都会先加1再考虑转移”。更精确的写法应该区分是否到达吸收态。不过我们利用E[k1]0可以统一写成E[i] 1 p * E[i1] (1-p) * F[1]对于i 0, ..., k1-1。 验证一下当i k1-1E[i] 1 p * 0 (1-p) * F[1]这意味着即使下一次击出好球成功结束也被计入了1次击球这是正确的因为成功的那一击本身是算作一次击球次数的。同理对于j 0, 1, ..., k2-1F[j] 1 (1-p) * F[j1] p * E[1]现在我们有k1个方程关于E[i]E[i] - p * E[i1] - (1-p) * F[1] 1其中i0,...,k1-1且E[k1]0。k2个方程关于F[j]F[j] - (1-p) * F[j1] - p * E[1] 1其中j0,...,k2-1且F[k2]0。这看起来像是一个巨大的、系数矩阵非常稀疏的线性方程组。未知数是E[0...k1-1]和F[0...k2-1]总共n k1 k2个。直接构建n x n的矩阵进行高斯消元复杂度是O(n^3)在k1, k2可能达到几十或上百时本题实际限制较小但思维可以拓展这并非最优且编码容易出错。寻找更巧妙的解法观察方程我们发现它其实具有强烈的递推或迭代形式。以E[i]的方程为例E[i] 1 p * E[i1] (1-p) * F[1]对于i从k1-1递减到0如果我们把F[1]看作一个常数那么E[i]可以通过反向递推用E[i1]表示这本质上是一个一阶线性递推。让我们尝试一下。令C (1-p) * F[1]为一个待定常数。 则有E[i] 1 C p * E[i1]。 从i k1-1开始E[k1-1] 1 C p * E[k1] 1 C p * 0 1 C。E[k1-2] 1 C p * E[k1-1] 1 C p*(1C) 1 C p pC (1p) C(1p)。 看起来有规律。我们可以解出这个递推的通项公式。设E[i] A_i B_i * C其中A_i,B_i是只与i和p有关的系数。 由E[i] 1 C p * E[i1]代入假设A_i B_i * C 1 C p * (A_{i1} B_{i1} * C) 1 p*A_{i1} C*(1 p*B_{i1})。 比较常数项和C的系数得到A_i 1 p * A_{i1}B_i 1 p * B_{i1}边界条件E[k1] 0即A_{k1} B_{k1} * C 0。为了分离我们通常令A_{k1} 0,B_{k1} 0。但注意E[k1]是已知的0不是AB*C形式它本身就是常数0。所以我们的递推边界是E[k1]0即A_{k1} 0,B_{k1} 0。这样我们可以从i k1-1递推到i 0计算出所有的A_i和B_i。最终E[0]可以表示为E[0] A_0 B_0 * C A_0 B_0 * (1-p) * F[1]。完全对称地对F[j]做类似处理。令D p * E[1]为另一个常数。 方程F[j] 1 D (1-p) * F[j1]。 设F[j] U_j V_j * D有递推关系U_j 1 (1-p) * U_{j1}V_j 1 (1-p) * V_{j1}边界U_{k2} 0,V_{k2} 0。 从j k2-1递推到j 0得到F[0] U_0 V_0 * D U_0 V_0 * p * E[1]。现在我们得到了两个关键关系式E[0] A_0 B_0 * (1-p) * F[1]F[0] U_0 V_0 * p * E[1]注意F[1]和E[1]本身也是未知数但它们也可以用A_i, B_i, U_j, V_j表示F[1] U_1 V_1 * D U_1 V_1 * p * E[1]E[1] A_1 B_1 * C A_1 B_1 * (1-p) * F[1]看E[1]和F[1]又形成了一个二元一次方程组将F[1]的表达式代入E[1]的表达式E[1] A_1 B_1 * (1-p) * (U_1 V_1 * p * E[1])整理得E[1] A_1 B_1*(1-p)*U_1 B_1*(1-p)*V_1*p*E[1] E[1] * (1 - B_1*(1-p)*V_1*p) A_1 B_1*(1-p)*U_1 E[1] (A_1 B_1*(1-p)*U_1) / (1 - B_1*(1-p)*V_1*p)同理可以解出F[1]。一旦求出E[1]和F[1]代回最初的式子就能求出E[0]也就是我们最终要求的答案。实操心得这个化简过程是本题最核心的思维跳跃点。直接高斯消元是“暴力通用解”而通过分析方程结构利用递推关系将未知数个数从k1k2个减少到仅仅2个E[1]和F[1]极大地简化了计算。这要求我们对线性方程组的系数结构有敏锐的洞察力。在实际比赛中如果k1, k2很大比如上千这种化简是唯一可行的办法。即使本题限制小掌握这种思路也远超题目本身的价值。4. 算法步骤与代码实现详解经过上一节的数学推导我们已经将问题化归为清晰的计算步骤。现在我们来一步步实现它并讨论代码细节和易错点。算法流程输入读取测试用例数T。对于每个用例读取p,k1,k2。注意p是概率题目可能给出的是百分比形式或直接是小数需根据题目实际输入格式处理。通常 LightOJ 此题输入p为类似 “0.5” 的小数。处理极端情况如果p 1百分之百打好球那么永远不会因为连续坏球失败。期望次数就是连续打出k1个好球的期望次数这是一个几何分布每次成功概率为1但需连续成功。实际上因为每次必然成功所以击球次数就是确定的k1。但我们的公式在p1时(1-p)0会导致分母为零需要特判。同理如果p 0期望次数就是k2。如果k1 1或k2 1意味着一次好球就成功或一次坏球就失败这也需要小心处理递推边界。不过我们的通用公式通常能处理但为了数值稳定性特判更安全。计算系数 A_i, B_i (对于好球链)初始化A[k1] 0,B[k1] 0。从i k1-1递减到0A[i] 1 p * A[i1]B[i] 1 p * B[i1]计算完成后我们得到A_0, B_0, A_1, B_1。计算系数 U_j, V_j (对于坏球链)初始化U[k2] 0,V[k2] 0。从j k2-1递减到0U[j] 1 (1-p) * U[j1]V[j] 1 (1-p) * V[j1]计算完成后得到U_0, V_0, U_1, V_1。解二元一次方程组求 E[1] 和 F[1]根据推导E1 (A_1 B_1 * (1-p) * U_1) / (1 - B_1 * (1-p) * V_1 * p)F1 U_1 V_1 * p * E1或者用对称公式求F1这里有一个巨大的坑浮点数除法的精度和除零问题。当p接近 1 或 0 时分母(1 - B_1*(1-p)*V_1*p)可能接近零导致结果溢出或得到inf/nan。这就是为什么步骤2中强调要特判p1和p0的原因。即使p不绝对等于1但如果是0.999999也可能导致数值不稳定。一个稳健的方法是当分母的绝对值小于一个很小的阈值如1e-12时采用特判或使用更高精度的数据类型。计算最终答案 E[0]ans E0 A_0 B_0 * (1-p) * F1输出按照题目要求格式化输出答案。LightOJ 通常要求绝对误差或相对误差在1e-6以内。下面是我用 C 实现的代码核心部分省略输入输出框架#include bits/stdc.h using namespace std; double solve(double p, int k1, int k2) { // 特判 if (fabs(p - 1.0) 1e-12) { // 几乎必然成功 return k1; // 确定需要 k1 次击球 } if (fabs(p) 1e-12) { // 几乎必然失败 return k2; } if (k1 1 k2 1) { // 一次好球或坏球就结束期望次数就是1次因为第一次击球必然结束 // 但更一般地可以用公式这里特判逻辑清晰。 return 1.0; } // 计算好球链系数 A, B vectordouble A(k1 1, 0), B(k1 1, 0); // A[k1]0, B[k1]0 已初始化 for (int i k1 - 1; i 0; --i) { A[i] 1.0 p * A[i 1]; B[i] 1.0 p * B[i 1]; } // 计算坏球链系数 U, V vectordouble U(k2 1, 0), V(k2 1, 0); for (int j k2 - 1; j 0; --j) { U[j] 1.0 (1 - p) * U[j 1]; V[j] 1.0 (1 - p) * V[j 1]; } double A1 A[1], B1 B[1]; double U1 U[1], V1 V[1]; // 解 E[1] double denominator 1.0 - B1 * (1 - p) * V1 * p; // 再次检查分母虽然已特判p0,1但中间计算可能仍导致接近零 if (fabs(denominator) 1e-12) { // 这种情况发生在极其特殊的情况下例如 k11, k21 且 p0.5 // 可以返回一个直接计算的值或者用一个很小的数避免除零 // 根据对称性当 p0.5, k1k21 时E[1] 应为 0? 实际上此时状态定义有些微妙。 // 更稳妥的方式如果分母接近零说明方程组近似奇异可能对应 p0.5 且 k1,k2 很小的情况。 // 我们可以直接使用对称性此时 E[1] 和 F[1] 应该相等设其为 x。 // 从 E[1] A1 B1*(1-p)*F[1] 和 F[1] U1 V1*p*E[1]且 p0.5, A1U1, B1V1 // 解得 x A1 / (1 - 0.25*B1*B1) 或其他形式。为了简单这里可以返回一个近似值。 // 但经过测试对于常见数据denominator 不会正好为0除非完全对称且系数特殊。 // 一个工程处理如果分母绝对值太小直接返回一个较大的数或特定值并提示。 // 实际上本题官方数据应避免这种情况。我们加一个保护性判断 return 0.0; // 仅为示例实际应根据数学推导计算 } double E1 (A1 B1 * (1 - p) * U1) / denominator; double F1 U1 V1 * p * E1; // 最终答案 double ans A[0] B[0] * (1 - p) * F1; return ans; }代码细节与避坑指南浮点数比较不要用比较浮点数要使用fabs(a-b) eps。eps通常取1e-9或1e-12。除零保护即使数学上分母不为零浮点计算也可能因为精度产生一个极小的数。用fabs(denom) eps判断并处理是保证程序鲁棒性的关键。数组大小A, B数组需要开到k11因为用到了A[k1]和B[k1]。U, V同理。特判的优先级先特判p1和p0再处理一般情况。因为这两种情况下递推公式中的(1-p)或p为零可能导致系数B_1或V_1的计算出现0/0型未定义虽然从极限角度看有意义直接特判更干净。理解答案的意义最终答案ans可能不是整数它是一个数学期望。对于p0.5, k12, k23这样的输入答案大概在 2 到 3 之间这是合理的。5. 测试与验证如何确保你的解是正确的写完代码后不能盲目提交需要设计测试用例进行验证。对于概率期望题有几种验证思路暴力模拟验证对小数据当k1和k2很小比如都不超过5时可以用蒙特卡洛方法进行模拟。随机生成大量的击球序列根据规则统计平均长度与你的程序输出对比。这是最直观的验证。# 一个简单的 Python 模拟示例 import random def simulate(p, k1, k2, trials1000000): total_steps 0 for _ in range(trials): streak_good 0 streak_bad 0 steps 0 while True: steps 1 if random.random() p: # 好球 streak_good 1 streak_bad 0 if streak_good k1: break else: # 坏球 streak_bad 1 streak_good 0 if streak_bad k2: break total_steps steps return total_steps / trials # 将 simulate(p,k1,k2) 的结果与你程序计算的 ans 对比看是否接近。注意模拟次数要足够多如百万次以减少随机误差。比较时由于浮点数误差两者相差在1e-3以内通常可以接受。特殊值验证Case 1:p 1, k1 5, k2 10。显然每次都是好球5次后成功。期望应为5。你的程序应输出5.000000。Case 2:p 0, k1 5, k2 10。每次都是坏球10次后失败。期望应为10.000000。Case 3:p 0.5, k1 1, k2 1。第一次击球无论好坏都结束。期望为1。Case 4:p 0.5, k1 2, k2 2。这是一个对称情况。可以手动计算或通过简单推理每次击球好坏概率各半相当于在“连续两个相同结果”时结束。这个期望值可以计算出来约为3。你的程序应该输出一个接近的值。Case 5:p 0.01, k1 100, k2 2。好球概率极低几乎肯定会因为连续两个坏球而快速失败。期望值应略大于2因为有可能第一球是好球打断坏球连续。你的程序结果应在2.0x左右。对拍验证如果你有用另一种方法如直接高斯消元实现的程序可以对随机生成的中小规模数据k1, k2 10进行对拍比较结果是否一致。利用对称性当p 0.5时问题关于好球和坏球对称。因此交换k1和k2期望值应该不变。这是一个很好的检验。常见错误排查答案输出为nan或inf几乎肯定是除零错误。检查p1或p0的特判以及分母denominator的保护性判断。答案与模拟结果偏差较大首先检查模拟代码是否正确比如循环条件、计数清零。其次检查你的系数递推公式是否正确尤其是A[i] 1 p*A[i1]中的1是否漏了。这个1代表了从当前状态到下一次击球的那一次尝试。对于k11或k21答案错误检查你的数组是否越界。例如当k11时E[1]就是E[k1]应该是0但你的代码中A[1]和B[1]是通过递推从i0计算的吗注意我们的递推是从ik1-1递减到0。如果k11那么k1-10循环只计算i0。此时A[1]和B[1]是初始化的0这是正确的。所以代码应能正确处理。浮点数精度导致 WALightOJ 通常要求误差1e-6。确保使用double类型输出时用printf(“%.6lf\n”, ans 1e-10)或cout fixed setprecision(6) ans endl;。加一个极小值 (1e-10) 可以避免因精度问题输出-0.000000。6. 思路延伸与同类问题归纳解决 LightOJ 1408我们掌握了一套“组合拳”定义状态 - 写出期望方程 - 化简方程利用递推 - 求解关键变量。这套方法可以推广到许多类似的“带吸收态的马尔可夫链期望步数”问题。变体1目标模式不止一个假设结束条件不是简单的连续k1个好球而是某个特定的击球结果序列比如“好-坏-好”求期望次数。此时状态定义需要更复杂可能要用到字符串匹配中的“失效函数”KMP算法中的next数组来定义状态。但核心思路不变定义状态为“当前已匹配的模式前缀长度”写出状态转移方程求解线性方程组。变体2多维度状态假设规则是如果累计好球数达到K1不要求连续则成功累计坏球数达到K2则失败。那么状态可以定义为(a, b)表示当前累计好球数a和累计坏球数b。状态空间大小为K1 * K2可以用类似的期望DP方程并通过高斯消元或迭代法求解。这类问题通常出现在游戏角色升级、资源收集等场景中。变体3有成本的决策如果击球手每次击球前可以选择“保守打法”提高p但减少得分或“激进打法”降低p但增加得分目标是在成功结束前最大化总得分期望。这就变成了一个马尔可夫决策过程MDP可以用动态规划求解最优策略。对于开发者的启示 这类问题本质上是随机过程建模。在游戏开发中你可以用它来计算一个玩家收集齐一套卡牌的平均开包次数在运维中可以用来分析一个系统在连续发生几次警告后触发报警的平均时间在金融中可以模拟价格连续上涨或下跌达到某个阈值所需的平均交易次数。掌握从问题描述中提炼状态、建立转移方程的能力远比记住这道题的解法更重要。最后再分享一个我调试此类题目时的小技巧手动计算最小规模实例。对于本题可以试一下k11, k22的情况。设E为初始状态无连续到结束的期望F为连续一个坏球后的期望。列出方程E 1 p*0 (1-p)*F因为k11所以打出一个好球直接成功期望贡献为10F 1 (1-p)*0 p*E因为k22所以连续一个坏球后再出一个坏球就失败 解得E (1 (1-p)) / (1 - p*(1-p))。你可以把这个公式和你的程序输出对比也能帮助理解整个状态转移过程。这种小规模验证是确保你对模型理解无误的试金石。
返回列表