拓冰建站拓冰建站
首页 / 资讯中心 / 正文

蓝桥杯印章题解析:动态规划求解赠券收集概率问题

1. 项目概述从一道蓝桥杯真题看动态规划的概率建模最近在整理蓝桥杯的算法训练题翻到了ALGO-1007 “印章”这道题。说实话第一次看到题目描述时我愣了一下因为它看起来更像是一道数学概率题而不是传统的算法题。但恰恰是这种跨界让它成为了检验选手是否真正理解动态规划思想并能将其灵活应用于实际问题中的绝佳试金石。这道题的核心是给定n种不同的印章每次购买随机获得其中一种概率均等问要集齐所有n种印章平均需要购买多少次或者更具体地说购买m次后能集齐所有印章的概率是多少这其实就是经典的“赠券收集问题”的一个变体。很多朋友可能在概率论课本里见过它但真要动手用程序特别是用动态规划来求解里面有不少细节值得深挖。我在实际解题和教学过程中发现很多初学者卡壳的地方不在于动态规划的模板而在于如何将这样一个带有“期望”和“概率”的实际问题准确地翻译成状态定义和状态转移方程。今天我就结合这道蓝桥杯真题把从问题理解、思路拆解到状态设计、方程推导再到代码实现和边界处理的完整过程给大家捋一遍。无论你是正在备赛蓝桥杯还是想巩固动态规划的应用相信这篇都能给你带来一些实实在在的启发。2. 问题核心与数学模型建立2.1 问题重述与关键信息提取我们先来把题目翻译成更清晰的数学语言。题目通常是这样描述的有n种不同的印章每次购买一个印章拿到每种印章的概率都是1/n且每次购买相互独立。现在我们要计算在购买了m次之后能够集齐全部n种印章的概率。这里有几个关键点必须第一时间抓住印章种类n这是一个给定的正整数代表我们需要收集的目标种类总数。购买次数m这是我们计划进行的购买次数也是我们求解概率时的条件。“集齐”的定义在m次购买结束后我们手中拥有的印章种类恰好覆盖了全部n种。注意是种类覆盖不关心每种印章的具体数量只要至少有一个就行。这意味着在m次购买中n种印章每一种都至少出现了一次。随机性每次购买都是一次独立的伯努利试验结果有n种等可能的选择。所以问题的输入通常是n和m输出是一个概率值。这立刻让我们意识到暴力模拟进行大量次数实验取频率在算法竞赛中是不可行的因为m可能很大我们需要一个高效的计算公式或算法。2.2 从概率计算到动态规划的思路转换最直接的想法是使用概率论中的容斥原理。集齐所有印章的对立事件是“至少有一种印章没被收集到”。设事件A_i为“第i种印章没有被收集到”那么所求概率P(集齐) 1 - P(至少一个A_i发生)。通过容斥原理可以展开计算。这个方法在数学上是清晰的但对于编程实现尤其是处理大数时的组合数计算可能会比较繁琐且容易在精度上出问题。动态规划的思路则更加“计算机友好”它通过将大问题分解为重叠子问题来递推求解。我们怎么定义状态呢核心在于刻画“收集进度”。在购买了k次之后我们并不关心具体买了哪些印章只关心我们已经收集到了多少种不同的印章。因为每次购买都是随机的、无记忆的当前状态只和“已收集种类数”以及“购买次数”有关这完美符合动态规划“最优子结构”和“无后效性”的要求。因此我们定义状态dp[i][j]表示购买了i次之后恰好收集到j种不同印章的概率。这里i的范围是0到mj的范围是0到n。最终我们要求的答案就是dp[m][n]。2.3 状态转移方程的推导动态规划最核心也最考验人的部分来了状态如何转移假设我们已经知道dp[i][j]即买了i次有了j种那么买第i1次时会发生什么第i1次购买的结果只有两种可能买到一种已经拥有的印章种类当前已经有j种所以买到旧种类的概率是j / n。发生这种情况后购买次数变为i1但收集到的种类数仍然是j。所以这个分支对dp[i1][j]有贡献贡献值为dp[i][j] * (j / n)。买到一种新的印章种类当前已经有j种那么新种类有n - j种所以买到新种类的概率是(n - j) / n。发生这种情况后购买次数变为i1收集到的种类数变为j1。所以这个分支对dp[i1][j1]有贡献贡献值为dp[i][j] * ((n - j) / n)。由此我们可以得到状态转移方程dp[i1][j] dp[i][j] * (j / n)dp[i1][j1] dp[i][j] * ((n - j) / n)注意这里用的是而不是因为到达dp[i1][j]这个状态的可能路径不止一条比如从dp[i][j]买旧章或者从dp[i][j-1]买新章但状态定义是“恰好j种”后者实际上属于另一个转移分支。我们的方程描述的是从dp[i][j]出发的转移对后续状态的贡献。更准确和通用的递推式是从dp[i][j]的角度思考它可能由哪些前序状态转移而来dp[i][j]可以由dp[i-1][j]转移而来即上一次已经有j种这次又买到了旧的概率是dp[i-1][j] * (j / n)。dp[i][j]也可以由dp[i-1][j-1]转移而来即上一次有j-1种这次买到了新的概率是dp[i-1][j-1] * ((n - (j-1)) / n)。因此标准的递推方程为dp[i][j] dp[i-1][j] * (j / n) dp[i-1][j-1] * ((n - j 1) / n)其中i 1,j 1且j i(因为买了i次最多只能有i种且不能超过n种)。2.4 边界条件的确定任何动态规划都不能忽略边界否则递推无从开始。dp[0][0] 1购买0次拥有0种印章这是确定的概率为1。dp[0][j] 0 (j0)购买0次不可能拥有任何印章。dp[i][0] 0 (i0)只要购买次数大于0就不可能拥有0种印章因为每次购买必然获得一种。从物理意义上也可以理解为dp[i][0] dp[i-1][0] * (0/n) 0。有了状态定义、转移方程和边界条件我们的数学模型就完全建立起来了。接下来就是如何将这个模型转化为高效、准确的代码。3. 算法实现与代码细节解析3.1 数据结构选择与初始化我们使用一个二维数组dp来存储概率。dp[i][j]表示购买i次后恰好拥有j种印章的概率。数组大小应为(m1) x (n1)以容纳从0到m和0到n的索引。初始化是关键的第一步# 假设 n, m 已从输入读取 dp [[0.0] * (n 1) for _ in range(m 1)] dp[0][0] 1.0这里使用浮点数0.0和1.0来确保进行的是浮点数运算。如果m和n很大比如几十上百这个二维数组所占内存是(m1)*(n1)*8字节假设双精度浮点对于算法竞赛常见的限制如m, n 1000通常是可接受的。如果m更大可以考虑滚动数组优化因为dp[i]只依赖于dp[i-1]。3.2 递推过程编码根据递推方程dp[i][j] dp[i-1][j] * (j/n) dp[i-1][j-1] * ((n-j1)/n)我们可以用两层循环来实现。这里有一个非常重要的细节循环的顺序和范围。外层循环i从1到m代表购买次数。内层循环j从1到min(i, n)。因为买了i次最多收集到min(i, n)种。j不能超过n这是显然的。在计算dp[i][j]时我们需要确保j-1 0。由于j从1开始j-1最小为0是合法的。但dp[i-1][j-1]当j1时就是dp[i-1][0]我们之前已经将其初始化为0i0时所以计算是安全的。代码框架如下for i in range(1, m 1): # j 的范围上限是 min(i, n)因为买了i次不可能拥有超过i种也不可能超过n种 for j in range(1, min(i, n) 1): # 情况1第i次买到已有的j种之一 p1 dp[i-1][j] * (j / n) # 情况2第i次买到新的种类之前有j-1种 # 注意当j1时j-10dp[i-1][0]在i0时为0所以此项为0符合逻辑 p2 dp[i-1][j-1] * ((n - (j - 1)) / n) dp[i][j] p1 p2实操心得在计算(n - (j - 1)) / n时我强烈建议先将其转化为(n - j 1) / n这样更直观也不容易因为括号弄错而出错。很多同学在这里写成(n - j - 1) / n导致结果完全错误。3.3 最终答案与输出处理经过上述递推dp[m][n]就是我们要求的答案购买m次后集齐全部n种印章的概率。但是这里有一个极其重要的边界情况如果m n那么无论运气多好购买次数少于种类数是绝对不可能集齐的。例如有5种印章只买4次怎么可能集齐5种呢所以在这种情况下概率应为0。因此在输出前必须进行判断if m n: result 0.0 else: result dp[m][n]输出时通常要求保留小数点后4位或指定格式。在Python中可以使用print(“{:.4f}”.format(result))。3.4 代码优化滚动数组当m和n较大时比如上千(m1)*(n1)的二维数组可能占用较多内存几MB到十几MB。由于状态转移只依赖于前一行i-1我们可以使用滚动数组将空间复杂度从 O(m*n) 优化到 O(n)。我们只需要两个一维数组prev表示上一行i-1curr表示当前行i。在每一轮外层循环结束后将curr赋值给prev然后清空或复用curr用于下一轮。优化后的代码结构if m n: print(“0.0000”) return prev [0.0] * (n 1) prev[0] 1.0 # dp[0][0] 1 for i in range(1, m 1): curr [0.0] * (n 1) # 注意当i1时j最大为min(1, n)1所以curr[0]不会被赋值保持为0这符合dp[1][0]0 for j in range(1, min(i, n) 1): p1 prev[j] * (j / n) p2 prev[j-1] * ((n - j 1) / n) curr[j] p1 p2 prev curr result prev[n] # 循环结束后prev 存储的是第m行的数据 print(“{:.4f}”.format(result))使用滚动数组后空间消耗大大减少代码依然清晰。这是解决这类线性递推动态规划问题的常用技巧。4. 测试、调试与常见问题排查4.1 构造测试用例验证算法理论推导和代码写完了不代表就对了。必须用多种测试用例来验证。我们可以从简单情况入手这些情况往往可以手工计算或直观判断。基础验证n1, m1只有1种印章买1次必然集齐。概率应为1。程序输出1.0000不可能情况n3, m23种印章买2次不可能集齐。概率应为0。程序输出0.0000小规模精确计算n2, m2两种印章A和B。买两次集齐的情况有AB, BA。总共有2^24种等可能序列。概率 2/4 0.5。程序应输出0.5000小规模精确计算n2, m3总序列数2^38。集齐至少一个A和一个B的序列排除全A(AAA)和全B(BBB)共8-26种。概率 6/8 0.75。手工按DP推导dp[3][2]应等于0.75。程序输出0.7500中等规模验证n5, m10。这个手工算很麻烦但我们可以用程序的中间结果进行合理性检查。例如dp[10][5]的概率应该是一个介于0和1之间的数并且dp[10][1]到dp[10][4]的概率之和应该等于1 - dp[10][5]。可以用打印中间值的方式来辅助验证。4.2 常见错误与排查技巧在实际编写和调试过程中我遇到过也看到学生们常犯以下几种错误错误1忽略m n的特判这是最致命的逻辑错误。如果不加判断当m n时我们的DP循环内层j的范围min(i, n)在i n时永远达不到jn。最终dp[m][n]访问的是初始值0结果输出0.0000看似对了但这是巧合。DP数组的dp[m][n]位置根本没有被计算过。更严谨的做法是直接在最开始判断如果m n则输出0并返回避免无意义的计算。错误2状态转移方程系数写错尤其是dp[i-1][j-1] * ((n - j 1) / n)这一项。(n - j 1)代表的是在已有j-1种时剩余新种类的数量。很容易错写成(n - j) / n或(n - j - 1) / n。一个检查方法是代入简单值当n3, j1时已有0种新种类有3种系数应为3/31。如果写成(3-1)/32/3就错了。错误3循环变量范围设置不当内层循环j必须从1开始因为dp[i][0](i0) 为0且转移方程中用到j-1。同时上限必须是min(i, n)。如果写成for j in range(1, n1)当i n时会计算一些不可能的状态如dp[2][3]买2次有3种虽然因为dp[i-1][j]可能为0而不一定导致错误但增加了不必要的计算也不符合状态定义。错误4浮点数精度问题虽然本题对精度要求通常不高保留4位小数但如果n和m很大进行多次浮点乘加运算可能会累积误差。在绝大多数评测环境下Python的浮点数双精度足以应对。如果非常担心精度可以考虑使用高精度小数库decimal但会牺牲速度。一个折中的方法是在递推过程中尽量使用除法而非乘法或者调整运算顺序。不过对于蓝桥杯等竞赛通常用浮点数直接计算即可。错误5初始化不完整只初始化了dp[0][0] 1但没有显式地将dp[0][j] (j0)和dp[i][0] (i0)设为0。在Python中创建二维浮点数组时默认就是0.0所以问题不大。但在一些其他语言中或者使用列表推导式时如果处理不当可能会遗留垃圾值。最安全的做法是显式地进行全部初始化。4.3 调试与日志输出建议当程序结果不对时不要盲目修改代码。建议增加中间输出打印出小规模测试用例如n2, m3的整个DP表然后与手工计算的DP表进行对比。这样可以快速定位是初始化、转移方程还是循环范围出了问题。例如可以写一个调试函数def print_dp(dp, n, m): print(“i\\j”, end“\t”) for j in range(n1): print(j, end“\t”) print() for i in range(m1): print(i, end“\t”) for j in range(n1): print(“{:.3f}”.format(dp[i][j]), end“\t”) print()对于n2, m3正确的DP表应该是i\j 0 1 2 0 1.000 0.000 0.000 1 0.000 1.000 0.000 2 0.000 0.500 0.500 3 0.000 0.250 0.750最后dp[3][2] 0.750。如果你的表不一样就很容易看出哪一步开始出错了。5. 算法扩展与思维提升5.1 计算期望次数原题是求给定次数m后的概率。一个相关的问题是集齐所有n种印章平均需要购买多少次即期望次数E这就是经典的赠券收集问题期望公式E n * (1 1/2 1/3 ... 1/n)。当n很大时它约等于n * ln(n) γ*n 0.5γ是欧拉常数。我们可以用动态规划来求这个期望吗当然可以但状态定义需要改变。我们定义f[i]为已经收集到i种不同印章时还需要购买次数的期望。那么最终答案就是f[0]从0种开始到集齐的期望次数。状态转移当已有i种时下一次购买以i/n的概率买到旧的状态仍为“有i种”还需期望次数为1 f[i]。以(n-i)/n的概率买到新的状态变为“有i1种”还需期望次数为1 f[i1]。因此有方程f[i] (i/n)*(1 f[i]) ((n-i)/n)*(1 f[i1])。 化简后f[i] 1 (i/n)*f[i] ((n-i)/n)*f[i1]。 解出f[i]f[i] n/(n-i) f[i1]。 边界条件f[n] 0已经集齐不需要再买了。然后从后往前递推def expected_times(n): f [0.0] * (n 1) f[n] 0.0 for i in range(n-1, -1, -1): f[i] n / (n - i) f[i1] return f[0]这个递推比求概率的二维DP简单得多而且给出了期望值的精确解。将两个问题对比着理解能让你对动态规划建模的灵活性有更深的认识。5.2 概率计算与容斥原理的对比我们之前提到了容斥原理。对于求P(m, n)m次集齐n种的概率容斥原理的公式为P(m, n) (1/n^m) * Σ_{k0}^{n} (-1)^k * C(n, k) * (n-k)^m其中C(n, k)是组合数。这个公式可以直接计算但当n和m较大时计算组合数和幂可能涉及大数和高精度实现起来比动态规划更复杂且容易数值溢出。动态规划的优势在于过程清晰每一步都是简单的乘加运算易于实现和调试并且能自然地求出所有中间状态dp[i][j]im, jn这些中间状态有时本身也有意义。5.3 从“印章”到一类问题的抽象“印章”问题本质上是一个概率动态规划问题属于“离散时间、离散状态”的随机过程模型。它有一大类相似的问题例如抽卡问题一个卡池有n张不同的SSR每次抽卡获得每张的概率相等或不等问抽m次集齐的概率或期望次数。病毒传播模型简化一个网络有n个节点每次随机感染一个节点问m次传播后所有节点都被感染的概率。信息覆盖问题一条消息在n个人中随机传递每次传递给一个人问传递m次后所有人都知道消息的概率。解决这类问题的通用步骤是定义状态找到能够描述过程进度的关键变量如已收集种类数、已感染节点数。确定状态转移分析从一个状态到下一个状态的所有可能情况及其概率。建立方程根据全概率公式写出状态间的递推关系。确定边界找到递推的起点通常是初始状态。选择求解方法根据需求求概率、期望、分布编写递推或迭代程序。掌握了这个套路再遇到类似的题目你就不会无从下手了。蓝桥杯这道“印章”题就是一个训练这种建模能力的经典入门题。它告诉我们动态规划不只是用来求最优解更是解决具有重叠子问题的计数、概率问题的强大工具。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门