ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

动态规划实战:从赠券收集问题到蓝桥杯印章题的概率计算

动态规划实战:从赠券收集问题到蓝桥杯印章题的概率计算 1. 项目概述从一道蓝桥杯真题看动态规划的核心思想最近在整理蓝桥杯的算法训练题翻到了ALGO-1007这道“印章”题。这道题在各大算法社区和备考群里的讨论热度一直不低很多朋友第一次接触时都会被它看似简单的描述和背后复杂的概率计算给绕进去。本质上这是一道经典的动态规划Dynamic Programming, DP概率期望问题它完美地融合了组合数学和递推思想是检验你是否真正理解DP状态定义和转移方程的试金石。题目大意是这样的有n种不同的印章每次购买随机获得其中一种概率均等。问要集齐所有n种印章平均需要购买多少次或者说求购买m次后能集齐所有印章的概率是多少原题通常以求概率的形式出现。这听起来是不是很像小时候吃干脆面集卡或者玩手游抽卡集图鉴的场景没错这就是经典的“赠券收集问题”Coupon Collector‘s Problem的一个变体或具体求解实现。对于正在备战蓝桥杯或其他算法竞赛的同学来说啃下这道题意义重大。它不仅能帮你巩固动态规划这个必考大项更能让你深刻理解如何将现实中的概率问题转化为计算机可一步步推导的模型。接下来我就结合自己的解题和教学经验把这道题从问题解析、思路建立、到代码实现的每一步掰开揉碎讲清楚尤其会重点分享几个容易卡壳的思维陷阱和调试技巧。2. 问题解析与数学模型建立2.1 题意重述与输入输出分析我们先抛开算法把题目用更直白的话翻译一遍。以最常见的题目表述为例输入两个整数n和m用空格隔开。n代表印章的总种类数比如有5种不同的印章图案。m代表你一共购买了印章的次数。输出一个浮点数表示在购买m次之后你已经集齐了全部n种印章的概率。结果通常要求保留小数点后4位。约束条件1 ≤ n, m ≤ 20。这个数据范围很重要它直接暗示了我们算法的可行方向。因为n和m最大才20这意味着即使我们使用时间复杂度或空间复杂度较高的算法比如O(n * m * 2^n)的状压DP在计算量上也是勉强可接受的。但更优的DP可以做到O(n*m)。核心难点每次购买是独立随机事件且获得每种印章的概率是1/n。我们要求的是一个累积概率——在经历了m次随机事件后达到某个特定状态集齐所有种类的概率。这种“过程”和“状态”的描述强烈指向了动态规划。2.2 为什么是动态规划—— 识别问题特征动态规划适用于解决具有以下特征的问题最优子结构一个问题的最优解包含其子问题的最优解。重叠子问题在递归求解过程中会反复计算相同的子问题。对于本题“购买m次后集齐”的概率可以分解为最后一步第m次购买发生了什么在第m-1次购买后我们已经收集到了几种不同的印章显然第m步的状态集齐与否完全依赖于第m-1步的状态已收集的种类数。而计算“从i种到集齐”的概率会被在计算不同m时反复用到。这完美契合了DP的应用场景。2.3 状态定义抓住问题本质这是DP最关键也最考验功力的一步。定义不好后面推导和编码都会异常痛苦。我们定义dp[i][j]i表示已经购买了i次印章。j表示在购买了i次后已经收集到了j种不同的印章。那么dp[i][j]的值就表示购买i次后恰好收集到j种不同印章的概率。为什么这样定义最终我们要的是dp[m][n]即购买m次后恰好收集到n种也就是集齐的概率。这个定义将“购买次数”和“收集进度”这两个维度结合了起来形成了一个清晰的状态空间。所有可能的购买过程都会落在这个二维状态矩阵中的某个路径上。状态空间大小i从0到mj从0到min(i, n)因为买了i次最多只能有i种但也不能超过总种类n。所以总状态数大约为O(m*n)对于本题最大20*20400个状态非常小。3. 动态规划转移方程推导有了状态定义接下来就是思考状态之间如何转移。即如何从dp[i-1][?]推导出dp[i][j]。考虑第i次购买它只会发生两种情况买到了一个新的、之前没有的印章种类。买到了一个旧的、已经拥有的印章种类。3.1 状态转移分析假设在进行第i次购买之前我们已经购买了i-1次并且已经拥有了j种不同的印章。情况一第i次买到新种类前提条件当前已拥有j种总共有n种。那么还没收集到的种类有n-j种。发生概率每次购买获得任意一种特定印章的概率是1/n。这里有n-j种都是“新”的。所以本次购买到新种类的概率是(n-j) / n。状态转移如果这件事发生了那么购买i次后拥有的种类数就会从j变成j1。对dp[i][j1]的贡献dp[i-1][j] * (n-j)/n。情况二第i次买到旧种类前提条件当前已拥有j种。发生概率这j种印章都是“旧”的。所以本次购买到旧种类的概率是j / n。状态转移如果这件事发生了那么购买i次后拥有的种类数仍然是j。对dp[i][j]的贡献dp[i-1][j] * j/n。3.2 完整的状态转移方程综合以上两种情况我们可以得到递推公式对于i 1且j 1dp[i][j] dp[i-1][j-1] * ((n - (j-1)) / n) dp[i-1][j] * (j / n)让我们仔细拆解这个公式dp[i-1][j-1] * ((n - (j-1)) / n)这部分对应“买到新种类”。i-1次后有j-1种第i次买到一种新的从剩下的n-(j-1)种里买概率是(n-(j-1))/n之后状态变为(i, j)。dp[i-1][j] * (j / n)这部分对应“买到旧种类”。i-1次后已经有j种第i次买到的还是这j种之一概率是j/n状态保持为(i, j)。3.3 边界条件初始化任何DP都需要一个起点。对于本题dp[0][0] 1.0购买了0次拥有0种印章这是一个确定事件概率为1。dp[0][j] 0.0 (j0)购买了0次不可能拥有任何印章概率为0。dp[i][0] 0.0 (i0)只要购买过i0至少会拥有1种印章除非概率为0但这里不可能所以拥有0种的概率为0。实际上从转移方程看dp[i][0]也无法从dp[i-1][-1]转移过来所以直接初始化为0即可。注意这里有一个极其关键的细节也是很多初学者第一次编码出错的地方。j的范围必须满足j i且j n。因为买了i次最多只能拥有i种不同的印章。在循环遍历时如果不注意这个条件就会访问到无意义的状态比如dp[1][2]。4. 代码实现与逐行解析理论清晰后我们来看代码实现。这里以Python为例因为它语法简洁非常适合表达算法逻辑。4.1 基础版本代码def seal_probability(n, m): # 初始化一个 (m1) x (n1) 的二维数组全部赋值为0.0 dp [[0.0] * (n 1) for _ in range(m 1)] # 初始化边界条件 dp[0][0] 1.0 # 动态规划递推 for i in range(1, m 1): # 购买次数从1到m # j的范围最多不能超过i买的次数也不能超过n总种类 for j in range(1, min(i, n) 1): # 状态转移方程 p_new (n - (j - 1)) / n # 买到新印章的概率 p_old j / n # 买到旧印章的概率 # 从 dp[i-1][j-1] 转移过来上次少一种这次买到新的 if j - 1 0: dp[i][j] dp[i - 1][j - 1] * p_new # 从 dp[i-1][j] 转移过来上次种类数已够这次买到旧的 if j i - 1: # 确保 i-1 j即上次状态是可能的 dp[i][j] dp[i - 1][j] * p_old # 最终答案购买m次后恰好拥有n种印章的概率 return dp[m][n] # 示例5种印章购买10次后集齐的概率 n, m 5, 10 result seal_probability(n, m) print(f{result:.4f}) # 输出结果保留4位小数4.2 代码优化与细节处理上面的代码是直接翻译自转移方程但我们可以写得更加严谨和高效。避免冗余判断内层循环j的范围已经通过min(i, n)控制所以j i天然成立。因此dp[i-1][j]这个状态在j i-1时总是有效的。我们可以简化判断逻辑。处理浮点数精度虽然Python的浮点数精度对于本题足够但良好的习惯是意识到浮点运算可能存在的微小误差。在竞赛中通常要求输出与标准答案误差在一定范围内即算正确。空间优化可选观察状态转移方程dp[i][j]只依赖于dp[i-1][j]和dp[i-1][j-1]即只依赖于上一行。因此我们可以使用滚动数组将空间复杂度从O(m*n)优化到O(n)。这对于本题n,m20意义不大但是一个很好的DP优化练习。优化后的代码def seal_probability_opt(n, m): # 使用滚动数组只保留两行当前行和上一行 dp_prev [0.0] * (n 1) dp_curr [0.0] * (n 1) dp_prev[0] 1.0 # dp[0][0] 1.0 for i in range(1, m 1): # 每一行开始前清空当前行或者新建一个数组 dp_curr [0.0] * (n 1) # j的范围1 j min(i, n) for j in range(1, min(i, n) 1): p_new (n - (j - 1)) / n p_old j / n # 状态转移 dp_curr[j] dp_prev[j - 1] * p_new dp_prev[j] * p_old # 滚动当前行变成下一轮的上一行 dp_prev dp_curr[:] # 注意要用切片复制而不是直接赋值 # 循环结束后dp_prev 对应的是 dp[m][*] return dp_prev[n]5. 算法验证与测试用例设计写完代码不能盲目相信必须用测试用例来验证。设计测试用例是算法能力的重要组成部分。5.1 边界测试用例最小输入n1, m1分析只有1种印章买1次必定集齐。预期输出概率为1.0000。验证dp[1][1]。初始dp[0][0]1。第一次购买买到新印章唯一的一种概率为(1-0)/11。所以dp[1][1] dp[0][0]*1 1。购买次数少于种类数n5, m3分析只有3次购买机会不可能集齐5种印章。预期输出概率为0.0000。验证我们的DP中j最大为min(i, n)。最终求的是dp[3][5]这个状态在循环中根本不会被计算因为min(3,5)3初始值就是0.0。购买次数等于种类数n3, m3分析这是一个经典情况。集齐意味着三次购买恰好都是不同的印章。手算验证第一次随便买概率1。第二次买到与第一次不同的概率是2/3。第三次买到与前两次都不同的概率是1/3。总概率为1 * (2/3) * (1/3) 2/9 ≈ 0.2222。程序验证运行程序看输出是否约为0.2222。5.2 常规测试用例与思维陷阱用例n2, m3手算枚举法 共有2^38种等可能的购买序列如AAA, AAB, ABA, ABB, BAA, BAB, BBA, BBB。 其中“集齐两种”意味着序列中至少有一个A和一个B。 排除全AAAA和全BBBB两种情况剩下6种。 概率为6/8 0.75。程序验证应输出0.7500。思维陷阱概率是累加的吗有同学可能会想第一次买到一种概率是1第二次买到另一种的概率是1/2第三次…然后把这些概率加起来或乘起来。这是错误的。我们计算的是特定事件序列的联合概率必须用DP来考虑所有可能的历史路径。DP中的dp[i][j]正是代表了所有能达到(i, j)状态的不同路径的概率之和。5.3 使用暴力枚举进行对拍小数据对于n和m很小的情况比如都小于5我们可以写一个暴力枚举所有可能购买序列的程序来验证DP结果的正确性。这是调试算法最可靠的方法之一。import itertools def brute_force(n, m): # 生成所有长度为m的序列每个元素是[0, n-1]的整数代表印章类型 all_sequences itertools.product(range(n), repeatm) favorable 0 total n ** m for seq in all_sequences: # 判断这个序列是否包含了所有n种印章 if len(set(seq)) n: favorable 1 return favorable / total # 测试 n3, m3 print(fDP 结果: {seal_probability(3, 3):.6f}) print(f暴力枚举结果: {brute_force(3, 3):.6f}) # 两者应该完全相等在浮点数精度内6. 常见错误与调试心得在实际解题和教学中我见过同学们踩过各种各样的坑。这里总结几个最常见的6.1 错误1状态定义混淆错误表现将dp[i][j]定义为“购买i次后至少收集到j种的概率”。问题分析“至少”这个定义会导致状态转移变得极其复杂因为状态之间包含关系重叠不满足DP“状态互斥且完备”的要求。比如“至少2种”包含了“恰好2种”、“恰好3种”……转移时无法清晰划分。正确做法必须定义为“恰好j种”。最终答案就是dp[m][n]。这是最清晰、最无歧义的定义。6.2 错误2整数除法与浮点数错误表现在计算(n - (j - 1)) / n或j / n时如果n和j都是整数在Python 2或某些语言中/操作是整数除法结果会被截断为0。解决方案确保使用浮点数除法。在Python 3中/默认是浮点除法。但为了清晰和安全可以显式地将分子或分母转为浮点数(n - (j - 1.0)) / n。6.3 错误3数组越界与无效状态访问错误表现循环中j的范围写成了for j in range(1, n1)当i j时会去访问dp[i-1][j]而这个状态买了i-1次却有了j种j i-1是不可能的其值应为0但更严重的是可能访问到未初始化的内存或导致逻辑错误。解决方案严格限制内层循环for j in range(1, min(i, n) 1)。这是保证DP正确性的关键一步。6.4 错误4初始化遗漏错误表现只初始化了dp[0][0] 1但在转移方程中当j1时dp[i][1]需要用到dp[i-1][1]和dp[i-1][0]。如果dp[i-1][0]没有正确初始化为0(对于i-10)结果就会出错。解决方案在创建二维数组后显式地将其所有元素初始化为0.0。或者在使用滚动数组时在每一轮开始前清空当前行。6.5 调试技巧打印DP表格当结果不对时最有效的调试方法就是打印出整个dp表格对于小数据观察每个状态的值是否符合预期。def debug_dp(n, m): dp [[0.0] * (n 1) for _ in range(m 1)] dp[0][0] 1.0 for i in range(1, m 1): for j in range(1, min(i, n) 1): p_new (n - (j - 1)) / n p_old j / n val 0.0 if j - 1 0: val dp[i - 1][j - 1] * p_new if j i - 1: val dp[i - 1][j] * p_old dp[i][j] val print(fdp[{i}][{j}] {val:.4f}, end | ) print() # 换行 return dp[m][n]通过观察中间状态你可以快速定位是从哪一步开始计算出现了偏差。7. 算法扩展与思维提升解决了基础问题后我们可以思考一些变种和延伸这能极大提升对同类问题的理解。7.1 变种1求收集全部印章的期望次数这是“赠券收集问题”的标准问法。已知有n种印章求集齐所有印章所需购买次数的数学期望E(n)。解法这需要用到概率论中的期望公式。设T为从拥有k种到拥有k1种所需次数的随机变量它服从几何分布每次试验购买成功的概率是p (n-k)/n。几何分布的期望是1/p。因此从0种到集齐n种的总期望次数为E(n) n/n n/(n-1) n/(n-2) ... n/1 n * (1 1/2 1/3 ... 1/n)这个和式是n乘以第n个调和数H_n。当n5时E(5) ≈ 5 * (1 1/2 1/3 1/4 1/5) ≈ 11.4167。这意味着平均需要买11-12次才能集齐5种印章。这个结果和我们之前计算m10时概率还不太高是吻合的。7.2 变种2每种印章概率不同如果每种印章被抽中的概率不同比如稀有印章概率低那么我们的DP转移方程需要修改。dp[i][j]的状态定义可能不够因为“拥有j种”不知道具体是哪j种。这就需要用到状态压缩DP状压DP用一个n位的二进制数来记录具体收集了哪些印章。状态数会变为O(m * 2^n)在n20时勉强可算2^20 ≈ 1e6但m不能太大。7.3 与背包问题的类比这道题和动态规划里的“分组背包”问题有神似之处。可以把每次购买看作一个“阶段”把“收集到j种印章”看作背包的“容量”而每次购买带来的“新种类”或“旧种类”就是不同的“物品”其“价值”是概率转移过程就是在更新到达每个“容量”的概率总和。理解这种类比有助于你将DP思想融会贯通。8. 在蓝桥杯及其他竞赛中的实战建议先暴力再优化如果一时想不出DP方程对于n, m很小的情况可以先尝试用DFS枚举所有可能序列来计算概率这至少能帮你验证后续DP算法的正确性也能通过对暴力法的分析来启发DP状态的定义。画状态转移图在纸上画出dp[i][j]的二维表格用箭头标出每个状态从哪里转移而来。这对于理清转移关系、确定循环顺序和边界条件非常有帮助。重视数据范围本题n, m 20是强烈的提示暗示可以用O(n*m)或O(n*m*2^n)的算法。如果数据范围变成n, m 1000那么O(n*m)的DP千万级运算可能就需要考虑优化或者寻找更优的数学公式了。精度处理蓝桥杯有时会要求输出特定小数位。务必使用print(f”{result:.4f}”)或print(“{:.4f}”.format(result))来控制格式。对于特别大的m概率值可能非常小需要注意浮点数的精度下限。时间分配在赛场上如果遇到此类题思考加编码应在30分钟内完成。如果超过这个时间仍无头绪可以考虑先跳过做其他更有把握的题目。回过头看ALGO-1007 “印章”这道题之所以经典就是因为它用一个生活化的场景包装了一个深刻的动态规划概率模型。它考察的不仅仅是对DP公式的生搬硬套更是对问题建模、状态抽象和边界处理的全方位能力。把这道题吃透以后再遇到“抽卡”、“收集”、“过程概率”这类问题你心里就有了一个坚实的解题框架。编程竞赛的魅力就在于此它锻炼的是一种将复杂现实问题转化为清晰可计算逻辑的思维能力这种能力远比记住十种排序算法更有价值。
返回列表