动态规划实战:从赠券收集问题到蓝桥杯印章题的概率计算
2026/8/27 16:13:57 网站建设 项目流程

1. 项目概述:从一道蓝桥杯真题看动态规划的核心思想

最近在整理蓝桥杯的算法训练题,翻到了ALGO-1007这道“印章”题。这道题在各大算法社区和备考群里的讨论热度一直不低,很多朋友第一次接触时,都会被它看似简单的描述和背后复杂的概率计算给绕进去。本质上,这是一道经典的动态规划(Dynamic Programming, DP)概率期望问题,它完美地融合了组合数学和递推思想,是检验你是否真正理解DP状态定义和转移方程的试金石。

题目大意是这样的:有n种不同的印章,每次购买随机获得其中一种(概率均等)。问要集齐所有n种印章,平均需要购买多少次?或者说,求购买m次后能集齐所有印章的概率是多少?(原题通常以求概率的形式出现)。这听起来是不是很像小时候吃干脆面集卡,或者玩手游抽卡集图鉴的场景?没错,这就是经典的“赠券收集问题”(Coupon Collector‘s Problem)的一个变体或具体求解实现。

对于正在备战蓝桥杯或其他算法竞赛的同学来说,啃下这道题意义重大。它不仅能帮你巩固动态规划这个必考大项,更能让你深刻理解如何将现实中的概率问题,转化为计算机可一步步推导的模型。接下来,我就结合自己的解题和教学经验,把这道题从问题解析、思路建立、到代码实现的每一步掰开揉碎讲清楚,尤其会重点分享几个容易卡壳的思维陷阱和调试技巧。

2. 问题解析与数学模型建立

2.1 题意重述与输入输出分析

我们先抛开算法,把题目用更直白的话翻译一遍。以最常见的题目表述为例:

输入:两个整数nm,用空格隔开。

  • n代表印章的总种类数(比如有5种不同的印章图案)。
  • m代表你一共购买了印章的次数。

输出:一个浮点数,表示在购买m次之后,你已经集齐了全部n种印章的概率。结果通常要求保留小数点后4位。

约束条件1 ≤ n, m ≤ 20。这个数据范围很重要,它直接暗示了我们算法的可行方向。因为nm最大才20,这意味着即使我们使用时间复杂度或空间复杂度较高的算法(比如O(n * m * 2^n)的状压DP),在计算量上也是勉强可接受的。但更优的DP可以做到O(n*m)

核心难点:每次购买是独立随机事件,且获得每种印章的概率是1/n。我们要求的是一个累积概率——在经历了m次随机事件后,达到某个特定状态(集齐所有种类)的概率。这种“过程”和“状态”的描述,强烈指向了动态规划。

2.2 为什么是动态规划?—— 识别问题特征

动态规划适用于解决具有以下特征的问题:

  1. 最优子结构:一个问题的最优解包含其子问题的最优解。
  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种(也就是集齐)的概率。这个定义将“购买次数”和“收集进度”这两个维度结合了起来,形成了一个清晰的状态空间。所有可能的购买过程,都会落在这个二维状态矩阵中的某个路径上。

状态空间大小i0mj0min(i, n)(因为买了i次,最多只能有i种,但也不能超过总种类n)。所以总状态数大约为O(m*n),对于本题最大20*20=400个状态,非常小。

3. 动态规划转移方程推导

有了状态定义,接下来就是思考状态之间如何转移。即:如何从dp[i-1][?]推导出dp[i][j]

考虑第i次购买,它只会发生两种情况:

  1. 买到了一个新的、之前没有的印章种类。
  2. 买到了一个旧的、已经拥有的印章种类。

3.1 状态转移分析

假设在进行第i次购买之前,我们已经购买了i-1次,并且已经拥有了j种不同的印章。

  • 情况一:第i次买到新种类

    • 前提条件:当前已拥有j种,总共有n种。那么还没收集到的种类有n-j种。
    • 发生概率:每次购买,获得任意一种特定印章的概率是1/n。这里有n-j种都是“新”的。所以,本次购买到新种类的概率是(n-j) / n
    • 状态转移:如果这件事发生了,那么购买i次后,拥有的种类数就会从j变成j+1
    • dp[i][j+1]的贡献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 >= 1j >= 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 (j>0):购买了0次,不可能拥有任何印章,概率为0。
  • dp[i][0] = 0.0 (i>0):只要购买过(i>0),至少会拥有1种印章(除非概率为0,但这里不可能),所以拥有0种的概率为0。实际上,从转移方程看,dp[i][0]也无法从dp[i-1][-1]转移过来,所以直接初始化为0即可。

注意:这里有一个极其关键的细节,也是很多初学者第一次编码出错的地方。j的范围必须满足j <= ij <= n。因为买了i次,最多只能拥有i种不同的印章。在循环遍历时,如果不注意这个条件,就会访问到无意义的状态(比如dp[1][2])。

4. 代码实现与逐行解析

理论清晰后,我们来看代码实现。这里以Python为例,因为它语法简洁,非常适合表达算法逻辑。

4.1 基础版本代码

def seal_probability(n, m): # 初始化一个 (m+1) x (n+1) 的二维数组,全部赋值为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 代码优化与细节处理

上面的代码是直接翻译自转移方程,但我们可以写得更加严谨和高效。

  1. 避免冗余判断:内层循环j的范围已经通过min(i, n)控制,所以j <= i天然成立。因此,dp[i-1][j]这个状态在j <= i-1时总是有效的。我们可以简化判断逻辑。
  2. 处理浮点数精度:虽然Python的浮点数精度对于本题足够,但良好的习惯是意识到浮点运算可能存在的微小误差。在竞赛中,通常要求输出与标准答案误差在一定范围内即算正确。
  3. 空间优化(可选):观察状态转移方程,dp[i][j]只依赖于dp[i-1][j]dp[i-1][j-1],即只依赖于上一行。因此,我们可以使用滚动数组将空间复杂度从O(m*n)优化到O(n)。这对于本题n,m<=20意义不大,但是一个很好的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 边界测试用例

  1. 最小输入n=1, m=1

    • 分析:只有1种印章,买1次必定集齐。
    • 预期输出:概率为1.0000
    • 验证dp[1][1]。初始dp[0][0]=1。第一次购买,买到新印章(唯一的一种)概率为(1-0)/1=1。所以dp[1][1] = dp[0][0]*1 = 1
  2. 购买次数少于种类数n=5, m=3

    • 分析:只有3次购买机会,不可能集齐5种印章。
    • 预期输出:概率为0.0000
    • 验证:我们的DP中,j最大为min(i, n)。最终求的是dp[3][5],这个状态在循环中根本不会被计算(因为min(3,5)=3),初始值就是0.0
  3. 购买次数等于种类数n=3, m=3

    • 分析:这是一个经典情况。集齐意味着三次购买恰好都是不同的印章。
    • 手算验证:第一次随便买(概率1)。第二次买到与第一次不同的概率是2/3。第三次买到与前两次都不同的概率是1/3。总概率为1 * (2/3) * (1/3) = 2/9 ≈ 0.2222
    • 程序验证:运行程序,看输出是否约为0.2222

5.2 常规测试用例与思维陷阱

  1. 用例n=2, m=3

    • 手算(枚举法): 共有2^3=8种等可能的购买序列(如AAA, AAB, ABA, ABB, BAA, BAB, BBA, BBB)。 其中“集齐两种”意味着序列中至少有一个A和一个B。 排除全A(AAA)和全B(BBB)两种情况,剩下6种。 概率为6/8 = 0.75
    • 程序验证:应输出0.7500
  2. 思维陷阱:概率是累加的吗?有同学可能会想,第一次买到一种概率是1,第二次买到另一种的概率是1/2,第三次…然后把这些概率加起来或乘起来。这是错误的。我们计算的是特定事件序列的联合概率,必须用DP来考虑所有可能的历史路径。DP中的dp[i][j]正是代表了所有能达到(i, j)状态的不同路径的概率之和。

5.3 使用暴力枚举进行对拍(小数据)

对于nm很小的情况(比如都小于5),我们可以写一个暴力枚举所有可能购买序列的程序,来验证DP结果的正确性。这是调试算法最可靠的方法之一。

import itertools def brute_force(n, m): # 生成所有长度为m的序列,每个元素是[0, n-1]的整数,代表印章类型 all_sequences = itertools.product(range(n), repeat=m) favorable = 0 total = n ** m for seq in all_sequences: # 判断这个序列是否包含了所有n种印章 if len(set(seq)) == n: favorable += 1 return favorable / total # 测试 n=3, m=3 print(f"DP 结果: {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)) / nj / n时,如果nj都是整数,在Python 2或某些语言中,/操作是整数除法,结果会被截断为0。
  • 解决方案:确保使用浮点数除法。在Python 3中,/默认是浮点除法。但为了清晰和安全,可以显式地将分子或分母转为浮点数:(n - (j - 1.0)) / n

6.3 错误3:数组越界与无效状态访问

  • 错误表现:循环中j的范围写成了for j in range(1, n+1),当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,但在转移方程中,当j=1时,dp[i][1]需要用到dp[i-1][1]dp[i-1][0]。如果dp[i-1][0]没有正确初始化为0(对于i-1>0),结果就会出错。
  • 解决方案:在创建二维数组后,显式地将其所有元素初始化为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(f"dp[{i}][{j}] = {val:.4f}", end=' | ') print() # 换行 return dp[m][n]

通过观察中间状态,你可以快速定位是从哪一步开始计算出现了偏差。

7. 算法扩展与思维提升

解决了基础问题后,我们可以思考一些变种和延伸,这能极大提升对同类问题的理解。

7.1 变种1:求收集全部印章的期望次数

这是“赠券收集问题”的标准问法。已知有n种印章,求集齐所有印章所需购买次数的数学期望E(n)

解法:这需要用到概率论中的期望公式。设T为从拥有k种到拥有k+1种所需次数的随机变量,它服从几何分布,每次试验(购买)成功的概率是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。当n=5时,E(5) ≈ 5 * (1 + 1/2 + 1/3 + 1/4 + 1/5) ≈ 11.4167。这意味着平均需要买11-12次才能集齐5种印章。这个结果和我们之前计算m=10时概率还不太高是吻合的。

7.2 变种2:每种印章概率不同

如果每种印章被抽中的概率不同(比如稀有印章概率低),那么我们的DP转移方程需要修改。dp[i][j]的状态定义可能不够,因为“拥有j种”不知道具体是哪j种。这就需要用到状态压缩DP(状压DP),用一个n位的二进制数来记录具体收集了哪些印章。状态数会变为O(m * 2^n),在n<=20时勉强可算(2^20 ≈ 1e6),但m不能太大。

7.3 与背包问题的类比

这道题和动态规划里的“分组背包”问题有神似之处。可以把每次购买看作一个“阶段”,把“收集到j种印章”看作背包的“容量”,而每次购买带来的“新种类”或“旧种类”就是不同的“物品”,其“价值”是概率,转移过程就是在更新到达每个“容量”的概率总和。理解这种类比,有助于你将DP思想融会贯通。

8. 在蓝桥杯及其他竞赛中的实战建议

  1. 先暴力,再优化:如果一时想不出DP方程,对于n, m很小的情况,可以先尝试用DFS枚举所有可能序列来计算概率,这至少能帮你验证后续DP算法的正确性,也能通过对暴力法的分析来启发DP状态的定义。
  2. 画状态转移图:在纸上画出dp[i][j]的二维表格,用箭头标出每个状态从哪里转移而来。这对于理清转移关系、确定循环顺序和边界条件非常有帮助。
  3. 重视数据范围:本题n, m <= 20是强烈的提示,暗示可以用O(n*m)O(n*m*2^n)的算法。如果数据范围变成n, m <= 1000,那么O(n*m)的DP(千万级运算)可能就需要考虑优化或者寻找更优的数学公式了。
  4. 精度处理:蓝桥杯有时会要求输出特定小数位。务必使用print(f”{result:.4f}”)print(“{:.4f}”.format(result))来控制格式。对于特别大的m,概率值可能非常小,需要注意浮点数的精度下限。
  5. 时间分配:在赛场上,如果遇到此类题,思考加编码应在30分钟内完成。如果超过这个时间仍无头绪,可以考虑先跳过,做其他更有把握的题目。

回过头看,ALGO-1007 “印章”这道题之所以经典,就是因为它用一个生活化的场景,包装了一个深刻的动态规划概率模型。它考察的不仅仅是对DP公式的生搬硬套,更是对问题建模、状态抽象和边界处理的全方位能力。把这道题吃透,以后再遇到“抽卡”、“收集”、“过程概率”这类问题,你心里就有了一个坚实的解题框架。编程竞赛的魅力就在于此,它锻炼的是一种将复杂现实问题转化为清晰可计算逻辑的思维能力,这种能力远比记住十种排序算法更有价值。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询